overleaf/services/track-changes/app/js/UpdateCompressor.js
2021-07-13 12:04:43 +01:00

340 lines
9.7 KiB
JavaScript

/* eslint-disable
camelcase,
handle-callback-err,
new-cap,
no-throw-literal,
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
* DS103: Rewrite code to no longer use __guard__
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
let oneMinute, twoMegabytes, UpdateCompressor
const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos)
const strRemove = (s1, pos, length) => s1.slice(0, pos) + s1.slice(pos + length)
const { diff_match_patch } = require('../lib/diff_match_patch')
const dmp = new diff_match_patch()
module.exports = UpdateCompressor = {
NOOP: 'noop',
// Updates come from the doc updater in format
// {
// op: [ { ... op1 ... }, { ... op2 ... } ]
// meta: { ts: ..., user_id: ... }
// }
// but it's easier to work with on op per update, so convert these updates to
// our compressed format
// [{
// op: op1
// meta: { start_ts: ... , end_ts: ..., user_id: ... }
// }, {
// op: op2
// meta: { start_ts: ... , end_ts: ..., user_id: ... }
// }]
convertToSingleOpUpdates(updates) {
const splitUpdates = []
for (const update of Array.from(updates)) {
// Reject any non-insert or delete ops, i.e. comments
const ops = update.op.filter(o => o.i != null || o.d != null)
if (ops.length === 0) {
splitUpdates.push({
op: UpdateCompressor.NOOP,
meta: {
start_ts: update.meta.start_ts || update.meta.ts,
end_ts: update.meta.end_ts || update.meta.ts,
user_id: update.meta.user_id,
},
v: update.v,
})
} else {
for (const op of Array.from(ops)) {
splitUpdates.push({
op,
meta: {
start_ts: update.meta.start_ts || update.meta.ts,
end_ts: update.meta.end_ts || update.meta.ts,
user_id: update.meta.user_id,
},
v: update.v,
})
}
}
}
return splitUpdates
},
concatUpdatesWithSameVersion(updates) {
const concattedUpdates = []
for (const update of Array.from(updates)) {
const lastUpdate = concattedUpdates[concattedUpdates.length - 1]
if (lastUpdate != null && lastUpdate.v === update.v) {
if (update.op !== UpdateCompressor.NOOP) {
lastUpdate.op.push(update.op)
}
} else {
const nextUpdate = {
op: [],
meta: update.meta,
v: update.v,
}
if (update.op !== UpdateCompressor.NOOP) {
nextUpdate.op.push(update.op)
}
concattedUpdates.push(nextUpdate)
}
}
return concattedUpdates
},
compressRawUpdates(lastPreviousUpdate, rawUpdates) {
if (
__guard__(
lastPreviousUpdate != null ? lastPreviousUpdate.op : undefined,
x => x.length
) > 1
) {
// if the last previous update was an array op, don't compress onto it.
// The avoids cases where array length changes but version number doesn't
return [lastPreviousUpdate].concat(
UpdateCompressor.compressRawUpdates(null, rawUpdates)
)
}
if (lastPreviousUpdate != null) {
rawUpdates = [lastPreviousUpdate].concat(rawUpdates)
}
let updates = UpdateCompressor.convertToSingleOpUpdates(rawUpdates)
updates = UpdateCompressor.compressUpdates(updates)
return UpdateCompressor.concatUpdatesWithSameVersion(updates)
},
compressUpdates(updates) {
if (updates.length === 0) {
return []
}
let compressedUpdates = [updates.shift()]
for (const update of Array.from(updates)) {
const lastCompressedUpdate = compressedUpdates.pop()
if (lastCompressedUpdate != null) {
compressedUpdates = compressedUpdates.concat(
UpdateCompressor._concatTwoUpdates(lastCompressedUpdate, update)
)
} else {
compressedUpdates.push(update)
}
}
return compressedUpdates
},
MAX_TIME_BETWEEN_UPDATES: (oneMinute = 60 * 1000),
MAX_UPDATE_SIZE: (twoMegabytes = 2 * 1024 * 1024),
_concatTwoUpdates(firstUpdate, secondUpdate) {
let offset
firstUpdate = {
op: firstUpdate.op,
meta: {
user_id: firstUpdate.meta.user_id || null,
start_ts: firstUpdate.meta.start_ts || firstUpdate.meta.ts,
end_ts: firstUpdate.meta.end_ts || firstUpdate.meta.ts,
},
v: firstUpdate.v,
}
secondUpdate = {
op: secondUpdate.op,
meta: {
user_id: secondUpdate.meta.user_id || null,
start_ts: secondUpdate.meta.start_ts || secondUpdate.meta.ts,
end_ts: secondUpdate.meta.end_ts || secondUpdate.meta.ts,
},
v: secondUpdate.v,
}
if (firstUpdate.meta.user_id !== secondUpdate.meta.user_id) {
return [firstUpdate, secondUpdate]
}
if (
secondUpdate.meta.start_ts - firstUpdate.meta.end_ts >
UpdateCompressor.MAX_TIME_BETWEEN_UPDATES
) {
return [firstUpdate, secondUpdate]
}
const firstOp = firstUpdate.op
const secondOp = secondUpdate.op
const firstSize =
(firstOp.i != null ? firstOp.i.length : undefined) ||
(firstOp.d != null ? firstOp.d.length : undefined)
const secondSize =
(secondOp.i != null ? secondOp.i.length : undefined) ||
(secondOp.d != null ? secondOp.d.length : undefined)
// Two inserts
if (
firstOp.i != null &&
secondOp.i != null &&
firstOp.p <= secondOp.p &&
secondOp.p <= firstOp.p + firstOp.i.length &&
firstSize + secondSize < UpdateCompressor.MAX_UPDATE_SIZE
) {
return [
{
meta: {
start_ts: firstUpdate.meta.start_ts,
end_ts: secondUpdate.meta.end_ts,
user_id: firstUpdate.meta.user_id,
},
op: {
p: firstOp.p,
i: strInject(firstOp.i, secondOp.p - firstOp.p, secondOp.i),
},
v: secondUpdate.v,
},
]
// Two deletes
} else if (
firstOp.d != null &&
secondOp.d != null &&
secondOp.p <= firstOp.p &&
firstOp.p <= secondOp.p + secondOp.d.length &&
firstSize + secondSize < UpdateCompressor.MAX_UPDATE_SIZE
) {
return [
{
meta: {
start_ts: firstUpdate.meta.start_ts,
end_ts: secondUpdate.meta.end_ts,
user_id: firstUpdate.meta.user_id,
},
op: {
p: secondOp.p,
d: strInject(secondOp.d, firstOp.p - secondOp.p, firstOp.d),
},
v: secondUpdate.v,
},
]
// An insert and then a delete
} else if (
firstOp.i != null &&
secondOp.d != null &&
firstOp.p <= secondOp.p &&
secondOp.p <= firstOp.p + firstOp.i.length
) {
offset = secondOp.p - firstOp.p
const insertedText = firstOp.i.slice(offset, offset + secondOp.d.length)
// Only trim the insert when the delete is fully contained within in it
if (insertedText === secondOp.d) {
const insert = strRemove(firstOp.i, offset, secondOp.d.length)
return [
{
meta: {
start_ts: firstUpdate.meta.start_ts,
end_ts: secondUpdate.meta.end_ts,
user_id: firstUpdate.meta.user_id,
},
op: {
p: firstOp.p,
i: insert,
},
v: secondUpdate.v,
},
]
} else {
// This will only happen if the delete extends outside the insert
return [firstUpdate, secondUpdate]
}
// A delete then an insert at the same place, likely a copy-paste of a chunk of content
} else if (
firstOp.d != null &&
secondOp.i != null &&
firstOp.p === secondOp.p
) {
offset = firstOp.p
const diff_ops = this.diffAsShareJsOps(firstOp.d, secondOp.i)
if (diff_ops.length === 0) {
return [
{
// Noop
meta: {
start_ts: firstUpdate.meta.start_ts,
end_ts: secondUpdate.meta.end_ts,
user_id: firstUpdate.meta.user_id,
},
op: {
p: firstOp.p,
i: '',
},
v: secondUpdate.v,
},
]
} else {
return diff_ops.map(function (op) {
op.p += offset
return {
meta: {
start_ts: firstUpdate.meta.start_ts,
end_ts: secondUpdate.meta.end_ts,
user_id: firstUpdate.meta.user_id,
},
op,
v: secondUpdate.v,
}
})
}
} else {
return [firstUpdate, secondUpdate]
}
},
ADDED: 1,
REMOVED: -1,
UNCHANGED: 0,
diffAsShareJsOps(before, after, callback) {
if (callback == null) {
callback = function (error, ops) {}
}
const diffs = dmp.diff_main(before, after)
dmp.diff_cleanupSemantic(diffs)
const ops = []
let position = 0
for (const diff of Array.from(diffs)) {
const type = diff[0]
const content = diff[1]
if (type === this.ADDED) {
ops.push({
i: content,
p: position,
})
position += content.length
} else if (type === this.REMOVED) {
ops.push({
d: content,
p: position,
})
} else if (type === this.UNCHANGED) {
position += content.length
} else {
throw 'Unknown type'
}
}
return ops
},
}
function __guard__(value, transform) {
return typeof value !== 'undefined' && value !== null
? transform(value)
: undefined
}