ChunkTranslator.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import _ from 'lodash'
  2. import logger from '@overleaf/logger'
  3. import OError from '@overleaf/o-error'
  4. import * as HistoryStoreManager from './HistoryStoreManager.js'
  5. import * as WebApiManager from './WebApiManager.js'
  6. import * as Errors from './Errors.js'
  7. export function convertToSummarizedUpdates(chunk, callback) {
  8. const version = chunk.chunk.startVersion
  9. const { files } = chunk.chunk.history.snapshot
  10. const builder = new UpdateSetBuilder(version, files)
  11. for (const change of chunk.chunk.history.changes) {
  12. try {
  13. builder.applyChange(change)
  14. } catch (error1) {
  15. const error = error1
  16. return callback(error)
  17. }
  18. }
  19. callback(null, builder.summarizedUpdates)
  20. }
  21. export function convertToDiffUpdates(
  22. projectId,
  23. chunk,
  24. pathname,
  25. fromVersion,
  26. toVersion,
  27. callback
  28. ) {
  29. let error
  30. let version = chunk.chunk.startVersion
  31. const { files } = chunk.chunk.history.snapshot
  32. const builder = new UpdateSetBuilder(version, files)
  33. let file = null
  34. for (const change of chunk.chunk.history.changes) {
  35. // Because we're referencing by pathname, which can change, we
  36. // want to get the first file in the range fromVersion:toVersion
  37. // that has the pathname we want. Note that this might not exist yet
  38. // at fromVersion, so we'll just settle for the first one we find
  39. // after that.
  40. if (fromVersion <= version && version <= toVersion) {
  41. if (file == null) {
  42. file = builder.getFile(pathname)
  43. }
  44. }
  45. try {
  46. builder.applyChange(change)
  47. } catch (error1) {
  48. error = error1
  49. return callback(error)
  50. }
  51. version += 1
  52. }
  53. // Versions act as fence posts, with updates taking us from one to another,
  54. // so we also need to check after the final update, when we're at the last version.
  55. if (fromVersion <= version && version <= toVersion) {
  56. if (file == null) {
  57. file = builder.getFile(pathname)
  58. }
  59. }
  60. // return an empty diff if the file was flagged as missing with an explicit null
  61. if (builder.getFile(pathname) === null) {
  62. return callback(null, { initialContent: '', updates: [] })
  63. }
  64. if (file == null) {
  65. error = new Errors.NotFoundError(
  66. `pathname '${pathname}' not found in range`
  67. )
  68. return callback(error)
  69. }
  70. WebApiManager.getHistoryId(projectId, (err, historyId) => {
  71. if (err) {
  72. return callback(err)
  73. }
  74. file.getDiffUpdates(historyId, fromVersion, toVersion, callback)
  75. })
  76. }
  77. class UpdateSetBuilder {
  78. constructor(startVersion, files) {
  79. this.version = startVersion
  80. this.summarizedUpdates = []
  81. this.files = Object.create(null)
  82. for (const pathname in files) {
  83. // initialize file from snapshot
  84. const data = files[pathname]
  85. this.files[pathname] = new File(pathname, data, startVersion)
  86. }
  87. }
  88. getFile(pathname) {
  89. return this.files[pathname]
  90. }
  91. applyChange(change) {
  92. const timestamp = new Date(change.timestamp)
  93. let authors = _.map(change.authors, id => {
  94. if (id == null) {
  95. return null
  96. }
  97. return id
  98. })
  99. authors = authors.concat(change.v2Authors || [])
  100. this.currentUpdate = {
  101. meta: {
  102. users: authors,
  103. start_ts: timestamp.getTime(),
  104. end_ts: timestamp.getTime(),
  105. },
  106. v: this.version,
  107. pathnames: new Set([]),
  108. project_ops: [],
  109. }
  110. if (change.origin) {
  111. this.currentUpdate.meta.origin = change.origin
  112. }
  113. for (const op of change.operations) {
  114. this.applyOperation(op, timestamp, authors)
  115. }
  116. this.currentUpdate.pathnames = Array.from(this.currentUpdate.pathnames)
  117. this.summarizedUpdates.push(this.currentUpdate)
  118. this.version += 1
  119. }
  120. applyOperation(op, timestamp, authors) {
  121. if (UpdateSetBuilder._isTextOperation(op)) {
  122. this.applyTextOperation(op, timestamp, authors)
  123. } else if (UpdateSetBuilder._isRenameOperation(op)) {
  124. this.applyRenameOperation(op, timestamp, authors)
  125. } else if (UpdateSetBuilder._isRemoveFileOperation(op)) {
  126. this.applyRemoveFileOperation(op, timestamp, authors)
  127. } else if (UpdateSetBuilder._isAddFileOperation(op)) {
  128. this.applyAddFileOperation(op, timestamp, authors)
  129. }
  130. }
  131. applyTextOperation(operation, timestamp, authors) {
  132. const { pathname } = operation
  133. if (pathname === '') {
  134. // this shouldn't happen, but we continue to allow the user to see the history
  135. logger.warn(
  136. { operation, timestamp, authors },
  137. 'pathname is empty for text operation'
  138. )
  139. return
  140. }
  141. const file = this.files[pathname]
  142. if (file == null) {
  143. // this shouldn't happen, but we continue to allow the user to see the history
  144. logger.warn(
  145. { operation, timestamp, authors },
  146. 'file is missing for text operation'
  147. )
  148. this.files[pathname] = null // marker for a missing file
  149. return
  150. }
  151. file.applyTextOperation(authors, timestamp, this.version, operation)
  152. this.currentUpdate.pathnames.add(pathname)
  153. }
  154. applyRenameOperation(operation, timestamp, authors) {
  155. const { pathname, newPathname } = operation
  156. const file = this.files[pathname]
  157. if (file == null) {
  158. // this shouldn't happen, but we continue to allow the user to see the history
  159. logger.warn(
  160. { operation, timestamp, authors },
  161. 'file is missing for rename operation'
  162. )
  163. this.files[pathname] = null // marker for a missing file
  164. return
  165. }
  166. file.rename(newPathname)
  167. delete this.files[pathname]
  168. this.files[newPathname] = file
  169. this.currentUpdate.project_ops.push({
  170. rename: { pathname, newPathname },
  171. })
  172. }
  173. applyAddFileOperation(operation, timestamp, authors) {
  174. const { pathname } = operation
  175. // add file
  176. this.files[pathname] = new File(pathname, operation.file, this.version)
  177. this.currentUpdate.project_ops.push({ add: { pathname } })
  178. }
  179. applyRemoveFileOperation(operation, timestamp, authors) {
  180. const { pathname } = operation
  181. const file = this.files[pathname]
  182. if (file == null) {
  183. // this shouldn't happen, but we continue to allow the user to see the history
  184. logger.warn(
  185. { operation, timestamp, authors },
  186. 'pathname not found when removing file'
  187. )
  188. this.files[pathname] = null // marker for a missing file
  189. return
  190. }
  191. delete this.files[pathname]
  192. this.currentUpdate.project_ops.push({ remove: { pathname } })
  193. }
  194. static _isTextOperation(op) {
  195. return Object.prototype.hasOwnProperty.call(op, 'textOperation')
  196. }
  197. static _isRenameOperation(op) {
  198. return (
  199. Object.prototype.hasOwnProperty.call(op, 'newPathname') &&
  200. op.newPathname !== ''
  201. )
  202. }
  203. static _isRemoveFileOperation(op) {
  204. return (
  205. Object.prototype.hasOwnProperty.call(op, 'newPathname') &&
  206. op.newPathname === ''
  207. )
  208. }
  209. static _isAddFileOperation(op) {
  210. return Object.prototype.hasOwnProperty.call(op, 'file')
  211. }
  212. }
  213. class File {
  214. constructor(pathname, snapshot, initialVersion) {
  215. this.pathname = pathname
  216. this.snapshot = snapshot
  217. this.initialVersion = initialVersion
  218. this.operations = []
  219. }
  220. applyTextOperation(authors, timestamp, version, operation) {
  221. this.operations.push({ authors, timestamp, version, operation })
  222. }
  223. rename(pathname) {
  224. this.pathname = pathname
  225. }
  226. getDiffUpdates(historyId, fromVersion, toVersion, callback) {
  227. if (this.snapshot.stringLength == null) {
  228. // Binary file
  229. return callback(null, { binary: true })
  230. }
  231. HistoryStoreManager.getProjectBlob(
  232. historyId,
  233. this.snapshot.hash,
  234. (error, content) => {
  235. if (error != null) {
  236. return callback(OError.tag(error))
  237. }
  238. let initialContent = content
  239. const updates = []
  240. for (let operation of this.operations) {
  241. let authors, ops, timestamp, version
  242. ;({ authors, timestamp, version, operation } = operation)
  243. ;({ content, ops } = this._convertTextOperation(content, operation))
  244. // Keep updating our initialContent, until we're actually in the version
  245. // we want to diff, at which point initialContent is the content just before
  246. // the diff updates we will return
  247. if (version < fromVersion) {
  248. initialContent = content
  249. }
  250. // We only need to return the updates between fromVersion and toVersion
  251. if (fromVersion <= version && version < toVersion) {
  252. updates.push({
  253. meta: {
  254. users: authors,
  255. start_ts: timestamp.getTime(),
  256. end_ts: timestamp.getTime(),
  257. },
  258. v: version,
  259. op: ops,
  260. })
  261. }
  262. }
  263. callback(null, { initialContent, updates })
  264. }
  265. )
  266. }
  267. _convertTextOperation(content, operation) {
  268. const textUpdateBuilder = new TextUpdateBuilder(content)
  269. for (const op of operation.textOperation || []) {
  270. textUpdateBuilder.applyOp(op)
  271. }
  272. textUpdateBuilder.finish()
  273. return {
  274. content: textUpdateBuilder.result,
  275. ops: textUpdateBuilder.changes,
  276. }
  277. }
  278. }
  279. class TextUpdateBuilder {
  280. constructor(source) {
  281. this.source = source
  282. this.sourceCursor = 0
  283. this.result = ''
  284. this.changes = []
  285. }
  286. applyOp(op) {
  287. if (TextUpdateBuilder._isRetainOperation(op)) {
  288. this.applyRetain(op)
  289. }
  290. if (TextUpdateBuilder._isInsertOperation(op)) {
  291. this.applyInsert(op)
  292. }
  293. if (TextUpdateBuilder._isDeleteOperation(op)) {
  294. this.applyDelete(-op)
  295. }
  296. }
  297. applyRetain(offset) {
  298. this.result += this.source.slice(
  299. this.sourceCursor,
  300. this.sourceCursor + offset
  301. )
  302. this.sourceCursor += offset
  303. }
  304. applyInsert(content) {
  305. this.changes.push({
  306. i: content,
  307. p: this.result.length,
  308. })
  309. this.result += content
  310. // The source cursor doesn't advance
  311. }
  312. applyDelete(offset) {
  313. const deletedContent = this.source.slice(
  314. this.sourceCursor,
  315. this.sourceCursor + offset
  316. )
  317. this.changes.push({
  318. d: deletedContent,
  319. p: this.result.length,
  320. })
  321. this.sourceCursor += offset
  322. }
  323. finish() {
  324. if (this.sourceCursor < this.source.length) {
  325. this.result += this.source.slice(this.sourceCursor)
  326. }
  327. }
  328. static _isRetainOperation(op) {
  329. return typeof op === 'number' && op > 0
  330. }
  331. static _isInsertOperation(op) {
  332. return typeof op === 'string'
  333. }
  334. static _isDeleteOperation(op) {
  335. return typeof op === 'number' && op < 0
  336. }
  337. }