RangesManager.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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 } = 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').HistoryOp} HistoryOp
  17. * @typedef {import('./types').HistoryUpdate} HistoryUpdate
  18. * @typedef {import('./types').InsertOp} InsertOp
  19. * @typedef {import('./types').Op} Op
  20. * @typedef {import('./types').Ranges} Ranges
  21. * @typedef {import('./types').TrackedChange} TrackedChange
  22. * @typedef {import('./types').Update} Update
  23. */
  24. const RANGE_DELTA_BUCKETS = [0, 1, 2, 3, 4, 5, 10, 20, 50]
  25. const RangesManager = {
  26. MAX_COMMENTS: 500,
  27. MAX_CHANGES: 2000,
  28. /**
  29. * Apply an update to the given doc (lines and ranges) and return new ranges
  30. *
  31. * @param {string} projectId
  32. * @param {string} docId
  33. * @param {Ranges} ranges - ranges before the updates were applied
  34. * @param {Update[]} updates
  35. * @param {string[]} newDocLines - the document lines after the updates were applied
  36. * @param {object} opts
  37. * @param {boolean} [opts.historyRangesSupport] - whether history ranges support is enabled
  38. * @returns {{ newRanges: Ranges, rangesWereCollapsed: boolean, historyUpdates: HistoryUpdate[] }}
  39. */
  40. applyUpdate(projectId, docId, ranges, updates, newDocLines, opts = {}) {
  41. if (ranges == null) {
  42. ranges = {}
  43. }
  44. if (updates == null) {
  45. updates = []
  46. }
  47. const { changes, comments } = _.cloneDeep(ranges)
  48. const rangesTracker = new RangesTracker(changes, comments)
  49. const [emptyRangeCountBefore, totalRangeCountBefore] =
  50. RangesManager._emptyRangesCount(rangesTracker)
  51. const historyUpdates = []
  52. for (const update of updates) {
  53. rangesTracker.track_changes = Boolean(update.meta?.tc)
  54. if (update.meta?.tc) {
  55. rangesTracker.setIdSeed(update.meta.tc)
  56. }
  57. const historyOps = []
  58. for (const op of update.op) {
  59. if (opts.historyRangesSupport) {
  60. historyOps.push(
  61. getHistoryOp(op, rangesTracker.comments, rangesTracker.changes)
  62. )
  63. } else if (isInsert(op) || isDelete(op)) {
  64. historyOps.push(op)
  65. }
  66. rangesTracker.applyOp(op, { user_id: update.meta?.user_id })
  67. }
  68. historyUpdates.push({ ...update, op: historyOps })
  69. }
  70. if (
  71. rangesTracker.changes?.length > RangesManager.MAX_CHANGES ||
  72. rangesTracker.comments?.length > RangesManager.MAX_COMMENTS
  73. ) {
  74. throw new Error('too many comments or tracked changes')
  75. }
  76. try {
  77. // This is a consistency check that all of our ranges and
  78. // comments still match the corresponding text
  79. rangesTracker.validate(newDocLines.join('\n'))
  80. } catch (err) {
  81. logger.error(
  82. { err, projectId, docId, newDocLines, updates },
  83. 'error validating ranges'
  84. )
  85. throw err
  86. }
  87. const [emptyRangeCountAfter, totalRangeCountAfter] =
  88. RangesManager._emptyRangesCount(rangesTracker)
  89. const rangesWereCollapsed =
  90. emptyRangeCountAfter > emptyRangeCountBefore ||
  91. totalRangeCountAfter + 1 < totalRangeCountBefore // also include the case where multiple ranges were removed
  92. // monitor the change in range count, we may want to snapshot before large decreases
  93. if (totalRangeCountAfter < totalRangeCountBefore) {
  94. Metrics.histogram(
  95. 'range-delta',
  96. totalRangeCountBefore - totalRangeCountAfter,
  97. RANGE_DELTA_BUCKETS,
  98. { status_code: rangesWereCollapsed ? 'saved' : 'unsaved' }
  99. )
  100. }
  101. const newRanges = RangesManager._getRanges(rangesTracker)
  102. logger.debug(
  103. {
  104. projectId,
  105. docId,
  106. changesCount: newRanges.changes?.length,
  107. commentsCount: newRanges.comments?.length,
  108. rangesWereCollapsed,
  109. },
  110. 'applied updates to ranges'
  111. )
  112. return { newRanges, rangesWereCollapsed, historyUpdates }
  113. },
  114. acceptChanges(changeIds, ranges) {
  115. const { changes, comments } = ranges
  116. logger.debug(`accepting ${changeIds.length} changes in ranges`)
  117. const rangesTracker = new RangesTracker(changes, comments)
  118. rangesTracker.removeChangeIds(changeIds)
  119. const newRanges = RangesManager._getRanges(rangesTracker)
  120. return newRanges
  121. },
  122. deleteComment(commentId, ranges) {
  123. const { changes, comments } = ranges
  124. logger.debug({ commentId }, 'deleting comment in ranges')
  125. const rangesTracker = new RangesTracker(changes, comments)
  126. rangesTracker.removeCommentId(commentId)
  127. const newRanges = RangesManager._getRanges(rangesTracker)
  128. return newRanges
  129. },
  130. _getRanges(rangesTracker) {
  131. // Return the minimal data structure needed, since most documents won't have any
  132. // changes or comments
  133. const response = {}
  134. if (rangesTracker.changes != null && rangesTracker.changes.length > 0) {
  135. response.changes = rangesTracker.changes
  136. }
  137. if (rangesTracker.comments != null && rangesTracker.comments.length > 0) {
  138. response.comments = rangesTracker.comments
  139. }
  140. return response
  141. },
  142. _emptyRangesCount(ranges) {
  143. let emptyCount = 0
  144. let totalCount = 0
  145. for (const comment of ranges.comments || []) {
  146. totalCount++
  147. if (comment.op.c === '') {
  148. emptyCount++
  149. }
  150. }
  151. for (const change of ranges.changes || []) {
  152. totalCount++
  153. if (change.op.i != null) {
  154. if (change.op.i === '') {
  155. emptyCount++
  156. }
  157. }
  158. }
  159. return [emptyCount, totalCount]
  160. },
  161. }
  162. /**
  163. * Calculate ops to be sent to the history system.
  164. *
  165. * @param {Op} op - the editor op
  166. * @param {TrackedChange[]} changes - the list of tracked changes in the
  167. * document before the op is applied. That list, coming from
  168. * RangesTracker is ordered by position.
  169. * @returns {HistoryOp}
  170. */
  171. function getHistoryOp(op, comments, changes, opts = {}) {
  172. if (isInsert(op)) {
  173. return getHistoryOpForInsert(op, comments, changes)
  174. } else if (isDelete(op)) {
  175. return getHistoryOpForDelete(op, changes)
  176. } else if (isComment(op)) {
  177. return getHistoryOpForComment(op, changes)
  178. } else {
  179. throw new OError('Unrecognized op', { op })
  180. }
  181. }
  182. /**
  183. * Calculate history ops for an insert
  184. *
  185. * Inserts are moved forward by tracked deletes placed strictly before the
  186. * op. When an insert is made at the same position as a tracked delete, the
  187. * insert is placed before the tracked delete.
  188. *
  189. * We also add a commentIds property when inserts are made inside a comment.
  190. * The current behaviour is to include the insert in the comment only if the
  191. * insert is made strictly inside the comment. Inserts made at the edges are
  192. * not included in the comment.
  193. *
  194. * @param {InsertOp} op
  195. * @param {Comment[]} comments
  196. * @param {TrackedChange[]} changes
  197. * @returns {HistoryInsertOp}
  198. */
  199. function getHistoryOpForInsert(op, comments, changes) {
  200. let hpos = op.p
  201. let trackedDeleteRejection = false
  202. const commentIds = new Set()
  203. for (const comment of comments) {
  204. if (comment.op.p < op.p && op.p < comment.op.p + comment.op.c.length) {
  205. // Insert is inside the comment; add the comment id
  206. commentIds.add(comment.op.t)
  207. }
  208. }
  209. for (const change of changes) {
  210. if (!isDelete(change.op)) {
  211. // We're only interested in tracked deletes
  212. continue
  213. }
  214. if (change.op.p < op.p) {
  215. // Tracked delete is before the op. Move the op forward.
  216. hpos += change.op.d.length
  217. } else if (change.op.p === op.p) {
  218. // Tracked delete is at the same position as the op. The insert comes before
  219. // the tracked delete so it doesn't move.
  220. if (op.u && change.op.d.startsWith(op.i)) {
  221. // We're undoing and the insert matches the start of the tracked
  222. // delete. RangesManager treats this as a tracked delete rejection. We
  223. // will note this in the op so that project-history can take the
  224. // appropriate action.
  225. trackedDeleteRejection = true
  226. }
  227. } else {
  228. // Tracked delete is after the insert. Tracked deletes are ordered, so
  229. // we know that all subsequent tracked deletes will be after the insert
  230. // and we can bail out.
  231. break
  232. }
  233. }
  234. /** @type {HistoryInsertOp} */
  235. const historyOp = { ...op }
  236. if (commentIds.size > 0) {
  237. historyOp.commentIds = Array.from(commentIds)
  238. }
  239. if (hpos !== op.p) {
  240. historyOp.hpos = hpos
  241. }
  242. if (trackedDeleteRejection) {
  243. historyOp.trackedDeleteRejection = true
  244. }
  245. return historyOp
  246. }
  247. /**
  248. * Calculate history op for a delete
  249. *
  250. * Deletes are moved forward by tracked deletes placed before or at the position of the
  251. * op. If a tracked delete is inside the delete, the delete is split in parts
  252. * so that characters are deleted around the tracked delete, but the tracked
  253. * delete itself is not deleted.
  254. *
  255. * @param {DeleteOp} op
  256. * @param {TrackedChange[]} changes
  257. * @returns {HistoryDeleteOp}
  258. */
  259. function getHistoryOpForDelete(op, changes, opts = {}) {
  260. let hpos = op.p
  261. const opEnd = op.p + op.d.length
  262. /** @type HistoryDeleteTrackedChange[] */
  263. const changesInsideDelete = []
  264. for (const change of changes) {
  265. if (change.op.p <= op.p) {
  266. if (isDelete(change.op)) {
  267. // Tracked delete is before or at the position of the incoming delete.
  268. // Move the op forward.
  269. hpos += change.op.d.length
  270. } else if (isInsert(change.op)) {
  271. const changeEnd = change.op.p + change.op.i.length
  272. const endPos = Math.min(changeEnd, opEnd)
  273. if (endPos > op.p) {
  274. // Part of the tracked insert is inside the delete
  275. changesInsideDelete.push({
  276. type: 'insert',
  277. offset: 0,
  278. length: endPos - op.p,
  279. })
  280. }
  281. }
  282. } else if (change.op.p < op.p + op.d.length) {
  283. // Tracked change inside the deleted text. Record it for the history system.
  284. if (isDelete(change.op)) {
  285. changesInsideDelete.push({
  286. type: 'delete',
  287. offset: change.op.p - op.p,
  288. length: change.op.d.length,
  289. })
  290. } else if (isInsert(change.op)) {
  291. changesInsideDelete.push({
  292. type: 'insert',
  293. offset: change.op.p - op.p,
  294. length: Math.min(change.op.i.length, opEnd - change.op.p),
  295. })
  296. }
  297. } else {
  298. // We've seen all tracked changes before or inside the delete
  299. break
  300. }
  301. }
  302. /** @type {HistoryDeleteOp} */
  303. const historyOp = { ...op }
  304. if (hpos !== op.p) {
  305. historyOp.hpos = hpos
  306. }
  307. if (changesInsideDelete.length > 0) {
  308. historyOp.trackedChanges = changesInsideDelete
  309. }
  310. return historyOp
  311. }
  312. /**
  313. * Calculate history ops for a comment
  314. *
  315. * Comments are moved forward by tracked deletes placed before or at the
  316. * position of the op. If a tracked delete is inside the comment, the length of
  317. * the comment is extended to include the tracked delete.
  318. *
  319. * @param {CommentOp} op
  320. * @param {TrackedChange[]} changes
  321. * @returns {HistoryCommentOp}
  322. */
  323. function getHistoryOpForComment(op, changes) {
  324. let hpos = op.p
  325. let hlen = op.c.length
  326. for (const change of changes) {
  327. if (!isDelete(change.op)) {
  328. // We're only interested in tracked deletes
  329. continue
  330. }
  331. if (change.op.p <= op.p) {
  332. // Tracked delete is before or at the position of the incoming comment.
  333. // Move the op forward.
  334. hpos += change.op.d.length
  335. } else if (change.op.p < op.p + op.c.length) {
  336. // Tracked comment inside the comment. Extend the length
  337. hlen += change.op.d.length
  338. } else {
  339. // We've seen all tracked deletes before or inside the comment
  340. break
  341. }
  342. }
  343. /** @type {HistoryCommentOp} */
  344. const historyOp = { ...op }
  345. if (hpos !== op.p) {
  346. historyOp.hpos = hpos
  347. }
  348. if (hlen !== op.c.length) {
  349. historyOp.hlen = hlen
  350. }
  351. return historyOp
  352. }
  353. module.exports = RangesManager