UpdateCompressor.js 8.7 KB

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