RangesManager.js 10 KB

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