RangesManager.js 11 KB

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