overleaf/services/document-updater/app/js/RangesTracker.js
Eric Mc Sween 4d70bd664f Reintroduce Node 12 and metrics upgrades
These changes were previously merged, not deployed, and reverted. This
reverts the revert.

This reverts commit a6b8c6c658b33b6eee78b8b99e43308f32211ae2, reversing
changes made to 93c98921372eed4244d22fce800716cb27eca299.
2021-04-01 15:51:00 -04:00

849 lines
30 KiB
JavaScript

/* eslint-disable
camelcase,
no-return-assign,
no-undef,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
// This file is shared between document-updater and web, so that the server and client share
// an identical track changes implementation. Do not edit it directly in web or document-updater,
// instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests
const load = function () {
let RangesTracker
return (RangesTracker = class RangesTracker {
// The purpose of this class is to track a set of inserts and deletes to a document, like
// track changes in Word. We store these as a set of ShareJs style ranges:
// {i: "foo", p: 42} # Insert 'foo' at offset 42
// {d: "bar", p: 37} # Delete 'bar' at offset 37
// We only track the inserts and deletes, not the whole document, but by being given all
// updates that are applied to a document, we can update these appropriately.
//
// Note that the set of inserts and deletes we store applies to the document as-is at the moment.
// So inserts correspond to text which is in the document, while deletes correspond to text which
// is no longer there, so their lengths do not affect the position of later offsets.
// E.g.
// this is the current text of the document
// |-----| |
// {i: "current ", p:12} -^ ^- {d: "old ", p: 31}
//
// Track changes rules (should be consistent with Word):
// * When text is inserted at a delete, the text goes to the left of the delete
// I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
// * Deleting content flagged as 'inserted' does not create a new delete marker, it only
// removes the insert marker. E.g.
// * "abdefghijkl" -> "abfghijkl" when 'de' is deleted. No delete marker added
// |---| <- inserted |-| <- inserted
// * Deletes overlapping regular text and inserted text will insert a delete marker for the
// regular text:
// "abcdefghijkl" -> "abcdejkl" when 'fghi' is deleted
// |----| |--||
// ^- inserted 'bcdefg' \ ^- deleted 'hi'
// \--inserted 'bcde'
// * Deletes overlapping other deletes are merged. E.g.
// "abcghijkl" -> "ahijkl" when 'bcg is deleted'
// | <- delete 'def' | <- delete 'bcdefg'
// * Deletes by another user will consume deletes by the first user
// * Inserts by another user will not combine with inserts by the first user. If they are in the
// middle of a previous insert by the first user, the original insert will be split into two.
constructor(changes, comments) {
if (changes == null) {
changes = []
}
this.changes = changes
if (comments == null) {
comments = []
}
this.comments = comments
this.setIdSeed(RangesTracker.generateIdSeed())
this.resetDirtyState()
}
getIdSeed() {
return this.id_seed
}
setIdSeed(seed) {
this.id_seed = seed
return (this.id_increment = 0)
}
static generateIdSeed() {
// Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part
// Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js
const pid = Math.floor(Math.random() * 32767).toString(16)
const machine = Math.floor(Math.random() * 16777216).toString(16)
const timestamp = Math.floor(new Date().valueOf() / 1000).toString(16)
return (
'00000000'.substr(0, 8 - timestamp.length) +
timestamp +
'000000'.substr(0, 6 - machine.length) +
machine +
'0000'.substr(0, 4 - pid.length) +
pid
)
}
static generateId() {
return this.generateIdSeed() + '000001'
}
newId() {
this.id_increment++
const increment = this.id_increment.toString(16)
const id =
this.id_seed + '000000'.substr(0, 6 - increment.length) + increment
return id
}
getComment(comment_id) {
let comment = null
for (const c of Array.from(this.comments)) {
if (c.id === comment_id) {
comment = c
break
}
}
return comment
}
removeCommentId(comment_id) {
const comment = this.getComment(comment_id)
if (comment == null) {
return
}
this.comments = this.comments.filter((c) => c.id !== comment_id)
return this._markAsDirty(comment, 'comment', 'removed')
}
moveCommentId(comment_id, position, text) {
return (() => {
const result = []
for (const comment of Array.from(this.comments)) {
if (comment.id === comment_id) {
comment.op.p = position
comment.op.c = text
result.push(this._markAsDirty(comment, 'comment', 'moved'))
} else {
result.push(undefined)
}
}
return result
})()
}
getChange(change_id) {
let change = null
for (const c of Array.from(this.changes)) {
if (c.id === change_id) {
change = c
break
}
}
return change
}
getChanges(change_ids) {
const changes_response = []
const ids_map = {}
for (const change_id of Array.from(change_ids)) {
ids_map[change_id] = true
}
for (const change of Array.from(this.changes)) {
if (ids_map[change.id]) {
delete ids_map[change.id]
changes_response.push(change)
}
}
return changes_response
}
removeChangeId(change_id) {
const change = this.getChange(change_id)
if (change == null) {
return
}
return this._removeChange(change)
}
removeChangeIds(change_to_remove_ids) {
if (
!(change_to_remove_ids != null
? change_to_remove_ids.length
: undefined) > 0
) {
return
}
const i = this.changes.length
const remove_change_id = {}
for (const change_id of Array.from(change_to_remove_ids)) {
remove_change_id[change_id] = true
}
const remaining_changes = []
for (const change of Array.from(this.changes)) {
if (remove_change_id[change.id]) {
delete remove_change_id[change.id]
this._markAsDirty(change, 'change', 'removed')
} else {
remaining_changes.push(change)
}
}
return (this.changes = remaining_changes)
}
validate(text) {
let content
for (const change of Array.from(this.changes)) {
if (change.op.i != null) {
content = text.slice(change.op.p, change.op.p + change.op.i.length)
if (content !== change.op.i) {
throw new Error(
`Change (${JSON.stringify(
change
)}) doesn't match text (${JSON.stringify(content)})`
)
}
}
}
for (const comment of Array.from(this.comments)) {
content = text.slice(comment.op.p, comment.op.p + comment.op.c.length)
if (content !== comment.op.c) {
throw new Error(
`Comment (${JSON.stringify(
comment
)}) doesn't match text (${JSON.stringify(content)})`
)
}
}
return true
}
applyOp(op, metadata) {
if (metadata == null) {
metadata = {}
}
if (metadata.ts == null) {
metadata.ts = new Date()
}
// Apply an op that has been applied to the document to our changes to keep them up to date
if (op.i != null) {
this.applyInsertToChanges(op, metadata)
return this.applyInsertToComments(op)
} else if (op.d != null) {
this.applyDeleteToChanges(op, metadata)
return this.applyDeleteToComments(op)
} else if (op.c != null) {
return this.addComment(op, metadata)
} else {
throw new Error('unknown op type')
}
}
applyOps(ops, metadata) {
if (metadata == null) {
metadata = {}
}
return Array.from(ops).map((op) => this.applyOp(op, metadata))
}
addComment(op, metadata) {
const existing = this.getComment(op.t)
if (existing != null) {
this.moveCommentId(op.t, op.p, op.c)
return existing
} else {
let comment
this.comments.push(
(comment = {
id: op.t || this.newId(),
op: {
// Copy because we'll modify in place
c: op.c,
p: op.p,
t: op.t
},
metadata
})
)
this._markAsDirty(comment, 'comment', 'added')
return comment
}
}
applyInsertToComments(op) {
return (() => {
const result = []
for (const comment of Array.from(this.comments)) {
if (op.p <= comment.op.p) {
comment.op.p += op.i.length
result.push(this._markAsDirty(comment, 'comment', 'moved'))
} else if (op.p < comment.op.p + comment.op.c.length) {
const offset = op.p - comment.op.p
comment.op.c =
comment.op.c.slice(0, +(offset - 1) + 1 || undefined) +
op.i +
comment.op.c.slice(offset)
result.push(this._markAsDirty(comment, 'comment', 'moved'))
} else {
result.push(undefined)
}
}
return result
})()
}
applyDeleteToComments(op) {
const op_start = op.p
const op_length = op.d.length
const op_end = op.p + op_length
return (() => {
const result = []
for (const comment of Array.from(this.comments)) {
const comment_start = comment.op.p
const comment_end = comment.op.p + comment.op.c.length
const comment_length = comment_end - comment_start
if (op_end <= comment_start) {
// delete is fully before comment
comment.op.p -= op_length
result.push(this._markAsDirty(comment, 'comment', 'moved'))
} else if (op_start >= comment_end) {
// delete is fully after comment, nothing to do
} else {
// delete and comment overlap
var remaining_after, remaining_before
if (op_start <= comment_start) {
remaining_before = ''
} else {
remaining_before = comment.op.c.slice(0, op_start - comment_start)
}
if (op_end >= comment_end) {
remaining_after = ''
} else {
remaining_after = comment.op.c.slice(op_end - comment_start)
}
// Check deleted content matches delete op
const deleted_comment = comment.op.c.slice(
remaining_before.length,
comment_length - remaining_after.length
)
const offset = Math.max(0, comment_start - op_start)
const deleted_op_content = op.d
.slice(offset)
.slice(0, deleted_comment.length)
if (deleted_comment !== deleted_op_content) {
throw new Error('deleted content does not match comment content')
}
comment.op.p = Math.min(comment_start, op_start)
comment.op.c = remaining_before + remaining_after
result.push(this._markAsDirty(comment, 'comment', 'moved'))
}
}
return result
})()
}
applyInsertToChanges(op, metadata) {
let change
const op_start = op.p
const op_length = op.i.length
const op_end = op.p + op_length
const undoing = !!op.u
let already_merged = false
let previous_change = null
const moved_changes = []
const remove_changes = []
const new_changes = []
for (let i = 0; i < this.changes.length; i++) {
change = this.changes[i]
const change_start = change.op.p
if (change.op.d != null) {
// Shift any deletes after this along by the length of this insert
if (op_start < change_start) {
change.op.p += op_length
moved_changes.push(change)
} else if (op_start === change_start) {
// If we are undoing, then we want to cancel any existing delete ranges if we can.
// Check if the insert matches the start of the delete, and just remove it from the delete instead if so.
if (
undoing &&
change.op.d.length >= op.i.length &&
change.op.d.slice(0, op.i.length) === op.i
) {
change.op.d = change.op.d.slice(op.i.length)
change.op.p += op.i.length
if (change.op.d === '') {
remove_changes.push(change)
} else {
moved_changes.push(change)
}
already_merged = true
} else {
change.op.p += op_length
moved_changes.push(change)
}
}
} else if (change.op.i != null) {
var offset
const change_end = change_start + change.op.i.length
const is_change_overlapping =
op_start >= change_start && op_start <= change_end
// Only merge inserts if they are from the same user
const is_same_user = metadata.user_id === change.metadata.user_id
// If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also
// an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete.
// E.g.
// foo|<--- about to insert 'b' here
// inserted 'foo' --^ ^-- deleted 'bar'
// should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
const next_change = this.changes[i + 1]
const is_op_adjacent_to_next_delete =
next_change != null &&
next_change.op.d != null &&
op.p === change_end &&
next_change.op.p === op.p
const will_op_cancel_next_delete =
undoing &&
is_op_adjacent_to_next_delete &&
next_change.op.d.slice(0, op.i.length) === op.i
// If there is a delete at the start of the insert, and we're inserting
// at the start, we SHOULDN'T merge since the delete acts as a partition.
// The previous op will be the delete, but it's already been shifted by this insert
//
// I.e.
// Originally: |-- existing insert --|
// | <- existing delete at same offset
//
// Now: |-- existing insert --| <- not shifted yet
// |-- this insert --|| <- existing delete shifted along to end of this op
//
// After: |-- existing insert --|
// |-- this insert --|| <- existing delete
//
// Without the delete, the inserts would be merged.
const is_insert_blocked_by_delete =
previous_change != null &&
previous_change.op.d != null &&
previous_change.op.p === op_end
// If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
// then we merge them into one.
if (
this.track_changes &&
is_change_overlapping &&
!is_insert_blocked_by_delete &&
!already_merged &&
!will_op_cancel_next_delete &&
is_same_user
) {
offset = op_start - change_start
change.op.i =
change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
change.metadata.ts = metadata.ts
already_merged = true
moved_changes.push(change)
} else if (op_start <= change_start) {
// If we're fully before the other insert we can just shift the other insert by our length.
// If they are touching, and should have been merged, they will have been above.
// If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well
change.op.p += op_length
moved_changes.push(change)
} else if (
(!is_same_user || !this.track_changes) &&
change_start < op_start &&
op_start < change_end
) {
// This user is inserting inside a change by another user, so we need to split the
// other user's change into one before and after this one.
offset = op_start - change_start
const before_content = change.op.i.slice(0, offset)
const after_content = change.op.i.slice(offset)
// The existing change can become the 'before' change
change.op.i = before_content
moved_changes.push(change)
// Create a new op afterwards
const after_change = {
op: {
i: after_content,
p: change_start + offset + op_length
},
metadata: {}
}
for (const key in change.metadata) {
const value = change.metadata[key]
after_change.metadata[key] = value
}
new_changes.push(after_change)
}
}
previous_change = change
}
if (this.track_changes && !already_merged) {
this._addOp(op, metadata)
}
for ({ op, metadata } of Array.from(new_changes)) {
this._addOp(op, metadata)
}
for (change of Array.from(remove_changes)) {
this._removeChange(change)
}
return (() => {
const result = []
for (change of Array.from(moved_changes)) {
result.push(this._markAsDirty(change, 'change', 'moved'))
}
return result
})()
}
applyDeleteToChanges(op, metadata) {
let change
const op_start = op.p
const op_length = op.d.length
const op_end = op.p + op_length
const remove_changes = []
let moved_changes = []
// We might end up modifying our delete op if it merges with existing deletes, or cancels out
// with an existing insert. Since we might do multiple modifications, we record them and do
// all the modifications after looping through the existing changes, so as not to mess up the
// offset indexes as we go.
const op_modifications = []
for (change of Array.from(this.changes)) {
var change_start
if (change.op.i != null) {
change_start = change.op.p
const change_end = change_start + change.op.i.length
if (op_end <= change_start) {
// Shift ops after us back by our length
change.op.p -= op_length
moved_changes.push(change)
} else if (op_start >= change_end) {
// Delete is after insert, nothing to do
} else {
// When the new delete overlaps an insert, we should remove the part of the insert that
// is now deleted, and also remove the part of the new delete that overlapped. I.e.
// the two cancel out where they overlap.
var delete_remaining_after,
delete_remaining_before,
insert_remaining_after,
insert_remaining_before
if (op_start >= change_start) {
// |-- existing insert --|
// insert_remaining_before -> |.....||-- new delete --|
delete_remaining_before = ''
insert_remaining_before = change.op.i.slice(
0,
op_start - change_start
)
} else {
// delete_remaining_before -> |.....||-- existing insert --|
// |-- new delete --|
delete_remaining_before = op.d.slice(0, change_start - op_start)
insert_remaining_before = ''
}
if (op_end <= change_end) {
// |-- existing insert --|
// |-- new delete --||.....| <- insert_remaining_after
delete_remaining_after = ''
insert_remaining_after = change.op.i.slice(op_end - change_start)
} else {
// |-- existing insert --||.....| <- delete_remaining_after
// |-- new delete --|
delete_remaining_after = op.d.slice(change_end - op_start)
insert_remaining_after = ''
}
const insert_remaining =
insert_remaining_before + insert_remaining_after
if (insert_remaining.length > 0) {
change.op.i = insert_remaining
change.op.p = Math.min(change_start, op_start)
change.metadata.ts = metadata.ts
moved_changes.push(change)
} else {
remove_changes.push(change)
}
// We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
// afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the
// chunk in the middle not covered by these.
const delete_removed_length =
op.d.length -
delete_remaining_before.length -
delete_remaining_after.length
const delete_removed_start = delete_remaining_before.length
const modification = {
d: op.d.slice(
delete_removed_start,
delete_removed_start + delete_removed_length
),
p: delete_removed_start
}
if (modification.d.length > 0) {
op_modifications.push(modification)
}
}
} else if (change.op.d != null) {
change_start = change.op.p
if (
op_end < change_start ||
(!this.track_changes && op_end === change_start)
) {
// Shift ops after us back by our length.
// If we're tracking changes, it must be strictly before, since we'll merge
// below if they are touching. Otherwise, touching is fine.
change.op.p -= op_length
moved_changes.push(change)
} else if (op_start <= change_start && change_start <= op_end) {
if (this.track_changes) {
// If we overlap a delete, add it in our content, and delete the existing change.
// It's easier to do it this way, rather than modifying the existing delete in case
// we overlap many deletes and we'd need to track that. We have a workaround to
// update the delete in place if possible below.
const offset = change_start - op_start
op_modifications.push({ i: change.op.d, p: offset })
remove_changes.push(change)
} else {
change.op.p = op_start
moved_changes.push(change)
}
}
}
}
// Copy rather than modify because we still need to apply it to comments
op = {
p: op.p,
d: this._applyOpModifications(op.d, op_modifications)
}
for (change of Array.from(remove_changes)) {
// This is a bit of hack to avoid removing one delete and replacing it with another.
// If we don't do this, it causes the UI to flicker
if (
op.d.length > 0 &&
change.op.d != null &&
op.p <= change.op.p &&
change.op.p <= op.p + op.d.length
) {
change.op.p = op.p
change.op.d = op.d
change.metadata = metadata
moved_changes.push(change)
op.d = '' // stop it being added
} else {
this._removeChange(change)
}
}
if (this.track_changes && op.d.length > 0) {
this._addOp(op, metadata)
} else {
// It's possible that we deleted an insert between two other inserts. I.e.
// If we delete 'user_2 insert' in:
// |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
// it becomes:
// |-- user_1 insert --||-- user_1 insert --|
// We need to merge these together again
const results = this._scanAndMergeAdjacentUpdates()
moved_changes = moved_changes.concat(results.moved_changes)
for (change of Array.from(results.remove_changes)) {
this._removeChange(change)
moved_changes = moved_changes.filter((c) => c !== change)
}
}
return (() => {
const result = []
for (change of Array.from(moved_changes)) {
result.push(this._markAsDirty(change, 'change', 'moved'))
}
return result
})()
}
_addOp(op, metadata) {
const change = {
id: this.newId(),
op: this._clone(op), // Don't take a reference to the existing op since we'll modify this in place with future changes
metadata: this._clone(metadata)
}
this.changes.push(change)
// Keep ops in order of offset, with deletes before inserts
this.changes.sort(function (c1, c2) {
const result = c1.op.p - c2.op.p
if (result !== 0) {
return result
} else if (c1.op.i != null && c2.op.d != null) {
return 1
} else if (c1.op.d != null && c2.op.i != null) {
return -1
} else {
return 0
}
})
return this._markAsDirty(change, 'change', 'added')
}
_removeChange(change) {
this.changes = this.changes.filter((c) => c.id !== change.id)
return this._markAsDirty(change, 'change', 'removed')
}
_applyOpModifications(content, op_modifications) {
// Put in descending position order, with deleting first if at the same offset
// (Inserting first would modify the content that the delete will delete)
op_modifications.sort(function (a, b) {
const result = b.p - a.p
if (result !== 0) {
return result
} else if (a.i != null && b.d != null) {
return 1
} else if (a.d != null && b.i != null) {
return -1
} else {
return 0
}
})
for (const modification of Array.from(op_modifications)) {
if (modification.i != null) {
content =
content.slice(0, modification.p) +
modification.i +
content.slice(modification.p)
} else if (modification.d != null) {
if (
content.slice(
modification.p,
modification.p + modification.d.length
) !== modification.d
) {
throw new Error(
`deleted content does not match. content: ${JSON.stringify(
content
)}; modification: ${JSON.stringify(modification)}`
)
}
content =
content.slice(0, modification.p) +
content.slice(modification.p + modification.d.length)
}
}
return content
}
_scanAndMergeAdjacentUpdates() {
// This should only need calling when deleting an update between two
// other updates. There's no other way to get two adjacent updates from the
// same user, since they would be merged on insert.
let previous_change = null
const remove_changes = []
const moved_changes = []
for (const change of Array.from(this.changes)) {
if (
(previous_change != null ? previous_change.op.i : undefined) !=
null &&
change.op.i != null
) {
const previous_change_end =
previous_change.op.p + previous_change.op.i.length
const previous_change_user_id = previous_change.metadata.user_id
const change_start = change.op.p
const change_user_id = change.metadata.user_id
if (
previous_change_end === change_start &&
previous_change_user_id === change_user_id
) {
remove_changes.push(change)
previous_change.op.i += change.op.i
moved_changes.push(previous_change)
}
} else if (
(previous_change != null ? previous_change.op.d : undefined) !=
null &&
change.op.d != null &&
previous_change.op.p === change.op.p
) {
// Merge adjacent deletes
previous_change.op.d += change.op.d
remove_changes.push(change)
moved_changes.push(previous_change)
} else {
// Only update to the current change if we haven't removed it.
previous_change = change
}
}
return { moved_changes, remove_changes }
}
resetDirtyState() {
return (this._dirtyState = {
comment: {
moved: {},
removed: {},
added: {}
},
change: {
moved: {},
removed: {},
added: {}
}
})
}
getDirtyState() {
return this._dirtyState
}
_markAsDirty(object, type, action) {
return (this._dirtyState[type][action][object.id] = object)
}
_clone(object) {
const clone = {}
for (const k in object) {
const v = object[k]
clone[k] = v
}
return clone
}
})
}
if (typeof define !== 'undefined' && define !== null) {
define([], load)
} else {
module.exports = load()
}