RangesManager.js 20 KB

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