RangesManager.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. // @ts-check
  2. const RangesTracker = require('@overleaf/ranges-tracker')
  3. const logger = require('@overleaf/logger')
  4. const OError = require('@overleaf/o-error')
  5. const Metrics = require('./Metrics')
  6. const _ = require('lodash')
  7. const { isInsert, isDelete, isComment, getDocLength } = require('./Utils')
  8. /**
  9. * @import { Comment, CommentOp, InsertOp, DeleteOp, HistoryOp, Op } from './types'
  10. * @import { HistoryCommentOp, HistoryDeleteOp, HistoryInsertOp, HistoryRetainOp } from './types'
  11. * @import { HistoryDeleteTrackedChange, HistoryUpdate, Ranges, TrackedChange, Update } from './types'
  12. */
  13. const RANGE_DELTA_BUCKETS = [0, 1, 2, 3, 4, 5, 10, 20, 50]
  14. const RangesManager = {
  15. MAX_COMMENTS: 500,
  16. MAX_CHANGES: 2000,
  17. /**
  18. * Apply an update to the given doc (lines and ranges) and return new ranges
  19. *
  20. * @param {string} projectId
  21. * @param {string} docId
  22. * @param {Ranges} ranges - ranges before the updates were applied
  23. * @param {Update[]} updates
  24. * @param {string[]} newDocLines - the document lines after the updates were applied
  25. * @param {object} opts
  26. * @param {boolean} [opts.historyRangesSupport] - whether history ranges support is enabled
  27. * @returns {{ newRanges: Ranges, rangesWereCollapsed: boolean, historyUpdates: HistoryUpdate[] }}
  28. */
  29. applyUpdate(projectId, docId, ranges, updates, newDocLines, opts = {}) {
  30. if (ranges == null) {
  31. ranges = {}
  32. }
  33. if (updates == null) {
  34. updates = []
  35. }
  36. const { changes, comments } = _.cloneDeep(ranges)
  37. const rangesTracker = new RangesTracker(changes, comments)
  38. const [emptyRangeCountBefore, totalRangeCountBefore] =
  39. RangesManager._emptyRangesCount(rangesTracker)
  40. const historyUpdates = []
  41. for (const update of updates) {
  42. const trackingChanges = Boolean(update.meta?.tc)
  43. rangesTracker.track_changes = trackingChanges
  44. if (update.meta?.tc) {
  45. rangesTracker.setIdSeed(update.meta.tc)
  46. }
  47. const historyOps = []
  48. for (const op of update.op) {
  49. let croppedCommentOps = []
  50. if (opts.historyRangesSupport) {
  51. historyOps.push(
  52. getHistoryOp(op, rangesTracker.comments, rangesTracker.changes)
  53. )
  54. if (isDelete(op) && trackingChanges) {
  55. // If a tracked delete overlaps a comment, the comment must be
  56. // cropped. The extent of the cropping is calculated before the
  57. // delete is applied, but the cropping operations are applied
  58. // later, after the delete is applied.
  59. croppedCommentOps = getCroppedCommentOps(op, rangesTracker.comments)
  60. }
  61. } else if (isInsert(op) || isDelete(op)) {
  62. historyOps.push(op)
  63. }
  64. rangesTracker.applyOp(op, { user_id: update.meta?.user_id })
  65. if (croppedCommentOps.length > 0) {
  66. historyOps.push(
  67. ...croppedCommentOps.map(op =>
  68. getHistoryOpForComment(op, rangesTracker.changes)
  69. )
  70. )
  71. }
  72. }
  73. if (historyOps.length > 0) {
  74. historyUpdates.push({ ...update, op: historyOps })
  75. }
  76. }
  77. sanityCheckTrackedChanges(projectId, docId, rangesTracker.changes)
  78. if (
  79. rangesTracker.changes?.length > RangesManager.MAX_CHANGES ||
  80. rangesTracker.comments?.length > RangesManager.MAX_COMMENTS
  81. ) {
  82. throw new Error('too many comments or tracked changes')
  83. }
  84. try {
  85. // This is a consistency check that all of our ranges and
  86. // comments still match the corresponding text
  87. rangesTracker.validate(newDocLines.join('\n'))
  88. } catch (err) {
  89. logger.error(
  90. { err, projectId, docId, newDocLines, updates },
  91. 'error validating ranges'
  92. )
  93. throw err
  94. }
  95. const [emptyRangeCountAfter, totalRangeCountAfter] =
  96. RangesManager._emptyRangesCount(rangesTracker)
  97. const rangesWereCollapsed =
  98. emptyRangeCountAfter > emptyRangeCountBefore ||
  99. totalRangeCountAfter + 1 < totalRangeCountBefore // also include the case where multiple ranges were removed
  100. // monitor the change in range count, we may want to snapshot before large decreases
  101. if (totalRangeCountAfter < totalRangeCountBefore) {
  102. Metrics.histogram(
  103. 'range-delta',
  104. totalRangeCountBefore - totalRangeCountAfter,
  105. RANGE_DELTA_BUCKETS,
  106. { status_code: rangesWereCollapsed ? 'saved' : 'unsaved' }
  107. )
  108. }
  109. const newRanges = RangesManager._getRanges(rangesTracker)
  110. logger.debug(
  111. {
  112. projectId,
  113. docId,
  114. changesCount: newRanges.changes?.length,
  115. commentsCount: newRanges.comments?.length,
  116. rangesWereCollapsed,
  117. },
  118. 'applied updates to ranges'
  119. )
  120. return { newRanges, rangesWereCollapsed, historyUpdates }
  121. },
  122. acceptChanges(projectId, docId, changeIds, ranges) {
  123. const { changes, comments } = ranges
  124. logger.debug(`accepting ${changeIds.length} changes in ranges`)
  125. const rangesTracker = new RangesTracker(changes, comments)
  126. rangesTracker.removeChangeIds(changeIds)
  127. sanityCheckTrackedChanges(projectId, docId, rangesTracker.changes)
  128. const newRanges = RangesManager._getRanges(rangesTracker)
  129. return newRanges
  130. },
  131. deleteComment(commentId, ranges) {
  132. const { changes, comments } = ranges
  133. logger.debug({ commentId }, 'deleting comment in ranges')
  134. const rangesTracker = new RangesTracker(changes, comments)
  135. rangesTracker.removeCommentId(commentId)
  136. const newRanges = RangesManager._getRanges(rangesTracker)
  137. return newRanges
  138. },
  139. /**
  140. *
  141. * @param {object} args
  142. * @param {string} args.docId
  143. * @param {string[]} args.acceptedChangeIds
  144. * @param {TrackedChange[]} args.changes
  145. * @param {string} args.pathname
  146. * @param {string} args.projectHistoryId
  147. * @param {string[]} args.lines
  148. */
  149. getHistoryUpdatesForAcceptedChanges({
  150. docId,
  151. acceptedChangeIds,
  152. changes,
  153. pathname,
  154. projectHistoryId,
  155. lines,
  156. }) {
  157. /** @type {(change: TrackedChange) => boolean} */
  158. const isAccepted = change => acceptedChangeIds.includes(change.id)
  159. const historyOps = []
  160. // Keep ops in order of offset, with deletes before inserts
  161. const sortedChanges = changes.slice().sort(function (c1, c2) {
  162. const result = c1.op.p - c2.op.p
  163. if (result !== 0) {
  164. return result
  165. } else if (isInsert(c1.op) && isDelete(c2.op)) {
  166. return 1
  167. } else if (isDelete(c1.op) && isInsert(c2.op)) {
  168. return -1
  169. } else {
  170. return 0
  171. }
  172. })
  173. const docLength = getDocLength(lines)
  174. let historyDocLength = docLength
  175. for (const change of sortedChanges) {
  176. if (isDelete(change.op)) {
  177. historyDocLength += change.op.d.length
  178. }
  179. }
  180. let unacceptedDeletes = 0
  181. for (const change of sortedChanges) {
  182. /** @type {HistoryOp | undefined} */
  183. let op
  184. if (isDelete(change.op)) {
  185. if (isAccepted(change)) {
  186. op = {
  187. p: change.op.p,
  188. d: change.op.d,
  189. }
  190. if (unacceptedDeletes > 0) {
  191. op.hpos = op.p + unacceptedDeletes
  192. }
  193. } else {
  194. unacceptedDeletes += change.op.d.length
  195. }
  196. } else if (isInsert(change.op)) {
  197. if (isAccepted(change)) {
  198. op = {
  199. p: change.op.p,
  200. r: change.op.i,
  201. tracking: { type: 'none' },
  202. }
  203. if (unacceptedDeletes > 0) {
  204. op.hpos = op.p + unacceptedDeletes
  205. }
  206. }
  207. }
  208. if (!op) {
  209. continue
  210. }
  211. /** @type {HistoryUpdate} */
  212. const historyOp = {
  213. doc: docId,
  214. op: [op],
  215. meta: {
  216. ...change.metadata,
  217. ts: Date.now(),
  218. doc_length: docLength,
  219. pathname,
  220. },
  221. }
  222. if (projectHistoryId) {
  223. historyOp.projectHistoryId = projectHistoryId
  224. }
  225. if (historyOp.meta && historyDocLength !== docLength) {
  226. historyOp.meta.history_doc_length = historyDocLength
  227. }
  228. historyOps.push(historyOp)
  229. if (isDelete(change.op) && isAccepted(change)) {
  230. historyDocLength -= change.op.d.length
  231. }
  232. }
  233. return historyOps
  234. },
  235. _getRanges(rangesTracker) {
  236. // Return the minimal data structure needed, since most documents won't have any
  237. // changes or comments
  238. const response = {}
  239. if (rangesTracker.changes != null && rangesTracker.changes.length > 0) {
  240. response.changes = rangesTracker.changes
  241. }
  242. if (rangesTracker.comments != null && rangesTracker.comments.length > 0) {
  243. response.comments = rangesTracker.comments
  244. }
  245. return response
  246. },
  247. _emptyRangesCount(ranges) {
  248. let emptyCount = 0
  249. let totalCount = 0
  250. for (const comment of ranges.comments || []) {
  251. totalCount++
  252. if (comment.op.c === '') {
  253. emptyCount++
  254. }
  255. }
  256. for (const change of ranges.changes || []) {
  257. totalCount++
  258. if (change.op.i != null) {
  259. if (change.op.i === '') {
  260. emptyCount++
  261. }
  262. }
  263. }
  264. return [emptyCount, totalCount]
  265. },
  266. }
  267. /**
  268. * Calculate ops to be sent to the history system.
  269. *
  270. * @param {Op} op - the editor op
  271. * @param {TrackedChange[]} changes - the list of tracked changes in the
  272. * document before the op is applied. That list, coming from
  273. * RangesTracker is ordered by position.
  274. * @returns {HistoryOp}
  275. */
  276. function getHistoryOp(op, comments, changes, opts = {}) {
  277. if (isInsert(op)) {
  278. return getHistoryOpForInsert(op, comments, changes)
  279. } else if (isDelete(op)) {
  280. return getHistoryOpForDelete(op, changes)
  281. } else if (isComment(op)) {
  282. return getHistoryOpForComment(op, changes)
  283. } else {
  284. throw new OError('Unrecognized op', { op })
  285. }
  286. }
  287. /**
  288. * Calculate history ops for an insert
  289. *
  290. * Inserts are moved forward by tracked deletes placed strictly before the
  291. * op. When an insert is made at the same position as a tracked delete, the
  292. * insert is placed before the tracked delete.
  293. *
  294. * We also add a commentIds property when inserts are made inside a comment.
  295. * The current behaviour is to include the insert in the comment only if the
  296. * insert is made strictly inside the comment. Inserts made at the edges are
  297. * not included in the comment.
  298. *
  299. * @param {InsertOp} op
  300. * @param {Comment[]} comments
  301. * @param {TrackedChange[]} changes
  302. * @returns {HistoryInsertOp}
  303. */
  304. function getHistoryOpForInsert(op, comments, changes) {
  305. let hpos = op.p
  306. let trackedDeleteRejection = false
  307. const commentIds = new Set()
  308. for (const comment of comments) {
  309. if (comment.op.p < op.p && op.p < comment.op.p + comment.op.c.length) {
  310. // Insert is inside the comment; add the comment id
  311. commentIds.add(comment.op.t)
  312. }
  313. }
  314. for (const change of changes) {
  315. if (!isDelete(change.op)) {
  316. // We're only interested in tracked deletes
  317. continue
  318. }
  319. if (change.op.p < op.p) {
  320. // Tracked delete is before the op. Move the op forward.
  321. hpos += change.op.d.length
  322. } else if (change.op.p === op.p) {
  323. // Tracked delete is at the same position as the op. The insert comes before
  324. // the tracked delete so it doesn't move.
  325. if (op.u && change.op.d.startsWith(op.i)) {
  326. // We're undoing and the insert matches the start of the tracked
  327. // delete. RangesManager treats this as a tracked delete rejection. We
  328. // will note this in the op so that project-history can take the
  329. // appropriate action.
  330. trackedDeleteRejection = true
  331. }
  332. } else {
  333. // Tracked delete is after the insert. Tracked deletes are ordered, so
  334. // we know that all subsequent tracked deletes will be after the insert
  335. // and we can bail out.
  336. break
  337. }
  338. }
  339. /** @type {HistoryInsertOp} */
  340. const historyOp = { ...op }
  341. if (commentIds.size > 0) {
  342. historyOp.commentIds = Array.from(commentIds)
  343. }
  344. if (hpos !== op.p) {
  345. historyOp.hpos = hpos
  346. }
  347. if (trackedDeleteRejection) {
  348. historyOp.trackedDeleteRejection = true
  349. }
  350. return historyOp
  351. }
  352. /**
  353. * Calculate history op for a delete
  354. *
  355. * Deletes are moved forward by tracked deletes placed before or at the position of the
  356. * op. If a tracked delete is inside the delete, the delete is split in parts
  357. * so that characters are deleted around the tracked delete, but the tracked
  358. * delete itself is not deleted.
  359. *
  360. * @param {DeleteOp} op
  361. * @param {TrackedChange[]} changes
  362. * @returns {HistoryDeleteOp}
  363. */
  364. function getHistoryOpForDelete(op, changes, opts = {}) {
  365. let hpos = op.p
  366. const opEnd = op.p + op.d.length
  367. /** @type HistoryDeleteTrackedChange[] */
  368. const changesInsideDelete = []
  369. for (const change of changes) {
  370. if (change.op.p <= op.p) {
  371. if (isDelete(change.op)) {
  372. // Tracked delete is before or at the position of the incoming delete.
  373. // Move the op forward.
  374. hpos += change.op.d.length
  375. } else if (isInsert(change.op)) {
  376. const changeEnd = change.op.p + change.op.i.length
  377. const endPos = Math.min(changeEnd, opEnd)
  378. if (endPos > op.p) {
  379. // Part of the tracked insert is inside the delete
  380. changesInsideDelete.push({
  381. type: 'insert',
  382. offset: 0,
  383. length: endPos - op.p,
  384. })
  385. }
  386. }
  387. } else if (change.op.p < op.p + op.d.length) {
  388. // Tracked change inside the deleted text. Record it for the history system.
  389. if (isDelete(change.op)) {
  390. changesInsideDelete.push({
  391. type: 'delete',
  392. offset: change.op.p - op.p,
  393. length: change.op.d.length,
  394. })
  395. } else if (isInsert(change.op)) {
  396. changesInsideDelete.push({
  397. type: 'insert',
  398. offset: change.op.p - op.p,
  399. length: Math.min(change.op.i.length, opEnd - change.op.p),
  400. })
  401. }
  402. } else {
  403. // We've seen all tracked changes before or inside the delete
  404. break
  405. }
  406. }
  407. /** @type {HistoryDeleteOp} */
  408. const historyOp = { ...op }
  409. if (hpos !== op.p) {
  410. historyOp.hpos = hpos
  411. }
  412. if (changesInsideDelete.length > 0) {
  413. historyOp.trackedChanges = changesInsideDelete
  414. }
  415. return historyOp
  416. }
  417. /**
  418. * Calculate history ops for a comment
  419. *
  420. * Comments are moved forward by tracked deletes placed before or at the
  421. * position of the op. If a tracked delete is inside the comment, the length of
  422. * the comment is extended to include the tracked delete.
  423. *
  424. * @param {CommentOp} op
  425. * @param {TrackedChange[]} changes
  426. * @returns {HistoryCommentOp}
  427. */
  428. function getHistoryOpForComment(op, changes) {
  429. let hpos = op.p
  430. let hlen = op.c.length
  431. for (const change of changes) {
  432. if (!isDelete(change.op)) {
  433. // We're only interested in tracked deletes
  434. continue
  435. }
  436. if (change.op.p <= op.p) {
  437. // Tracked delete is before or at the position of the incoming comment.
  438. // Move the op forward.
  439. hpos += change.op.d.length
  440. } else if (change.op.p < op.p + op.c.length) {
  441. // Tracked comment inside the comment. Extend the length
  442. hlen += change.op.d.length
  443. } else {
  444. // We've seen all tracked deletes before or inside the comment
  445. break
  446. }
  447. }
  448. /** @type {HistoryCommentOp} */
  449. const historyOp = { ...op }
  450. if (hpos !== op.p) {
  451. historyOp.hpos = hpos
  452. }
  453. if (hlen !== op.c.length) {
  454. historyOp.hlen = hlen
  455. }
  456. return historyOp
  457. }
  458. /**
  459. * Return the ops necessary to properly crop comments when a tracked delete is
  460. * received
  461. *
  462. * The editor treats a tracked delete as a proper delete and updates the
  463. * comment range accordingly. The history doesn't do that and remembers the
  464. * extent of the comment in the tracked delete. In order to keep the history
  465. * consistent with the editor, we'll send ops that will crop the comment in
  466. * the history.
  467. *
  468. * @param {DeleteOp} op
  469. * @param {Comment[]} comments
  470. * @returns {CommentOp[]}
  471. */
  472. function getCroppedCommentOps(op, comments) {
  473. const deleteStart = op.p
  474. const deleteLength = op.d.length
  475. const deleteEnd = deleteStart + deleteLength
  476. /** @type {HistoryCommentOp[]} */
  477. const historyCommentOps = []
  478. for (const comment of comments) {
  479. const commentStart = comment.op.p
  480. const commentLength = comment.op.c.length
  481. const commentEnd = commentStart + commentLength
  482. if (deleteStart <= commentStart && deleteEnd > commentStart) {
  483. // The comment overlaps the start of the comment or all of it.
  484. const overlapLength = Math.min(deleteEnd, commentEnd) - commentStart
  485. /** @type {CommentOp} */
  486. const commentOp = {
  487. p: deleteStart,
  488. c: comment.op.c.slice(overlapLength),
  489. t: comment.op.t,
  490. }
  491. if (comment.op.resolved) {
  492. commentOp.resolved = true
  493. }
  494. historyCommentOps.push(commentOp)
  495. } else if (
  496. deleteStart > commentStart &&
  497. deleteStart < commentEnd &&
  498. deleteEnd >= commentEnd
  499. ) {
  500. // The comment overlaps the end of the comment.
  501. const overlapLength = commentEnd - deleteStart
  502. /** @type {CommentOp} */
  503. const commentOp = {
  504. p: commentStart,
  505. c: comment.op.c.slice(0, -overlapLength),
  506. t: comment.op.t,
  507. }
  508. if (comment.op.resolved) {
  509. commentOp.resolved = true
  510. }
  511. historyCommentOps.push(commentOp)
  512. }
  513. }
  514. return historyCommentOps
  515. }
  516. /**
  517. * Check some tracked changes assumptions:
  518. *
  519. * - Tracked changes can't be empty
  520. * - Tracked inserts can't overlap with another tracked change
  521. * - There can't be two tracked deletes at the same position
  522. * - Ranges should be ordered by position, deletes before inserts
  523. *
  524. * If any assumption isn't upheld, log a warning.
  525. *
  526. * @param {string} projectId
  527. * @param {string} docId
  528. * @param {TrackedChange[]} changes
  529. */
  530. function sanityCheckTrackedChanges(projectId, docId, changes) {
  531. let lastDeletePos = -1 // allow a tracked delete at position 0
  532. let lastInsertEnd = 0
  533. let ok = true
  534. let badChangeIndex
  535. for (let i = 0; i < changes.length; i++) {
  536. const change = changes[i]
  537. const op = change.op
  538. if ('i' in op) {
  539. if (op.i.length === 0 || op.p < lastDeletePos || op.p < lastInsertEnd) {
  540. ok = false
  541. badChangeIndex = i
  542. break
  543. }
  544. lastInsertEnd = op.p + op.i.length
  545. } else if ('d' in op) {
  546. if (op.d.length === 0 || op.p <= lastDeletePos || op.p < lastInsertEnd) {
  547. ok = false
  548. badChangeIndex = i
  549. break
  550. }
  551. lastDeletePos = op.p
  552. }
  553. }
  554. if (ok) {
  555. return
  556. }
  557. const changeRanges = []
  558. for (const change of changes) {
  559. if ('i' in change.op) {
  560. changeRanges.push({
  561. id: change.id,
  562. p: change.op.p,
  563. i: change.op.i.length,
  564. })
  565. } else if ('d' in change.op) {
  566. changeRanges.push({
  567. id: change.id,
  568. p: change.op.p,
  569. d: change.op.d.length,
  570. })
  571. }
  572. }
  573. logger.warn(
  574. { projectId, docId, changes: changeRanges, badChangeIndex },
  575. 'Malformed tracked changes detected'
  576. )
  577. }
  578. module.exports = RangesManager