SnapshotManager.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. // @ts-check
  2. import { callbackify } from 'node:util'
  3. import Core from 'overleaf-editor-core'
  4. import { Readable as StringStream } from 'node:stream'
  5. import OError from '@overleaf/o-error'
  6. import * as HistoryStoreManager from './HistoryStoreManager.js'
  7. import * as WebApiManager from './WebApiManager.js'
  8. import * as Errors from './Errors.js'
  9. import _ from 'lodash'
  10. /**
  11. * @import { Snapshot } from 'overleaf-editor-core'
  12. * @import { RangesSnapshot } from './types'
  13. */
  14. StringStream.prototype._read = function () {}
  15. const MAX_REQUESTS = 4 // maximum number of parallel requests to v1 history service
  16. /**
  17. *
  18. * @param {string} projectId
  19. * @param {number} version
  20. * @param {string} pathname
  21. */
  22. async function getFileSnapshotStream(projectId, version, pathname) {
  23. const snapshot = await _getSnapshotAtVersion(projectId, version)
  24. const file = snapshot.getFile(pathname)
  25. if (file == null) {
  26. throw new Errors.NotFoundError(`${pathname} not found`, {
  27. projectId,
  28. version,
  29. pathname,
  30. })
  31. }
  32. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  33. if (file.isEditable()) {
  34. await file.load('eager', HistoryStoreManager.getBlobStore(historyId))
  35. const stream = new StringStream()
  36. stream.push(file.getContent({ filterTrackedDeletes: true }))
  37. stream.push(null)
  38. return stream
  39. } else {
  40. return await HistoryStoreManager.promises.getProjectBlobStream(
  41. historyId,
  42. file.getHash()
  43. )
  44. }
  45. }
  46. /**
  47. * Constructs a snapshot of the ranges in a document-updater compatible format.
  48. * Positions will be relative to a document where tracked deletes have been
  49. * removed from the string. This also means that if a tracked delete overlaps
  50. * a comment range, the comment range will be truncated.
  51. *
  52. * @param {string} projectId
  53. * @param {number} version
  54. * @param {string} pathname
  55. * @returns {Promise<RangesSnapshot>}
  56. */
  57. async function getRangesSnapshot(projectId, version, pathname) {
  58. const snapshot = await _getSnapshotAtVersion(projectId, version)
  59. const file = snapshot.getFile(pathname)
  60. if (!file) {
  61. throw new Errors.NotFoundError(`${pathname} not found`, {
  62. projectId,
  63. version,
  64. pathname,
  65. })
  66. }
  67. if (!file.isEditable()) {
  68. throw new Error('File is not editable')
  69. }
  70. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  71. await file.load('eager', HistoryStoreManager.getBlobStore(historyId))
  72. const content = file.getContent()
  73. if (content == null) {
  74. throw new Error('Unable to read file contents')
  75. }
  76. const trackedChanges = file.getTrackedChanges().asSorted()
  77. const comments = file.getComments().toArray()
  78. const docUpdaterCompatibleTrackedChanges = []
  79. let trackedDeletionOffset = 0
  80. for (const trackedChange of trackedChanges) {
  81. const isTrackedDeletion = trackedChange.tracking.type === 'delete'
  82. const trackedChangeContent = content.slice(
  83. trackedChange.range.start,
  84. trackedChange.range.end
  85. )
  86. const tcContent = isTrackedDeletion
  87. ? { d: trackedChangeContent }
  88. : { i: trackedChangeContent }
  89. docUpdaterCompatibleTrackedChanges.push({
  90. op: {
  91. p: trackedChange.range.start - trackedDeletionOffset,
  92. ...tcContent,
  93. },
  94. metadata: {
  95. ts: trackedChange.tracking.ts.toISOString(),
  96. user_id: trackedChange.tracking.userId,
  97. },
  98. })
  99. if (isTrackedDeletion) {
  100. trackedDeletionOffset += trackedChange.range.length
  101. }
  102. }
  103. // Comments are shifted left by the length of any previous tracked deletions.
  104. // If they overlap with a tracked deletion, they are truncated.
  105. //
  106. // Example:
  107. // { } comment
  108. // [ ] tracked deletion
  109. // the quic[k {b]rown [fox] jum[ps} ove]r the lazy dog
  110. // => rown jum
  111. // starting at position 8
  112. const trackedDeletions = trackedChanges.filter(
  113. tc => tc.tracking.type === 'delete'
  114. )
  115. const docUpdaterCompatibleComments = []
  116. for (const comment of comments) {
  117. let trackedDeletionIndex = 0
  118. if (comment.ranges.length === 0) {
  119. // Translate detached comments into zero length comments at position 0
  120. docUpdaterCompatibleComments.push({
  121. op: {
  122. p: 0,
  123. c: '',
  124. t: comment.id,
  125. resolved: comment.resolved,
  126. },
  127. })
  128. continue
  129. }
  130. // Consider a multiple range comment as a single comment that joins all its
  131. // ranges
  132. const commentStart = comment.ranges[0].start
  133. const commentEnd = comment.ranges[comment.ranges.length - 1].end
  134. let commentContent = ''
  135. // Docupdater position
  136. let position = commentStart
  137. while (trackedDeletions[trackedDeletionIndex]?.range.end <= commentStart) {
  138. // Skip over tracked deletions that are before the current comment range
  139. position -= trackedDeletions[trackedDeletionIndex].range.length
  140. trackedDeletionIndex++
  141. }
  142. if (trackedDeletions[trackedDeletionIndex]?.range.start < commentStart) {
  143. // There's overlap with a tracked deletion, move the position left and
  144. // truncate the overlap
  145. position -=
  146. commentStart - trackedDeletions[trackedDeletionIndex].range.start
  147. }
  148. // Cursor in the history content
  149. let cursor = commentStart
  150. while (cursor < commentEnd) {
  151. const trackedDeletion = trackedDeletions[trackedDeletionIndex]
  152. if (!trackedDeletion || trackedDeletion.range.start >= commentEnd) {
  153. // We've run out of relevant tracked changes
  154. commentContent += content.slice(cursor, commentEnd)
  155. break
  156. }
  157. if (trackedDeletion.range.start > cursor) {
  158. // There's a gap between the current cursor and the tracked deletion
  159. commentContent += content.slice(cursor, trackedDeletion.range.start)
  160. }
  161. if (trackedDeletion.range.end <= commentEnd) {
  162. // Skip to the end of the tracked delete
  163. cursor = trackedDeletion.range.end
  164. trackedDeletionIndex++
  165. } else {
  166. // We're done with that comment
  167. break
  168. }
  169. }
  170. docUpdaterCompatibleComments.push({
  171. op: {
  172. p: position,
  173. c: commentContent,
  174. t: comment.id,
  175. resolved: comment.resolved,
  176. },
  177. id: comment.id,
  178. })
  179. }
  180. return {
  181. changes: docUpdaterCompatibleTrackedChanges,
  182. comments: docUpdaterCompatibleComments,
  183. }
  184. }
  185. /**
  186. * Gets the file metadata at a specific version.
  187. *
  188. * @param {string} projectId
  189. * @param {number} version
  190. * @param {string} pathname
  191. * @returns {Promise<{metadata: any}>}
  192. */
  193. async function getFileMetadataSnapshot(projectId, version, pathname) {
  194. const snapshot = await _getSnapshotAtVersion(projectId, version)
  195. const file = snapshot.getFile(pathname)
  196. if (!file) {
  197. throw new Errors.NotFoundError(`${pathname} not found`, {
  198. projectId,
  199. version,
  200. pathname,
  201. })
  202. }
  203. const rawMetadata = file.getMetadata()
  204. const metadata = _.isEmpty(rawMetadata) ? undefined : rawMetadata
  205. return { metadata }
  206. }
  207. // Returns project snapshot containing the document content for files with
  208. // text operations in the relevant chunk, and hashes for unmodified/binary
  209. // files. Used by git bridge to get the state of the project.
  210. async function getProjectSnapshot(projectId, version) {
  211. const snapshot = await _getSnapshotAtVersion(projectId, version)
  212. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  213. await _loadFilesLimit(
  214. snapshot,
  215. 'eager',
  216. HistoryStoreManager.getBlobStore(historyId)
  217. )
  218. return {
  219. projectId,
  220. files: snapshot.getFileMap().map(file => {
  221. if (!file) {
  222. return null
  223. }
  224. const content = file.getContent({
  225. filterTrackedDeletes: true,
  226. })
  227. if (content === null) {
  228. return { data: { hash: file.getHash() } }
  229. }
  230. return { data: { content } }
  231. }),
  232. }
  233. }
  234. async function getPathsAtVersion(projectId, version) {
  235. const snapshot = await _getSnapshotAtVersion(projectId, version)
  236. return {
  237. paths: snapshot.getFilePathnames(),
  238. }
  239. }
  240. /**
  241. *
  242. * @param {string} projectId
  243. * @param {number} version
  244. */
  245. async function _getSnapshotAtVersion(projectId, version) {
  246. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  247. const data = await HistoryStoreManager.promises.getChunkAtVersion(
  248. projectId,
  249. historyId,
  250. version
  251. )
  252. const chunk = Core.Chunk.fromRaw(data.chunk)
  253. const snapshot = chunk.getSnapshot()
  254. const changes = chunk.getChanges().slice(0, version - chunk.getStartVersion())
  255. snapshot.applyAll(changes)
  256. return snapshot
  257. }
  258. /**
  259. * @param {string} projectId
  260. * @param {string} historyId
  261. * @return {Promise<Record<string, import('overleaf-editor-core').File>>}
  262. */
  263. async function getLatestSnapshotFiles(projectId, historyId) {
  264. const data = await HistoryStoreManager.promises.getMostRecentChunk(
  265. projectId,
  266. historyId
  267. )
  268. return await getLatestSnapshotFilesForChunk(historyId, data)
  269. }
  270. /**
  271. * @param {string} historyId
  272. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} chunk
  273. * @return {Promise<Record<string, import('overleaf-editor-core').File>>}
  274. */
  275. async function getLatestSnapshotFilesForChunk(historyId, chunk) {
  276. const { snapshot } = getLatestSnapshotFromChunk(chunk)
  277. const snapshotFiles = await snapshot.loadFiles(
  278. 'lazy',
  279. HistoryStoreManager.getBlobStore(historyId)
  280. )
  281. return snapshotFiles
  282. }
  283. /**
  284. * @param {string} projectId
  285. * @param {string} historyId
  286. * @return {Promise<{version: number, snapshot: import('overleaf-editor-core').Snapshot}>}
  287. */
  288. async function getLatestSnapshot(projectId, historyId) {
  289. const data = await HistoryStoreManager.promises.getMostRecentChunk(
  290. projectId,
  291. historyId
  292. )
  293. return getLatestSnapshotFromChunk(data)
  294. }
  295. /**
  296. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} data
  297. * @return {{version: number, snapshot: import('overleaf-editor-core').Snapshot}}
  298. */
  299. function getLatestSnapshotFromChunk(data) {
  300. if (data == null || data.chunk == null) {
  301. throw new OError('undefined chunk')
  302. }
  303. // apply all the changes in the chunk to get the current snapshot
  304. const chunk = Core.Chunk.fromRaw(data.chunk)
  305. const snapshot = chunk.getSnapshot()
  306. const changes = chunk.getChanges()
  307. snapshot.applyAll(changes)
  308. return {
  309. snapshot,
  310. version: chunk.getEndVersion(),
  311. }
  312. }
  313. async function getChangesSince(projectId, historyId, sinceVersion) {
  314. const allChanges = []
  315. let nextVersion
  316. while (true) {
  317. let data
  318. if (nextVersion) {
  319. data = await HistoryStoreManager.promises.getChunkAtVersion(
  320. projectId,
  321. historyId,
  322. nextVersion
  323. )
  324. } else {
  325. data = await HistoryStoreManager.promises.getMostRecentChunk(
  326. projectId,
  327. historyId
  328. )
  329. }
  330. if (data == null || data.chunk == null) {
  331. throw new OError('undefined chunk')
  332. }
  333. const chunk = Core.Chunk.fromRaw(data.chunk)
  334. if (sinceVersion > chunk.getEndVersion()) {
  335. throw new OError('requested version past the end')
  336. }
  337. const changes = chunk.getChanges()
  338. if (chunk.getStartVersion() > sinceVersion) {
  339. allChanges.unshift(...changes)
  340. nextVersion = chunk.getStartVersion()
  341. } else {
  342. allChanges.unshift(
  343. ...changes.slice(sinceVersion - chunk.getStartVersion())
  344. )
  345. break
  346. }
  347. }
  348. return allChanges
  349. }
  350. async function getChangesInChunkSince(projectId, historyId, sinceVersion) {
  351. const latestChunk = Core.Chunk.fromRaw(
  352. (
  353. await HistoryStoreManager.promises.getMostRecentChunk(
  354. projectId,
  355. historyId
  356. )
  357. ).chunk
  358. )
  359. if (sinceVersion > latestChunk.getEndVersion()) {
  360. throw new Errors.BadRequestError(
  361. 'requested version past the end of the history'
  362. )
  363. }
  364. const latestStartVersion = latestChunk.getStartVersion()
  365. let chunk = latestChunk
  366. if (sinceVersion < latestStartVersion) {
  367. chunk = Core.Chunk.fromRaw(
  368. (
  369. await HistoryStoreManager.promises.getChunkAtVersion(
  370. projectId,
  371. historyId,
  372. sinceVersion
  373. )
  374. ).chunk
  375. )
  376. }
  377. const changes = chunk
  378. .getChanges()
  379. .slice(sinceVersion - chunk.getStartVersion())
  380. return { latestStartVersion, changes }
  381. }
  382. async function _loadFilesLimit(snapshot, kind, blobStore) {
  383. await snapshot.fileMap.mapAsync(async file => {
  384. // only load changed files or files with tracked changes, others can be
  385. // dereferenced from their blobs (this method is only used by the git
  386. // bridge which understands how to load blobs).
  387. if (!file.isEditable() || (file.getHash() && !file.getRangesHash())) {
  388. return
  389. }
  390. await file.load(kind, blobStore)
  391. }, MAX_REQUESTS)
  392. }
  393. // EXPORTS
  394. const getChangesSinceCb = callbackify(getChangesSince)
  395. const getChangesInChunkSinceCb = callbackify(getChangesInChunkSince)
  396. const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
  397. const getProjectSnapshotCb = callbackify(getProjectSnapshot)
  398. const getLatestSnapshotCb = callbackify(getLatestSnapshot)
  399. const getLatestSnapshotFilesCb = callbackify(getLatestSnapshotFiles)
  400. const getLatestSnapshotFilesForChunkCb = callbackify(
  401. getLatestSnapshotFilesForChunk
  402. )
  403. const getRangesSnapshotCb = callbackify(getRangesSnapshot)
  404. const getFileMetadataSnapshotCb = callbackify(getFileMetadataSnapshot)
  405. const getPathsAtVersionCb = callbackify(getPathsAtVersion)
  406. export {
  407. getLatestSnapshotFromChunk,
  408. getChangesSinceCb as getChangesSince,
  409. getChangesInChunkSinceCb as getChangesInChunkSince,
  410. getFileSnapshotStreamCb as getFileSnapshotStream,
  411. getProjectSnapshotCb as getProjectSnapshot,
  412. getFileMetadataSnapshotCb as getFileMetadataSnapshot,
  413. getLatestSnapshotCb as getLatestSnapshot,
  414. getLatestSnapshotFilesCb as getLatestSnapshotFiles,
  415. getLatestSnapshotFilesForChunkCb as getLatestSnapshotFilesForChunk,
  416. getRangesSnapshotCb as getRangesSnapshot,
  417. getPathsAtVersionCb as getPathsAtVersion,
  418. }
  419. export const promises = {
  420. getChangesSince,
  421. getChangesInChunkSince,
  422. getFileSnapshotStream,
  423. getProjectSnapshot,
  424. getLatestSnapshot,
  425. getLatestSnapshotFiles,
  426. getLatestSnapshotFilesForChunk,
  427. getRangesSnapshot,
  428. getPathsAtVersion,
  429. getFileMetadataSnapshot,
  430. }