RangesManager.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  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. * @typedef {import('./types').Comment} Comment
  10. * @typedef {import('./types').CommentOp} CommentOp
  11. * @typedef {import('./types').DeleteOp} DeleteOp
  12. * @typedef {import('./types').HistoryCommentOp} HistoryCommentOp
  13. * @typedef {import('./types').HistoryDeleteOp} HistoryDeleteOp
  14. * @typedef {import('./types').HistoryDeleteTrackedChange} HistoryDeleteTrackedChange
  15. * @typedef {import('./types').HistoryInsertOp} HistoryInsertOp
  16. * @typedef {import('./types').HistoryRetainOp} HistoryRetainOp
  17. * @typedef {import('./types').HistoryOp} HistoryOp
  18. * @typedef {import('./types').HistoryUpdate} HistoryUpdate
  19. * @typedef {import('./types').InsertOp} InsertOp
  20. * @typedef {import('./types').Op} Op
  21. * @typedef {import('./types').Ranges} Ranges
  22. * @typedef {import('./types').TrackedChange} TrackedChange
  23. * @typedef {import('./types').Update} Update
  24. */
  25. const RANGE_DELTA_BUCKETS = [0, 1, 2, 3, 4, 5, 10, 20, 50]
  26. const RangesManager = {
  27. MAX_COMMENTS: 500,
  28. MAX_CHANGES: 2000,
  29. /**
  30. * Apply an update to the given doc (lines and ranges) and return new ranges
  31. *
  32. * @param {string} projectId
  33. * @param {string} docId
  34. * @param {Ranges} ranges - ranges before the updates were applied
  35. * @param {Update[]} updates
  36. * @param {string[]} newDocLines - the document lines after the updates were applied
  37. * @param {object} opts
  38. * @param {boolean} [opts.historyRangesSupport] - whether history ranges support is enabled
  39. * @returns {{ newRanges: Ranges, rangesWereCollapsed: boolean, historyUpdates: HistoryUpdate[] }}
  40. */
  41. applyUpdate(projectId, docId, ranges, updates, newDocLines, opts = {}) {
  42. if (ranges == null) {
  43. ranges = {}
  44. }
  45. if (updates == null) {
  46. updates = []
  47. }
  48. const { changes, comments } = _.cloneDeep(ranges)
  49. const rangesTracker = new RangesTracker(changes, comments)
  50. const [emptyRangeCountBefore, totalRangeCountBefore] =
  51. RangesManager._emptyRangesCount(rangesTracker)
  52. const historyUpdates = []
  53. for (const update of updates) {
  54. const trackingChanges = Boolean(update.meta?.tc)
  55. rangesTracker.track_changes = trackingChanges
  56. if (update.meta?.tc) {
  57. rangesTracker.setIdSeed(update.meta.tc)
  58. }
  59. const historyOps = []
  60. for (const op of update.op) {
  61. let croppedCommentOps = []
  62. if (opts.historyRangesSupport) {
  63. historyOps.push(
  64. getHistoryOp(op, rangesTracker.comments, rangesTracker.changes)
  65. )
  66. if (isDelete(op) && trackingChanges) {
  67. // If a tracked delete overlaps a comment, the comment must be
  68. // cropped. The extent of the cropping is calculated before the
  69. // delete is applied, but the cropping operations are applied
  70. // later, after the delete is applied.
  71. croppedCommentOps = getCroppedCommentOps(op, rangesTracker.comments)
  72. }
  73. } else if (isInsert(op) || isDelete(op)) {
  74. historyOps.push(op)
  75. }
  76. rangesTracker.applyOp(op, { user_id: update.meta?.user_id })
  77. if (croppedCommentOps.length > 0) {
  78. historyOps.push(
  79. ...croppedCommentOps.map(op =>
  80. getHistoryOpForComment(op, rangesTracker.changes)
  81. )
  82. )
  83. }
  84. }
  85. if (historyOps.length > 0) {
  86. historyUpdates.push({ ...update, op: historyOps })
  87. }
  88. }
  89. if (
  90. rangesTracker.changes?.length > RangesManager.MAX_CHANGES ||
  91. rangesTracker.comments?.length > RangesManager.MAX_COMMENTS
  92. ) {
  93. throw new Error('too many comments or tracked changes')
  94. }
  95. try {
  96. // This is a consistency check that all of our ranges and
  97. // comments still match the corresponding text
  98. rangesTracker.validate(newDocLines.join('\n'))
  99. } catch (err) {
  100. logger.error(
  101. { err, projectId, docId, newDocLines, updates },
  102. 'error validating ranges'
  103. )
  104. throw err
  105. }
  106. const [emptyRangeCountAfter, totalRangeCountAfter] =
  107. RangesManager._emptyRangesCount(rangesTracker)
  108. const rangesWereCollapsed =
  109. emptyRangeCountAfter > emptyRangeCountBefore ||
  110. totalRangeCountAfter + 1 < totalRangeCountBefore // also include the case where multiple ranges were removed
  111. // monitor the change in range count, we may want to snapshot before large decreases
  112. if (totalRangeCountAfter < totalRangeCountBefore) {
  113. Metrics.histogram(
  114. 'range-delta',
  115. totalRangeCountBefore - totalRangeCountAfter,
  116. RANGE_DELTA_BUCKETS,
  117. { status_code: rangesWereCollapsed ? 'saved' : 'unsaved' }
  118. )
  119. }
  120. const newRanges = RangesManager._getRanges(rangesTracker)
  121. logger.debug(
  122. {
  123. projectId,
  124. docId,
  125. changesCount: newRanges.changes?.length,
  126. commentsCount: newRanges.comments?.length,
  127. rangesWereCollapsed,
  128. },
  129. 'applied updates to ranges'
  130. )
  131. return { newRanges, rangesWereCollapsed, historyUpdates }
  132. },
  133. acceptChanges(changeIds, ranges) {
  134. const { changes, comments } = ranges
  135. logger.debug(`accepting ${changeIds.length} changes in ranges`)
  136. const rangesTracker = new RangesTracker(changes, comments)
  137. rangesTracker.removeChangeIds(changeIds)
  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. doc_length: docLength,
  228. pathname,
  229. },
  230. }
  231. if (projectHistoryId) {
  232. historyOp.projectHistoryId = projectHistoryId
  233. }
  234. if (historyOp.meta && historyDocLength !== docLength) {
  235. historyOp.meta.history_doc_length = historyDocLength
  236. }
  237. historyOps.push(historyOp)
  238. if (isDelete(change.op) && isAccepted(change)) {
  239. historyDocLength -= change.op.d.length
  240. }
  241. }
  242. return historyOps
  243. },
  244. _getRanges(rangesTracker) {
  245. // Return the minimal data structure needed, since most documents won't have any
  246. // changes or comments
  247. const response = {}
  248. if (rangesTracker.changes != null && rangesTracker.changes.length > 0) {
  249. response.changes = rangesTracker.changes
  250. }
  251. if (rangesTracker.comments != null && rangesTracker.comments.length > 0) {
  252. response.comments = rangesTracker.comments
  253. }
  254. return response
  255. },
  256. _emptyRangesCount(ranges) {
  257. let emptyCount = 0
  258. let totalCount = 0
  259. for (const comment of ranges.comments || []) {
  260. totalCount++
  261. if (comment.op.c === '') {
  262. emptyCount++
  263. }
  264. }
  265. for (const change of ranges.changes || []) {
  266. totalCount++
  267. if (change.op.i != null) {
  268. if (change.op.i === '') {
  269. emptyCount++
  270. }
  271. }
  272. }
  273. return [emptyCount, totalCount]
  274. },
  275. }
  276. /**
  277. * Calculate ops to be sent to the history system.
  278. *
  279. * @param {Op} op - the editor op
  280. * @param {TrackedChange[]} changes - the list of tracked changes in the
  281. * document before the op is applied. That list, coming from
  282. * RangesTracker is ordered by position.
  283. * @returns {HistoryOp}
  284. */
  285. function getHistoryOp(op, comments, changes, opts = {}) {
  286. if (isInsert(op)) {
  287. return getHistoryOpForInsert(op, comments, changes)
  288. } else if (isDelete(op)) {
  289. return getHistoryOpForDelete(op, changes)
  290. } else if (isComment(op)) {
  291. return getHistoryOpForComment(op, changes)
  292. } else {
  293. throw new OError('Unrecognized op', { op })
  294. }
  295. }
  296. /**
  297. * Calculate history ops for an insert
  298. *
  299. * Inserts are moved forward by tracked deletes placed strictly before the
  300. * op. When an insert is made at the same position as a tracked delete, the
  301. * insert is placed before the tracked delete.
  302. *
  303. * We also add a commentIds property when inserts are made inside a comment.
  304. * The current behaviour is to include the insert in the comment only if the
  305. * insert is made strictly inside the comment. Inserts made at the edges are
  306. * not included in the comment.
  307. *
  308. * @param {InsertOp} op
  309. * @param {Comment[]} comments
  310. * @param {TrackedChange[]} changes
  311. * @returns {HistoryInsertOp}
  312. */
  313. function getHistoryOpForInsert(op, comments, changes) {
  314. let hpos = op.p
  315. let trackedDeleteRejection = false
  316. const commentIds = new Set()
  317. for (const comment of comments) {
  318. if (comment.op.p < op.p && op.p < comment.op.p + comment.op.c.length) {
  319. // Insert is inside the comment; add the comment id
  320. commentIds.add(comment.op.t)
  321. }
  322. }
  323. for (const change of changes) {
  324. if (!isDelete(change.op)) {
  325. // We're only interested in tracked deletes
  326. continue
  327. }
  328. if (change.op.p < op.p) {
  329. // Tracked delete is before the op. Move the op forward.
  330. hpos += change.op.d.length
  331. } else if (change.op.p === op.p) {
  332. // Tracked delete is at the same position as the op. The insert comes before
  333. // the tracked delete so it doesn't move.
  334. if (op.u && change.op.d.startsWith(op.i)) {
  335. // We're undoing and the insert matches the start of the tracked
  336. // delete. RangesManager treats this as a tracked delete rejection. We
  337. // will note this in the op so that project-history can take the
  338. // appropriate action.
  339. trackedDeleteRejection = true
  340. }
  341. } else {
  342. // Tracked delete is after the insert. Tracked deletes are ordered, so
  343. // we know that all subsequent tracked deletes will be after the insert
  344. // and we can bail out.
  345. break
  346. }
  347. }
  348. /** @type {HistoryInsertOp} */
  349. const historyOp = { ...op }
  350. if (commentIds.size > 0) {
  351. historyOp.commentIds = Array.from(commentIds)
  352. }
  353. if (hpos !== op.p) {
  354. historyOp.hpos = hpos
  355. }
  356. if (trackedDeleteRejection) {
  357. historyOp.trackedDeleteRejection = true
  358. }
  359. return historyOp
  360. }
  361. /**
  362. * Calculate history op for a delete
  363. *
  364. * Deletes are moved forward by tracked deletes placed before or at the position of the
  365. * op. If a tracked delete is inside the delete, the delete is split in parts
  366. * so that characters are deleted around the tracked delete, but the tracked
  367. * delete itself is not deleted.
  368. *
  369. * @param {DeleteOp} op
  370. * @param {TrackedChange[]} changes
  371. * @returns {HistoryDeleteOp}
  372. */
  373. function getHistoryOpForDelete(op, changes, opts = {}) {
  374. let hpos = op.p
  375. const opEnd = op.p + op.d.length
  376. /** @type HistoryDeleteTrackedChange[] */
  377. const changesInsideDelete = []
  378. for (const change of changes) {
  379. if (change.op.p <= op.p) {
  380. if (isDelete(change.op)) {
  381. // Tracked delete is before or at the position of the incoming delete.
  382. // Move the op forward.
  383. hpos += change.op.d.length
  384. } else if (isInsert(change.op)) {
  385. const changeEnd = change.op.p + change.op.i.length
  386. const endPos = Math.min(changeEnd, opEnd)
  387. if (endPos > op.p) {
  388. // Part of the tracked insert is inside the delete
  389. changesInsideDelete.push({
  390. type: 'insert',
  391. offset: 0,
  392. length: endPos - op.p,
  393. })
  394. }
  395. }
  396. } else if (change.op.p < op.p + op.d.length) {
  397. // Tracked change inside the deleted text. Record it for the history system.
  398. if (isDelete(change.op)) {
  399. changesInsideDelete.push({
  400. type: 'delete',
  401. offset: change.op.p - op.p,
  402. length: change.op.d.length,
  403. })
  404. } else if (isInsert(change.op)) {
  405. changesInsideDelete.push({
  406. type: 'insert',
  407. offset: change.op.p - op.p,
  408. length: Math.min(change.op.i.length, opEnd - change.op.p),
  409. })
  410. }
  411. } else {
  412. // We've seen all tracked changes before or inside the delete
  413. break
  414. }
  415. }
  416. /** @type {HistoryDeleteOp} */
  417. const historyOp = { ...op }
  418. if (hpos !== op.p) {
  419. historyOp.hpos = hpos
  420. }
  421. if (changesInsideDelete.length > 0) {
  422. historyOp.trackedChanges = changesInsideDelete
  423. }
  424. return historyOp
  425. }
  426. /**
  427. * Calculate history ops for a comment
  428. *
  429. * Comments are moved forward by tracked deletes placed before or at the
  430. * position of the op. If a tracked delete is inside the comment, the length of
  431. * the comment is extended to include the tracked delete.
  432. *
  433. * @param {CommentOp} op
  434. * @param {TrackedChange[]} changes
  435. * @returns {HistoryCommentOp}
  436. */
  437. function getHistoryOpForComment(op, changes) {
  438. let hpos = op.p
  439. let hlen = op.c.length
  440. for (const change of changes) {
  441. if (!isDelete(change.op)) {
  442. // We're only interested in tracked deletes
  443. continue
  444. }
  445. if (change.op.p <= op.p) {
  446. // Tracked delete is before or at the position of the incoming comment.
  447. // Move the op forward.
  448. hpos += change.op.d.length
  449. } else if (change.op.p < op.p + op.c.length) {
  450. // Tracked comment inside the comment. Extend the length
  451. hlen += change.op.d.length
  452. } else {
  453. // We've seen all tracked deletes before or inside the comment
  454. break
  455. }
  456. }
  457. /** @type {HistoryCommentOp} */
  458. const historyOp = { ...op }
  459. if (hpos !== op.p) {
  460. historyOp.hpos = hpos
  461. }
  462. if (hlen !== op.c.length) {
  463. historyOp.hlen = hlen
  464. }
  465. return historyOp
  466. }
  467. /**
  468. * Return the ops necessary to properly crop comments when a tracked delete is
  469. * received
  470. *
  471. * The editor treats a tracked delete as a proper delete and updates the
  472. * comment range accordingly. The history doesn't do that and remembers the
  473. * extent of the comment in the tracked delete. In order to keep the history
  474. * consistent with the editor, we'll send ops that will crop the comment in
  475. * the history.
  476. *
  477. * @param {DeleteOp} op
  478. * @param {Comment[]} comments
  479. * @returns {CommentOp[]}
  480. */
  481. function getCroppedCommentOps(op, comments) {
  482. const deleteStart = op.p
  483. const deleteLength = op.d.length
  484. const deleteEnd = deleteStart + deleteLength
  485. /** @type {HistoryCommentOp[]} */
  486. const historyCommentOps = []
  487. for (const comment of comments) {
  488. const commentStart = comment.op.p
  489. const commentLength = comment.op.c.length
  490. const commentEnd = commentStart + commentLength
  491. if (deleteStart <= commentStart && deleteEnd > commentStart) {
  492. // The comment overlaps the start of the comment or all of it.
  493. const overlapLength = Math.min(deleteEnd, commentEnd) - commentStart
  494. /** @type {CommentOp} */
  495. const commentOp = {
  496. p: deleteStart,
  497. c: comment.op.c.slice(overlapLength),
  498. t: comment.op.t,
  499. }
  500. if (comment.op.resolved) {
  501. commentOp.resolved = true
  502. }
  503. historyCommentOps.push(commentOp)
  504. } else if (
  505. deleteStart > commentStart &&
  506. deleteStart < commentEnd &&
  507. deleteEnd >= commentEnd
  508. ) {
  509. // The comment overlaps the end of the comment.
  510. const overlapLength = commentEnd - deleteStart
  511. /** @type {CommentOp} */
  512. const commentOp = {
  513. p: commentStart,
  514. c: comment.op.c.slice(0, -overlapLength),
  515. t: comment.op.t,
  516. }
  517. if (comment.op.resolved) {
  518. commentOp.resolved = true
  519. }
  520. historyCommentOps.push(commentOp)
  521. }
  522. }
  523. return historyCommentOps
  524. }
  525. module.exports = RangesManager