first commit
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Blob = require('../blob')
|
||||
const FileData = require('./')
|
||||
|
||||
/**
|
||||
* @import { RawBinaryFileData } from '../types'
|
||||
*/
|
||||
|
||||
class BinaryFileData extends FileData {
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @param {number} byteLength
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(hash, byteLength) {
|
||||
super()
|
||||
assert.match(hash, Blob.HEX_HASH_RX, 'BinaryFileData: bad hash')
|
||||
assert.integer(byteLength, 'BinaryFileData: bad byteLength')
|
||||
assert.greaterOrEqual(byteLength, 0, 'BinaryFileData: low byteLength')
|
||||
|
||||
this.hash = hash
|
||||
this.byteLength = byteLength
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawBinaryFileData} raw
|
||||
* @returns {BinaryFileData}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new BinaryFileData(raw.hash, raw.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawBinaryFileData}
|
||||
*/
|
||||
toRaw() {
|
||||
return { hash: this.hash, byteLength: this.byteLength }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getHash() {
|
||||
return this.hash
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return false
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return this.byteLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toEager() {
|
||||
return this
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toLazy() {
|
||||
return this
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toHollow() {
|
||||
return FileData.createHollow(this.byteLength, null)
|
||||
}
|
||||
|
||||
/** @inheritdoc
|
||||
* @return {Promise<RawFileData>}
|
||||
*/
|
||||
async store() {
|
||||
return { hash: this.hash }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BinaryFileData
|
@@ -0,0 +1,28 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @import { ClearTrackingPropsRawData } from '../types'
|
||||
*/
|
||||
|
||||
class ClearTrackingProps {
|
||||
constructor() {
|
||||
this.type = 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
equals(other) {
|
||||
return other instanceof ClearTrackingProps
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ClearTrackingPropsRawData}
|
||||
*/
|
||||
toRaw() {
|
||||
return { type: 'none' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClearTrackingProps
|
124
libraries/overleaf-editor-core/lib/file_data/comment_list.js
Normal file
124
libraries/overleaf-editor-core/lib/file_data/comment_list.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// @ts-check
|
||||
const Comment = require('../comment')
|
||||
|
||||
/**
|
||||
* @import { CommentRawData } from "../types"
|
||||
* @import Range from "../range"
|
||||
*/
|
||||
|
||||
class CommentList {
|
||||
/**
|
||||
* @param {Comment[]} comments
|
||||
*/
|
||||
constructor(comments) {
|
||||
this.comments = new Map(comments.map(comment => [comment.id, comment]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {IterableIterator<Comment>}
|
||||
*/
|
||||
[Symbol.iterator]() {
|
||||
return this.comments.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contents of this list in an array
|
||||
*
|
||||
* @returns {Comment[]}
|
||||
*/
|
||||
toArray() {
|
||||
return Array.from(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the length of the comment list
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
get length() {
|
||||
return this.comments.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the raw version of the comment list
|
||||
*
|
||||
* @returns {CommentRawData[]}
|
||||
*/
|
||||
toRaw() {
|
||||
const raw = []
|
||||
for (const comment of this.comments.values()) {
|
||||
raw.push(comment.toRaw())
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Comment | undefined}
|
||||
*/
|
||||
getComment(id) {
|
||||
return this.comments.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Comment} newComment
|
||||
*/
|
||||
add(newComment) {
|
||||
this.comments.set(newComment.id, newComment)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
delete(id) {
|
||||
return this.comments.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CommentRawData[]} rawComments
|
||||
*/
|
||||
static fromRaw(rawComments) {
|
||||
return new CommentList(rawComments.map(Comment.fromRaw))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Range} range
|
||||
* @param {{ commentIds?: string[] }} opts
|
||||
*/
|
||||
applyInsert(range, opts = { commentIds: [] }) {
|
||||
if (!opts.commentIds) {
|
||||
opts.commentIds = []
|
||||
}
|
||||
for (const [commentId, comment] of this.comments) {
|
||||
const commentAfterInsert = comment.applyInsert(
|
||||
range.pos,
|
||||
range.length,
|
||||
opts.commentIds.includes(commentId)
|
||||
)
|
||||
this.comments.set(commentId, commentAfterInsert)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Range} range
|
||||
*/
|
||||
applyDelete(range) {
|
||||
for (const [commentId, comment] of this.comments) {
|
||||
const commentAfterDelete = comment.applyDelete(range)
|
||||
this.comments.set(commentId, commentAfterDelete)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Range} range
|
||||
* @returns {string[]}
|
||||
*/
|
||||
idsCoveringRange(range) {
|
||||
return Array.from(this.comments.entries())
|
||||
.filter(([, comment]) => comment.ranges.some(r => r.contains(range)))
|
||||
.map(([id]) => id)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CommentList
|
134
libraries/overleaf-editor-core/lib/file_data/hash_file_data.js
Normal file
134
libraries/overleaf-editor-core/lib/file_data/hash_file_data.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Blob = require('../blob')
|
||||
const FileData = require('./')
|
||||
/**
|
||||
* @import StringFileData from './string_file_data'
|
||||
* @import LazyStringFileData from './lazy_string_file_data'
|
||||
* @import HollowStringFileData from './hollow_string_file_data'
|
||||
* @import { BlobStore, RawHashFileData } from '../types'
|
||||
*/
|
||||
|
||||
class HashFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} hash
|
||||
* @param {string} [rangesHash]
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(hash, rangesHash) {
|
||||
super()
|
||||
assert.match(hash, Blob.HEX_HASH_RX, 'HashFileData: bad hash')
|
||||
if (rangesHash) {
|
||||
assert.match(
|
||||
rangesHash,
|
||||
Blob.HEX_HASH_RX,
|
||||
'HashFileData: bad ranges hash'
|
||||
)
|
||||
}
|
||||
this.hash = hash
|
||||
this.rangesHash = rangesHash
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RawHashFileData} raw
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new HashFileData(raw.hash, raw.rangesHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawHashFileData}
|
||||
*/
|
||||
toRaw() {
|
||||
/** @type RawHashFileData */
|
||||
const raw = { hash: this.hash }
|
||||
if (this.rangesHash) {
|
||||
raw.rangesHash = this.rangesHash
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {string}
|
||||
*/
|
||||
getHash() {
|
||||
return this.hash
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
getRangesHash() {
|
||||
return this.rangesHash
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {BlobStore} blobStore
|
||||
* @returns {Promise<StringFileData>}
|
||||
*/
|
||||
async toEager(blobStore) {
|
||||
const lazyFileData = await this.toLazy(blobStore)
|
||||
return await lazyFileData.toEager(blobStore)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {BlobStore} blobStore
|
||||
* @returns {Promise<LazyStringFileData>}
|
||||
*/
|
||||
async toLazy(blobStore) {
|
||||
const [blob, rangesBlob] = await Promise.all([
|
||||
blobStore.getBlob(this.hash),
|
||||
this.rangesHash
|
||||
? blobStore.getBlob(this.rangesHash)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
if (rangesBlob === null) {
|
||||
// We attempted to look up the blob, but none was found
|
||||
throw new Error('Failed to look up rangesHash in blobStore')
|
||||
}
|
||||
if (!blob) throw new Error('blob not found: ' + this.hash)
|
||||
// TODO(das7pad): inline 2nd path of FileData.createLazyFromBlobs?
|
||||
// @ts-ignore
|
||||
return FileData.createLazyFromBlobs(blob, rangesBlob)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {BlobStore} blobStore
|
||||
* @returns {Promise<HollowStringFileData>}
|
||||
*/
|
||||
async toHollow(blobStore) {
|
||||
const blob = await blobStore.getBlob(this.hash)
|
||||
if (!blob) {
|
||||
throw new Error('Failed to look up hash in blobStore')
|
||||
}
|
||||
// TODO(das7pad): inline 2nd path of FileData.createHollow?
|
||||
// @ts-ignore
|
||||
return FileData.createHollow(blob.getByteLength(), blob.getStringLength())
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {Promise<RawHashFileData>}
|
||||
*/
|
||||
async store() {
|
||||
/** @type RawHashFileData */
|
||||
const raw = { hash: this.hash }
|
||||
if (this.rangesHash) {
|
||||
raw.rangesHash = this.rangesHash
|
||||
}
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HashFileData
|
@@ -0,0 +1,55 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const FileData = require('./')
|
||||
|
||||
/**
|
||||
* @import { RawHollowBinaryFileData } from '../types'
|
||||
*/
|
||||
|
||||
class HollowBinaryFileData extends FileData {
|
||||
/**
|
||||
* @param {number} byteLength
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(byteLength) {
|
||||
super()
|
||||
assert.integer(byteLength, 'HollowBinaryFileData: bad byteLength')
|
||||
assert.greaterOrEqual(byteLength, 0, 'HollowBinaryFileData: low byteLength')
|
||||
this.byteLength = byteLength
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawHollowBinaryFileData} raw
|
||||
* @returns {HollowBinaryFileData}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new HollowBinaryFileData(raw.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawHollowBinaryFileData}
|
||||
*/
|
||||
toRaw() {
|
||||
return { byteLength: this.byteLength }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return this.byteLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return false
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toHollow() {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HollowBinaryFileData
|
@@ -0,0 +1,69 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const FileData = require('./')
|
||||
|
||||
/**
|
||||
* @import { RawHollowStringFileData } from '../types'
|
||||
* @import EditOperation from '../operation/edit_operation'
|
||||
*/
|
||||
|
||||
class HollowStringFileData extends FileData {
|
||||
/**
|
||||
* @param {number} stringLength
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(stringLength) {
|
||||
super()
|
||||
assert.integer(stringLength, 'HollowStringFileData: bad stringLength')
|
||||
assert.greaterOrEqual(
|
||||
stringLength,
|
||||
0,
|
||||
'HollowStringFileData: low stringLength'
|
||||
)
|
||||
this.stringLength = stringLength
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawHollowStringFileData} raw
|
||||
* @returns {HollowStringFileData}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new HollowStringFileData(raw.stringLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawHollowStringFileData}
|
||||
*/
|
||||
toRaw() {
|
||||
return { stringLength: this.stringLength }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getStringLength() {
|
||||
return this.stringLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toHollow() {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} operation
|
||||
*/
|
||||
edit(operation) {
|
||||
this.stringLength = operation.applyToLength(this.stringLength)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HollowStringFileData
|
229
libraries/overleaf-editor-core/lib/file_data/index.js
Normal file
229
libraries/overleaf-editor-core/lib/file_data/index.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// @ts-check
|
||||
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Blob = require('../blob')
|
||||
|
||||
/**
|
||||
* @import { BlobStore, ReadonlyBlobStore, RawFileData, CommentRawData } from "../types"
|
||||
* @import EditOperation from "../operation/edit_operation"
|
||||
* @import CommentList from "../file_data/comment_list"
|
||||
* @import TrackedChangeList from "../file_data/tracked_change_list"
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper to represent the content of a file. This class and its subclasses
|
||||
* should be used only through {@link File}.
|
||||
*/
|
||||
class FileData {
|
||||
/** @see File.fromRaw
|
||||
* @param {RawFileData} raw
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
// TODO(das7pad): can we teach typescript to understand our polymorphism?
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'hash')) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'byteLength'))
|
||||
// @ts-ignore
|
||||
return BinaryFileData.fromRaw(raw)
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'stringLength'))
|
||||
// @ts-ignore
|
||||
return LazyStringFileData.fromRaw(raw)
|
||||
// @ts-ignore
|
||||
return HashFileData.fromRaw(raw)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'byteLength'))
|
||||
// @ts-ignore
|
||||
return HollowBinaryFileData.fromRaw(raw)
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'stringLength'))
|
||||
// @ts-ignore
|
||||
return HollowStringFileData.fromRaw(raw)
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'content'))
|
||||
// @ts-ignore
|
||||
return StringFileData.fromRaw(raw)
|
||||
throw new Error('FileData: bad raw object ' + JSON.stringify(raw))
|
||||
}
|
||||
|
||||
/** @see File.createHollow
|
||||
* @param {number} byteLength
|
||||
* @param {number} [stringLength]
|
||||
*/
|
||||
static createHollow(byteLength, stringLength) {
|
||||
if (stringLength == null) {
|
||||
return new HollowBinaryFileData(byteLength)
|
||||
}
|
||||
return new HollowStringFileData(stringLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File.createLazyFromBlob
|
||||
* @param {Blob} blob
|
||||
* @param {Blob} [rangesBlob]
|
||||
*/
|
||||
static createLazyFromBlobs(blob, rangesBlob) {
|
||||
assert.instance(blob, Blob, 'FileData: bad blob')
|
||||
const stringLength = blob.getStringLength()
|
||||
if (stringLength == null) {
|
||||
return new BinaryFileData(blob.getHash(), blob.getByteLength())
|
||||
}
|
||||
return new LazyStringFileData(
|
||||
blob.getHash(),
|
||||
rangesBlob?.getHash(),
|
||||
stringLength
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {RawFileData}
|
||||
*/
|
||||
toRaw() {
|
||||
throw new Error('FileData: toRaw not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#getHash
|
||||
* @return {string | null | undefined}
|
||||
*/
|
||||
|
||||
getHash() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#getHash
|
||||
* @return {string | null | undefined}
|
||||
*/
|
||||
getRangesHash() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#getContent
|
||||
* @param {import('../file').FileGetContentOptions} [opts]
|
||||
* @return {string | null | undefined}
|
||||
*/
|
||||
getContent(opts = {}) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#isEditable
|
||||
* @return {boolean | null | undefined} null if it is not currently known
|
||||
*/
|
||||
isEditable() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#getByteLength
|
||||
* @return {number | null | undefined}
|
||||
*/
|
||||
getByteLength() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#getStringLength
|
||||
* @return {number | null | undefined}
|
||||
*/
|
||||
getStringLength() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#edit
|
||||
* @param {EditOperation} editOperation
|
||||
*/
|
||||
edit(editOperation) {
|
||||
throw new Error('edit not implemented for ' + JSON.stringify(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {ReadonlyBlobStore} blobStore
|
||||
* @return {Promise<FileData>}
|
||||
* @abstract
|
||||
* @see FileData#load
|
||||
*/
|
||||
async toEager(blobStore) {
|
||||
throw new Error('toEager not implemented for ' + JSON.stringify(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {ReadonlyBlobStore} blobStore
|
||||
* @return {Promise<FileData>}
|
||||
* @abstract
|
||||
* @see FileData#load
|
||||
*/
|
||||
async toLazy(blobStore) {
|
||||
throw new Error('toLazy not implemented for ' + JSON.stringify(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {ReadonlyBlobStore} blobStore
|
||||
* @return {Promise<FileData>}
|
||||
* @abstract
|
||||
* @see FileData#load
|
||||
*/
|
||||
async toHollow(blobStore) {
|
||||
throw new Error('toHollow not implemented for ' + JSON.stringify(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#load
|
||||
* @param {string} kind
|
||||
* @param {ReadonlyBlobStore} blobStore
|
||||
* @return {Promise<FileData>}
|
||||
*/
|
||||
async load(kind, blobStore) {
|
||||
if (kind === 'eager') return await this.toEager(blobStore)
|
||||
if (kind === 'lazy') return await this.toLazy(blobStore)
|
||||
if (kind === 'hollow') return await this.toHollow(blobStore)
|
||||
throw new Error('bad file data load kind: ' + kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#store
|
||||
* @function
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise<RawFileData>} a raw HashFile
|
||||
* @abstract
|
||||
*/
|
||||
async store(blobStore) {
|
||||
throw new Error('store not implemented for ' + JSON.stringify(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#getComments
|
||||
* @function
|
||||
* @return {CommentList}
|
||||
* @abstract
|
||||
*/
|
||||
getComments() {
|
||||
throw new Error('getComments not implemented for ' + JSON.stringify(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#getTrackedChanges
|
||||
* @function
|
||||
* @return {TrackedChangeList}
|
||||
* @abstract
|
||||
*/
|
||||
getTrackedChanges() {
|
||||
throw new Error(
|
||||
'getTrackedChanges not implemented for ' + JSON.stringify(this)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FileData
|
||||
|
||||
const BinaryFileData = require('./binary_file_data')
|
||||
const HashFileData = require('./hash_file_data')
|
||||
const HollowBinaryFileData = require('./hollow_binary_file_data')
|
||||
const HollowStringFileData = require('./hollow_string_file_data')
|
||||
const LazyStringFileData = require('./lazy_string_file_data')
|
||||
const StringFileData = require('./string_file_data')
|
@@ -0,0 +1,190 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Blob = require('../blob')
|
||||
const FileData = require('./')
|
||||
const EagerStringFileData = require('./string_file_data')
|
||||
const EditOperation = require('../operation/edit_operation')
|
||||
const EditOperationBuilder = require('../operation/edit_operation_builder')
|
||||
|
||||
/**
|
||||
* @import { BlobStore, ReadonlyBlobStore, RangesBlob, RawFileData, RawLazyStringFileData } from '../types'
|
||||
*/
|
||||
|
||||
class LazyStringFileData extends FileData {
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @param {string | undefined} rangesHash
|
||||
* @param {number} stringLength
|
||||
* @param {Array.<EditOperation>} [operations]
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(hash, rangesHash, stringLength, operations) {
|
||||
super()
|
||||
assert.match(hash, Blob.HEX_HASH_RX)
|
||||
if (rangesHash) {
|
||||
assert.match(rangesHash, Blob.HEX_HASH_RX)
|
||||
}
|
||||
assert.greaterOrEqual(stringLength, 0)
|
||||
assert.maybe.array.of.instance(operations, EditOperation)
|
||||
|
||||
this.hash = hash
|
||||
this.rangesHash = rangesHash
|
||||
this.stringLength = stringLength
|
||||
this.operations = operations || []
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawLazyStringFileData} raw
|
||||
* @returns {LazyStringFileData}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new LazyStringFileData(
|
||||
raw.hash,
|
||||
raw.rangesHash,
|
||||
raw.stringLength,
|
||||
raw.operations && _.map(raw.operations, EditOperationBuilder.fromJSON)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawLazyStringFileData}
|
||||
*/
|
||||
toRaw() {
|
||||
/** @type RawLazyStringFileData */
|
||||
const raw = {
|
||||
hash: this.hash,
|
||||
stringLength: this.stringLength,
|
||||
}
|
||||
if (this.rangesHash) {
|
||||
raw.rangesHash = this.rangesHash
|
||||
}
|
||||
if (this.operations.length) {
|
||||
raw.operations = _.map(this.operations, function (operation) {
|
||||
return operation.toJSON()
|
||||
})
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getHash() {
|
||||
if (this.operations.length) return null
|
||||
return this.hash
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getRangesHash() {
|
||||
if (this.operations.length) return null
|
||||
return this.rangesHash
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* For project quota checking, we approximate the byte length by the UTF-8
|
||||
* length for hollow files. This isn't strictly speaking correct; it is an
|
||||
* underestimate of byte length.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
getByteLength() {
|
||||
return this.stringLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getStringLength() {
|
||||
return this.stringLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached text operations that are to be applied to this file to get
|
||||
* from the content with its last known hash to its latest content.
|
||||
*
|
||||
* @return {Array.<EditOperation>}
|
||||
*/
|
||||
getOperations() {
|
||||
return this.operations
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {ReadonlyBlobStore} blobStore
|
||||
* @returns {Promise<EagerStringFileData>}
|
||||
*/
|
||||
async toEager(blobStore) {
|
||||
const [content, ranges] = await Promise.all([
|
||||
blobStore.getString(this.hash),
|
||||
this.rangesHash
|
||||
? /** @type {Promise<RangesBlob>} */ (
|
||||
blobStore.getObject(this.rangesHash)
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
const file = new EagerStringFileData(
|
||||
content,
|
||||
ranges?.comments,
|
||||
ranges?.trackedChanges
|
||||
)
|
||||
applyOperations(this.operations, file)
|
||||
return file
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toLazy() {
|
||||
return this
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toHollow() {
|
||||
// TODO(das7pad): inline 2nd path of FileData.createLazyFromBlobs?
|
||||
// @ts-ignore
|
||||
return FileData.createHollow(null, this.stringLength)
|
||||
}
|
||||
|
||||
/** @inheritdoc
|
||||
* @param {EditOperation} operation
|
||||
*/
|
||||
edit(operation) {
|
||||
this.stringLength = operation.applyToLength(this.stringLength)
|
||||
this.operations.push(operation)
|
||||
}
|
||||
|
||||
/** @inheritdoc
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise<RawFileData>}
|
||||
*/
|
||||
async store(blobStore) {
|
||||
if (this.operations.length === 0) {
|
||||
/** @type RawFileData */
|
||||
const raw = { hash: this.hash }
|
||||
if (this.rangesHash) {
|
||||
raw.rangesHash = this.rangesHash
|
||||
}
|
||||
return raw
|
||||
}
|
||||
const eager = await this.toEager(blobStore)
|
||||
this.operations.length = 0
|
||||
/** @type RawFileData */
|
||||
return await eager.store(blobStore)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {EditOperation[]} operations
|
||||
* @param {EagerStringFileData} file
|
||||
* @returns {void}
|
||||
*/
|
||||
function applyOperations(operations, file) {
|
||||
_.each(operations, operation => operation.apply(file))
|
||||
}
|
||||
|
||||
module.exports = LazyStringFileData
|
151
libraries/overleaf-editor-core/lib/file_data/string_file_data.js
Normal file
151
libraries/overleaf-editor-core/lib/file_data/string_file_data.js
Normal file
@@ -0,0 +1,151 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const FileData = require('./')
|
||||
const CommentList = require('./comment_list')
|
||||
const TrackedChangeList = require('./tracked_change_list')
|
||||
|
||||
/**
|
||||
* @import { StringFileRawData, RawFileData, BlobStore, CommentRawData } from "../types"
|
||||
* @import { TrackedChangeRawData, RangesBlob } from "../types"
|
||||
* @import EditOperation from "../operation/edit_operation"
|
||||
*/
|
||||
|
||||
class StringFileData extends FileData {
|
||||
/**
|
||||
* @param {string} content
|
||||
* @param {CommentRawData[]} [rawComments]
|
||||
* @param {TrackedChangeRawData[]} [rawTrackedChanges]
|
||||
*/
|
||||
constructor(content, rawComments = [], rawTrackedChanges = []) {
|
||||
super()
|
||||
assert.string(content)
|
||||
this.content = content
|
||||
this.comments = CommentList.fromRaw(rawComments)
|
||||
this.trackedChanges = TrackedChangeList.fromRaw(rawTrackedChanges)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StringFileRawData} raw
|
||||
* @returns {StringFileData}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new StringFileData(
|
||||
raw.content,
|
||||
raw.comments || [],
|
||||
raw.trackedChanges || []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {StringFileRawData}
|
||||
*/
|
||||
toRaw() {
|
||||
/** @type StringFileRawData */
|
||||
const raw = { content: this.content }
|
||||
|
||||
if (this.comments.length) {
|
||||
raw.comments = this.comments.toRaw()
|
||||
}
|
||||
|
||||
if (this.trackedChanges.length) {
|
||||
raw.trackedChanges = this.trackedChanges.toRaw()
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {import('../file').FileGetContentOptions} [opts]
|
||||
*/
|
||||
getContent(opts = {}) {
|
||||
let content = ''
|
||||
let cursor = 0
|
||||
if (opts.filterTrackedDeletes) {
|
||||
for (const tc of this.trackedChanges.asSorted()) {
|
||||
if (tc.tracking.type !== 'delete') {
|
||||
continue
|
||||
}
|
||||
if (cursor < tc.range.start) {
|
||||
content += this.content.slice(cursor, tc.range.start)
|
||||
}
|
||||
// skip the tracked change
|
||||
cursor = tc.range.end
|
||||
}
|
||||
}
|
||||
if (cursor < this.content.length) {
|
||||
content += this.content.slice(cursor)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return Buffer.byteLength(this.content)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getStringLength() {
|
||||
return this.content.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} operation */
|
||||
edit(operation) {
|
||||
operation.apply(this)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getComments() {
|
||||
return this.comments
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getTrackedChanges() {
|
||||
return this.trackedChanges
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {Promise<StringFileData>}
|
||||
*/
|
||||
async toEager() {
|
||||
return this
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async toHollow() {
|
||||
return FileData.createHollow(this.getByteLength(), this.getStringLength())
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise<RawFileData>}
|
||||
*/
|
||||
async store(blobStore) {
|
||||
const blob = await blobStore.putString(this.content)
|
||||
if (this.comments.comments.size || this.trackedChanges.length) {
|
||||
/** @type {RangesBlob} */
|
||||
const ranges = {
|
||||
comments: this.getComments().toRaw(),
|
||||
trackedChanges: this.trackedChanges.toRaw(),
|
||||
}
|
||||
const rangesBlob = await blobStore.putObject(ranges)
|
||||
return { hash: blob.getHash(), rangesHash: rangesBlob.getHash() }
|
||||
}
|
||||
return { hash: blob.getHash() }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StringFileData
|
@@ -0,0 +1,89 @@
|
||||
// @ts-check
|
||||
const Range = require('../range')
|
||||
const TrackingProps = require('./tracking_props')
|
||||
|
||||
/**
|
||||
* @import { TrackedChangeRawData } from "../types"
|
||||
*/
|
||||
|
||||
class TrackedChange {
|
||||
/**
|
||||
* @param {Range} range
|
||||
* @param {TrackingProps} tracking
|
||||
*/
|
||||
constructor(range, tracking) {
|
||||
/**
|
||||
* @readonly
|
||||
* @type {Range}
|
||||
*/
|
||||
this.range = range
|
||||
/**
|
||||
* @readonly
|
||||
* @type {TrackingProps}
|
||||
*/
|
||||
this.tracking = tracking
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TrackedChangeRawData} raw
|
||||
* @returns {TrackedChange}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new TrackedChange(
|
||||
Range.fromRaw(raw.range),
|
||||
TrackingProps.fromRaw(raw.tracking)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {TrackedChangeRawData}
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
range: this.range.toRaw(),
|
||||
tracking: this.tracking.toRaw(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the tracked change can be merged with another
|
||||
* @param {TrackedChange} other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canMerge(other) {
|
||||
if (!(other instanceof TrackedChange)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
this.tracking.type === other.tracking.type &&
|
||||
this.tracking.userId === other.tracking.userId &&
|
||||
this.range.touches(other.range) &&
|
||||
this.range.canMerge(other.range)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges another tracked change into this, updating the range and tracking
|
||||
* timestamp
|
||||
* @param {TrackedChange} other
|
||||
* @returns {TrackedChange}
|
||||
*/
|
||||
merge(other) {
|
||||
if (!this.canMerge(other)) {
|
||||
throw new Error('Cannot merge tracked changes')
|
||||
}
|
||||
return new TrackedChange(
|
||||
this.range.merge(other.range),
|
||||
new TrackingProps(
|
||||
this.tracking.type,
|
||||
this.tracking.userId,
|
||||
this.tracking.ts.getTime() > other.tracking.ts.getTime()
|
||||
? this.tracking.ts
|
||||
: other.tracking.ts
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TrackedChange
|
@@ -0,0 +1,276 @@
|
||||
// @ts-check
|
||||
const Range = require('../range')
|
||||
const TrackedChange = require('./tracked_change')
|
||||
const TrackingProps = require('../file_data/tracking_props')
|
||||
|
||||
/**
|
||||
* @import { TrackingDirective, TrackedChangeRawData } from "../types"
|
||||
*/
|
||||
|
||||
class TrackedChangeList {
|
||||
/**
|
||||
*
|
||||
* @param {TrackedChange[]} trackedChanges
|
||||
*/
|
||||
constructor(trackedChanges) {
|
||||
/**
|
||||
* @type {TrackedChange[]}
|
||||
*/
|
||||
this._trackedChanges = trackedChanges
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TrackedChangeRawData[]} raw
|
||||
* @returns {TrackedChangeList}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new TrackedChangeList(raw.map(TrackedChange.fromRaw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the tracked changes to a raw object
|
||||
* @returns {TrackedChangeRawData[]}
|
||||
*/
|
||||
toRaw() {
|
||||
return this._trackedChanges.map(change => change.toRaw())
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this._trackedChanges.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {readonly TrackedChange[]}
|
||||
*/
|
||||
asSorted() {
|
||||
// NOTE: Once all code dependent on this is typed, we can just return
|
||||
// _trackedChanges.
|
||||
return Array.from(this._trackedChanges)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tracked changes that are fully included in the range
|
||||
* @param {Range} range
|
||||
* @returns {TrackedChange[]}
|
||||
*/
|
||||
inRange(range) {
|
||||
return this._trackedChanges.filter(change => range.contains(change.range))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tracking props for a given range.
|
||||
* @param {Range} range
|
||||
* @returns {TrackingProps | undefined}
|
||||
*/
|
||||
propsAtRange(range) {
|
||||
return this._trackedChanges.find(change => change.range.contains(range))
|
||||
?.tracking
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the tracked changes that are fully included in the range
|
||||
* @param {Range} range
|
||||
*/
|
||||
removeInRange(range) {
|
||||
this._trackedChanges = this._trackedChanges.filter(
|
||||
change => !range.contains(change.range)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a tracked change to the list
|
||||
* @param {TrackedChange} trackedChange
|
||||
*/
|
||||
add(trackedChange) {
|
||||
this._trackedChanges.push(trackedChange)
|
||||
this._mergeRanges()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses consecutive (and compatible) ranges
|
||||
* @returns {void}
|
||||
*/
|
||||
_mergeRanges() {
|
||||
if (this._trackedChanges.length < 2) {
|
||||
return
|
||||
}
|
||||
// ranges are non-overlapping so we can sort based on their first indices
|
||||
this._trackedChanges.sort((a, b) => a.range.start - b.range.start)
|
||||
const newTrackedChanges = [this._trackedChanges[0]]
|
||||
for (let i = 1; i < this._trackedChanges.length; i++) {
|
||||
const last = newTrackedChanges[newTrackedChanges.length - 1]
|
||||
const current = this._trackedChanges[i]
|
||||
if (last.range.overlaps(current.range)) {
|
||||
throw new Error('Ranges cannot overlap')
|
||||
}
|
||||
if (current.range.isEmpty()) {
|
||||
throw new Error('Tracked changes range cannot be empty')
|
||||
}
|
||||
if (last.canMerge(current)) {
|
||||
newTrackedChanges[newTrackedChanges.length - 1] = last.merge(current)
|
||||
} else {
|
||||
newTrackedChanges.push(current)
|
||||
}
|
||||
}
|
||||
this._trackedChanges = newTrackedChanges
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} cursor
|
||||
* @param {string} insertedText
|
||||
* @param {{tracking?: TrackingProps}} opts
|
||||
*/
|
||||
applyInsert(cursor, insertedText, opts = {}) {
|
||||
const newTrackedChanges = []
|
||||
for (const trackedChange of this._trackedChanges) {
|
||||
if (
|
||||
// If the cursor is before or at the insertion point, we need to move
|
||||
// the tracked change
|
||||
trackedChange.range.startIsAfter(cursor) ||
|
||||
cursor === trackedChange.range.start
|
||||
) {
|
||||
newTrackedChanges.push(
|
||||
new TrackedChange(
|
||||
trackedChange.range.moveBy(insertedText.length),
|
||||
trackedChange.tracking
|
||||
)
|
||||
)
|
||||
} else if (cursor === trackedChange.range.end) {
|
||||
// The insertion is at the end of the tracked change. So we don't need
|
||||
// to move it.
|
||||
newTrackedChanges.push(trackedChange)
|
||||
} else if (trackedChange.range.containsCursor(cursor)) {
|
||||
// If the tracked change is in the inserted text, we need to expand it
|
||||
// split in three chunks. The middle one is added if it is a tracked insertion
|
||||
const [firstRange, , thirdRange] = trackedChange.range.insertAt(
|
||||
cursor,
|
||||
insertedText.length
|
||||
)
|
||||
const firstPart = new TrackedChange(firstRange, trackedChange.tracking)
|
||||
if (!firstPart.range.isEmpty()) {
|
||||
newTrackedChanges.push(firstPart)
|
||||
}
|
||||
// second part will be added at the end if it is a tracked insertion
|
||||
const thirdPart = new TrackedChange(thirdRange, trackedChange.tracking)
|
||||
if (!thirdPart.range.isEmpty()) {
|
||||
newTrackedChanges.push(thirdPart)
|
||||
}
|
||||
} else {
|
||||
newTrackedChanges.push(trackedChange)
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.tracking) {
|
||||
// This is a new tracked change
|
||||
const newTrackedChange = new TrackedChange(
|
||||
new Range(cursor, insertedText.length),
|
||||
opts.tracking
|
||||
)
|
||||
newTrackedChanges.push(newTrackedChange)
|
||||
}
|
||||
this._trackedChanges = newTrackedChanges
|
||||
this._mergeRanges()
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} cursor
|
||||
* @param {number} length
|
||||
*/
|
||||
applyDelete(cursor, length) {
|
||||
const newTrackedChanges = []
|
||||
for (const trackedChange of this._trackedChanges) {
|
||||
const deletedRange = new Range(cursor, length)
|
||||
// If the tracked change is after the deletion, we need to move it
|
||||
if (deletedRange.contains(trackedChange.range)) {
|
||||
continue
|
||||
} else if (deletedRange.overlaps(trackedChange.range)) {
|
||||
const newRange = trackedChange.range.subtract(deletedRange)
|
||||
if (!newRange.isEmpty()) {
|
||||
newTrackedChanges.push(
|
||||
new TrackedChange(newRange, trackedChange.tracking)
|
||||
)
|
||||
}
|
||||
} else if (trackedChange.range.startIsAfter(cursor)) {
|
||||
newTrackedChanges.push(
|
||||
new TrackedChange(
|
||||
trackedChange.range.moveBy(-length),
|
||||
trackedChange.tracking
|
||||
)
|
||||
)
|
||||
} else {
|
||||
newTrackedChanges.push(trackedChange)
|
||||
}
|
||||
}
|
||||
this._trackedChanges = newTrackedChanges
|
||||
this._mergeRanges()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} cursor
|
||||
* @param {number} length
|
||||
* @param {{tracking?: TrackingDirective}} opts
|
||||
*/
|
||||
applyRetain(cursor, length, opts = {}) {
|
||||
// If there's no tracking info, leave everything as-is
|
||||
if (!opts.tracking) {
|
||||
return
|
||||
}
|
||||
const newTrackedChanges = []
|
||||
const retainedRange = new Range(cursor, length)
|
||||
for (const trackedChange of this._trackedChanges) {
|
||||
if (retainedRange.contains(trackedChange.range)) {
|
||||
// Remove the range
|
||||
} else if (retainedRange.overlaps(trackedChange.range)) {
|
||||
if (trackedChange.range.contains(retainedRange)) {
|
||||
const [leftRange, rightRange] = trackedChange.range.splitAt(cursor)
|
||||
if (!leftRange.isEmpty()) {
|
||||
newTrackedChanges.push(
|
||||
new TrackedChange(leftRange, trackedChange.tracking)
|
||||
)
|
||||
}
|
||||
if (!rightRange.isEmpty() && rightRange.length > length) {
|
||||
newTrackedChanges.push(
|
||||
new TrackedChange(
|
||||
rightRange.moveBy(length).shrinkBy(length),
|
||||
trackedChange.tracking
|
||||
)
|
||||
)
|
||||
}
|
||||
} else if (retainedRange.start <= trackedChange.range.start) {
|
||||
// overlaps to the left
|
||||
const [, reducedRange] = trackedChange.range.splitAt(
|
||||
retainedRange.end
|
||||
)
|
||||
if (!reducedRange.isEmpty()) {
|
||||
newTrackedChanges.push(
|
||||
new TrackedChange(reducedRange, trackedChange.tracking)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// overlaps to the right
|
||||
const [reducedRange] = trackedChange.range.splitAt(cursor)
|
||||
if (!reducedRange.isEmpty()) {
|
||||
newTrackedChanges.push(
|
||||
new TrackedChange(reducedRange, trackedChange.tracking)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// keep the range
|
||||
newTrackedChanges.push(trackedChange)
|
||||
}
|
||||
}
|
||||
if (opts.tracking instanceof TrackingProps) {
|
||||
// This is a new tracked change
|
||||
const newTrackedChange = new TrackedChange(retainedRange, opts.tracking)
|
||||
newTrackedChanges.push(newTrackedChange)
|
||||
}
|
||||
this._trackedChanges = newTrackedChanges
|
||||
this._mergeRanges()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TrackedChangeList
|
@@ -0,0 +1,67 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* @import { TrackingPropsRawData, TrackingDirective } from "../types"
|
||||
*/
|
||||
|
||||
class TrackingProps {
|
||||
/**
|
||||
*
|
||||
* @param {'insert' | 'delete'} type
|
||||
* @param {string} userId
|
||||
* @param {Date} ts
|
||||
*/
|
||||
constructor(type, userId, ts) {
|
||||
/**
|
||||
* @readonly
|
||||
* @type {'insert' | 'delete'}
|
||||
*/
|
||||
this.type = type
|
||||
/**
|
||||
* @readonly
|
||||
* @type {string}
|
||||
*/
|
||||
this.userId = userId
|
||||
/**
|
||||
* @readonly
|
||||
* @type {Date}
|
||||
*/
|
||||
this.ts = ts
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TrackingPropsRawData} raw
|
||||
* @returns {TrackingProps}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new TrackingProps(raw.type, raw.userId, new Date(raw.ts))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {TrackingPropsRawData}
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
type: this.type,
|
||||
userId: this.userId,
|
||||
ts: this.ts.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TrackingDirective} [other]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
equals(other) {
|
||||
if (!(other instanceof TrackingProps)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
this.type === other.type &&
|
||||
this.userId === other.userId &&
|
||||
this.ts.getTime() === other.ts.getTime()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TrackingProps
|
Reference in New Issue
Block a user