UpdateCompressor.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. // @ts-check
  2. import OError from '@overleaf/o-error'
  3. import DMP from 'diff-match-patch'
  4. /**
  5. * @import { DeleteOp, InsertOp, Op, Update } from './types'
  6. */
  7. const MAX_TIME_BETWEEN_UPDATES = 60 * 1000 // one minute
  8. const MAX_UPDATE_SIZE = 2 * 1024 * 1024 // 2 MB
  9. const ADDED = 1
  10. const REMOVED = -1
  11. const UNCHANGED = 0
  12. const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos)
  13. const strRemove = (s1, pos, length) => s1.slice(0, pos) + s1.slice(pos + length)
  14. const dmp = new DMP()
  15. dmp.Diff_Timeout = 0.1 // prevent the diff algorithm from searching too hard for changes in unrelated content
  16. const cloneWithOp = function (update, op) {
  17. // to improve performance, shallow clone the update
  18. // and its meta property (also an object), then
  19. // overwrite the op property directly.
  20. update = Object.assign({}, update)
  21. update.meta = Object.assign({}, update.meta)
  22. update.op = op
  23. return update
  24. }
  25. const mergeUpdatesWithOp = function (firstUpdate, secondUpdate, op) {
  26. // We want to take doc_length and ts from the firstUpdate, v and doc_hash from the second
  27. const update = cloneWithOp(firstUpdate, op)
  28. if (secondUpdate.v != null) {
  29. update.v = secondUpdate.v
  30. }
  31. if (secondUpdate.meta.doc_hash != null) {
  32. update.meta.doc_hash = secondUpdate.meta.doc_hash
  33. } else {
  34. delete update.meta.doc_hash
  35. }
  36. return update
  37. }
  38. /**
  39. * Adjust the given length to account for the given op
  40. *
  41. * The resulting length is the new length of the doc after the op is applied.
  42. *
  43. * @param {number} length
  44. * @param {Op} op
  45. * @param {object} opts
  46. * @param {boolean} [opts.tracked] - whether or not the update is a tracked change
  47. * @returns {number} the adjusted length
  48. */
  49. function adjustLengthByOp(length, op, opts = {}) {
  50. if ('i' in op && op.i != null) {
  51. if (op.trackedDeleteRejection) {
  52. // Tracked delete rejection: will be translated into a retain
  53. return length
  54. } else {
  55. return length + op.i.length
  56. }
  57. } else if ('d' in op && op.d != null) {
  58. if (opts.tracked) {
  59. // Tracked delete: will be translated into a retain, except where it overlaps tracked inserts.
  60. for (const change of op.trackedChanges ?? []) {
  61. if (change.type === 'insert') {
  62. length -= change.length
  63. }
  64. }
  65. return length
  66. } else {
  67. return length - op.d.length
  68. }
  69. } else if ('r' in op && op.r != null) {
  70. return length
  71. } else if ('c' in op && op.c != null) {
  72. return length
  73. } else {
  74. throw new OError('unexpected op type')
  75. }
  76. }
  77. /**
  78. * Updates come from the doc updater in format
  79. * {
  80. * op: [ { ... op1 ... }, { ... op2 ... } ]
  81. * meta: { ts: ..., user_id: ... }
  82. * }
  83. * but it's easier to work with on op per update, so convert these updates to
  84. * our compressed format
  85. * [{
  86. * op: op1
  87. * meta: { ts: ..., user_id: ... }
  88. * }, {
  89. * op: op2
  90. * meta: { ts: ..., user_id: ... }
  91. * }]
  92. *
  93. * @param {Update[]} updates
  94. * @returns {Update[]} single op updates
  95. */
  96. export function convertToSingleOpUpdates(updates) {
  97. const splitUpdates = []
  98. for (const update of updates) {
  99. if (!('op' in update)) {
  100. // Not a text op, likely a project strucure op
  101. splitUpdates.push(update)
  102. continue
  103. }
  104. const ops = update.op
  105. let docLength = update.meta.history_doc_length ?? update.meta.doc_length
  106. // Temporary fix for document-updater sending a length of -1 for empty
  107. // documents. This can be removed after all queues have been flushed.
  108. if (docLength === -1) {
  109. docLength = 0
  110. }
  111. const docHash = update.meta.doc_hash
  112. for (const op of ops) {
  113. const splitUpdate = cloneWithOp(update, op)
  114. // Only the last update will keep the doc_hash property
  115. delete splitUpdate.meta.doc_hash
  116. if (docLength != null) {
  117. splitUpdate.meta.doc_length = docLength
  118. docLength = adjustLengthByOp(docLength, op, {
  119. tracked: update.meta.tc != null,
  120. })
  121. delete splitUpdate.meta.history_doc_length
  122. }
  123. splitUpdates.push(splitUpdate)
  124. }
  125. if (docHash != null && splitUpdates.length > 0) {
  126. splitUpdates[splitUpdates.length - 1].meta.doc_hash = docHash
  127. }
  128. }
  129. return splitUpdates
  130. }
  131. export function filterBlankUpdates(updates) {
  132. // Diffing an insert and delete can return blank inserts and deletes
  133. // which the OL history service doesn't have an equivalent for.
  134. //
  135. // NOTE: this relies on the updates only containing either op.i or op.d entries
  136. // but not both, which is the case because diffAsShareJsOps does this
  137. return updates.filter(
  138. update => !(update.op && (update.op.i === '' || update.op.d === ''))
  139. )
  140. }
  141. export function concatUpdatesWithSameVersion(updates) {
  142. const concattedUpdates = []
  143. for (let update of updates) {
  144. if (update.op != null) {
  145. update = cloneWithOp(update, [update.op])
  146. const lastUpdate = concattedUpdates[concattedUpdates.length - 1]
  147. if (
  148. lastUpdate != null &&
  149. lastUpdate.op != null &&
  150. lastUpdate.v === update.v &&
  151. lastUpdate.doc === update.doc &&
  152. lastUpdate.pathname === update.pathname
  153. ) {
  154. lastUpdate.op = lastUpdate.op.concat(update.op)
  155. if (update.meta.doc_hash == null) {
  156. delete lastUpdate.meta.doc_hash
  157. } else {
  158. lastUpdate.meta.doc_hash = update.meta.doc_hash
  159. }
  160. } else {
  161. concattedUpdates.push(update)
  162. }
  163. } else {
  164. concattedUpdates.push(update)
  165. }
  166. }
  167. return concattedUpdates
  168. }
  169. export function compressRawUpdates(rawUpdates) {
  170. let updates = convertToSingleOpUpdates(rawUpdates)
  171. updates = compressUpdates(updates)
  172. updates = filterBlankUpdates(updates)
  173. updates = concatUpdatesWithSameVersion(updates)
  174. return updates
  175. }
  176. export function compressUpdates(updates) {
  177. if (updates.length === 0) {
  178. return []
  179. }
  180. let compressedUpdates = [updates.shift()]
  181. for (const update of updates) {
  182. const lastCompressedUpdate = compressedUpdates.pop()
  183. if (lastCompressedUpdate != null) {
  184. const newCompressedUpdates = _concatTwoUpdates(
  185. lastCompressedUpdate,
  186. update
  187. )
  188. compressedUpdates = compressedUpdates.concat(newCompressedUpdates)
  189. } else {
  190. compressedUpdates.push(update)
  191. }
  192. }
  193. return compressedUpdates
  194. }
  195. /**
  196. * If possible, merge two updates into a single update that has the same effect.
  197. *
  198. * It's useful to do some of this work at this point while we're dealing with
  199. * document-updater updates. The deletes, in particular include the deleted
  200. * text. This allows us to find pieces of inserts and deletes that cancel each
  201. * other out because they insert/delete the exact same text. This compression
  202. * makes the diff smaller.
  203. */
  204. function _concatTwoUpdates(firstUpdate, secondUpdate) {
  205. // Previously we cloned firstUpdate and secondUpdate at this point but we
  206. // can skip this step because whenever they are returned with
  207. // modification there is always a clone at that point via
  208. // mergeUpdatesWithOp.
  209. if (firstUpdate.op == null || secondUpdate.op == null) {
  210. // Project structure ops
  211. return [firstUpdate, secondUpdate]
  212. }
  213. if (
  214. firstUpdate.doc !== secondUpdate.doc ||
  215. firstUpdate.pathname !== secondUpdate.pathname
  216. ) {
  217. return [firstUpdate, secondUpdate]
  218. }
  219. if (firstUpdate.meta.user_id !== secondUpdate.meta.user_id) {
  220. return [firstUpdate, secondUpdate]
  221. }
  222. if (
  223. (firstUpdate.meta.type === 'external' &&
  224. secondUpdate.meta.type !== 'external') ||
  225. (firstUpdate.meta.type !== 'external' &&
  226. secondUpdate.meta.type === 'external') ||
  227. (firstUpdate.meta.type === 'external' &&
  228. secondUpdate.meta.type === 'external' &&
  229. firstUpdate.meta.source !== secondUpdate.meta.source)
  230. ) {
  231. return [firstUpdate, secondUpdate]
  232. }
  233. if (secondUpdate.meta.ts - firstUpdate.meta.ts > MAX_TIME_BETWEEN_UPDATES) {
  234. return [firstUpdate, secondUpdate]
  235. }
  236. if (
  237. (firstUpdate.meta.tc == null && secondUpdate.meta.tc != null) ||
  238. (firstUpdate.meta.tc != null && secondUpdate.meta.tc == null)
  239. ) {
  240. // One update is tracking changes and the other isn't. Tracking changes
  241. // results in different behaviour in the history, so we need to keep these
  242. // two updates separate.
  243. return [firstUpdate, secondUpdate]
  244. }
  245. if (Boolean(firstUpdate.op.u) !== Boolean(secondUpdate.op.u)) {
  246. // One update is an undo and the other isn't. If we were to merge the two
  247. // updates, we would have to choose one value for the flag, which would be
  248. // partially incorrect. Moreover, a tracked delete that is also an undo is
  249. // treated as a tracked insert rejection by the history, so these updates
  250. // need to be well separated.
  251. return [firstUpdate, secondUpdate]
  252. }
  253. if (
  254. firstUpdate.op.trackedDeleteRejection ||
  255. secondUpdate.op.trackedDeleteRejection
  256. ) {
  257. // Do not merge tracked delete rejections. Each tracked delete rejection is
  258. // a separate operation.
  259. return [firstUpdate, secondUpdate]
  260. }
  261. if (
  262. firstUpdate.op.trackedChanges != null ||
  263. secondUpdate.op.trackedChanges != null
  264. ) {
  265. // Do not merge ops that span tracked changes.
  266. // TODO: This could theoretically be handled, but it would be complex. One
  267. // would need to take tracked deletes into account when merging inserts and
  268. // deletes together.
  269. return [firstUpdate, secondUpdate]
  270. }
  271. const firstOp = firstUpdate.op
  272. const secondOp = secondUpdate.op
  273. const firstSize =
  274. (firstOp.i && firstOp.i.length) || (firstOp.d && firstOp.d.length)
  275. const secondSize =
  276. (secondOp.i && secondOp.i.length) || (secondOp.d && secondOp.d.length)
  277. const firstOpInsideSecondOp =
  278. secondOp.p <= firstOp.p && firstOp.p <= secondOp.p + secondSize
  279. const secondOpInsideFirstOp =
  280. firstOp.p <= secondOp.p && secondOp.p <= firstOp.p + firstSize
  281. const combinedLengthUnderLimit = firstSize + secondSize < MAX_UPDATE_SIZE
  282. // Two inserts
  283. if (
  284. firstOp.i != null &&
  285. secondOp.i != null &&
  286. secondOpInsideFirstOp &&
  287. combinedLengthUnderLimit &&
  288. insertOpsInsideSameComments(firstOp, secondOp)
  289. ) {
  290. return [
  291. mergeUpdatesWithOp(firstUpdate, secondUpdate, {
  292. ...firstOp,
  293. i: strInject(firstOp.i, secondOp.p - firstOp.p, secondOp.i),
  294. }),
  295. ]
  296. }
  297. // Two deletes
  298. if (
  299. firstOp.d != null &&
  300. secondOp.d != null &&
  301. firstOpInsideSecondOp &&
  302. combinedLengthUnderLimit &&
  303. firstUpdate.meta.tc == null &&
  304. secondUpdate.meta.tc == null
  305. ) {
  306. return [
  307. mergeUpdatesWithOp(firstUpdate, secondUpdate, {
  308. ...secondOp,
  309. d: strInject(secondOp.d, firstOp.p - secondOp.p, firstOp.d),
  310. }),
  311. ]
  312. }
  313. // An insert and then a delete
  314. if (
  315. firstOp.i != null &&
  316. secondOp.d != null &&
  317. secondOpInsideFirstOp &&
  318. firstUpdate.meta.tc == null &&
  319. secondUpdate.meta.tc == null
  320. ) {
  321. const offset = secondOp.p - firstOp.p
  322. const insertedText = firstOp.i.slice(offset, offset + secondOp.d.length)
  323. // Only trim the insert when the delete is fully contained within in it
  324. if (insertedText === secondOp.d) {
  325. const insert = strRemove(firstOp.i, offset, secondOp.d.length)
  326. if (insert === '') {
  327. return []
  328. } else {
  329. return [
  330. mergeUpdatesWithOp(firstUpdate, secondUpdate, {
  331. ...firstOp,
  332. i: insert,
  333. }),
  334. ]
  335. }
  336. } else {
  337. // This will only happen if the delete extends outside the insert
  338. return [firstUpdate, secondUpdate]
  339. }
  340. }
  341. // A delete then an insert at the same place, likely a copy-paste of a chunk of content
  342. if (
  343. firstOp.d != null &&
  344. secondOp.i != null &&
  345. firstOp.p === secondOp.p &&
  346. firstUpdate.meta.tc == null &&
  347. secondUpdate.meta.tc == null
  348. ) {
  349. const offset = firstOp.p
  350. const hoffset = firstOp.hpos
  351. const diffUpdates = diffAsShareJsOps(firstOp.d, secondOp.i).map(
  352. function (op) {
  353. // diffAsShareJsOps() returns ops with positions relative to the position
  354. // of the copy/paste. We need to adjust these positions so that they
  355. // apply to the whole document instead.
  356. const pos = op.p
  357. op.p = pos + offset
  358. if (hoffset != null) {
  359. op.hpos = pos + hoffset
  360. }
  361. if (firstOp.u && secondOp.u) {
  362. op.u = true
  363. }
  364. if ('i' in op && secondOp.commentIds != null) {
  365. // Make sure that commentIds metadata is propagated to inserts
  366. op.commentIds = secondOp.commentIds
  367. }
  368. return mergeUpdatesWithOp(firstUpdate, secondUpdate, op)
  369. }
  370. )
  371. // Doing a diff like this loses track of the doc lengths for each
  372. // update, so recalculate them
  373. let docLength =
  374. firstUpdate.meta.history_doc_length ?? firstUpdate.meta.doc_length
  375. for (const update of diffUpdates) {
  376. update.meta.doc_length = docLength
  377. docLength = adjustLengthByOp(docLength, update.op, {
  378. tracked: update.meta.tc != null,
  379. })
  380. delete update.meta.history_doc_length
  381. }
  382. return diffUpdates
  383. }
  384. return [firstUpdate, secondUpdate]
  385. }
  386. /**
  387. * Return the diff between two strings
  388. *
  389. * @param {string} before
  390. * @param {string} after
  391. * @returns {(InsertOp | DeleteOp)[]} the ops that generate that diff
  392. */
  393. export function diffAsShareJsOps(before, after) {
  394. const diffs = dmp.diff_main(before, after)
  395. dmp.diff_cleanupSemantic(diffs)
  396. const ops = []
  397. let position = 0
  398. for (const diff of diffs) {
  399. const type = diff[0]
  400. const content = diff[1]
  401. if (type === ADDED) {
  402. ops.push({
  403. i: content,
  404. p: position,
  405. })
  406. position += content.length
  407. } else if (type === REMOVED) {
  408. ops.push({
  409. d: content,
  410. p: position,
  411. })
  412. } else if (type === UNCHANGED) {
  413. position += content.length
  414. } else {
  415. throw new Error('Unknown type')
  416. }
  417. }
  418. return ops
  419. }
  420. /**
  421. * Checks if two insert ops are inside the same comments
  422. *
  423. * @param {InsertOp} op1
  424. * @param {InsertOp} op2
  425. * @returns {boolean}
  426. */
  427. function insertOpsInsideSameComments(op1, op2) {
  428. const commentIds1 = op1.commentIds
  429. const commentIds2 = op2.commentIds
  430. if (commentIds1 == null && commentIds2 == null) {
  431. // None are inside comments
  432. return true
  433. }
  434. if (
  435. commentIds1 != null &&
  436. commentIds2 != null &&
  437. commentIds1.every(id => commentIds2.includes(id)) &&
  438. commentIds2.every(id => commentIds1.includes(id))
  439. ) {
  440. // Both are inside the same comments
  441. return true
  442. }
  443. return false
  444. }