RangesManager.js 15 KB

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