SnapshotManager.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. // @ts-check
  2. import { callbackify } from 'util'
  3. import Core from 'overleaf-editor-core'
  4. import { Readable as StringStream } from '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. trackedDeletionOffset = 0
  118. let trackedDeletionIndex = 0
  119. if (comment.ranges.length === 0) {
  120. // Translate detached comments into zero length comments at position 0
  121. docUpdaterCompatibleComments.push({
  122. op: {
  123. p: 0,
  124. c: '',
  125. t: comment.id,
  126. resolved: comment.resolved,
  127. },
  128. })
  129. continue
  130. }
  131. for (const commentRange of comment.ranges) {
  132. let commentRangeContent = ''
  133. let offsetFromOverlappingRangeAtStart = 0
  134. while (
  135. trackedDeletionIndex < trackedDeletions.length &&
  136. trackedDeletions[trackedDeletionIndex].range.start <
  137. commentRange.start &&
  138. trackedDeletions[trackedDeletionIndex].range.end <= commentRange.start
  139. ) {
  140. // Skip over tracked deletions that are before the current comment range
  141. trackedDeletionOffset +=
  142. trackedDeletions[trackedDeletionIndex].range.length
  143. trackedDeletionIndex++
  144. }
  145. if (
  146. trackedDeletions[trackedDeletionIndex]?.range.start < commentRange.start
  147. ) {
  148. // There's overlap with a tracked deletion, move the position left and
  149. // truncate the overlap
  150. offsetFromOverlappingRangeAtStart =
  151. commentRange.start -
  152. trackedDeletions[trackedDeletionIndex].range.start
  153. }
  154. // The position of the comment in the document after tracked deletions
  155. const position =
  156. commentRange.start -
  157. trackedDeletionOffset -
  158. offsetFromOverlappingRangeAtStart
  159. let cursor = commentRange.start
  160. while (cursor < commentRange.end) {
  161. const trackedDeletion = trackedDeletions[trackedDeletionIndex]
  162. if (
  163. !trackedDeletion ||
  164. trackedDeletion.range.start >= commentRange.end
  165. ) {
  166. // We've run out of relevant tracked changes
  167. commentRangeContent += content.slice(cursor, commentRange.end)
  168. break
  169. }
  170. if (trackedDeletion.range.start > cursor) {
  171. // There's a gap between the current cursor and the tracked deletion
  172. commentRangeContent += content.slice(
  173. cursor,
  174. trackedDeletion.range.start
  175. )
  176. }
  177. // Skip to the end of the tracked delete
  178. cursor = trackedDeletion.range.end
  179. trackedDeletionIndex++
  180. trackedDeletionOffset += trackedDeletion.range.length
  181. }
  182. docUpdaterCompatibleComments.push({
  183. op: {
  184. p: position,
  185. c: commentRangeContent,
  186. t: comment.id,
  187. resolved: comment.resolved,
  188. },
  189. })
  190. }
  191. }
  192. return {
  193. changes: docUpdaterCompatibleTrackedChanges,
  194. comments: docUpdaterCompatibleComments,
  195. }
  196. }
  197. /**
  198. * Gets the file metadata at a specific version.
  199. *
  200. * @param {string} projectId
  201. * @param {number} version
  202. * @param {string} pathname
  203. * @returns {Promise<{metadata: any}>}
  204. */
  205. async function getFileMetadataSnapshot(projectId, version, pathname) {
  206. const snapshot = await _getSnapshotAtVersion(projectId, version)
  207. const file = snapshot.getFile(pathname)
  208. if (!file) {
  209. throw new Errors.NotFoundError(`${pathname} not found`, {
  210. projectId,
  211. version,
  212. pathname,
  213. })
  214. }
  215. const rawMetadata = file.getMetadata()
  216. const metadata = _.isEmpty(rawMetadata) ? undefined : rawMetadata
  217. return { metadata }
  218. }
  219. // Returns project snapshot containing the document content for files with
  220. // text operations in the relevant chunk, and hashes for unmodified/binary
  221. // files. Used by git bridge to get the state of the project.
  222. async function getProjectSnapshot(projectId, version) {
  223. const snapshot = await _getSnapshotAtVersion(projectId, version)
  224. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  225. await _loadFilesLimit(
  226. snapshot,
  227. 'eager',
  228. HistoryStoreManager.getBlobStore(historyId)
  229. )
  230. return {
  231. projectId,
  232. files: snapshot.getFileMap().map(file => {
  233. if (!file) {
  234. return null
  235. }
  236. const content = file.getContent({
  237. filterTrackedDeletes: true,
  238. })
  239. if (content === null) {
  240. return { data: { hash: file.getHash() } }
  241. }
  242. return { data: { content } }
  243. }),
  244. }
  245. }
  246. async function getPathsAtVersion(projectId, version) {
  247. const snapshot = await _getSnapshotAtVersion(projectId, version)
  248. return {
  249. paths: snapshot.getFilePathnames(),
  250. }
  251. }
  252. /**
  253. *
  254. * @param {string} projectId
  255. * @param {number} version
  256. */
  257. async function _getSnapshotAtVersion(projectId, version) {
  258. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  259. const data = await HistoryStoreManager.promises.getChunkAtVersion(
  260. projectId,
  261. historyId,
  262. version
  263. )
  264. const chunk = Core.Chunk.fromRaw(data.chunk)
  265. const snapshot = chunk.getSnapshot()
  266. const changes = chunk.getChanges().slice(0, version - chunk.getStartVersion())
  267. snapshot.applyAll(changes)
  268. return snapshot
  269. }
  270. async function getLatestSnapshotFiles(projectId, historyId) {
  271. const { snapshot } = await getLatestSnapshot(projectId, historyId)
  272. const snapshotFiles = await snapshot.loadFiles(
  273. 'lazy',
  274. HistoryStoreManager.getBlobStore(historyId)
  275. )
  276. return snapshotFiles
  277. }
  278. async function getLatestSnapshot(projectId, historyId) {
  279. const data = await HistoryStoreManager.promises.getMostRecentChunk(
  280. projectId,
  281. historyId
  282. )
  283. if (data == null || data.chunk == null) {
  284. throw new OError('undefined chunk')
  285. }
  286. // apply all the changes in the chunk to get the current snapshot
  287. const chunk = Core.Chunk.fromRaw(data.chunk)
  288. const snapshot = chunk.getSnapshot()
  289. const changes = chunk.getChanges()
  290. snapshot.applyAll(changes)
  291. return {
  292. snapshot,
  293. version: chunk.getEndVersion(),
  294. }
  295. }
  296. async function getChangesSince(projectId, historyId, sinceVersion) {
  297. const allChanges = []
  298. let nextVersion
  299. while (true) {
  300. let data
  301. if (nextVersion) {
  302. data = await HistoryStoreManager.promises.getChunkAtVersion(
  303. projectId,
  304. historyId,
  305. nextVersion
  306. )
  307. } else {
  308. data = await HistoryStoreManager.promises.getMostRecentChunk(
  309. projectId,
  310. historyId
  311. )
  312. }
  313. if (data == null || data.chunk == null) {
  314. throw new OError('undefined chunk')
  315. }
  316. const chunk = Core.Chunk.fromRaw(data.chunk)
  317. if (sinceVersion > chunk.getEndVersion()) {
  318. throw new OError('requested version past the end')
  319. }
  320. const changes = chunk.getChanges()
  321. if (chunk.getStartVersion() > sinceVersion) {
  322. allChanges.unshift(...changes)
  323. nextVersion = chunk.getStartVersion()
  324. } else {
  325. allChanges.unshift(
  326. ...changes.slice(sinceVersion - chunk.getStartVersion())
  327. )
  328. break
  329. }
  330. }
  331. return allChanges
  332. }
  333. async function getChangesInChunkSince(projectId, historyId, sinceVersion) {
  334. const latestChunk = Core.Chunk.fromRaw(
  335. (
  336. await HistoryStoreManager.promises.getMostRecentChunk(
  337. projectId,
  338. historyId
  339. )
  340. ).chunk
  341. )
  342. if (sinceVersion > latestChunk.getEndVersion()) {
  343. throw new Errors.BadRequestError(
  344. 'requested version past the end of the history'
  345. )
  346. }
  347. const latestStartVersion = latestChunk.getStartVersion()
  348. let chunk = latestChunk
  349. if (sinceVersion < latestStartVersion) {
  350. chunk = Core.Chunk.fromRaw(
  351. (
  352. await HistoryStoreManager.promises.getChunkAtVersion(
  353. projectId,
  354. historyId,
  355. sinceVersion
  356. )
  357. ).chunk
  358. )
  359. }
  360. const changes = chunk
  361. .getChanges()
  362. .slice(sinceVersion - chunk.getStartVersion())
  363. return { latestStartVersion, changes }
  364. }
  365. async function _loadFilesLimit(snapshot, kind, blobStore) {
  366. await snapshot.fileMap.mapAsync(async file => {
  367. // only load changed files or files with tracked changes, others can be
  368. // dereferenced from their blobs (this method is only used by the git
  369. // bridge which understands how to load blobs).
  370. if (!file.isEditable() || (file.getHash() && !file.getRangesHash())) {
  371. return
  372. }
  373. await file.load(kind, blobStore)
  374. }, MAX_REQUESTS)
  375. }
  376. // EXPORTS
  377. const getChangesSinceCb = callbackify(getChangesSince)
  378. const getChangesInChunkSinceCb = callbackify(getChangesInChunkSince)
  379. const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
  380. const getProjectSnapshotCb = callbackify(getProjectSnapshot)
  381. const getLatestSnapshotCb = callbackify(getLatestSnapshot)
  382. const getLatestSnapshotFilesCb = callbackify(getLatestSnapshotFiles)
  383. const getRangesSnapshotCb = callbackify(getRangesSnapshot)
  384. const getFileMetadataSnapshotCb = callbackify(getFileMetadataSnapshot)
  385. const getPathsAtVersionCb = callbackify(getPathsAtVersion)
  386. export {
  387. getChangesSinceCb as getChangesSince,
  388. getChangesInChunkSinceCb as getChangesInChunkSince,
  389. getFileSnapshotStreamCb as getFileSnapshotStream,
  390. getProjectSnapshotCb as getProjectSnapshot,
  391. getFileMetadataSnapshotCb as getFileMetadataSnapshot,
  392. getLatestSnapshotCb as getLatestSnapshot,
  393. getLatestSnapshotFilesCb as getLatestSnapshotFiles,
  394. getRangesSnapshotCb as getRangesSnapshot,
  395. getPathsAtVersionCb as getPathsAtVersion,
  396. }
  397. export const promises = {
  398. getChangesSince,
  399. getChangesInChunkSince,
  400. getFileSnapshotStream,
  401. getProjectSnapshot,
  402. getLatestSnapshot,
  403. getLatestSnapshotFiles,
  404. getRangesSnapshot,
  405. getPathsAtVersion,
  406. getFileMetadataSnapshot,
  407. }