SnapshotManager.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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. /**
  10. * @typedef {import('stream').Readable} ReadableStream
  11. * @typedef {import('overleaf-editor-core').Snapshot} Snapshot
  12. * @typedef {import('./types').RangesSnapshot} RangesSnapshot
  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) {
  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. for (const commentRange of comment.ranges) {
  120. let commentRangeContent = ''
  121. let offsetFromOverlappingRangeAtStart = 0
  122. while (
  123. trackedDeletionIndex < trackedDeletions.length &&
  124. trackedDeletions[trackedDeletionIndex].range.start <
  125. commentRange.start &&
  126. trackedDeletions[trackedDeletionIndex].range.end <= commentRange.start
  127. ) {
  128. // Skip over tracked deletions that are before the current comment range
  129. trackedDeletionOffset +=
  130. trackedDeletions[trackedDeletionIndex].range.length
  131. trackedDeletionIndex++
  132. }
  133. if (
  134. trackedDeletions[trackedDeletionIndex]?.range.start < commentRange.start
  135. ) {
  136. // There's overlap with a tracked deletion, move the position left and
  137. // truncate the overlap
  138. offsetFromOverlappingRangeAtStart =
  139. commentRange.start -
  140. trackedDeletions[trackedDeletionIndex].range.start
  141. }
  142. // The position of the comment in the document after tracked deletions
  143. const position =
  144. commentRange.start -
  145. trackedDeletionOffset -
  146. offsetFromOverlappingRangeAtStart
  147. let cursor = commentRange.start
  148. while (cursor < commentRange.end) {
  149. const trackedDeletion = trackedDeletions[trackedDeletionIndex]
  150. if (
  151. !trackedDeletion ||
  152. trackedDeletion.range.start >= commentRange.end
  153. ) {
  154. // We've run out of relevant tracked changes
  155. commentRangeContent += content.slice(cursor, commentRange.end)
  156. break
  157. }
  158. if (trackedDeletion.range.start > cursor) {
  159. // There's a gap between the current cursor and the tracked deletion
  160. commentRangeContent += content.slice(
  161. cursor,
  162. trackedDeletion.range.start
  163. )
  164. }
  165. // Skip to the end of the tracked delete
  166. cursor = trackedDeletion.range.end
  167. trackedDeletionIndex++
  168. trackedDeletionOffset += trackedDeletion.range.length
  169. }
  170. docUpdaterCompatibleComments.push({
  171. op: {
  172. p: position,
  173. c: commentRangeContent,
  174. t: comment.id,
  175. resolved: comment.resolved,
  176. },
  177. })
  178. }
  179. }
  180. return {
  181. changes: docUpdaterCompatibleTrackedChanges,
  182. comments: docUpdaterCompatibleComments,
  183. }
  184. }
  185. // Returns project snapshot containing the document content for files with
  186. // text operations in the relevant chunk, and hashes for unmodified/binary
  187. // files. Used by git bridge to get the state of the project.
  188. async function getProjectSnapshot(projectId, version) {
  189. const snapshot = await _getSnapshotAtVersion(projectId, version)
  190. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  191. await _loadFilesLimit(
  192. snapshot,
  193. 'eager',
  194. HistoryStoreManager.getBlobStore(historyId)
  195. )
  196. return {
  197. projectId,
  198. files: snapshot.getFileMap().map(file => {
  199. if (!file) {
  200. return null
  201. }
  202. const content = file.getContent({
  203. filterTrackedDeletes: true,
  204. })
  205. if (content === null) {
  206. return { data: { hash: file.getHash() } }
  207. }
  208. return { data: { content } }
  209. }),
  210. }
  211. }
  212. /**
  213. *
  214. * @param {string} projectId
  215. * @param {number} version
  216. */
  217. async function _getSnapshotAtVersion(projectId, version) {
  218. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  219. const data = await HistoryStoreManager.promises.getChunkAtVersion(
  220. projectId,
  221. historyId,
  222. version
  223. )
  224. const chunk = Core.Chunk.fromRaw(data.chunk)
  225. const snapshot = chunk.getSnapshot()
  226. const changes = chunk.getChanges().slice(0, version - chunk.getStartVersion())
  227. snapshot.applyAll(changes)
  228. return snapshot
  229. }
  230. async function getLatestSnapshot(projectId, historyId) {
  231. const data = await HistoryStoreManager.promises.getMostRecentChunk(
  232. projectId,
  233. historyId
  234. )
  235. if (data == null || data.chunk == null) {
  236. throw new OError('undefined chunk')
  237. }
  238. // apply all the changes in the chunk to get the current snapshot
  239. const chunk = Core.Chunk.fromRaw(data.chunk)
  240. const snapshot = chunk.getSnapshot()
  241. const changes = chunk.getChanges()
  242. snapshot.applyAll(changes)
  243. const snapshotFiles = await snapshot.loadFiles(
  244. 'lazy',
  245. HistoryStoreManager.getBlobStore(historyId)
  246. )
  247. return snapshotFiles
  248. }
  249. async function _loadFilesLimit(snapshot, kind, blobStore) {
  250. await snapshot.fileMap.mapAsync(async file => {
  251. // only load changed files or files with tracked changes, others can be
  252. // dereferenced from their blobs (this method is only used by the git
  253. // bridge which understands how to load blobs).
  254. if (!file.isEditable() || (file.getHash() && !file.getRangesHash())) {
  255. return
  256. }
  257. await file.load(kind, blobStore)
  258. }, MAX_REQUESTS)
  259. }
  260. // EXPORTS
  261. const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
  262. const getProjectSnapshotCb = callbackify(getProjectSnapshot)
  263. const getLatestSnapshotCb = callbackify(getLatestSnapshot)
  264. const getRangesSnapshotCb = callbackify(getRangesSnapshot)
  265. export {
  266. getFileSnapshotStreamCb as getFileSnapshotStream,
  267. getProjectSnapshotCb as getProjectSnapshot,
  268. getLatestSnapshotCb as getLatestSnapshot,
  269. getRangesSnapshotCb as getRangesSnapshot,
  270. }
  271. export const promises = {
  272. getFileSnapshotStream,
  273. getProjectSnapshot,
  274. getLatestSnapshot,
  275. getRangesSnapshot,
  276. }