UpdateCompressor.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import OError from '@overleaf/o-error'
  2. import DMP from 'diff-match-patch'
  3. const MAX_TIME_BETWEEN_UPDATES = 60 * 1000 // one minute
  4. const MAX_UPDATE_SIZE = 2 * 1024 * 1024 // 2 MB
  5. const ADDED = 1
  6. const REMOVED = -1
  7. const UNCHANGED = 0
  8. const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos)
  9. const strRemove = (s1, pos, length) => s1.slice(0, pos) + s1.slice(pos + length)
  10. const dmp = new DMP()
  11. dmp.Diff_Timeout = 0.1 // prevent the diff algorithm from searching too hard for changes in unrelated content
  12. const cloneWithOp = function (update, op) {
  13. // to improve performance, shallow clone the update
  14. // and its meta property (also an object), then
  15. // overwrite the op property directly.
  16. update = Object.assign({}, update)
  17. update.meta = Object.assign({}, update.meta)
  18. update.op = op
  19. return update
  20. }
  21. const mergeUpdatesWithOp = function (firstUpdate, secondUpdate, op) {
  22. // We want to take doc_length and ts from the firstUpdate, v from the second
  23. const update = cloneWithOp(firstUpdate, op)
  24. if (secondUpdate.v != null) {
  25. update.v = secondUpdate.v
  26. }
  27. return update
  28. }
  29. const adjustLengthByOp = function (length, op) {
  30. if (op.i != null) {
  31. return length + op.i.length
  32. } else if (op.d != null) {
  33. return length - op.d.length
  34. } else if (op.c != null) {
  35. return length
  36. } else {
  37. throw new OError('unexpected op type')
  38. }
  39. }
  40. // Updates come from the doc updater in format
  41. // {
  42. // op: [ { ... op1 ... }, { ... op2 ... } ]
  43. // meta: { ts: ..., user_id: ... }
  44. // }
  45. // but it's easier to work with on op per update, so convert these updates to
  46. // our compressed format
  47. // [{
  48. // op: op1
  49. // meta: { ts: ..., user_id: ... }
  50. // }, {
  51. // op: op2
  52. // meta: { ts: ..., user_id: ... }
  53. // }]
  54. export function convertToSingleOpUpdates(updates) {
  55. const splitUpdates = []
  56. for (const update of updates) {
  57. if (update.op == null) {
  58. // Not a text op, likely a project strucure op
  59. splitUpdates.push(update)
  60. continue
  61. }
  62. const ops = update.op
  63. let { doc_length: docLength } = update.meta
  64. for (const op of ops) {
  65. const splitUpdate = cloneWithOp(update, op)
  66. if (docLength != null) {
  67. splitUpdate.meta.doc_length = docLength
  68. docLength = adjustLengthByOp(docLength, op)
  69. }
  70. splitUpdates.push(splitUpdate)
  71. }
  72. }
  73. return splitUpdates
  74. }
  75. export function filterBlankUpdates(updates) {
  76. // Diffing an insert and delete can return blank inserts and deletes
  77. // which the OL history service doesn't have an equivalent for.
  78. //
  79. // NOTE: this relies on the updates only containing either op.i or op.d entries
  80. // but not both, which is the case because diffAsShareJsOps does this
  81. return updates.filter(
  82. update => !(update.op && (update.op.i === '' || update.op.d === ''))
  83. )
  84. }
  85. export function concatUpdatesWithSameVersion(updates) {
  86. const concattedUpdates = []
  87. for (let update of updates) {
  88. if (update.op != null) {
  89. update = cloneWithOp(update, [update.op])
  90. const lastUpdate = concattedUpdates[concattedUpdates.length - 1]
  91. if (
  92. lastUpdate != null &&
  93. lastUpdate.op != null &&
  94. lastUpdate.v === update.v &&
  95. lastUpdate.doc === update.doc &&
  96. lastUpdate.pathname === update.pathname
  97. ) {
  98. lastUpdate.op = lastUpdate.op.concat(update.op)
  99. } else {
  100. concattedUpdates.push(update)
  101. }
  102. } else {
  103. concattedUpdates.push(update)
  104. }
  105. }
  106. return concattedUpdates
  107. }
  108. export function compressRawUpdates(rawUpdates) {
  109. let updates = convertToSingleOpUpdates(rawUpdates)
  110. updates = compressUpdates(updates)
  111. updates = filterBlankUpdates(updates)
  112. updates = concatUpdatesWithSameVersion(updates)
  113. return updates
  114. }
  115. export function compressUpdates(updates) {
  116. if (updates.length === 0) {
  117. return []
  118. }
  119. let compressedUpdates = [updates.shift()]
  120. for (const update of updates) {
  121. const lastCompressedUpdate = compressedUpdates.pop()
  122. if (lastCompressedUpdate != null) {
  123. const newCompressedUpdates = _concatTwoUpdates(
  124. lastCompressedUpdate,
  125. update
  126. )
  127. compressedUpdates = compressedUpdates.concat(newCompressedUpdates)
  128. } else {
  129. compressedUpdates.push(update)
  130. }
  131. }
  132. return compressedUpdates
  133. }
  134. function _concatTwoUpdates(firstUpdate, secondUpdate) {
  135. // Previously we cloned firstUpdate and secondUpdate at this point but we
  136. // can skip this step because whenever they are returned with
  137. // modification there is always a clone at that point via
  138. // mergeUpdatesWithOp.
  139. let offset
  140. if (firstUpdate.op == null || secondUpdate.op == null) {
  141. // Project structure ops
  142. return [firstUpdate, secondUpdate]
  143. }
  144. if (
  145. firstUpdate.doc !== secondUpdate.doc ||
  146. firstUpdate.pathname !== secondUpdate.pathname
  147. ) {
  148. return [firstUpdate, secondUpdate]
  149. }
  150. if (firstUpdate.meta.user_id !== secondUpdate.meta.user_id) {
  151. return [firstUpdate, secondUpdate]
  152. }
  153. if (
  154. (firstUpdate.meta.type === 'external' &&
  155. secondUpdate.meta.type !== 'external') ||
  156. (firstUpdate.meta.type !== 'external' &&
  157. secondUpdate.meta.type === 'external') ||
  158. (firstUpdate.meta.type === 'external' &&
  159. secondUpdate.meta.type === 'external' &&
  160. firstUpdate.meta.source !== secondUpdate.meta.source)
  161. ) {
  162. return [firstUpdate, secondUpdate]
  163. }
  164. if (secondUpdate.meta.ts - firstUpdate.meta.ts > MAX_TIME_BETWEEN_UPDATES) {
  165. return [firstUpdate, secondUpdate]
  166. }
  167. const firstOp = firstUpdate.op
  168. const secondOp = secondUpdate.op
  169. const firstSize =
  170. (firstOp.i && firstOp.i.length) || (firstOp.d && firstOp.d.length)
  171. const secondSize =
  172. (secondOp.i && secondOp.i.length) || (secondOp.d && secondOp.d.length)
  173. const firstOpInsideSecondOp =
  174. secondOp.p <= firstOp.p && firstOp.p <= secondOp.p + secondSize
  175. const secondOpInsideFirstOp =
  176. firstOp.p <= secondOp.p && secondOp.p <= firstOp.p + firstSize
  177. const combinedLengthUnderLimit = firstSize + secondSize < MAX_UPDATE_SIZE
  178. // Two inserts
  179. if (
  180. firstOp.i != null &&
  181. secondOp.i != null &&
  182. secondOpInsideFirstOp &&
  183. combinedLengthUnderLimit
  184. ) {
  185. return [
  186. mergeUpdatesWithOp(firstUpdate, secondUpdate, {
  187. p: firstOp.p,
  188. i: strInject(firstOp.i, secondOp.p - firstOp.p, secondOp.i),
  189. }),
  190. ]
  191. // Two deletes
  192. } else if (
  193. firstOp.d != null &&
  194. secondOp.d != null &&
  195. firstOpInsideSecondOp &&
  196. combinedLengthUnderLimit
  197. ) {
  198. return [
  199. mergeUpdatesWithOp(firstUpdate, secondUpdate, {
  200. p: secondOp.p,
  201. d: strInject(secondOp.d, firstOp.p - secondOp.p, firstOp.d),
  202. }),
  203. ]
  204. // An insert and then a delete
  205. } else if (firstOp.i != null && secondOp.d != null && secondOpInsideFirstOp) {
  206. offset = secondOp.p - firstOp.p
  207. const insertedText = firstOp.i.slice(offset, offset + secondOp.d.length)
  208. // Only trim the insert when the delete is fully contained within in it
  209. if (insertedText === secondOp.d) {
  210. const insert = strRemove(firstOp.i, offset, secondOp.d.length)
  211. if (insert === '') {
  212. return []
  213. } else {
  214. return [
  215. mergeUpdatesWithOp(firstUpdate, secondUpdate, {
  216. p: firstOp.p,
  217. i: insert,
  218. }),
  219. ]
  220. }
  221. } else {
  222. // This will only happen if the delete extends outside the insert
  223. return [firstUpdate, secondUpdate]
  224. }
  225. // A delete then an insert at the same place, likely a copy-paste of a chunk of content
  226. } else if (
  227. firstOp.d != null &&
  228. secondOp.i != null &&
  229. firstOp.p === secondOp.p
  230. ) {
  231. offset = firstOp.p
  232. const diffUpdates = diffAsShareJsOps(firstOp.d, secondOp.i).map(
  233. function (op) {
  234. op.p += offset
  235. return mergeUpdatesWithOp(firstUpdate, secondUpdate, op)
  236. }
  237. )
  238. // Doing a diff like this loses track of the doc lengths for each
  239. // update, so recalculate them
  240. let { doc_length: docLength } = firstUpdate.meta
  241. for (const update of diffUpdates) {
  242. update.meta.doc_length = docLength
  243. docLength = adjustLengthByOp(docLength, update.op)
  244. }
  245. return diffUpdates
  246. } else {
  247. return [firstUpdate, secondUpdate]
  248. }
  249. }
  250. export function diffAsShareJsOps(before, after) {
  251. const diffs = dmp.diff_main(before, after)
  252. dmp.diff_cleanupSemantic(diffs)
  253. const ops = []
  254. let position = 0
  255. for (const diff of diffs) {
  256. const type = diff[0]
  257. const content = diff[1]
  258. if (type === ADDED) {
  259. ops.push({
  260. i: content,
  261. p: position,
  262. })
  263. position += content.length
  264. } else if (type === REMOVED) {
  265. ops.push({
  266. d: content,
  267. p: position,
  268. })
  269. } else if (type === UNCHANGED) {
  270. position += content.length
  271. } else {
  272. throw new Error('Unknown type')
  273. }
  274. }
  275. return ops
  276. }