SnapshotManager.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  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 == 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. // Returns project snapshot containing the document content for files with
  198. // text operations in the relevant chunk, and hashes for unmodified/binary
  199. // files. Used by git bridge to get the state of the project.
  200. async function getProjectSnapshot(projectId, version) {
  201. const snapshot = await _getSnapshotAtVersion(projectId, version)
  202. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  203. await _loadFilesLimit(
  204. snapshot,
  205. 'eager',
  206. HistoryStoreManager.getBlobStore(historyId)
  207. )
  208. return {
  209. projectId,
  210. files: snapshot.getFileMap().map(file => {
  211. if (!file) {
  212. return null
  213. }
  214. const content = file.getContent({
  215. filterTrackedDeletes: true,
  216. })
  217. if (content === null) {
  218. return { data: { hash: file.getHash() } }
  219. }
  220. return { data: { content } }
  221. }),
  222. }
  223. }
  224. async function getPathsAtVersion(projectId, version) {
  225. const snapshot = await _getSnapshotAtVersion(projectId, version)
  226. return {
  227. paths: snapshot.getFilePathnames(),
  228. }
  229. }
  230. /**
  231. *
  232. * @param {string} projectId
  233. * @param {number} version
  234. */
  235. async function _getSnapshotAtVersion(projectId, version) {
  236. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  237. const data = await HistoryStoreManager.promises.getChunkAtVersion(
  238. projectId,
  239. historyId,
  240. version
  241. )
  242. const chunk = Core.Chunk.fromRaw(data.chunk)
  243. const snapshot = chunk.getSnapshot()
  244. const changes = chunk.getChanges().slice(0, version - chunk.getStartVersion())
  245. snapshot.applyAll(changes)
  246. return snapshot
  247. }
  248. async function getLatestSnapshot(projectId, historyId) {
  249. const data = await HistoryStoreManager.promises.getMostRecentChunk(
  250. projectId,
  251. historyId
  252. )
  253. if (data == null || data.chunk == null) {
  254. throw new OError('undefined chunk')
  255. }
  256. // apply all the changes in the chunk to get the current snapshot
  257. const chunk = Core.Chunk.fromRaw(data.chunk)
  258. const snapshot = chunk.getSnapshot()
  259. const changes = chunk.getChanges()
  260. snapshot.applyAll(changes)
  261. const snapshotFiles = await snapshot.loadFiles(
  262. 'lazy',
  263. HistoryStoreManager.getBlobStore(historyId)
  264. )
  265. return snapshotFiles
  266. }
  267. async function _loadFilesLimit(snapshot, kind, blobStore) {
  268. await snapshot.fileMap.mapAsync(async file => {
  269. // only load changed files or files with tracked changes, others can be
  270. // dereferenced from their blobs (this method is only used by the git
  271. // bridge which understands how to load blobs).
  272. if (!file.isEditable() || (file.getHash() && !file.getRangesHash())) {
  273. return
  274. }
  275. await file.load(kind, blobStore)
  276. }, MAX_REQUESTS)
  277. }
  278. // EXPORTS
  279. const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
  280. const getProjectSnapshotCb = callbackify(getProjectSnapshot)
  281. const getLatestSnapshotCb = callbackify(getLatestSnapshot)
  282. const getRangesSnapshotCb = callbackify(getRangesSnapshot)
  283. const getPathsAtVersionCb = callbackify(getPathsAtVersion)
  284. export {
  285. getFileSnapshotStreamCb as getFileSnapshotStream,
  286. getProjectSnapshotCb as getProjectSnapshot,
  287. getLatestSnapshotCb as getLatestSnapshot,
  288. getRangesSnapshotCb as getRangesSnapshot,
  289. getPathsAtVersionCb as getPathsAtVersion,
  290. }
  291. export const promises = {
  292. getFileSnapshotStream,
  293. getProjectSnapshot,
  294. getLatestSnapshot,
  295. getRangesSnapshot,
  296. getPathsAtVersion,
  297. }