RangesManager.js 11 KB

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