first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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')

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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