UpdateTranslator.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. // @ts-check
  2. import _ from 'lodash'
  3. import Core from 'overleaf-editor-core'
  4. import * as Errors from './Errors.js'
  5. import * as OperationsCompressor from './OperationsCompressor.js'
  6. /**
  7. * @typedef {import('./types.ts').AddDocUpdate} AddDocUpdate
  8. * @typedef {import('./types.ts').AddFileUpdate} AddFileUpdate
  9. * @typedef {import('./types.ts').CommentOp} CommentOp
  10. * @typedef {import('./types.ts').DeleteOp} DeleteOp
  11. * @typedef {import('./types.ts').InsertOp} InsertOp
  12. * @typedef {import('./types.ts').Op} Op
  13. * @typedef {import('./types.ts').RenameUpdate} RenameUpdate
  14. * @typedef {import('./types.ts').TextUpdate} TextUpdate
  15. * @typedef {import('./types.ts').DeleteCommentUpdate} DeleteCommentUpdate
  16. * @typedef {import('./types.ts').Update} Update
  17. * @typedef {import('./types.ts').UpdateWithBlob} UpdateWithBlob
  18. */
  19. /**
  20. * Convert updates into history changes
  21. *
  22. * @param {string} projectId
  23. * @param {UpdateWithBlob[]} updatesWithBlobs
  24. * @returns {Array<Core.Change | null>}
  25. */
  26. export function convertToChanges(projectId, updatesWithBlobs) {
  27. return updatesWithBlobs.map(update => _convertToChange(projectId, update))
  28. }
  29. /**
  30. * Convert an update into a history change
  31. *
  32. * @param {string} projectId
  33. * @param {UpdateWithBlob} updateWithBlob
  34. * @returns {Core.Change | null}
  35. */
  36. function _convertToChange(projectId, updateWithBlob) {
  37. let operations
  38. const { update } = updateWithBlob
  39. let projectVersion = null
  40. const v2DocVersions = {}
  41. if (_isRenameUpdate(update)) {
  42. operations = [
  43. {
  44. pathname: _convertPathname(update.pathname),
  45. newPathname: _convertPathname(update.new_pathname),
  46. },
  47. ]
  48. projectVersion = update.version
  49. } else if (isAddUpdate(update)) {
  50. operations = [
  51. {
  52. pathname: _convertPathname(update.pathname),
  53. file: {
  54. hash: updateWithBlob.blobHash,
  55. },
  56. },
  57. ]
  58. projectVersion = update.version
  59. } else if (isTextUpdate(update)) {
  60. const docLength = update.meta.doc_length
  61. let pathname = update.meta.pathname
  62. pathname = _convertPathname(pathname)
  63. const builder = new OperationsBuilder(docLength, pathname)
  64. // convert ops
  65. for (const op of update.op) {
  66. // if this throws an exception it will be caught in convertToChanges
  67. builder.addOp(op)
  68. }
  69. operations = builder.finish()
  70. // add doc version information if present
  71. if (update.v != null) {
  72. v2DocVersions[update.doc] = { pathname, v: update.v }
  73. }
  74. } else if (isDeleteCommentUpdate(update)) {
  75. operations = [
  76. {
  77. pathname: _convertPathname(update.pathname),
  78. deleteComment: update.deleteComment,
  79. },
  80. ]
  81. } else {
  82. const error = new Errors.UpdateWithUnknownFormatError(
  83. 'update with unknown format',
  84. { projectId, update }
  85. )
  86. throw error
  87. }
  88. let v2Authors
  89. if (update.meta.user_id === 'anonymous-user') {
  90. // history-v1 uses null to represent an anonymous author
  91. v2Authors = [null]
  92. } else {
  93. // user_id is missing on resync operations that update the contents of a doc
  94. v2Authors = _.compact([update.meta.user_id])
  95. }
  96. const rawChange = {
  97. operations,
  98. v2Authors,
  99. timestamp: new Date(update.meta.ts).toISOString(),
  100. projectVersion,
  101. v2DocVersions: Object.keys(v2DocVersions).length ? v2DocVersions : null,
  102. }
  103. if (update.meta.origin) {
  104. rawChange.origin = update.meta.origin
  105. } else if (update.meta.type === 'external' && update.meta.source) {
  106. rawChange.origin = { kind: update.meta.source }
  107. }
  108. const change = Core.Change.fromRaw(rawChange)
  109. if (change != null) {
  110. change.operations = OperationsCompressor.compressOperations(
  111. change.operations
  112. )
  113. }
  114. return change
  115. }
  116. /**
  117. * @param {Update} update
  118. * @returns {update is RenameUpdate}
  119. */
  120. function _isRenameUpdate(update) {
  121. return 'new_pathname' in update && update.new_pathname != null
  122. }
  123. /**
  124. * @param {Update} update
  125. * @returns {update is AddDocUpdate}
  126. */
  127. function _isAddDocUpdate(update) {
  128. return (
  129. 'doc' in update &&
  130. update.doc != null &&
  131. 'docLines' in update &&
  132. update.docLines != null
  133. )
  134. }
  135. /**
  136. * @param {Update} update
  137. * @returns {update is AddFileUpdate}
  138. */
  139. function _isAddFileUpdate(update) {
  140. return (
  141. 'file' in update &&
  142. update.file != null &&
  143. 'url' in update &&
  144. update.url != null
  145. )
  146. }
  147. /**
  148. * @param {Update} update
  149. * @returns {update is TextUpdate}
  150. */
  151. export function isTextUpdate(update) {
  152. return (
  153. 'doc' in update &&
  154. update.doc != null &&
  155. 'op' in update &&
  156. update.op != null &&
  157. 'pathname' in update.meta &&
  158. update.meta.pathname != null &&
  159. 'doc_length' in update.meta &&
  160. update.meta.doc_length != null
  161. )
  162. }
  163. export function isProjectStructureUpdate(update) {
  164. return isAddUpdate(update) || _isRenameUpdate(update)
  165. }
  166. /**
  167. * @param {Update} update
  168. * @returns {update is AddDocUpdate | AddFileUpdate}
  169. */
  170. export function isAddUpdate(update) {
  171. return _isAddDocUpdate(update) || _isAddFileUpdate(update)
  172. }
  173. /**
  174. * @param {Update} update
  175. * @returns {update is DeleteCommentUpdate}
  176. */
  177. export function isDeleteCommentUpdate(update) {
  178. return 'deleteComment' in update
  179. }
  180. export function _convertPathname(pathname) {
  181. // Strip leading /
  182. pathname = pathname.replace(/^\//, '')
  183. // Replace \\ with _. Backslashes are no longer allowed
  184. // in projects in web, but we have some which have gone through
  185. // into history before this restriction was added. This makes
  186. // them valid for the history store.
  187. // See https://github.com/overleaf/write_latex/issues/4471
  188. pathname = pathname.replace(/\\/g, '_')
  189. // workaround for filenames containing asterisks, this will
  190. // fail if a corresponding replacement file already exists but it
  191. // would fail anyway without this attempt to fix the pathname.
  192. // See https://github.com/overleaf/internal/issues/900
  193. pathname = pathname.replace(/\*/g, '__ASTERISK__')
  194. // workaround for filenames beginning with spaces
  195. // See https://github.com/overleaf/internal/issues/1404
  196. // note: we have already stripped any leading slash above
  197. pathname = pathname.replace(/^ /, '__SPACE__') // handle top-level
  198. pathname = pathname.replace(/\/ /g, '/__SPACE__') // handle folders
  199. return pathname
  200. }
  201. class OperationsBuilder {
  202. /**
  203. * @param {number} docLength
  204. * @param {string} pathname
  205. */
  206. constructor(docLength, pathname) {
  207. /**
  208. * List of operations being built
  209. */
  210. this.operations = []
  211. /**
  212. * Currently built text operation
  213. *
  214. * @type {(number | string)[]}
  215. */
  216. this.textOperation = []
  217. /**
  218. * Cursor inside the current text operation
  219. */
  220. this.cursor = 0
  221. this.docLength = docLength
  222. this.pathname = pathname
  223. }
  224. /**
  225. * @param {Op} op
  226. * @returns {void}
  227. */
  228. addOp(op) {
  229. if (isComment(op)) {
  230. // Close the current text operation
  231. this.pushTextOperation()
  232. // Add a comment operation
  233. this.operations.push({
  234. pathname: this.pathname,
  235. commentId: op.t,
  236. ranges: [
  237. {
  238. pos: op.p,
  239. length: op.c.length,
  240. },
  241. ],
  242. })
  243. return
  244. }
  245. if (!isInsert(op) && !isDelete(op)) {
  246. throw new Errors.UnexpectedOpTypeError('unexpected op type', { op })
  247. }
  248. // We sometimes receive operations that operate at positions outside the
  249. // docLength. Document updater coerces the position to the end of the
  250. // document. We do the same here.
  251. const pos = Math.min(op.p, this.docLength)
  252. if (pos < this.cursor) {
  253. this.pushTextOperation()
  254. // At this point, this.cursor === 0 and we can continue
  255. }
  256. if (pos > this.cursor) {
  257. this.retain(pos - this.cursor)
  258. }
  259. if (isInsert(op)) {
  260. this.insert(op.i)
  261. }
  262. if (isDelete(op)) {
  263. this.delete(op.d.length)
  264. }
  265. }
  266. retain(length) {
  267. this.textOperation.push(length)
  268. this.cursor += length
  269. }
  270. insert(str) {
  271. this.textOperation.push(str)
  272. this.cursor += str.length
  273. this.docLength += str.length
  274. }
  275. delete(length) {
  276. this.textOperation.push(-length)
  277. this.docLength -= length
  278. }
  279. pushTextOperation() {
  280. if (this.textOperation.length > 0)
  281. if (this.cursor < this.docLength) {
  282. this.retain(this.docLength - this.cursor)
  283. }
  284. if (this.textOperation.length > 0) {
  285. this.operations.push({
  286. pathname: this.pathname,
  287. textOperation: this.textOperation,
  288. })
  289. this.textOperation = []
  290. }
  291. this.cursor = 0
  292. }
  293. finish() {
  294. this.pushTextOperation()
  295. return this.operations
  296. }
  297. }
  298. /**
  299. * @param {Op} op
  300. * @returns {op is InsertOp}
  301. */
  302. function isInsert(op) {
  303. return 'i' in op && op.i != null
  304. }
  305. /**
  306. * @param {Op} op
  307. * @returns {op is DeleteOp}
  308. */
  309. function isDelete(op) {
  310. return 'd' in op && op.d != null
  311. }
  312. /**
  313. * @param {Op} op
  314. * @returns {op is CommentOp}
  315. */
  316. function isComment(op) {
  317. return 'c' in op && op.c != null && 't' in op && op.t != null
  318. }