| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- import pLimit from 'p-limit'
- import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
- import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
- import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
- const DOWNLOAD_BLOBS_CONCURRENCY = 10
- /**
- * Project snapshot container with on-demand refresh
- */
- export class ProjectSnapshot {
- private projectId: string
- private snapshot: Snapshot
- private version: number
- private blobStore: SimpleBlobStore
- private refreshPromise: Promise<void>
- private initialized: boolean
- private refreshing: boolean
- private queued: boolean
- constructor(projectId: string) {
- this.projectId = projectId
- this.snapshot = new Snapshot()
- this.version = 0
- this.refreshPromise = Promise.resolve()
- this.initialized = false
- this.refreshing = false
- this.queued = false
- this.blobStore = new SimpleBlobStore(this.projectId)
- }
- /**
- * Request a refresh of the snapshot.
- *
- * When the returned promise resolves, the snapshot is guaranteed to have been
- * updated at least to the version of the document that was current when the
- * function was called.
- */
- async refresh() {
- if (this.queued) {
- // There already is a queued refresh that will run after this call.
- // Just wait for it to complete.
- await this.refreshPromise
- } else if (this.refreshing) {
- // There is a refresh running, but no queued refresh. Queue a refresh
- // after this one and make it the new promise to wait for.
- this.refreshPromise = this.queueRefresh()
- await this.refreshPromise
- } else {
- // There is no refresh running. Start one.
- this.refreshPromise = this.startRefresh()
- await this.refreshPromise
- }
- }
- /**
- * Get the list of paths to editable docs.
- */
- getDocPaths(): string[] {
- const allPaths = this.snapshot.getFilePathnames()
- return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
- }
- /**
- * Get the doc content at the given path.
- */
- getDocContents(path: string): string | null {
- const file = this.snapshot.getFile(path)
- if (file == null) {
- return null
- }
- return file.getContent({ filterTrackedDeletes: true }) ?? null
- }
- /**
- * Immediately start a refresh
- */
- private async startRefresh() {
- this.refreshing = true
- try {
- if (!this.initialized) {
- await this.initialize()
- } else {
- await this.loadChanges()
- }
- } finally {
- this.refreshing = false
- }
- }
- /**
- * Queue a refresh after the currently running refresh
- */
- private async queueRefresh() {
- this.queued = true
- try {
- await this.refreshPromise
- } catch {
- // Ignore errors
- }
- this.queued = false
- await this.startRefresh()
- }
- /**
- * Initialize the snapshot using the project's latest chunk.
- *
- * This is run on the first refresh.
- */
- private async initialize() {
- await flushHistory(this.projectId)
- const chunk = await fetchLatestChunk(this.projectId)
- this.snapshot = chunk.getSnapshot()
- this.snapshot.applyAll(chunk.getChanges())
- this.version = chunk.getEndVersion()
- await this.loadDocs()
- this.initialized = true
- }
- /**
- * Apply changes since the last refresh.
- *
- * This is run on the second and subsequent refreshes
- */
- private async loadChanges() {
- await flushHistory(this.projectId)
- const changes = await fetchLatestChanges(this.projectId, this.version)
- this.snapshot.applyAll(changes)
- this.version += changes.length
- await this.loadDocs()
- }
- /**
- * Load all editable docs in the snapshot.
- *
- * This is done by converting any lazy file data into an "eager" file data. If
- * a doc is already loaded, the load is a no-op.
- */
- private async loadDocs() {
- const paths = this.getDocPaths()
- const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
- await Promise.all(
- paths.map(path =>
- limit(async () => {
- const file = this.snapshot.getFile(path)
- await file?.load('eager', this.blobStore)
- })
- )
- )
- }
- }
- /**
- * Blob store that fetches blobs from the history service
- */
- class SimpleBlobStore {
- private projectId: string
- constructor(projectId: string) {
- this.projectId = projectId
- }
- async getString(hash: string): Promise<string> {
- return await fetchBlob(this.projectId, hash)
- }
- async getObject(hash: string) {
- const blob = await this.getString(hash)
- return JSON.parse(blob)
- }
- }
- async function flushHistory(projectId: string) {
- await postJSON(`/project/${projectId}/flush`)
- }
- async function fetchLatestChunk(projectId: string): Promise<Chunk> {
- const response = await getJSON<{ chunk: RawChunk }>(
- `/project/${projectId}/latest/history`
- )
- return Chunk.fromRaw(response.chunk)
- }
- async function fetchLatestChanges(
- projectId: string,
- version: number
- ): Promise<Change[]> {
- const response = await getJSON<RawChange[]>(
- `/project/${projectId}/changes?since=${version}`
- )
- return response.map(Change.fromRaw).filter(change => change != null)
- }
- async function fetchBlob(projectId: string, hash: string): Promise<string> {
- const url = `/project/${projectId}/blob/${hash}`
- const res = await fetch(url)
- if (!res.ok) {
- throw new FetchError('Failed to fetch blob', url, undefined, res)
- }
- return await res.text()
- }
|