SnapshotManager.js 13 KB

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