RangesManager.js 18 KB

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