diff --git a/AGENTS.md b/AGENTS.md index 02059596a..79e613ea6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,9 +86,9 @@ Saved projects are listed through [ProjectSummaries](src/services/projectSummari ### Sessions -[AnalysisSessions](src/services/analysisSessions.ts) keeps the host's sessions, behind the `openSession`, `closeSession` and `setAnalysisLanguage` commands. A session is a handle on either one source project's draft, paired with the interlinear project it saves to, or a read-only Paratext 9 import. A source has at most one session: opening another closes it. Each session runs its commands one at a time through `run`. A command that changes stored analysis goes through `runEdit` instead, which a read-only session refuses. An edit reaches the repository as a `ChangeSet` of whole-record puts and deletes, applied through the session's [EditHistory](src/services/editHistory.ts) so that undo can revert it; boundary changes ride in the change set's scope record. The WebView doesn't use sessions yet. +[AnalysisSessions](src/services/analysisSessions.ts) keeps the host's sessions, behind the `openSession`, `closeSession` and `setAnalysisLanguage` commands. A session is a handle on either one source project's draft, paired with the interlinear project it saves to, or a read-only Paratext 9 import. A source has at most one session: opening another closes it. Opening a session also starts a fresh whole-text read of its source, which lasts until the session closes. Each session runs its commands one at a time through `run`. A command that changes stored analysis goes through `runEdit` instead, which a read-only session refuses. An edit reaches the repository as a `ChangeSet` of whole-record puts and deletes, applied through the session's [EditHistory](src/services/editHistory.ts) so that undo can revert it; boundary changes ride in the change set's scope record. Re-anchor passes are the exception. As the read delivers each book, `reanchorBook` re-anchors the draft to it through `run`; `reanchorMissingBooks` does the same for each linked book the text lacks. Both skip a read-only session. A pass is no undo step and leaves the unsaved mark as it was; the history records it, so that an undo or redo re-anchors what it restores. The WebView doesn't use sessions yet. -Host commands live in [src/services/commands/](src/services/commands/), one module per family. A command stages its changes in a [ChangeBuffer](src/services/commands/changeBuffer.ts), whose reads answer as the repository will once those changes apply, and `AnalysisSessions.edit` applies what it staged as one undo step after the approval rule passes. Each command copies the reducer it replaces, step for step, and its parity tests run one input through both with [runParity](src/__tests__/services/commands/parity.ts). A boundary command replaces no reducer; it copies the WebView's boundary dispatch, phrase breaks included, and `runBoundaryParity` runs one edit through both. A reducer or dispatch change in Phase 3 updates its command and parity tests in the same PR. +Host commands live in [src/services/commands/](src/services/commands/), one module per family. A command stages its changes in a [ChangeBuffer](src/services/commands/changeBuffer.ts), whose reads answer as the repository will once those changes apply, and `AnalysisSessions.edit` applies what it staged as one undo step after the approval rule passes. Each command copies the reducer it replaces, step for step, and its parity tests run one input through both with [runParity](src/__tests__/services/commands/parity.ts). A boundary command replaces no reducer; it copies the WebView's boundary dispatch, phrase breaks included, and `runBoundaryParity` runs one edit through both. The session then re-anchors the book's analysis to the new segments in the same undo step, as the WebView's pass does after a boundary edit. A reducer or dispatch change in Phase 3 updates its command and parity tests in the same PR. ### Components diff --git a/docs/data-layer-plan/plan.md b/docs/data-layer-plan/plan.md index 445cbf815..3c2d21cb3 100644 --- a/docs/data-layer-plan/plan.md +++ b/docs/data-layer-plan/plan.md @@ -29,7 +29,7 @@ The team decided these on 2026-10-09. "From" names the plan each choice came fro | Addressing | _Carried:_ each open draft gets a session, a host-side handle for the draft and its interlinear project, addressed by a `sessionId` | J | | Concurrent commands | _Carried:_ no segment-page versions; the host queues commands and reanchor passes per session and runs one at a time, since async commands would otherwise interleave, and only one WebView opens per source project | — | | Suggestions | _Carried:_ pool buckets, the approved payloads that share a normalized form, ride in segment pages and in replies; the WebView keeps `deriveTokenSuggestion` | J | -| Whole-text read | _Carried:_ the host reads every book of the source text at session open, re-anchors each linked book as it arrives and builds a form-in-text index, so Stale covers the whole draft once the read finishes. The checkpoint after 2e decides whether that cost is affordable; if not, the read stays on demand, as on main | — | +| Whole-text read | _Carried:_ the host reads every book of the source text at session open, re-anchors each book as it arrives and builds a form-in-text index, so Stale covers the whole draft once the read finishes. The checkpoint after 2e decides whether that cost is affordable; if not, the read stays on demand, as on main | — | ## The seam @@ -123,7 +123,7 @@ flowchart TB p0["0 Performance baseline · #407
Done: host and WebView measured;
baseline run on a packaged app"] p1["1 Seam and link ids · issues 1a–1e
Link ids, repository, conformance suite;
Save and picker in the host"] p2["2 Text in the host · issues 2a–2e
Outline and segment pages;
onAnalysisChanged for Scripture edits;
whole-text read and form index"] - p3["3 Host commands, built but not live · issues 3a–3m
Sessions, apply and undo, derived indexes, commands with parity tests, reanchor of every linked book, analysis and phrase spans in segment pages, the record cache.
Released builds still write through the WebView reducers."] + p3["3 Host commands, built but not live · issues 3a–3m
Sessions, apply and undo, derived indexes, commands with parity tests, reanchor of every book the read delivers, analysis and phrase spans in segment pages, the record cache.
Released builds still write through the WebView reducers."] subgraph p4["4 The switch · issues 4a–4c"] direction LR p4a["4a Behind a feature flag
Off by default;
both paths tested"] @@ -171,7 +171,7 @@ The host fetches and tokenizes the text, and the WebView renders from an outline - `getBookOutline` and `getSegments` (text only) feed the segment list, height prediction, the chapter list and the continuous view. Host marks record page-fetch latency. - A temporary `getTokenizedBook` gives the WebView whole tokenized books: for its reanchor passes and `useBookIndexes` until 4c, and for its whole-text read (the concordance and the catalog's "Word not in the text" and draft-wide Stale filters) until 5d. From 4a, segment pages and their phrase spans (3l) feed the token-level maps. The WebView stops fetching USJ. - `onAnalysisChanged` arrives here with its first producer: a Scripture edit names the whole book, and the WebView refetches. -- A whole-text read in `BookService` reads every book, keeps a form-in-text index (the normalized forms the text holds), and reports progress and failed books through `getTextReadStatus`. Nothing calls it until 4a; then `openSession` starts it, 3j re-anchors each linked book as it arrives, and 5a and 5d answer from it. +- A whole-text read in `BookService` reads every book, keeps a form-in-text index (the normalized forms the text holds), and reports progress and failed books through `getTextReadStatus`. Nothing calls it until 4a; then `openSession` starts it, 3j re-anchors each book as it arrives, and 5a and 5d answer from it. - These queries and the event are addressed by source project, since text needs no draft. They also accept a session from 3k, when analysis joins the segment pages; the source-project form is removed in 4c. Until 4c, boundary edits stay in the WebView draft, so the source-project form also takes the WebView's boundary delta: a boundary edit changes the request and the WebView refetches, and the import view sends none. **Done when:** the WebView neither fetches nor tokenizes USJ, and no view walks the whole book except through `getTokenizedBook`. @@ -187,7 +187,7 @@ The whole write path is built and tested in the host while the WebView keeps usi - **Parity tests.** Each family has them: they run one input through today's reducer and the new command and compare the results. - **Boundaries and wipes.** These aren't reducers, so their parity tests run against `wipeBook`, `wipeAll` and the WebView's boundary dispatch, which breaks every phrase a new boundary would cut. The host's split and move break those phrases in the same `ChangeSet`, so the WebView needs no book-wide token order for it. - **Removal previews.** A bulk gloss clear and a stale discard or reapply first ask about any unsaved breakdown on a record they would remove. Today that check runs the reducer, which 4c deletes, so 3i also builds `previewRemovals`. -- **The reanchor pass** over every book the draft links into, through the session's queue. +- **The reanchor pass** over every book the whole-text read delivers, through the session's queue. - **When it runs.** As 2e's read delivers each book, and again whenever `BookService` gets new text for one. After a pass for new text, that book's `onAnalysisChanged` goes out, so the refetch sees re-anchored links. - **Undo.** A pass isn't an undo step: an undo or redo re-anchors what it restores to the current text, as `recordBookPass` does today. - **Missing books.** Links into a book the text lacks go stale, as `reanchorDraftToMissingBook` does today. @@ -208,7 +208,7 @@ The WebView moves to the host write path in three releasable steps, with a rollb 1. **Wire it behind a feature flag, off by default.** The WebView can run on sessions, commands, queries and the cache. - **Read bridge.** A temporary `getFullAnalysis`, removed in 5d, feeds the catalog, the concordance and the deletion preview. - - **Reanchor.** With the flag on, the host re-anchors every linked book during the session-open read and whenever a linked book's text changes. The WebView's whole-text read still feeds the concordance and the catalog's text filters, but no longer re-anchors. + - **Reanchor.** With the flag on, the host re-anchors every book during the session-open read and whenever a book's text changes. The WebView's whole-text read still feeds the concordance and the catalog's text filters, but no longer re-anchors. - **Stale filter.** It learns from `getTextReadStatus` when it covers the whole draft. - **Flag.** 4a decides where it lives, such as a localStorage key or a hidden setting; developers and end-to-end tests must both be able to set it. End-to-end tests run on both paths. 2. **Flip the default,** once 4a's edit-latency check passes. The old path stays one release as the rollback; rolling back means a release that flips the default again. @@ -260,7 +260,7 @@ The plan splits into 31 work issues plus #407, each one or two PRs that leave th | **3g** | Segment free-translation and boundary commands, with parity tests; a split or move breaks every phrase the new boundary would cut, in the same `ChangeSet`. _Done when_ its commands pass their parity tests | 3d, 3f | | **3h** | Stale-handling commands, with parity tests; discard covers orphaned free translations. _Done when_ its commands pass their parity tests | 3d | | **3i** | Catalog-wide edits and wipes, with parity tests; a wipe's reply carries `hasFreeTranslations` beside the `ChangeSet`; `previewRemovals`. _Done when_ its commands pass their parity tests, a wipe's reply gives `hasFreeTranslations` as main computes it, and `previewRemovals` names the records `useGlossClearReclaims` and `useStaleLocationReclaims` do | 3d, 3h | -| **3j** | Reanchor pass in the host over every book the draft links into, through the session's queue, as 2e's read delivers each one and again whenever `BookService` gets new text for one, sending that book's `onAnalysisChanged` after the pass; not an undo step, since an undo or redo re-anchors what it restores to the current text, as main does; links into a book the text lacks go stale, as `reanchorDraftToMissingBook` does. _Done when_ its result matches the WebView passes on the #407 datasets, which hold no drift, on the drift cases in `reanchor-analysis.test.ts` and on the missing-book cases in `reanchor-draft.test.ts`, undo and redo restore content re-anchored to the current text, and a mid-session Scripture edit re-anchors the open book before its refetch | 2e, 3b | +| **3j** | Reanchor pass in the host over every book, through the session's queue, as 2e's read delivers each one and again whenever `BookService` gets new text for one; after a pass for new text, that book's `onAnalysisChanged` goes out; not an undo step, since an undo or redo re-anchors what it restores to the current text, as main does; links into a book the text lacks go stale, as `reanchorDraftToMissingBook` does. _Done when_ its result matches the WebView passes on the #407 datasets, which hold no drift, on the drift cases in `reanchor-analysis.test.ts` and on the missing-book cases in `reanchor-draft.test.ts`, undo and redo restore content re-anchored to the current text, and a mid-session Scripture edit re-anchors the open book before its refetch | 2e, 3b | | **3k** | Analysis in segment pages: `getSegments` gains links, payloads, pool buckets and the orphaned free translations placed in the range, the outline gains each segment's orphaned-card count, and command replies carry the touched pool buckets beside the `ChangeSet`; `onAnalysisChanged` gains segment ranges, analysis ids and affected cross-book queries; `getBookOutline`, `getSegments`, `getTextReadStatus` and `onAnalysisChanged` also accept a session, and only the session form returns the analysis fields. _Done when_ on the #407 datasets, `getSegments` returns the same links, payloads and pool buckets the WebView store holds for each range, orphaned free translations land where the WebView places them, and each command's `onAnalysisChanged` names exactly what its `ChangeSet` touched | 2e, 3d to 3i | | **3l** | Phrase spans in segment pages: the session form of `getSegments` also returns each phrase that touches the range with all its member tokens, and the WebView builds its token-level maps and boundary-slot guard from pages plus spans; a focus request from the catalog, the concordance or undo still lands when its target's page hasn't loaded. _Done when_ on the #407 datasets, phrase text, arcs and focus for phrases that span unloaded segments match what today's book-wide maps give, and a jump into a segment that hasn't loaded focuses its target | 3k | | **3m** | WebView record cache and page client, built and tested but not wired up; settles how a refetch that lands after a newer reply is kept from overwriting it. _Done when_ applying a command's reply to the cache gives the same segment pages and pool buckets as refetching them, after a boundary command the outline matches a refetch too, and a refetch that lands after a newer reply leaves the reply's records in place, in tests over the #407 datasets | 2b, 3l | diff --git a/perf/bench/cli.ts b/perf/bench/cli.ts index ff22d26a8..bdebcfb58 100644 --- a/perf/bench/cli.ts +++ b/perf/bench/cli.ts @@ -1,6 +1,8 @@ /* eslint-disable no-console -- a command-line tool reports to its terminal */ import type { Book, DraftProject, TextAnalysis } from 'interlinearizer'; import path from 'path'; +import { isDeepStrictEqual } from 'util'; +import type { DraftContent } from '../../src/hooks/useDraftProject'; import { tokenizeBook } from '../../src/parsers/papi/bookTokenizer'; import { extractBookFromUsj } from '../../src/parsers/papi/usjBookExtractor'; import analysisReducer, { @@ -13,7 +15,9 @@ import analysisReducer, { writeGloss, } from '../../src/store/analysisSlice'; import { createAnalysisStore } from '../../src/store'; -import { mergeAnalyses, splitAnalysisByBook } from '../../src/utils/analysis-book'; +import { emptyAnalysis } from '../../src/types/empty-factories'; +import { booksLinkedIn, mergeAnalyses, splitAnalysisByBook } from '../../src/utils/analysis-book'; +import { applyChangeSet } from '../../src/utils/change-set'; import { approvedAnalysisByToken, buildConcordanceEntries, @@ -23,7 +27,9 @@ import { import { FormIndex, formsOfBook } from '../../src/utils/form-index'; import { collatorForTag } from '../../src/utils/language-tags'; import { reanchorAnalysisToBook } from '../../src/utils/reanchor-analysis'; -import { MAX_UNDO_STEPS } from '../../src/utils/undo-history'; +import { changesOfReanchor } from '../../src/utils/reanchor-changes'; +import { reanchorDraftToBook, reanchorDraftToMissingBook } from '../../src/utils/reanchor-draft'; +import { type BookPass, MAX_UNDO_STEPS } from '../../src/utils/undo-history'; import { loadBooks, readUsj } from '../books'; import { readDraftJson, readManifest } from '../dataset/store'; import { TIERS, type TierSpec } from '../dataset/tiers'; @@ -173,6 +179,70 @@ function benchTier(tier: TierSpec, allBooks: () => Book[]): Record { + const inBook = byBook.get(bookCode) ?? emptyAnalysis(); + return { + ...draft, + analysis: { + ...emptyAnalysis(), + tokenAnalysisLinks: inBook.tokenAnalysisLinks, + phraseAnalysisLinks: inBook.phraseAnalysisLinks, + segmentAnalysisLinks: inBook.segmentAnalysisLinks, + segmentAnalyses: inBook.segmentAnalyses, + }, + }; + }; + const textBookCodes = new Set(books.map((book) => book.bookRef)); + const passes: [string, BookPass][] = [ + ...books.map((book): [string, BookPass] => [ + book.bookRef, + reanchorDraftToBook(book), + ]), + ...[...booksLinkedIn(analysis)] + .filter((bookCode) => !textBookCodes.has(bookCode)) + .map((bookCode): [string, BookPass] => [ + bookCode, + reanchorDraftToMissingBook(bookCode), + ]), + ]; + const hostChanges = (bookCode: string, pass: BookPass) => { + const alone = bookAlone(bookCode); + return changesOfReanchor(alone, pass(alone)); + }; + time( + 'reanchor.hostText', + measure(opts, noSetup, () => passes.forEach(([bookCode, pass]) => hostChanges(bookCode, pass))), + ); + // Each pass stamps what it rewrites with the time it ran, so two passes over a book differ there. + const unstamped = (records: TextAnalysis) => + JSON.stringify(records, (key, value) => (key === 'updatedAt' ? undefined : value)); + let changedByWebView = 0; + let changedByHost = 0; + let reanchorDisagreeing = 0; + passes.forEach(([bookCode, pass]) => { + const byWebView = pass(content); + const changes = hostChanges(bookCode, pass); + const webViewChanged = + byWebView.analysis !== analysis || byWebView.segmentation !== draft.segmentation; + if (webViewChanged) changedByWebView += 1; + if (changes) changedByHost += 1; + if (!webViewChanged && !changes) return; + const byHost = changes ? applyChangeSet(draft, changes).draft : draft; + if ( + unstamped(byHost.analysis) !== unstamped(byWebView.analysis) || + !isDeepStrictEqual(byHost.segmentation, byWebView.segmentation) + ) + reanchorDisagreeing += 1; + }); + console.log( + ` books re-anchored: ${passes.length}, changed by the WebView's passes ${changedByWebView}, by the host's ${changedByHost}, ${reanchorDisagreeing} disagreeing`, + ); + // The undo history holds a snapshot of the analysis before each of its steps. const undoBytes = retainedBytes(() => { let state: AnalysisState = { analysis, analysisLanguage: language }; @@ -195,6 +265,12 @@ function benchTier(tier: TierSpec, allBooks: () => Book[]): Record { + await (await store.open(SOURCE)).replaceAll({ ...exodusOnlyDraft(), dirty }); + + await (await store.open(SOURCE)).apply(translatingExodus(), { keepDirty: true }); + + await expect(reload(store)).resolves.toMatchObject({ + dirty, + analysis: { + segmentAnalyses: [expect.objectContaining({ freeTranslation: { en: 'And then.' } })], + }, + }); + }, + ); + + it('keeps the clean mark markClean left when asked to keep the mark', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(exodusOnlyDraft()); + await repository.apply(translatingExodus()); + await repository.markClean(); + + await repository.apply(deletingExodus(), { keepDirty: true }); + + const { dirty } = await reload(store); + expect(dirty).toBe(false); + }); + it('leaves the draft unsaved when undoing an edit made before it was marked clean', async () => { const repository = await store.open(SOURCE); await repository.replaceAll(exodusOnlyDraft()); diff --git a/src/__tests__/host-reanchor-parity.ts b/src/__tests__/host-reanchor-parity.ts new file mode 100644 index 000000000..05509cf50 --- /dev/null +++ b/src/__tests__/host-reanchor-parity.ts @@ -0,0 +1,50 @@ +/// + +import type { DraftProject } from 'interlinearizer'; +import type { DraftContent } from '../hooks/useDraftProject'; +import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; +import { splitAnalysisByBook } from '../utils/analysis-book'; +import { applyChangeSet } from '../utils/change-set'; +import { changesOfReanchor } from '../utils/reanchor-changes'; +import type { BookPass } from '../utils/undo-history'; + +function draftHolding({ analysis, segmentation }: DraftContent): DraftProject { + return { ...emptyDraft('src'), analysis, ...(segmentation && { segmentation }) }; +} + +/** + * Checks that the host's re-anchor of a book gives what the WebView's pass made. The changes the + * host applies bring the draft to what the pass made. A pass over only the book's links, as the + * host reads them, makes the same changes. + * + * @param content - The draft content the pass ran over. + * @param bookCode - The book re-anchored. + * @param pass - The WebView's pass over the book. It must stamp every run alike. + * @param reanchored - What `pass` made of `content`. + */ +export function expectHostReanchorParity( + content: DraftContent, + bookCode: string, + pass: BookPass, + reanchored: DraftContent, +): void { + const draft = draftHolding(content); + const changes = changesOfReanchor(draft, reanchored); + const applied = changes ? applyChangeSet(draft, changes).draft : draft; + expect(applied.analysis).toEqual(reanchored.analysis); + expect(applied.segmentation).toEqual(reanchored.segmentation); + + // As the host reads a book: its links, and the segment analyses they reference. + const inBook = splitAnalysisByBook(content.analysis).get(bookCode) ?? emptyAnalysis(); + const bookAlone: DraftContent = { + analysis: { + ...emptyAnalysis(), + tokenAnalysisLinks: inBook.tokenAnalysisLinks, + phraseAnalysisLinks: inBook.phraseAnalysisLinks, + segmentAnalysisLinks: inBook.segmentAnalysisLinks, + segmentAnalyses: inBook.segmentAnalyses, + }, + segmentation: content.segmentation, + }; + expect(changesOfReanchor(draftHolding(bookAlone), pass(bookAlone))).toEqual(changes); +} diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 54ea5ca51..8e4e90e63 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -26,6 +26,7 @@ import { toProjectSummary } from '../types/interlinear-project-summary'; import { createTestActivationContext, FIXTURE_STAMPS, + GEN_1_1_BOOK, makeStubProject, makeVerseBook, memoryStorage, @@ -296,6 +297,13 @@ describe('main', () => { expect(emitter.emit).toHaveBeenCalledWith({ notificationId: 'notification-1' }); }); + /** Lets every pending promise callback run. */ + function nextTick() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + describe('interlinearizer.onAnalysisChanged', () => { /** Gives each event its own emitter, as the platform does. */ function emittersByEvent() { @@ -314,6 +322,7 @@ describe('main', () => { const [listener] = jest.mocked(bookService.onBookTextChanged).mock.calls[0]; listener('src-project', 'GEN'); + await nextTick(); expect(emitters.get('interlinearizer.onAnalysisChanged')?.emit).toHaveBeenCalledWith({ sourceProjectId: 'src-project', @@ -329,22 +338,107 @@ describe('main', () => { listener('src-project', 'GEN'); listener('src-project', 'GEN'); + await nextTick(); const emit = emitters.get('interlinearizer.onAnalysisChanged')?.emit; expect(emit?.mock.calls[0][0].changeId).not.toBe(emit?.mock.calls[1][0].changeId); }); + it("emits an edit once the source's session has run what was sent before it", async () => { + const emitters = emittersByEvent(); + let settle: () => void = () => {}; + jest.spyOn(AnalysisSessions.prototype, 'settled').mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }), + ); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onBookTextChanged).mock.calls[0]; + const emit = emitters.get('interlinearizer.onAnalysisChanged')?.emit; + + listener('src-project', 'GEN'); + await nextTick(); + expect(emit).not.toHaveBeenCalled(); + settle(); + await nextTick(); + + expect(AnalysisSessions.prototype.settled).toHaveBeenCalledWith('src-project'); + expect(emit).toHaveBeenCalledTimes(1); + }); + it('disposes the emitter and stops listening on deactivation', async () => { const emitters = emittersByEvent(); - const unsubscribe = jest.fn(() => true); - jest.mocked(bookService.onBookTextChanged).mockReturnValue(unsubscribe); + const unsubscribers = [jest.fn(() => true), jest.fn(() => true), jest.fn(() => true)]; + jest.mocked(bookService.onBookTextChanged).mockReturnValue(unsubscribers[0]); + jest.mocked(bookService.onBookRead).mockReturnValue(unsubscribers[1]); + jest.mocked(bookService.onTextRead).mockReturnValue(unsubscribers[2]); const context = createTestActivationContext(); await activate(context); await context.registrations.runAllUnsubscribers(); expect(emitters.get('interlinearizer.onAnalysisChanged')?.dispose).toHaveBeenCalled(); - expect(unsubscribe).toHaveBeenCalled(); + unsubscribers.forEach((unsubscribe) => expect(unsubscribe).toHaveBeenCalled()); + }); + }); + + describe('re-anchoring', () => { + const book = GEN_1_1_BOOK; + + it("re-anchors each book read to the source's session", async () => { + const reanchorBook = jest + .spyOn(AnalysisSessions.prototype, 'reanchorBook') + .mockResolvedValue(undefined); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onBookRead).mock.calls[0]; + + listener('src-project', book); + + expect(reanchorBook).toHaveBeenCalledWith('src-project', book); + }); + + it('logs a re-anchor that fails', async () => { + jest + .spyOn(AnalysisSessions.prototype, 'reanchorBook') + .mockRejectedValue(new Error('disk full')); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onBookRead).mock.calls[0]; + + listener('src-project', book); + await nextTick(); + + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not re-anchor GEN of project src-project', + expect.any(Error), + ); + }); + + it("re-anchors the books the text lacks to the source's session", async () => { + const reanchorMissingBooks = jest + .spyOn(AnalysisSessions.prototype, 'reanchorMissingBooks') + .mockResolvedValue(undefined); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onTextRead).mock.calls[0]; + + listener('src-project', ['GEN']); + + expect(reanchorMissingBooks).toHaveBeenCalledWith('src-project', ['GEN']); + }); + + it('logs a re-anchor of the books the text lacks that fails', async () => { + jest + .spyOn(AnalysisSessions.prototype, 'reanchorMissingBooks') + .mockRejectedValue(new Error('disk full')); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onTextRead).mock.calls[0]; + + listener('src-project', ['GEN']); + await nextTick(); + + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not re-anchor the books project src-project lacks', + expect.any(Error), + ); }); }); @@ -2750,6 +2844,38 @@ describe('main', () => { }); }); + it("stales the free translation of a segment it splits, in the split's edit", async () => { + await storeDraftHolding({ + segmentAnalyses: [ + { + id: 'sa-1', + ...FIXTURE_STAMPS, + surfaceText: 'In the beginning God', + freeTranslation: { en: 'At first God' }, + }, + ], + segmentAnalysisLinks: [ + { + id: 'sl-1', + analysisId: 'sa-1', + ...FIXTURE_STAMPS, + status: 'approved', + segmentId: 'GEN 1:1', + }, + ], + }); + const { openSession, splitSegment } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + + const changes = JSON.parse(await splitSegment(sessionId, 'GEN 1:1:3')); + + expect(changes).toMatchObject({ + intent: 'splitSegment', + segmentAnalysisLinks: { puts: [{ id: 'sl-1', status: 'stale' }] }, + scope: { segmentation: { addedStarts: [{ tokenRef: 'GEN 1:1:3' }] } }, + }); + }); + const exodus: TokenSnapshot[] = [ { tokenRef: 'EXO 1:1:0', surfaceText: 'Now' }, { tokenRef: 'EXO 1:1:4', surfaceText: 'these' }, diff --git a/src/__tests__/services/analysisSessions.parity.test.ts b/src/__tests__/services/analysisSessions.parity.test.ts index 19ca95bd4..6493780a4 100644 --- a/src/__tests__/services/analysisSessions.parity.test.ts +++ b/src/__tests__/services/analysisSessions.parity.test.ts @@ -28,6 +28,8 @@ describe('setAnalysisLanguage parity with the reducer', () => { throw new Error('no import in this test'); }, getInterfaceLanguages: async () => [], + startTextRead: async () => {}, + stopTextRead: async () => {}, }); const { sessionId } = await sessions.open(SOURCE); diff --git a/src/__tests__/services/analysisSessions.test.ts b/src/__tests__/services/analysisSessions.test.ts index e0f4af6e2..bac539be9 100644 --- a/src/__tests__/services/analysisSessions.test.ts +++ b/src/__tests__/services/analysisSessions.test.ts @@ -1,6 +1,6 @@ /// -import type { DraftProject, InterlinearProject, TextAnalysis } from 'interlinearizer'; +import type { Book, DraftProject, InterlinearProject, TextAnalysis } from 'interlinearizer'; import { AnalysisSessions, type SessionHost, @@ -14,7 +14,15 @@ import { ReadOnlyAnalysisError } from '../../types/read-only-analysis-error'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { toProjectSummary } from '../../types/interlinear-project-summary'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; -import { FIXTURE_STAMPS, makeStubProject, memoryStorage, silentLogger } from '../test-helpers'; +import { + FIXTURE_STAMPS, + makeStubProject, + makeTokenLink, + makeVerseBook, + memoryStorage, + silentLogger, + tokenRefOf, +} from '../test-helpers'; const SOURCE = 'src-project'; const OTHER_SOURCE = 'other-project'; @@ -67,6 +75,8 @@ function makeSessions(projects: InterlinearProject[] = []) { ), ), getInterfaceLanguages: jest.fn(async (): Promise => []), + startTextRead: jest.fn, [string]>(async () => {}), + stopTextRead: jest.fn, [string]>(async () => {}), } satisfies SessionHost; const saveDraft = (draft: DraftProject) => new DraftRepository(storage, draft.sourceProjectId, logger).replaceAll(draft); @@ -604,6 +614,291 @@ describe('AnalysisSessions', () => { }); }); + describe('the whole-text read', () => { + it("starts reading the source's whole text afresh when a session opens", async () => { + const { host, sessions } = makeSessions(); + + await sessions.open(SOURCE); + + expect(host.startTextRead).toHaveBeenCalledWith(SOURCE); + }); + + it('ends the read once the commands sent before the close settle', async () => { + const { host, sessions } = makeSessions(); + const { sessionId } = await sessions.open(SOURCE); + const command = deferred(); + const running = sessions.run(sessionId, () => command.promise); + + const closing = sessions.close(sessionId); + await flushPromises(); + expect(host.stopTextRead).not.toHaveBeenCalled(); + command.resolve(); + await Promise.all([running, closing]); + + expect(host.stopTextRead).toHaveBeenCalledWith(SOURCE); + }); + + it("reads a reopened source's text again", async () => { + const { host, sessions } = makeSessions(); + await sessions.open(SOURCE); + + await sessions.open(SOURCE); + + expect(host.stopTextRead).toHaveBeenCalledTimes(1); + expect(host.startTextRead).toHaveBeenCalledTimes(2); + }); + }); + + describe('re-anchoring', () => { + /** Genesis 1:1 reading `text`, versioned by its text as a book's content hash would be. */ + function genesis(text: string): Book { + return { ...makeVerseBook([{ sid: 'GEN 1:1', text }]), textVersion: text }; + } + + /** A link to the payload `analysisId` on "beta" where `book` has it. */ + function betaLink(book: Book, analysisId: string) { + return makeTokenLink(analysisId, tokenRefOf(book, 'beta'), { + surfaceText: 'beta', + id: 'tl-beta', + }); + } + + const BEFORE = genesis('alpha beta'); + const AFTER = genesis('alpha and beta'); + + /** The stored link on "beta". */ + async function storedLink(storage: ReturnType['storage']) { + const { draft } = await new DraftRepository(storage, SOURCE, silentLogger()).exportAll(); + return draft.analysis.tokenAnalysisLinks[0]; + } + + /** A session over a draft glossing "beta" as `BEFORE` reads it. */ + async function sessionGlossingBeta() { + const made = makeSessions(); + await made.saveDraft({ + ...emptyDraft(SOURCE), + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'beta' }], + tokenAnalysisLinks: [betaLink(BEFORE, 'ta-1')], + }, + }); + const { sessionId } = await made.sessions.open(SOURCE); + return { ...made, sessionId }; + } + + it("re-anchors the session's draft to a book's current text", async () => { + const { sessions, storage } = await sessionGlossingBeta(); + + await sessions.reanchorBook(SOURCE, AFTER); + + expect((await storedLink(storage)).token.tokenRef).toBe(tokenRefOf(AFTER, 'beta')); + }); + + it('adds no undo step', async () => { + const { sessions, sessionId } = await sessionGlossingBeta(); + + await sessions.reanchorBook(SOURCE, AFTER); + + await expect(sessions.undo(sessionId)).resolves.toBeUndefined(); + }); + + /** A session over a draft that links into no book, and splits Genesis 1:1 at "beta". */ + async function sessionSplittingBeta() { + const made = makeSessions(); + await made.saveDraft({ + ...emptyDraft(SOURCE), + segmentation: { + removedVerseStarts: [], + addedStarts: [{ tokenRef: tokenRefOf(BEFORE, 'beta'), surfaceText: 'beta' }], + }, + }); + const { sessionId } = await made.sessions.open(SOURCE); + return { ...made, sessionId }; + } + + it('moves the splits of a book the draft does not link into', async () => { + const { sessions, storage } = await sessionSplittingBeta(); + + await sessions.reanchorBook(SOURCE, AFTER); + + const { draft } = await new DraftRepository(storage, SOURCE, silentLogger()).exportAll(); + expect(draft.segmentation?.addedStarts).toEqual([ + { tokenRef: tokenRefOf(AFTER, 'beta'), surfaceText: 'beta' }, + ]); + }); + + it('records the pass over a book the draft only splits', async () => { + const { sessions, sessionId } = await sessionSplittingBeta(); + const recordPass = await sessions.run(sessionId, async (session) => + jest.spyOn(session.history, 'recordPass'), + ); + + await sessions.reanchorBook(SOURCE, AFTER); + + expect(recordPass).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['records', 'it links into', AFTER, 1], + ['records no', 'it holds no analysis in', makeVerseBook([{ sid: 'EXO 1:1', text: 'x' }]), 0], + ])('%s the pass over a book %s', async (_records, _holds, book, recorded) => { + const { sessions, sessionId } = await sessionGlossingBeta(); + const recordPass = await sessions.run(sessionId, async (session) => + jest.spyOn(session.history, 'recordPass'), + ); + + await sessions.reanchorBook(SOURCE, book); + + expect(recordPass).toHaveBeenCalledTimes(recorded); + }); + + it('undoes and redoes edits re-anchored to the current text', async () => { + const { sessions, sessionId, storage } = await sessionGlossingBeta(); + const relinking = (analysisId: string) => ({ + intent: `link ${analysisId}`, + tokenAnalysisLinks: { puts: [betaLink(BEFORE, analysisId)], deletes: [] }, + }); + await sessions.runEdit(sessionId, (session) => session.history.apply(relinking('ta-2'))); + await sessions.reanchorBook(SOURCE, AFTER); + + await sessions.undo(sessionId); + expect(await storedLink(storage)).toEqual( + expect.objectContaining({ analysisId: 'ta-1', token: betaLink(AFTER, 'ta-1').token }), + ); + await sessions.redo(sessionId); + expect(await storedLink(storage)).toEqual( + expect.objectContaining({ analysisId: 'ta-2', token: betaLink(AFTER, 'ta-2').token }), + ); + }); + + it("re-anchors an edit to the books its command returns, in the edit's undo step", async () => { + const { sessions, sessionId, storage } = await sessionGlossingBeta(); + + const changes = await sessions.edit(sessionId, 'relink', async (buffer) => { + buffer.put('tokenAnalysisLinks', betaLink(BEFORE, 'ta-2')); + return [AFTER]; + }); + + expect(changes.tokenAnalysisLinks?.puts).toEqual([ + expect.objectContaining({ analysisId: 'ta-2', token: betaLink(AFTER, 'ta-2').token }), + ]); + expect(await storedLink(storage)).toEqual( + expect.objectContaining({ analysisId: 'ta-2', token: betaLink(AFTER, 'ta-2').token }), + ); + await sessions.undo(sessionId); + expect(await storedLink(storage)).toEqual(betaLink(BEFORE, 'ta-1')); + }); + + it.each([ + ['no books', []], + ['books it needs no re-anchoring to', [BEFORE]], + ])('applies an edit as staged when its command returns %s', async (_case, books) => { + const { sessions, sessionId, storage } = await sessionGlossingBeta(); + + await sessions.edit(sessionId, 'relink', async (buffer) => { + buffer.put('tokenAnalysisLinks', betaLink(BEFORE, 'ta-2')); + return books; + }); + + expect(await storedLink(storage)).toEqual(betaLink(BEFORE, 'ta-2')); + }); + + it('stales the links into the books the text lacks', async () => { + const { sessions, storage } = await sessionGlossingBeta(); + + await sessions.reanchorMissingBooks(SOURCE, ['EXO']); + + expect((await storedLink(storage)).status).toBe('stale'); + }); + + it('leaves the links into the books holding text', async () => { + const { sessions, storage } = await sessionGlossingBeta(); + + await sessions.reanchorMissingBooks(SOURCE, ['GEN']); + + expect((await storedLink(storage)).status).toBe('approved'); + }); + + it('re-anchors in the session turn, after the commands sent before', async () => { + const { sessions, sessionId, storage } = await sessionGlossingBeta(); + const command = deferred(); + const running = sessions.run(sessionId, () => command.promise); + + const reanchoring = sessions.reanchorBook(SOURCE, AFTER); + await flushPromises(); + expect((await storedLink(storage)).token.tokenRef).toBe(tokenRefOf(BEFORE, 'beta')); + command.resolve(); + await Promise.all([running, reanchoring]); + + expect((await storedLink(storage)).token.tokenRef).toBe(tokenRefOf(AFTER, 'beta')); + }); + + it('does nothing for a source with no session open', async () => { + const { sessions, saveDraft, storage } = makeSessions(); + await saveDraft({ + ...emptyDraft(SOURCE), + analysis: { ...emptyAnalysis(), tokenAnalysisLinks: [betaLink(BEFORE, 'ta-1')] }, + }); + + await sessions.reanchorBook(SOURCE, AFTER); + await sessions.reanchorMissingBooks(SOURCE, []); + + expect(await storedLink(storage)).toEqual(betaLink(BEFORE, 'ta-1')); + }); + + it("does nothing for a Paratext 9 import's session", async () => { + const { sessions } = makeSessions([makeImport()]); + const { sessionId } = await sessions.open(SOURCE, 'import-id'); + const applied = jest.fn(); + await sessions.run(sessionId, async (session) => { + jest.spyOn(session.repository, 'apply').mockImplementation(applied); + }); + + await sessions.reanchorBook(SOURCE, AFTER); + await sessions.reanchorMissingBooks(SOURCE, []); + + expect(applied).not.toHaveBeenCalled(); + }); + }); + + describe('settled', () => { + it("settles once the source's session has run what was sent before", async () => { + const { sessions } = makeSessions(); + const { sessionId } = await sessions.open(SOURCE); + const command = deferred(); + sessions.run(sessionId, () => command.promise); + let isSettled = false; + + const settling = sessions.settled(SOURCE).then(() => { + isSettled = true; + return isSettled; + }); + await flushPromises(); + expect(isSettled).toBe(false); + command.resolve(); + await settling; + + expect(isSettled).toBe(true); + }); + + it('settles even when a command sent before fails', async () => { + const { sessions } = makeSessions(); + const { sessionId } = await sessions.open(SOURCE); + sessions + .run(sessionId, async () => { + throw new Error('failed'); + }) + .catch(() => {}); + + await expect(sessions.settled(SOURCE)).resolves.toBeUndefined(); + }); + + it('settles at once for a source with no session open', async () => { + await expect(makeSessions().sessions.settled(SOURCE)).resolves.toBeUndefined(); + }); + }); + describe('setAnalysisLanguage', () => { it('sets the language the commands sent after it read', async () => { const { sessions } = makeSessions(); diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index 23d7d2c92..8aa71a1b7 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -16,7 +16,11 @@ import { getVerseBook, getWritingSystem, isFormInText, + onBookRead, onBookTextChanged, + onTextRead, + startTextRead, + stopTextRead, } from '../../services/bookService'; import type { BookOutline, SegmentPage } from '../../types/segment-pages'; import type { TextReadStatus } from '../../types/text-read-status'; @@ -755,6 +759,7 @@ describe('onBookTextChanged', () => { await getBookOutline('src', 'GEN'); project.subscriptions[0].deliver(usj('GEN', 'v2')); + await settle(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith('src', 'GEN'); @@ -766,6 +771,7 @@ describe('onBookTextChanged', () => { await getBookOutline('src', 'GEN'); project.subscriptions[0].deliver(usj('GEN', 'v2')); + await settle(); listeners.forEach((listener) => expect(listener).toHaveBeenCalledTimes(1)); }); @@ -819,6 +825,7 @@ describe('onBookTextChanged', () => { await settle(); subscriptionsTo(project, 'GEN')[1].deliver(usj('GEN', 'v3')); + await settle(); expect(listener).toHaveBeenCalledTimes(2); }); @@ -854,6 +861,7 @@ describe('onBookTextChanged', () => { await getBookOutline('src', 'GEN'); subscriptionsTo(project, 'GEN')[1].deliver(usj('GEN', 'v2')); + await settle(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith('src', 'GEN'); @@ -906,6 +914,7 @@ describe('onBookTextChanged', () => { await getBookOutline('src', 'EXO'); subscriptionsTo(project, 'EXO')[0].deliver(usj('EXO', 'v2')); + await settle(); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith('src', 'EXO'); @@ -924,6 +933,7 @@ describe('onBookTextChanged', () => { await earlier; subscriptionsTo(project, 'EXO')[0].deliver(usj('EXO', 'v2')); + await settle(); expect(listener).toHaveBeenCalledWith('src', 'EXO'); }); @@ -1230,6 +1240,411 @@ describe('onBookTextChanged', () => { }); }); +describe('onBookRead', () => { + const unsubscribers: Array<() => boolean> = []; + + /** A listener for books read, registered for the test and removed once it ends. */ + function listenForBooks(onCall?: (sourceProjectId: string, book: Book) => void) { + const listener = jest.fn(onCall); + unsubscribers.push(onBookRead(listener)); + return listener; + } + + beforeEach(() => { + segmentStandIns(); + }); + + afterEach(() => { + unsubscribers.splice(0).forEach((unsubscribe) => unsubscribe()); + }); + + it('passes each book the whole-text read reads, tokenized, with its project', async () => { + tokenizeToWords(); + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '101'; + const listener = listenForBooks(); + + readStatus(); + await settle(); + + expect( + listener.mock.calls.map(([sourceProjectId, book]) => [sourceProjectId, book.id]), + ).toEqual([ + ['src', 'GEN'], + ['src', 'LEV'], + ]); + expect(listener.mock.calls[0][1].segments[0].tokens[1].surfaceText).toBe('GEN-v1'); + }); + + it('passes no book that fails to read or has no text', async () => { + const project = serveProject((bookId) => + bookId === 'GEN' ? platformError('busy') : undefined, + ); + project.booksPresent = '11'; + const listener = listenForBooks(); + + readStatus(); + await settle(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('passes an edited book again before the edit is reported', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + project.booksPresent = '1'; + const heard: string[] = []; + listenForBooks((_, book) => heard.push(`read ${book.textVersion}`)); + unsubscribers.push(onBookTextChanged(() => heard.push('edited'))); + readStatus(); + await settle(); + + version = 'v2'; + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(heard).toEqual(['read v1@en', 'read v2@en', 'edited']); + }); + + it('passes the edited open book before the edit is reported, ahead of the read', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + const heard: string[] = []; + listenForBooks((_, book) => heard.push(`read ${book.textVersion}`)); + unsubscribers.push(onBookTextChanged(() => heard.push('edited'))); + await getBookOutline('src', 'GEN'); + + version = 'v2'; + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(heard).toEqual(['read v2@en', 'edited']); + }); + + it('reports an edit to the open book that no longer reads, passing nothing', async () => { + let delivery: Delivery = usj('GEN'); + const project = serveProject(() => delivery); + const listener = listenForBooks(); + const edits = jest.fn(); + unsubscribers.push(onBookTextChanged(edits)); + await getBookOutline('src', 'GEN'); + + delivery = platformError('busy'); + project.subscriptions[0].deliver(usj('GEN', 'v2')); + await settle(); + + expect(listener).not.toHaveBeenCalled(); + expect(edits).toHaveBeenCalledTimes(1); + }); + + it('passes and reports nothing for an open book edited as the project closes', async () => { + const project = serveProject(); + const listener = listenForBooks(); + const edits = jest.fn(); + unsubscribers.push(onBookTextChanged(edits)); + const outline = getBookOutline('src', 'GEN'); + await settle(); + project.subscriptions[0].deliver(usj('GEN')); + await outline; + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + const closed = closeProject('src'); + await settle(); + subscriptionsTo(project, 'GEN')[1].deliver(usj('GEN', 'v2')); + await closed; + await settle(); + + expect(listener).not.toHaveBeenCalled(); + expect(edits).not.toHaveBeenCalled(); + }); + + it('passes a book that failed to read once it reads', async () => { + let delivery: Delivery = platformError('busy'); + const project = serveProject(() => delivery); + project.booksPresent = '1'; + const listener = listenForBooks(); + readStatus(); + await settle(); + + delivery = usj('GEN', 'v2'); + subscriptionsTo(project, 'GEN').at(-1)?.deliver(delivery); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][1].textVersion).toBe('v2@en'); + }); + + it('passes nothing once the project closes', async () => { + const project = serveProject(); + project.booksPresent = '1'; + const listener = listenForBooks(); + readStatus(); + await settle(); + + const closed = closeProject('src'); + project.subscriptions[0].deliver(usj('GEN')); + await closed; + await settle(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not call a listener once it is removed', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + const listener = jest.fn(); + onBookRead(listener)(); + + readStatus(); + await settle(); + + expect(project.subscriptions).toHaveLength(1); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('onTextRead', () => { + const unsubscribers: Array<() => boolean> = []; + + /** A listener for the books holding text, registered for the test and removed once it ends. */ + function listenForText() { + const listener = jest.fn(); + unsubscribers.push(onTextRead(listener)); + return listener; + } + + afterEach(() => { + unsubscribers.splice(0).forEach((unsubscribe) => unsubscribe()); + }); + + it('reports the books holding text once every book is read', async () => { + const project = serveProject((bookId) => (bookId === 'EXO' ? undefined : usj(bookId))); + project.booksPresent = '111'; + const listener = listenForText(); + + readStatus(); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toBe('src'); + expect([...listener.mock.calls[0][1]].sort()).toEqual(['GEN', 'LEV']); + }); + + it('reports nothing while some books are still being read', async () => { + const project = serveProject(); + project.booksPresent = '11'; + const listener = listenForText(); + readStatus(); + await settle(); + + project.subscriptions[0].deliver(usj('GEN')); + await settle(); + + expect(listener).not.toHaveBeenCalled(); + await finishReading(project); + }); + + it('reports nothing while a book has failed to read, then reports once it reads', async () => { + let delivery: Delivery = platformError('busy'); + const project = serveProject((bookId) => (bookId === 'GEN' ? delivery : usj(bookId))); + project.booksPresent = '11'; + const listener = listenForText(); + readStatus(); + await settle(); + + expect(listener).not.toHaveBeenCalled(); + + delivery = usj('GEN'); + subscriptionsTo(project, 'GEN').at(-1)?.deliver(delivery); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('reports again when an edit takes away the text of a book', async () => { + let delivery: Delivery = usj('GEN'); + const project = serveProject((bookId) => (bookId === 'GEN' ? delivery : usj(bookId))); + project.booksPresent = '11'; + const listener = listenForText(); + readStatus(); + await settle(); + + delivery = undefined; + subscriptionsTo(project, 'GEN').at(-1)?.deliver(delivery); + await settle(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.mock.calls[1][1]).toEqual(['EXO']); + }); + + it('reports nothing again for an edit that leaves the same books holding text', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + const listener = listenForText(); + readStatus(); + await settle(); + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not call a listener once it is removed', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + const listener = jest.fn(); + onTextRead(listener)(); + + readStatus(); + await settle(); + + expect(project.subscriptions).toHaveLength(1); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('startTextRead and stopTextRead', () => { + const unsubscribers: Array<() => boolean> = []; + + afterEach(() => { + unsubscribers.splice(0).forEach((unsubscribe) => unsubscribe()); + }); + + it('starts reading the whole text without waiting for it', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '11'; + const listener = jest.fn(); + unsubscribers.push(onBookRead(listener)); + + await startTextRead('src'); + await settle(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(await readStatus()).toEqual({ booksRead: 2, bookCount: 2, failedBookIds: [] }); + }); + + it('reads the text again when a read is already done, passing every book again', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + const listener = jest.fn(); + unsubscribers.push(onBookRead(listener)); + readStatus(); + await settle(); + + await startTextRead('src'); + await settle(); + + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('stops watching the books of the read it replaces', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + readStatus(); + await settle(); + const [replaced] = project.subscriptions; + + await startTextRead('src'); + + expect(replaced.unsubscribe).toHaveBeenCalled(); + }); + + it('leaves listing failures for the status to report', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = new Error('offline'); + + await expect(startTextRead('src')).resolves.toBeUndefined(); + await settle(); + + await expect(readStatus()).rejects.toThrow('offline'); + }); + + it('ends the read, stopping its watches and its answers', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + readStatus(); + await settle(); + + await stopTextRead('src'); + + expect(project.subscriptions[0].unsubscribe).toHaveBeenCalled(); + expect(isFormInText('src', 'shared')).toBeUndefined(); + }); + + it('keeps the outlined book while ending the read', async () => { + segmentStandIns(); + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + await getBookOutline('src', 'EXO'); + readStatus(); + await settle(); + + await stopTextRead('src'); + + expect(subscriptionsTo(project, 'EXO')[0].unsubscribe).not.toHaveBeenCalled(); + }); + + it('does nothing for a project whose text is not being read', async () => { + await expect(stopTextRead('src')).resolves.toBeUndefined(); + }); + + it('keeps reading when the project closes, passing each edited book', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + const listener = jest.fn(); + unsubscribers.push(onBookRead(listener)); + await startTextRead('src'); + await settle(); + + await closeProject('src'); + subscriptionsTo(project, 'GEN')[0].deliver(usj('GEN', 'v2')); + await settle(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(await readStatus()).toEqual({ booksRead: 1, bookCount: 1, failedBookIds: [] }); + }); + + it('lets go of the kept books when the project closes during its read', async () => { + segmentStandIns(); + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + await getBookOutline('src', 'EXO'); + await startTextRead('src'); + await settle(); + + await closeProject('src'); + + expect(subscriptionsTo(project, 'EXO')[0].unsubscribe).toHaveBeenCalled(); + expect(subscriptionsTo(project, 'GEN')[0].unsubscribe).not.toHaveBeenCalled(); + }); + + it('ends a stopped read when the project closes', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + await startTextRead('src'); + await stopTextRead('src'); + readStatus(); + await settle(); + + await closeProject('src'); + + expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); + }); + + it('ends the read once all projects close', async () => { + const project = serveProject((bookId) => usj(bookId)); + project.booksPresent = '1'; + await startTextRead('src'); + await settle(); + + await closeAllProjects(); + + expect(project.subscriptions[0].unsubscribe).toHaveBeenCalled(); + expect(isFormInText('src', 'shared')).toBeUndefined(); + }); +}); + describe('getTextReadStatus', () => { it('answers once the books are known, before any is read', async () => { const project = serveProject(); diff --git a/src/__tests__/services/commands/boundaryCommands.test.ts b/src/__tests__/services/commands/boundaryCommands.test.ts index 0c9f5085b..16241e647 100644 --- a/src/__tests__/services/commands/boundaryCommands.test.ts +++ b/src/__tests__/services/commands/boundaryCommands.test.ts @@ -62,6 +62,8 @@ describe('splitSegment', () => { throw new Error('no import in this test'); }, getInterfaceLanguages: async () => [], + startTextRead: async () => {}, + stopTextRead: async () => {}, }); const stored = async () => { const { draft } = await new DraftRepository(storage, SOURCE, logger).exportAll(); diff --git a/src/__tests__/services/editHistory.test.ts b/src/__tests__/services/editHistory.test.ts index 9a9693cb4..2d526a2dd 100644 --- a/src/__tests__/services/editHistory.test.ts +++ b/src/__tests__/services/editHistory.test.ts @@ -1,11 +1,20 @@ /// -import type { TokenAnalysis } from 'interlinearizer'; +import type { Book, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; import { DraftRepository } from '../../services/draftRepository'; -import { EditHistory } from '../../services/editHistory'; +import { EditHistory, type ReanchorBooks } from '../../services/editHistory'; import type { ChangeSet } from '../../types/analysis-repository'; +import { applyChangeSet, mergeChangeSets } from '../../utils/change-set'; +import { reanchorChangesForBook } from '../../utils/reanchor-changes'; import { MAX_UNDO_STEPS } from '../../utils/undo-history'; -import { FIXTURE_STAMPS, memoryStorage, silentLogger } from '../test-helpers'; +import { + FIXTURE_STAMPS, + makeTokenLink, + makeVerseBook, + memoryStorage, + silentLogger, + tokenRefOf, +} from '../test-helpers'; function payload(id: string): TokenAnalysis { return { id, ...FIXTURE_STAMPS, surfaceText: id }; @@ -135,3 +144,235 @@ describe('EditHistory', () => { await expect(storedIds()).resolves.toStrictEqual(['a']); }); }); + +describe('EditHistory re-anchoring', () => { + /** A book of one verse reading `text`, versioned by its text as a book's content hash would. */ + function verseBook(sid: string, text: string): Book { + return { ...makeVerseBook([{ sid, text }]), textVersion: text }; + } + + function genesis(text: string): Book { + return verseBook('GEN 1:1', text); + } + + /** A link to the payload `analysisId` on the word `beta` where `book` has it. */ + function betaLink(book: Book, analysisId: string): TokenAnalysisLink { + return makeTokenLink(analysisId, tokenRefOf(book, 'beta'), { + surfaceText: 'beta', + id: 'tl-beta', + }); + } + + function linking(link: TokenAnalysisLink): ChangeSet { + return { intent: `link ${link.analysisId}`, tokenAnalysisLinks: { puts: [link], deletes: [] } }; + } + + /** + * A history over a draft in memory whose re-anchoring applies each book's pass in turn, with a + * way to run a pass the history records, as the session does, and to read back the stored link. + */ + function makeReanchoringHistory() { + const { storage } = memoryStorage(); + const repository = new DraftRepository(storage, 'src', silentLogger()); + const reanchorBooks = jest.fn, Parameters>( + async (changes, books) => { + let { draft } = applyChangeSet((await repository.exportAll()).draft, changes); + let reanchored: ChangeSet | undefined; + books.forEach((book) => { + const bookChanges = reanchorChangesForBook(draft, book); + if (!bookChanges) return; + draft = applyChangeSet(draft, bookChanges).draft; + reanchored = reanchored ? mergeChangeSets(reanchored, bookChanges) : bookChanges; + }); + return reanchored; + }, + ); + const history = new EditHistory(repository, reanchorBooks); + const pass = async (book: Book) => { + const changes = reanchorChangesForBook((await repository.exportAll()).draft, book); + if (changes) await repository.apply(changes); + history.recordPass(book); + }; + const storedLink = async () => + (await new DraftRepository(storage, 'src', silentLogger()).exportAll()).draft.analysis + .tokenAnalysisLinks[0]; + return { history, repository, reanchorBooks, pass, storedLink }; + } + + it('undoes an edit to the text a pass re-anchored to since', async () => { + const { history, pass, storedLink } = makeReanchoringHistory(); + const before = genesis('alpha beta'); + const after = genesis('new alpha beta'); + await history.apply(linking(betaLink(before, 'ta-1'))); + await history.apply(linking(betaLink(before, 'ta-2'))); + await pass(after); + + await history.undo(); + + const restored = await storedLink(); + expect(restored.analysisId).toBe('ta-1'); + expect(restored.token.tokenRef).toBe(tokenRefOf(after, 'beta')); + }); + + it('redoes an edit to the text a pass re-anchored to since the undo', async () => { + const { history, pass, storedLink } = makeReanchoringHistory(); + const before = genesis('alpha beta'); + const after = genesis('new alpha beta'); + await history.apply(linking(betaLink(before, 'ta-1'))); + await history.apply(linking(betaLink(before, 'ta-2'))); + await history.undo(); + await pass(after); + + await history.redo(); + + const restored = await storedLink(); + expect(restored.analysisId).toBe('ta-2'); + expect(restored.token.tokenRef).toBe(tokenRefOf(after, 'beta')); + }); + + it('returns the changes that reverted the edit with those that re-anchored them', async () => { + const { history, pass } = makeReanchoringHistory(); + const before = genesis('alpha beta'); + const after = genesis('new alpha beta'); + await history.apply(linking(betaLink(before, 'ta-1'))); + await history.apply(linking(betaLink(before, 'ta-2'))); + await pass(after); + + const undone = await history.undo(); + + expect(undone?.intent).toBe('link ta-2'); + expect(undone?.tokenAnalysisLinks?.puts).toEqual([ + expect.objectContaining({ + analysisId: 'ta-1', + token: { tokenRef: tokenRefOf(after, 'beta'), surfaceText: 'beta' }, + }), + ]); + }); + + it('re-anchors nothing for a pass the undone step already reflects', async () => { + const { history, reanchorBooks, pass } = makeReanchoringHistory(); + const before = genesis('alpha beta'); + const after = genesis('new alpha beta'); + await history.apply(linking(betaLink(before, 'ta-1'))); + await pass(after); + await history.apply(linking(betaLink(after, 'ta-2'))); + + await history.undo(); + + expect(reanchorBooks).not.toHaveBeenCalled(); + }); + + it('records no pass run while there is nothing to undo or redo', async () => { + const { history, reanchorBooks, pass } = makeReanchoringHistory(); + await pass(genesis('new alpha beta')); + await history.apply(linking(betaLink(genesis('new alpha beta'), 'ta-1'))); + + await history.undo(); + + expect(reanchorBooks).not.toHaveBeenCalled(); + }); + + it("re-anchors to each book's latest text, in the order the passes ran", async () => { + const { history, reanchorBooks, pass } = makeReanchoringHistory(); + const exodus = verseBook('EXO 1:1', 'delta'); + const latest = genesis('newer alpha beta'); + await history.apply(linking(betaLink(genesis('alpha beta'), 'ta-1'))); + await pass(genesis('new alpha beta')); + await pass(exodus); + await pass(latest); + + await history.undo(); + + expect(reanchorBooks).toHaveBeenCalledWith(expect.anything(), [exodus, latest]); + }); + + it('records nothing for a pass over text the book last read', async () => { + const { history, reanchorBooks, pass } = makeReanchoringHistory(); + const exodus = verseBook('EXO 1:1', 'delta'); + await history.apply(linking(betaLink(genesis('alpha beta'), 'ta-1'))); + await pass(genesis('new alpha beta')); + await pass(exodus); + await pass(genesis('new alpha beta')); + + await history.undo(); + + expect(reanchorBooks.mock.calls[0][1].map(({ bookRef }) => bookRef)).toEqual(['GEN', 'EXO']); + }); + + it('forgets a pass once no step to undo or redo predates it', async () => { + const { history, reanchorBooks, pass } = makeReanchoringHistory(); + await history.apply(linking(betaLink(genesis('alpha beta'), 'ta-1'))); + await pass(genesis('new alpha beta')); + await history.undo(); + reanchorBooks.mockClear(); + + await history.apply(linking(betaLink(genesis('new alpha beta'), 'ta-2'))); + await history.undo(); + + expect(reanchorBooks).not.toHaveBeenCalled(); + }); + + it('applies nothing and keeps the edit to undo when re-anchoring what it restores fails', async () => { + const { history, reanchorBooks, pass, storedLink } = makeReanchoringHistory(); + const before = genesis('alpha beta'); + const after = genesis('new alpha beta'); + await history.apply(linking(betaLink(before, 'ta-1'))); + await history.apply(linking(betaLink(before, 'ta-2'))); + await pass(after); + reanchorBooks.mockRejectedValueOnce(new Error('busy')); + + await expect(history.undo()).rejects.toThrow('busy'); + + expect((await storedLink()).analysisId).toBe('ta-2'); + await history.undo(); + expect(await storedLink()).toEqual( + expect.objectContaining({ + analysisId: 'ta-1', + token: { tokenRef: tokenRefOf(after, 'beta'), surfaceText: 'beta' }, + }), + ); + }); + + it('applies the edit and its re-anchoring as one', async () => { + const { history, pass, repository } = makeReanchoringHistory(); + const before = genesis('alpha beta'); + await history.apply(linking(betaLink(before, 'ta-1'))); + await history.apply(linking(betaLink(before, 'ta-2'))); + await pass(genesis('new alpha beta')); + const apply = jest.spyOn(repository, 'apply'); + + await history.undo(); + + expect(apply).toHaveBeenCalledTimes(1); + }); + + it('re-anchors what it restores from the draft as the step would leave it', async () => { + const { history, reanchorBooks, pass } = makeReanchoringHistory(); + const before = genesis('alpha beta'); + await history.apply(linking(betaLink(before, 'ta-1'))); + await history.apply(linking(betaLink(before, 'ta-2'))); + await pass(genesis('new alpha beta')); + + await history.undo(); + + expect(reanchorBooks).toHaveBeenCalledWith( + { + intent: 'link ta-2', + tokenAnalysisLinks: { puts: [betaLink(before, 'ta-1')], deletes: [] }, + }, + [genesis('new alpha beta')], + ); + }); + + it('re-anchors nothing restored when it has no way to', async () => { + const { storage } = memoryStorage(); + const history = new EditHistory(new DraftRepository(storage, 'src', silentLogger())); + await history.apply(linking(betaLink(genesis('alpha beta'), 'ta-1'))); + history.recordPass(genesis('new alpha beta')); + + await expect(history.undo()).resolves.toStrictEqual({ + intent: 'link ta-1', + tokenAnalysisLinks: { puts: [], deletes: ['tl-beta'] }, + }); + }); +}); diff --git a/src/__tests__/services/reanchorPasses.test.ts b/src/__tests__/services/reanchorPasses.test.ts new file mode 100644 index 000000000..551e94afa --- /dev/null +++ b/src/__tests__/services/reanchorPasses.test.ts @@ -0,0 +1,252 @@ +/// + +import type { Book, DraftProject, TextAnalysis } from 'interlinearizer'; +import { DraftRepository } from '../../services/draftRepository'; +import { + booksLinkedAmong, + reanchorRestored, + reanchorStoredBook, +} from '../../services/reanchorPasses'; +import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; +import { missingBook } from '../../utils/reanchor-draft'; +import { + FIXTURE_STAMPS, + makeTokenLink, + makeVerseBook, + memoryStorage, + silentLogger, + tokenRefOf, +} from '../test-helpers'; + +const SOURCE = 'src'; + +/** A book of one verse reading `text`. */ +function verseBook(sid: string, text: string): Book { + return makeVerseBook([{ sid, text }]); +} + +/** An analysis glossing `word` where `book` has it, under the link id `linkId`. */ +function glossing(book: Book, word: string, linkId: string): TextAnalysis { + return { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: `ta-${word}`, surfaceText: word }], + tokenAnalysisLinks: [ + makeTokenLink(`ta-${word}`, tokenRefOf(book, word), { surfaceText: word, id: linkId }), + ], + }; +} + +/** Genesis glossed on "beta" and Exodus on "delta", stored in memory. */ +async function storedDraft(extra: Partial = {}) { + const { storage } = memoryStorage(); + const genesis = glossing(verseBook('GEN 1:1', 'alpha beta'), 'beta', 'tl-gen'); + const exodus = glossing(verseBook('EXO 1:1', 'gamma delta'), 'delta', 'tl-exo'); + const draft: DraftProject = { + ...emptyDraft(SOURCE), + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [...genesis.tokenAnalyses, ...exodus.tokenAnalyses], + tokenAnalysisLinks: [...genesis.tokenAnalysisLinks, ...exodus.tokenAnalysisLinks], + }, + ...extra, + }; + await new DraftRepository(storage, SOURCE, silentLogger()).replaceAll(draft); + const repository = new DraftRepository(storage, SOURCE, silentLogger()); + const stored = async () => + (await new DraftRepository(storage, SOURCE, silentLogger()).exportAll()).draft; + return { repository, stored }; +} + +function linkNamed(draft: DraftProject, id: string) { + const link = draft.analysis.tokenAnalysisLinks.find((l) => l.id === id); + if (!link) throw new Error(`no link ${id}`); + return link; +} + +describe('reanchorStoredBook', () => { + it("re-anchors the stored draft to the book's current text, leaving other books alone", async () => { + const { repository, stored } = await storedDraft(); + const edited = verseBook('GEN 1:1', 'alpha and beta'); + + const { changes } = await reanchorStoredBook(repository, edited); + + const draft = await stored(); + expect(linkNamed(draft, 'tl-gen').token.tokenRef).toBe(tokenRefOf(edited, 'beta')); + expect(linkNamed(draft, 'tl-exo').token.tokenRef).toBe('EXO 1:1:6'); + expect(changes?.tokenAnalysisLinks?.puts.map(({ id }) => id)).toEqual(['tl-gen']); + }); + + it.each([false, true])( + 'leaves a draft whose unsaved mark is %s as it was, a pass being no edit', + async (dirty) => { + const { repository, stored } = await storedDraft({ dirty }); + + await reanchorStoredBook(repository, verseBook('GEN 1:1', 'alpha and beta')); + + await expect(stored()).resolves.toMatchObject({ dirty }); + }, + ); + + it('applies nothing when the draft already reads as the book does', async () => { + const { repository } = await storedDraft(); + const apply = jest.spyOn(repository, 'apply'); + + await expect( + reanchorStoredBook(repository, verseBook('GEN 1:1', 'alpha beta')), + ).resolves.toStrictEqual({ heldAnalysis: true }); + + expect(apply).not.toHaveBeenCalled(); + }); + + it("moves the draft's splits in the book, keeping its record", async () => { + const before = verseBook('GEN 1:1', 'alpha beta'); + const { repository, stored } = await storedDraft({ + suggestedName: 'Named', + segmentation: { + removedVerseStarts: [], + addedStarts: [{ tokenRef: tokenRefOf(before, 'beta'), surfaceText: 'beta' }], + }, + }); + const edited = verseBook('GEN 1:1', 'alpha and beta'); + + await reanchorStoredBook(repository, edited); + + const draft = await stored(); + expect(draft.segmentation?.addedStarts).toEqual([ + { tokenRef: tokenRefOf(edited, 'beta'), surfaceText: 'beta' }, + ]); + expect(draft.suggestedName).toBe('Named'); + }); + + it('stales the links into a book the text lacks', async () => { + const { repository, stored } = await storedDraft(); + + await reanchorStoredBook(repository, missingBook('GEN')); + + const draft = await stored(); + expect(linkNamed(draft, 'tl-gen').status).toBe('stale'); + expect(linkNamed(draft, 'tl-exo').status).toBe('approved'); + }); + + it('tells that the draft held no analysis in a book it neither links into nor splits', async () => { + const { repository } = await storedDraft(); + + await expect( + reanchorStoredBook(repository, verseBook('LEV 1:1', 'epsilon zeta')), + ).resolves.toStrictEqual({ heldAnalysis: false }); + }); + + it('tells that the draft held analysis in a book it only splits', async () => { + const leviticus = verseBook('LEV 1:1', 'epsilon zeta'); + const { repository } = await storedDraft({ + segmentation: { + removedVerseStarts: [], + addedStarts: [{ tokenRef: tokenRefOf(leviticus, 'zeta'), surfaceText: 'zeta' }], + }, + }); + + await expect(reanchorStoredBook(repository, leviticus)).resolves.toMatchObject({ + heldAnalysis: true, + }); + }); +}); + +describe('reanchorRestored', () => { + it('re-anchors what the changes would restore, applying nothing', async () => { + const { repository, stored } = await storedDraft(); + const before = verseBook('GEN 1:1', 'alpha beta'); + const edited = verseBook('GEN 1:1', 'alpha and beta'); + const restoring = { + intent: 'undo', + tokenAnalysisLinks: { + puts: [ + makeTokenLink('ta-beta', tokenRefOf(before, 'beta'), { surfaceText: 'beta', id: 'tl-2' }), + ], + deletes: [], + }, + }; + const apply = jest.spyOn(repository, 'apply'); + + const changes = await reanchorRestored(repository)(restoring, [edited]); + + expect(changes?.tokenAnalysisLinks?.puts).toEqual([ + expect.objectContaining({ id: 'tl-gen' }), + expect.objectContaining({ + id: 'tl-2', + token: { tokenRef: tokenRefOf(edited, 'beta'), surfaceText: 'beta' }, + }), + ]); + expect(apply).not.toHaveBeenCalled(); + expect((await stored()).analysis.tokenAnalysisLinks).toHaveLength(2); + }); + + it('re-anchors to each book in turn', async () => { + const { repository } = await storedDraft(); + + const changes = await reanchorRestored(repository)({ intent: 'undo' }, [ + verseBook('GEN 1:1', 'alpha and beta'), + missingBook('EXO'), + ]); + + expect(changes?.tokenAnalysisLinks?.puts.map(({ id, status }) => [id, status])).toEqual([ + ['tl-gen', 'approved'], + ['tl-exo', 'stale'], + ]); + }); + + it('stales a restored translation whose stored payload no link names', async () => { + const before = verseBook('GEN 1:1', 'alpha beta'); + const { repository } = await storedDraft({ + analysis: { + ...emptyAnalysis(), + segmentAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'sa-1', + surfaceText: before.segments[0].baselineText, + freeTranslation: { en: 'first second' }, + }, + ], + }, + }); + const restoring = { + intent: 'undo', + segmentAnalysisLinks: { + puts: [ + { + ...FIXTURE_STAMPS, + id: 'sl-1', + analysisId: 'sa-1', + status: 'approved' as const, + segmentId: before.segments[0].id, + }, + ], + deletes: [], + }, + }; + + const changes = await reanchorRestored(repository)(restoring, [ + verseBook('GEN 1:1', 'alpha gamma'), + ]); + + expect(changes?.segmentAnalysisLinks?.puts).toEqual([ + expect.objectContaining({ id: 'sl-1', status: 'stale' }), + ]); + }); + + it('gives nothing when what the changes would restore already reads as the books do', async () => { + const { repository } = await storedDraft(); + + await expect( + reanchorRestored(repository)({ intent: 'undo' }, [verseBook('GEN 1:1', 'alpha beta')]), + ).resolves.toBeUndefined(); + }); +}); + +describe('booksLinkedAmong', () => { + it('names the books given that the draft links into', async () => { + const { repository } = await storedDraft(); + + await expect(booksLinkedAmong(repository, ['GEN', 'LEV'])).resolves.toEqual(['GEN']); + }); +}); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index d379a3c71..8c276b5df 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -377,6 +377,17 @@ export function makeVerseBook( return tokenizeBook(makeRawBook(entries, frontMatter)); } +/** + * The ref of the first token of `book` reading `surfaceText`. + * + * @throws {Error} If no token of `book` reads `surfaceText`. + */ +export function tokenRefOf(book: Book, surfaceText: string): string { + const token = book.segments.flatMap((s) => s.tokens).find((t) => t.surfaceText === surfaceText); + if (!token) throw new Error(`fixture missing token ${surfaceText}`); + return token.ref; +} + /** * Builds a minimal `InterlinearProject` test fixture with stable defaults used across command * tests. diff --git a/src/__tests__/utils/change-set.test.ts b/src/__tests__/utils/change-set.test.ts index b0894f579..16a67f539 100644 --- a/src/__tests__/utils/change-set.test.ts +++ b/src/__tests__/utils/change-set.test.ts @@ -2,7 +2,12 @@ import type { DraftProject, TokenAnalysis } from 'interlinearizer'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; -import { applyChangeSet, isEmptyChangeSet, scopeRecordOf } from '../../utils/change-set'; +import { + applyChangeSet, + isEmptyChangeSet, + mergeChangeSets, + scopeRecordOf, +} from '../../utils/change-set'; import { FIXTURE_STAMPS } from '../test-helpers'; function payload(id: string, gloss?: string): TokenAnalysis { @@ -205,3 +210,80 @@ describe('isEmptyChangeSet', () => { expect(isEmptyChangeSet({ intent: 'edit', scope: scopeRecordOf(draftOf()) })).toBe(false); }); }); + +describe('mergeChangeSets', () => { + it('applies as its two change sets applied in turn', () => { + const draft = draftOf(payload('a'), payload('b'), payload('c')); + const first = { + intent: 'first', + tokenAnalyses: { puts: [payload('a', 'one'), payload('d')], deletes: ['b'] }, + }; + const then = { + intent: 'then', + tokenAnalyses: { puts: [payload('a', 'two'), payload('b', 'back')], deletes: ['d', 'c'] }, + }; + + const merged = applyChangeSet(draft, mergeChangeSets(first, then)).draft; + const inTurn = applyChangeSet(applyChangeSet(draft, first).draft, then).draft; + + expect(merged.analysis.tokenAnalyses).toEqual( + expect.arrayContaining(inTurn.analysis.tokenAnalyses), + ); + expect(merged.analysis.tokenAnalyses).toHaveLength(inTurn.analysis.tokenAnalyses.length); + }); + + it("is named by the first change set's intent", () => { + expect(mergeChangeSets({ intent: 'first' }, { intent: 'then' }).intent).toBe('first'); + }); + + it("keeps the later scope's record when both have one", () => { + const earlier = scopeRecordOf({ ...draftOf(), suggestedName: 'Earlier' }); + const later = scopeRecordOf({ ...draftOf(), suggestedName: 'Later' }); + + expect( + mergeChangeSets({ intent: 'a', scope: earlier }, { intent: 'b', scope: later }).scope, + ).toBe(later); + }); + + it("keeps the earlier scope's record when only it has one", () => { + const earlier = scopeRecordOf({ ...draftOf(), suggestedName: 'Earlier' }); + + expect(mergeChangeSets({ intent: 'a', scope: earlier }, { intent: 'b' }).scope).toBe(earlier); + }); + + it('merges every record kind both change sets change', () => { + const kinds = [ + 'segmentAnalyses', + 'segmentAnalysisLinks', + 'tokenAnalyses', + 'tokenAnalysisLinks', + 'phraseAnalyses', + 'phraseAnalysisLinks', + ] as const; + const deleting = (id: string) => + Object.fromEntries(kinds.map((kind) => [kind, { puts: [], deletes: [id] }])); + + const merged = mergeChangeSets( + { intent: 'a', ...deleting('x') }, + { intent: 'b', ...deleting('y') }, + ); + + expect(merged).toStrictEqual({ + intent: 'a', + ...Object.fromEntries(kinds.map((kind) => [kind, { puts: [], deletes: ['x', 'y'] }])), + }); + }); + + it('carries each kind that either change set changes, and no other', () => { + const merged = mergeChangeSets( + { intent: 'a', tokenAnalyses: { puts: [payload('a')], deletes: [] } }, + { intent: 'b', segmentAnalyses: { puts: [], deletes: ['s'] } }, + ); + + expect(merged).toStrictEqual({ + intent: 'a', + tokenAnalyses: { puts: [payload('a')], deletes: [] }, + segmentAnalyses: { puts: [], deletes: ['s'] }, + }); + }); +}); diff --git a/src/__tests__/utils/reanchor-analysis.test.ts b/src/__tests__/utils/reanchor-analysis.test.ts index 08aee5cac..204b5daa2 100644 --- a/src/__tests__/utils/reanchor-analysis.test.ts +++ b/src/__tests__/utils/reanchor-analysis.test.ts @@ -7,10 +7,13 @@ import type { TokenAnalysisLink, TokenSnapshot, } from 'interlinearizer'; +import type { DraftContent } from '../../hooks/useDraftProject'; import { reanchorAnalysisToBook } from '../../utils/reanchor-analysis'; +import type { BookPass } from '../../utils/undo-history'; import { resegmentBook } from '../../parsers/papi/resegmentBook'; import { mergeSegments, reanchorSegmentation } from '../../utils/segmentation'; import { emptyAnalysis } from '../../types/empty-factories'; +import { expectHostReanchorParity } from '../host-reanchor-parity'; import { makeVerseBook, makePhraseLink, makeTokenLink, FIXTURE_STAMPS } from '../test-helpers'; /** @@ -19,12 +22,19 @@ import { makeVerseBook, makePhraseLink, makeTokenLink, FIXTURE_STAMPS } from '.. */ const REANCHOR_STAMP = '2026-02-01T00:00:00.000Z'; +/** Re-anchors `analysis` to `book`, checking that the host's re-anchor gives the same. */ function reanchor( analysis: TextAnalysis, book: Book, storedSplits: TokenSnapshot[] = [], ): TextAnalysis { - return reanchorAnalysisToBook(analysis, book, REANCHOR_STAMP, storedSplits); + const pass: BookPass = (content) => ({ + analysis: reanchorAnalysisToBook(content.analysis, book, REANCHOR_STAMP, storedSplits), + segmentation: content.segmentation, + }); + const reanchored = pass({ analysis, segmentation: undefined }); + expectHostReanchorParity({ analysis, segmentation: undefined }, book.bookRef, pass, reanchored); + return reanchored.analysis; } /** Seeds a `TextAnalysis` carrying just the given token links. */ @@ -1195,7 +1205,12 @@ describe('reanchorAnalysisToBook', () => { ...base, segmentAnalysisLinks: [ base.segmentAnalysisLinks[0], - { ...base.segmentAnalysisLinks[0], analysisId: 'sa-2', segmentId: split.segments[2].id }, + { + ...base.segmentAnalysisLinks[0], + id: 'sl-2', + analysisId: 'sa-2', + segmentId: split.segments[2].id, + }, ], segmentAnalyses: [base.segmentAnalyses[0], { ...base.segmentAnalyses[0], id: 'sa-2' }], }; @@ -1261,6 +1276,7 @@ describe('reanchorAnalysisToBook', () => { base.segmentAnalysisLinks[0], { ...base.segmentAnalysisLinks[0], + id: 'sl-2', analysisId: 'sa-2', segmentId: splitAfter.segments[1].id, }, @@ -1288,6 +1304,7 @@ describe('reanchorAnalysisToBook', () => { { ...base.segmentAnalysisLinks[0], status: 'stale' as const }, { ...base.segmentAnalysisLinks[0], + id: 'sl-2', analysisId: 'sa-2', segmentId: splitAfter.segments[1].id, }, @@ -1324,7 +1341,7 @@ describe('reanchorAnalysisToBook', () => { ...base, segmentAnalysisLinks: [ { ...base.segmentAnalysisLinks[0], status: 'stale' as const }, - { ...base.segmentAnalysisLinks[0], analysisId: 'sa-2' }, + { ...base.segmentAnalysisLinks[0], id: 'sl-2', analysisId: 'sa-2' }, ], segmentAnalyses: [ base.segmentAnalyses[0], @@ -1344,7 +1361,12 @@ describe('reanchorAnalysisToBook', () => { ...base, segmentAnalysisLinks: [ { ...base.segmentAnalysisLinks[0], status: 'stale' as const }, - { ...base.segmentAnalysisLinks[0], analysisId: 'sa-2', status: 'stale' as const }, + { + ...base.segmentAnalysisLinks[0], + id: 'sl-2', + analysisId: 'sa-2', + status: 'stale' as const, + }, ], segmentAnalyses: [ base.segmentAnalyses[0], diff --git a/src/__tests__/utils/reanchor-changes.test.ts b/src/__tests__/utils/reanchor-changes.test.ts new file mode 100644 index 000000000..d0c6d7049 --- /dev/null +++ b/src/__tests__/utils/reanchor-changes.test.ts @@ -0,0 +1,106 @@ +/// + +import type { Book, DraftProject, SegmentationDelta, TextAnalysis } from 'interlinearizer'; +import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; +import { REANCHOR_INTENT, reanchorChangesForBook } from '../../utils/reanchor-changes'; +import { missingBook } from '../../utils/reanchor-draft'; +import { FIXTURE_STAMPS, makeTokenLink, makeVerseBook, tokenRefOf } from '../test-helpers'; + +/** A book of Genesis 1, one verse per text. */ +function genesis(...verses: string[]): Book { + return makeVerseBook(verses.map((text, index) => ({ sid: `GEN 1:${index + 1}`, text }))); +} + +/** An analysis glossing each of `words` where `book` has it. */ +function glossing(book: Book, words: string[]): TextAnalysis { + return { + ...emptyAnalysis(), + tokenAnalyses: words.map((word) => ({ + ...FIXTURE_STAMPS, + id: `ta-${word}`, + surfaceText: word, + })), + tokenAnalysisLinks: words.map((word) => + makeTokenLink(`ta-${word}`, tokenRefOf(book, word), { surfaceText: word }), + ), + }; +} + +function draftOf(analysis: TextAnalysis, segmentation?: SegmentationDelta): DraftProject { + return { ...emptyDraft('src'), analysis, ...(segmentation && { segmentation }) }; +} + +/** A split before "beta" in Genesis 1:1 reading `alpha beta`. */ +function splitBeforeBeta(): SegmentationDelta { + return { + removedVerseStarts: [], + addedStarts: [{ tokenRef: tokenRefOf(genesis('alpha beta'), 'beta'), surfaceText: 'beta' }], + }; +} + +describe('reanchorChangesForBook', () => { + it('puts only the links the pass rewrote, named as a re-anchor', () => { + const draft = draftOf(glossing(genesis('alpha beta', 'gamma'), ['beta', 'gamma'])); + + const changes = reanchorChangesForBook(draft, genesis('new alpha beta', 'gamma')); + + expect(changes?.intent).toBe(REANCHOR_INTENT); + expect(changes?.tokenAnalysisLinks?.puts.map(({ analysisId }) => analysisId)).toEqual([ + 'ta-beta', + ]); + expect(changes?.tokenAnalysisLinks?.deletes).toEqual([]); + expect(changes?.tokenAnalyses).toBeUndefined(); + expect(changes?.scope).toBeUndefined(); + }); + + it("carries the draft's whole record with a split it moved", () => { + const draft = { + ...draftOf(emptyAnalysis(), splitBeforeBeta()), + analysisLanguages: ['fr'], + suggestedName: 'Named', + dirty: true, + }; + + const scope = reanchorChangesForBook(draft, genesis('alpha and beta'))?.scope; + + expect(scope).toEqual( + expect.objectContaining({ + analysisLanguages: ['fr'], + suggestedName: 'Named', + segmentation: { + removedVerseStarts: [], + addedStarts: [{ tokenRef: 'GEN 1:1:10', surfaceText: 'beta' }], + }, + }), + ); + expect(scope).not.toHaveProperty('analysis'); + expect(scope).not.toHaveProperty('dirty'); + }); + + it('changes nothing when the book reads as the draft was written against', () => { + const book = genesis('alpha beta'); + + expect( + reanchorChangesForBook(draftOf(glossing(book, ['beta']), splitBeforeBeta()), book), + ).toBeUndefined(); + }); + + it('puts the links into a book the text lacks as stale, named as a re-anchor', () => { + const changes = reanchorChangesForBook( + draftOf(glossing(genesis('alpha beta'), ['beta'])), + missingBook('GEN'), + ); + + expect(changes?.intent).toBe(REANCHOR_INTENT); + expect(changes?.tokenAnalysisLinks?.puts.map(({ status }) => status)).toEqual(['stale']); + }); + + it("changes nothing for another book's links when a book the text lacks is passed", () => { + expect( + reanchorChangesForBook( + draftOf(glossing(genesis('alpha beta'), ['beta'])), + missingBook('EXO'), + ), + ).toBeUndefined(); + }); +}); diff --git a/src/__tests__/utils/reanchor-draft.test.ts b/src/__tests__/utils/reanchor-draft.test.ts index 82639ef55..606811011 100644 --- a/src/__tests__/utils/reanchor-draft.test.ts +++ b/src/__tests__/utils/reanchor-draft.test.ts @@ -1,10 +1,37 @@ /// -import type { TextAnalysis } from 'interlinearizer'; +import type { Book, TextAnalysis } from 'interlinearizer'; +import type { DraftContent } from '../../hooks/useDraftProject'; import { emptyAnalysis } from '../../types/empty-factories'; import { reanchorDraftToBook, reanchorDraftToMissingBook } from '../../utils/reanchor-draft'; +import type { BookPass } from '../../utils/undo-history'; +import { expectHostReanchorParity } from '../host-reanchor-parity'; import { FIXTURE_STAMPS, makeVerseBook } from '../test-helpers'; +beforeAll(() => { + // The host's re-anchor is checked against a second run of each pass, which must stamp alike. + jest.useFakeTimers({ now: new Date('2026-02-01T00:00:00.000Z') }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +/** Runs `pass` over `content` for `bookCode`, checking that the host's re-anchor gives the same. */ +function run(pass: BookPass, bookCode: string, content: DraftContent): DraftContent { + const reanchored = pass(content); + expectHostReanchorParity(content, bookCode, pass, reanchored); + return reanchored; +} + +function toBook(verseBook: Book, content: DraftContent): DraftContent { + return run(reanchorDraftToBook(verseBook), verseBook.bookRef, content); +} + +function toMissingBook(bookCode: string, content: DraftContent): DraftContent { + return run(reanchorDraftToMissingBook(bookCode), bookCode, content); +} + /** An analysis approving `gloss` on the sole occurrence of `surfaceText` in a one-verse `text`. */ function glossedOn(text: string, surfaceText: string, gloss: string): TextAnalysis { const token = makeVerseBook([{ sid: 'GEN 1:1', text }]).segments[0].tokens.find( @@ -34,7 +61,7 @@ describe('reanchorDraftToBook', () => { segmentation: undefined, }; - const reanchored = reanchorDraftToBook(verseBook)(content); + const reanchored = toBook(verseBook, content); const moved = verseBook.segments[0].tokens.find((t) => t.surfaceText === 'unbelievable'); expect(reanchored.analysis.tokenAnalysisLinks[0].token.tokenRef).toBe(moved?.ref); @@ -50,7 +77,7 @@ describe('reanchorDraftToBook', () => { }, }; - const reanchored = reanchorDraftToBook(verseBook)(content); + const reanchored = toBook(verseBook, content); expect(reanchored.segmentation?.addedStarts).toEqual([ { tokenRef: 'GEN 1:1:10', surfaceText: 'beta' }, @@ -79,7 +106,7 @@ describe('reanchorDraftToBook', () => { }, }; - const reanchored = reanchorDraftToBook(verseBook)(content); + const reanchored = toBook(verseBook, content); expect(reanchored.analysis.segmentAnalysisLinks[0].segmentId).toBe('GEN 1:1:10'); }); @@ -92,7 +119,7 @@ describe('reanchorDraftToBook', () => { addedStarts: [{ tokenRef: 'GEN 1:1:6', surfaceText: 'beta' }], }; - const reanchored = reanchorDraftToBook(verseBook)({ analysis, segmentation }); + const reanchored = toBook(verseBook, { analysis, segmentation }); expect(reanchored.analysis).toBe(analysis); expect(reanchored.segmentation).toBe(segmentation); @@ -103,7 +130,7 @@ describe('reanchorDraftToMissingBook', () => { it('stales an approval in the book', () => { const content = { analysis: glossedOn('alpha beta', 'beta', 'bêta'), segmentation: undefined }; - const reanchored = reanchorDraftToMissingBook('GEN')(content); + const reanchored = toMissingBook('GEN', content); expect(reanchored.analysis.tokenAnalysisLinks[0].status).toBe('stale'); }); @@ -126,7 +153,7 @@ describe('reanchorDraftToMissingBook', () => { segmentation: undefined, }; - const reanchored = reanchorDraftToMissingBook('GEN')(content); + const reanchored = toMissingBook('GEN', content); expect(reanchored.analysis.segmentAnalysisLinks[0].status).toBe('stale'); }); @@ -134,7 +161,7 @@ describe('reanchorDraftToMissingBook', () => { it("leaves another book's approvals approved", () => { const content = { analysis: glossedOn('alpha beta', 'beta', 'bêta'), segmentation: undefined }; - const reanchored = reanchorDraftToMissingBook('EXO')(content); + const reanchored = toMissingBook('EXO', content); expect(reanchored.analysis.tokenAnalysisLinks[0].status).toBe('approved'); }); diff --git a/src/main.ts b/src/main.ts index 3200325c7..24d44e7aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -148,6 +148,8 @@ const sessions = new AnalysisSessions({ openPt9ImportRepository: (interlinearProjectId) => projectStorage.openPt9ImportRepository(executionToken, interlinearProjectId), getInterfaceLanguages: () => papi.settings.get('platform.interfaceLanguage'), + startTextRead: (sourceProjectId) => bookService.startTextRead(sourceProjectId), + stopTextRead: (sourceProjectId) => bookService.stopTextRead(sourceProjectId), }); // #endregion @@ -785,9 +787,9 @@ async function setAnalysisLanguage(sessionId: string, analysisLanguage: string): /** * Reverts a session's latest edit not yet undone. * - * @returns The changes that reverted it, as JSON, or `undefined` when there is none. - * @throws If the session is read-only or not open, or the changes cannot be applied. The error is - * logged before rethrowing. + * @returns The changes it applied, as JSON, or `undefined` when there is none. + * @throws If the session is read-only or not open, or the undo fails. The error is logged before + * rethrowing. */ async function undoEdit(sessionId: string): Promise { try { @@ -802,9 +804,9 @@ async function undoEdit(sessionId: string): Promise { /** * Redoes a session's latest undone edit. * - * @returns The changes that redid it, as JSON, or `undefined` when there is none. - * @throws If the session is read-only or not open, or the changes cannot be applied. The error is - * logged before rethrowing. + * @returns The changes it applied, as JSON, or `undefined` when there is none. + * @throws If the session is read-only or not open, or the redo fails. The error is logged before + * rethrowing. */ async function redoEdit(sessionId: string): Promise { try { @@ -844,7 +846,7 @@ async function checkIndexes(sessionId: string): Promise { async function runSessionEdit( sessionId: string, intent: string, - command: (buffer: ChangeBuffer, session: Session) => Promise, + command: (buffer: ChangeBuffer, session: Session) => Promise, ): Promise { try { return JSON.stringify(await sessions.edit(sessionId, intent, command)); @@ -1239,12 +1241,11 @@ function boundaryEditStamp() { * the edit fails. The error is logged before rethrowing. */ function mergeSegments(sessionId: string, secondSegmentStartRef: string): Promise { - return runSessionEdit(sessionId, 'mergeSegments', async (buffer, session) => - boundaryCommands.mergeSegments(buffer, { - verseBook: await verseBookOf(session, secondSegmentStartRef), - secondSegmentStartRef, - }), - ); + return runSessionEdit(sessionId, 'mergeSegments', async (buffer, session) => { + const verseBook = await verseBookOf(session, secondSegmentStartRef); + await boundaryCommands.mergeSegments(buffer, { verseBook, secondSegmentStartRef }); + return [verseBook]; + }); } /** @@ -1256,13 +1257,11 @@ function mergeSegments(sessionId: string, secondSegmentStartRef: string): Promis * the edit is refused or fails. The error is logged before rethrowing. */ function splitSegment(sessionId: string, tokenRef: string): Promise { - return runSessionEdit(sessionId, 'splitSegment', async (buffer, session) => - boundaryCommands.splitSegment(buffer, { - ...boundaryEditStamp(), - verseBook: await verseBookOf(session, tokenRef), - tokenRef, - }), - ); + return runSessionEdit(sessionId, 'splitSegment', async (buffer, session) => { + const verseBook = await verseBookOf(session, tokenRef); + await boundaryCommands.splitSegment(buffer, { ...boundaryEditStamp(), verseBook, tokenRef }); + return [verseBook]; + }); } /** @@ -1275,14 +1274,16 @@ function splitSegment(sessionId: string, tokenRef: string): Promise { * rethrowing. */ function moveBoundary(sessionId: string, fromRef: string, toRef: string): Promise { - return runSessionEdit(sessionId, 'moveBoundary', async (buffer, session) => - boundaryCommands.moveBoundary(buffer, { + return runSessionEdit(sessionId, 'moveBoundary', async (buffer, session) => { + const verseBook = await verseBookOf(session, toRef); + await boundaryCommands.moveBoundary(buffer, { ...boundaryEditStamp(), - verseBook: await verseBookOf(session, toRef), + verseBook, fromRef, toRef, - }), - ); + }); + return [verseBook]; + }); } /** @@ -2634,12 +2635,35 @@ export async function activate(context: ExecutionActivationContext): Promise + sessions + .reanchorBook(sourceProjectId, book) + .catch((e) => + logger.warn( + `Interlinearizer: could not re-anchor ${book.bookRef} of project ${sourceProjectId}`, + e, + ), + ), + ); + const textReadUnsubscriber = bookService.onTextRead((sourceProjectId, textBookIds) => + sessions + .reanchorMissingBooks(sourceProjectId, textBookIds) + .catch((e) => + logger.warn( + `Interlinearizer: could not re-anchor the books project ${sourceProjectId} lacks`, + e, + ), + ), + ); + // A book's new text reaches its pass before its edit is reported, so the event waits for the pass. const bookTextUnsubscriber = bookService.onBookTextChanged((sourceProjectId, bookId) => - analysisChangedEmitter.emit({ - sourceProjectId, - changeId: crypto.randomUUID(), - scriptureEdit: { bookId }, - }), + sessions.settled(sourceProjectId).then(() => + analysisChangedEmitter.emit({ + sourceProjectId, + changeId: crypto.randomUUID(), + scriptureEdit: { bookId }, + }), + ), ); const openLexiconChooserCommandRegistration = await papi.commands.registerCommand( @@ -2752,6 +2776,8 @@ export async function activate(context: ExecutionActivationContext): Promise; + /** Starts reading the source project's whole text afresh, without waiting on the read. */ + startTextRead(sourceProjectId: string): Promise; + /** Ends the source project's whole-text read. */ + stopTextRead(sourceProjectId: string): Promise; +} + +/** + * Re-anchors the session's draft to `book`. The pass is recorded in the session's history only when + * the draft linked into the book or held boundary edits in it, so an undo or redo re-anchors only + * those books. + */ +async function reanchorNow(session: Session, book: Book): Promise { + const { heldAnalysis } = await reanchorStoredBook(session.repository, book); + if (heldAnalysis) session.history.recordPass(book); } type OpenSession = { @@ -99,8 +117,9 @@ export class AnalysisSessions { /** * Opens a session read-only on `interlinearProjectId` when it names a Paratext 9 import, * otherwise on the source project's draft, which saves to `interlinearProjectId` when given. - * Opening closes the source's earlier session, if any, and starts the new one on what storage - * holds, with nothing to undo. + * Opening closes the source's earlier session, if any. The new session starts on what storage + * holds, with nothing to undo. Opening also starts a fresh read of the source's whole text, which + * lasts until the session closes. * * The session starts on the opened draft's first analysis language. When the draft lists none, it * starts on the platform's first interface language, or {@link UNDETERMINED_LANGUAGE} when the @@ -125,7 +144,7 @@ export class AnalysisSessions { id: crypto.randomUUID(), scope, repository, - history: new EditHistory(repository), + history: new EditHistory(repository, reanchorRestored(repository)), analysisLanguage: opened.analysisLanguages[0] ?? ((await this.host.getInterfaceLanguages())[0] || UNDETERMINED_LANGUAGE), @@ -133,6 +152,7 @@ export class AnalysisSessions { const entry: OpenSession = { session, queue: Promise.resolve() }; this.sessions.set(session.id, entry); this.sessionsBySource.set(sourceProjectId, entry); + await this.host.startTextRead(sourceProjectId); return { sessionId: session.id, readOnly: repository.capabilities.readOnly, @@ -210,58 +230,124 @@ export class AnalysisSessions { * applies as one undo step, marking the draft dirty; a command staging nothing applies nothing. * When the draft lists no analysis languages, that step adds the session's analysis language. * + * When `command` returns books, the step also re-anchors the draft, as the staged changes leave + * it, to each book's text in turn. + * * @returns The changes applied, naming `intent`. * @throws {ReadOnlyAnalysisError} If the session is read-only. * @throws {ApprovalRuleError} If the changes would give a token or segment a second approved * link, or put a token in a second approved phrase; nothing is applied. * @throws {Error} If the session is not open. - * @throws Whatever the command, reading the draft's record, or applying its changes throws; - * nothing is applied. + * @throws Whatever the command, reading the draft, or applying its changes throws; nothing is + * applied. */ edit( sessionId: string, intent: string, - command: (buffer: ChangeBuffer, session: Session) => Promise, + command: (buffer: ChangeBuffer, session: Session) => Promise, ): Promise { return this.runEdit(sessionId, async (session) => { const buffer = new ChangeBuffer(session.repository); - await command(buffer, session); - const changes = buffer.toChangeSet(intent); - if (isEmptyChangeSet(changes)) return changes; + const reanchorTo = await command(buffer, session); + const staged = buffer.toChangeSet(intent); + if (isEmptyChangeSet(staged)) return staged; await assertApprovalRule(buffer, session.repository); - const scope = changes.scope ?? (await session.repository.getScope()); + const scope = staged.scope ?? (await session.repository.getScope()); if (scope.analysisLanguages.length === 0) { - changes.scope = { ...scope, analysisLanguages: [session.analysisLanguage] }; + staged.scope = { ...scope, analysisLanguages: [session.analysisLanguage] }; } + const reanchored = + reanchorTo && reanchorTo.length > 0 + ? await reanchorRestored(session.repository)(staged, reanchorTo) + : undefined; + const changes = reanchored ? mergeChangeSets(staged, reanchored) : staged; await session.history.apply(changes); return changes; }); } /** - * Reverts the session's latest edit not yet undone. + * Reverts the session's latest edit not yet undone, re-anchoring what it restores to the current + * text of each book re-anchored since. * - * @returns The changes that reverted it, or `undefined` when there is none. + * @returns The changes that reverted the edit and re-anchored what the undo restored, or + * `undefined` when there is none. * @throws {ReadOnlyAnalysisError} If the session is read-only. * @throws {Error} If the session is not open. - * @throws Whatever applying the changes throws; the edit stays the next to undo. + * @throws Whatever re-anchoring or applying the changes throws; nothing is applied, and the edit + * stays the next to undo. */ undo(sessionId: string): Promise { return this.runEdit(sessionId, (session) => session.history.undo()); } /** - * Redoes the session's latest undone edit. + * Redoes the session's latest undone edit, re-anchoring what it restores to the current text of + * each book re-anchored since. * - * @returns The changes that redid it, or `undefined` when there is none. + * @returns The changes that redid the edit and re-anchored what the redo restored, or `undefined` + * when there is none. * @throws {ReadOnlyAnalysisError} If the session is read-only. * @throws {Error} If the session is not open. - * @throws Whatever applying the changes throws; the edit stays the next to redo. + * @throws Whatever re-anchoring or applying the changes throws; nothing is applied, and the edit + * stays the next to redo. */ redo(sessionId: string): Promise { return this.runEdit(sessionId, (session) => session.history.redo()); } + /** + * Re-anchors the draft of the source project's session to `book`'s current text, in the session's + * turn and as no undo step. Where the draft links into the book or holds boundary edits in it, + * what a later undo or redo restores is re-anchored to it too. A book the text lacks is passed as + * a book with no segments. Does nothing when the source has no session open, or only a read-only + * one. + * + * @throws Whatever reading the draft or applying the changes throws; nothing is applied. + */ + reanchorBook(sourceProjectId: string, book: Book): Promise { + return this.runOnWritable(sourceProjectId, (session) => reanchorNow(session, book)); + } + + /** + * Re-anchors the draft of the source project's session, as {@link reanchorBook} does, to each book + * the text lacks. A book the text lacks is a linked book that `textBookIds` does not list. + * + * @throws Whatever reading the draft or applying the changes throws; the books before stay + * re-anchored. + */ + reanchorMissingBooks(sourceProjectId: string, textBookIds: readonly string[]): Promise { + return this.runOnWritable(sourceProjectId, async (session) => { + const present = new Set(textBookIds); + const missing = await booksLinkedAmong( + session.repository, + Canon.allBookIds.filter((bookCode) => !present.has(bookCode)), + ); + await missing.reduce>( + (previous, bookCode) => previous.then(() => reanchorNow(session, missingBook(bookCode))), + Promise.resolve(), + ); + }); + } + + /** + * Settles once every command sent so far to the source project's session has settled, at once + * when the source has no session open. Never rejects. + */ + async settled(sourceProjectId: string): Promise { + await this.sessionsBySource.get(sourceProjectId)?.queue; + } + + /** Runs `command` on the source project's session unless it has none open or only a read-only one. */ + private runOnWritable( + sourceProjectId: string, + command: (session: Session) => Promise, + ): Promise { + const entry = this.sessionsBySource.get(sourceProjectId); + if (!entry || entry.session.repository.capabilities.readOnly) return Promise.resolve(); + return this.run(entry.session.id, command); + } + /** * Resolves the scope for a session on the source project, paired with `interlinearProjectId` when * given. @@ -287,9 +373,9 @@ export class AnalysisSessions { } /** - * Makes the session unreachable by id or source, then, for a draft, releases its repository once - * the commands sent to it have settled. A session that an earlier step already closed is left - * alone, so its repository is released only once. + * Makes the session unreachable by id or source, then, once the commands sent to it have settled, + * ends the source's whole-text read and, for a draft, releases its repository. A session that an + * earlier step already closed is left alone, so its repository is released only once. */ private async closeNow(entry: OpenSession): Promise { const { id, scope } = entry.session; @@ -297,6 +383,7 @@ export class AnalysisSessions { this.sessions.delete(id); this.sessionsBySource.delete(scope.sourceProjectId); await entry.queue; + await this.host.stopTextRead(scope.sourceProjectId); if (scope.kind === 'draft') await this.host.releaseDraftRepository(scope.sourceProjectId); } diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 91042b05c..3375e95c8 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -72,17 +72,26 @@ interface TextRead { readBookIds: Set; /** Books whose latest read failed. */ failedBookIds: Set; + /** Books whose latest read found text. */ + textBookIds: Set; forms: FormIndex; /** Stops watching each book the read has started, by book id. */ watches: Map Promise>; } /** - * Each source project's whole-text read, from the first status asked for until the project closes, - * or until its books could not be listed. + * Each source project's whole-text read. A read starts with the first status asked for, or with + * {@link startTextRead}. It ends with {@link stopTextRead}, or once its books could not be listed. + * Closing the project ends it too, except between {@link startTextRead} and {@link stopTextRead}. */ const textReads = new Map(); +/** + * The source projects that {@link startTextRead} has been called for, and {@link stopTextRead} has + * not been called for since. + */ +const heldReads = new Set(); + /** Receives the source project and book of each edit to a watched book's text. */ type BookTextListener = (sourceProjectId: string, bookId: string) => void; @@ -92,6 +101,19 @@ function reportEdit(sourceProjectId: string, bookId: string): void { bookTextListeners.forEach((listener) => listener(sourceProjectId, bookId)); } +/** + * Receives the source project and tokenized text of each book that a whole-text read takes in, and + * of the open book once it is edited. + */ +type BookReadListener = (sourceProjectId: string, book: Book) => void; + +const bookReadListeners = new Set(); + +/** Receives the source project and the books holding text, of a whole-text read with none failed. */ +type TextReadListener = (sourceProjectId: string, textBookIds: string[]) => void; + +const textReadListeners = new Set(); + /** * Resolves the writing system tag of the source project's text, `und` when the project declares * none. @@ -165,12 +187,13 @@ function holdBook(sourceProjectId: string, bookId: string, writingSystem: string if (!isOpen) return; // Read again at once, so the book stays watched and a refetch started by the listeners sees // the edit. - openBooks.set(sourceProjectId, holdBook(sourceProjectId, bookId, writingSystem)); + const reheld = holdBook(sourceProjectId, bookId, writingSystem); + openBooks.set(sourceProjectId, reheld); // The whole-text read's watch may have first read the book before the edit or after it, so the // read itself reads the book again over a new watch and reports the edit. const read = textReads.get(sourceProjectId); if (read?.watches.has(bookId)) rereadTextBook(sourceProjectId, read, bookId); - else reportEdit(sourceProjectId, bookId); + else reportOpenBookEdit(sourceProjectId, reheld); }); const book = perfTrack('getTokenizedBook:usj', watch.text).then( (usj) => @@ -185,6 +208,19 @@ function holdBook(sourceProjectId: string, bookId: string, writingSystem: string return { bookId, writingSystem, book, watch }; } +/** + * Passes the open book's edited text to {@link onBookRead}'s listeners once it is read again, then + * reports the edit, unless the project closed meanwhile. A book that fails to read again, or has no + * text, is reported all the same. + */ +async function reportOpenBookEdit(sourceProjectId: string, held: HeldBook): Promise { + const closeCount = closeCounts.get(sourceProjectId); + const book = await held.book.catch(() => undefined); + if (closeCounts.get(sourceProjectId) !== closeCount) return; + if (book) bookReadListeners.forEach((listener) => listener(sourceProjectId, book)); + reportEdit(sourceProjectId, held.bookId); +} + /** * Finds the source project's book tokenized under the project's language tag, and keeps it as the * project's latest book, and as its open book too when `isOpen` and no later outline request has @@ -285,6 +321,32 @@ export function onBookTextChanged(listener: BookTextListener): () => boolean { return () => bookTextListeners.delete(listener); } +/** + * Calls `listener` with each book of a source project that holds text, until the returned function + * is called. A book is passed when the whole-text read first reaches it, and again each time its + * text changes. The open book's edited text is passed even before the read reaches that book. + * + * For a change, `listener` is called before {@link onBookTextChanged}'s listeners hear of it. A book + * whose read fails, or that holds no text, is not passed. + */ +export function onBookRead(listener: BookReadListener): () => boolean { + bookReadListeners.add(listener); + return () => bookReadListeners.delete(listener); +} + +/** + * Calls `listener` with the books of a source project that hold text, until the returned function + * is called. `listener` is first called once the whole-text read has read every book with none + * failed. + * + * `listener` is called again whenever a book gains or loses its text. `listener` is also called + * when a retried read succeeds and no other book's read is still failing. + */ +export function onTextRead(listener: TextReadListener): () => boolean { + textReadListeners.add(listener); + return () => textReadListeners.delete(listener); +} + /** * Outlines a book of the source project: its segments, segmented by the boundaries * `segmentationJson` names, each without its text. The book becomes the project's open book, whose @@ -396,9 +458,9 @@ function rereadHeldBook(sourceProjectId: string, bookId: string): void { } /** - * Reads `bookId` of `read`'s text into its form index, and watches the book until the read ends or - * reads it again. An edit to the book lets go of its kept copies, and is reported once the book is - * read again. + * Reads `bookId` of `read`'s text into its form index, passing its text to {@link onBookRead}'s + * listeners, and watches the book until the read ends or reads it again. An edit to the book lets + * go of its kept copies, and is reported once the book is read again. * * @returns Once the book's text has arrived, whether this read filed the book as read or failed. */ @@ -419,14 +481,14 @@ async function readTextBook( read.usjPdp, ); read.watches.set(bookId, stop); - const forms = await perfTrack('readText:usj', text) - .then((usj) => - usj - ? perfTime('readText:tokenize', () => - formsOfBook(tokenizeBook(extractBookFromUsj(usj, read.writingSystem))), - ) - : new Set(), - ) + const delivered = await perfTrack('readText:usj', text) + .then((usj): { book?: Book; forms: Set } => { + if (!usj) return { forms: new Set() }; + return perfTime('readText:tokenize', () => { + const book = tokenizeBook(extractBookFromUsj(usj, read.writingSystem)); + return { book, forms: formsOfBook(book) }; + }); + }) .catch((e) => { logger.warn(`Could not read ${bookId} of project ${sourceProjectId}: ${e}`); return undefined; @@ -434,19 +496,38 @@ async function readTextBook( if (textReads.get(sourceProjectId) !== read) return false; // A later read of the book reads newer text, so this one files only a book none has filed. if (read.watches.get(bookId) !== stop && read.readBookIds.has(bookId)) return false; - if (forms) { - read.forms.setBook(bookId, forms); + if (delivered) { + read.forms.setBook(bookId, delivered.forms); read.failedBookIds.delete(bookId); + if (delivered.book) read.textBookIds.add(bookId); + else read.textBookIds.delete(bookId); } else { read.failedBookIds.add(bookId); } read.readBookIds.add(bookId); + const book = delivered?.book; + if (book) bookReadListeners.forEach((listener) => listener(sourceProjectId, book)); return true; } +/** The books of `read`'s text that hold text, once every book is read and none failed. */ +function textBookIdsOf(read: TextRead): string[] | undefined { + if (!read.isRead || read.failedBookIds.size > 0) return undefined; + return [...read.textBookIds]; +} + +/** Tells {@link onTextRead}'s listeners which books hold text, once `read` knows. */ +function reportTextRead(sourceProjectId: string, read: TextRead): void { + const textBookIds = textBookIdsOf(read); + if (textBookIds) + textReadListeners.forEach((listener) => listener(sourceProjectId, [...textBookIds])); +} + /** * Reads `bookId` of `read`'s text again, then reports its edit unless the read has ended, a later - * read of the book will report it, or the book failed to read both before and again. + * read of the book will report it, or the book failed to read both before and again. Reports the + * books holding text first when the read changes which they are, or when the read clears a + * failure. */ async function rereadTextBook( sourceProjectId: string, @@ -454,9 +535,13 @@ async function rereadTextBook( bookId: string, ): Promise { const wasFailed = read.failedBookIds.has(bookId); + const hadText = read.textBookIds.has(bookId); if (!(await readTextBook(sourceProjectId, read, bookId))) return; + const isFailed = read.failedBookIds.has(bookId); + if (!isFailed && (wasFailed || hadText !== read.textBookIds.has(bookId))) + reportTextRead(sourceProjectId, read); // Core calls back with an error after every save while a book can't be read. - if (!wasFailed || !read.failedBookIds.has(bookId)) reportEdit(sourceProjectId, bookId); + if (!wasFailed || !isFailed) reportEdit(sourceProjectId, bookId); } /** Lists the source project's books in canonical order, with the tag their text is written in. */ @@ -489,11 +574,13 @@ async function readText(sourceProjectId: string, read: TextRead): Promise await readNext(); }; await Promise.all(Array.from({ length: READ_CONCURRENCY }, readNext)); - if (isCurrent()) read.isRead = true; + if (!isCurrent()) return; + read.isRead = true; + reportTextRead(sourceProjectId, read); } /** Starts reading the source project's whole text. */ -function startTextRead(sourceProjectId: string): TextRead { +function beginTextRead(sourceProjectId: string): TextRead { const usjPdp = papi.projectDataProviders.get('platformScripture.USJ_Book', sourceProjectId); const read: TextRead = { listed: Promise.all([listBooks(sourceProjectId), usjPdp]).then(([listed]) => listed), @@ -502,6 +589,7 @@ function startTextRead(sourceProjectId: string): TextRead { isRead: false, readBookIds: new Set(), failedBookIds: new Set(), + textBookIds: new Set(), forms: new FormIndex(), watches: new Map(), }; @@ -513,15 +601,15 @@ function startTextRead(sourceProjectId: string): TextRead { /** * Reports how far reading the source project's whole text has got, starting the read when the * project has none. The read keeps a form index that {@link isFormInText} answers from, and watches - * each book it has read, reporting its edits through {@link onBookTextChanged}, until the project - * closes. + * each book it has read, reporting its edits through {@link onBookTextChanged}, until the read + * ends. * * @returns Once the text's books are known, the `TextReadStatus` as JSON. * @throws If the books or the provider of their text cannot be got. The read is dropped, so the * next request starts it again. */ export async function getTextReadStatus(sourceProjectId: string): Promise { - const read = textReads.get(sourceProjectId) ?? startTextRead(sourceProjectId); + const read = textReads.get(sourceProjectId) ?? beginTextRead(sourceProjectId); const { bookIds } = await read.listed; const { readBookIds, failedBookIds } = read; const status: TextReadStatus = { @@ -543,32 +631,68 @@ export function isFormInText(sourceProjectId: string, form: string): boolean | u return read.forms.has(normalizeSurfaceForm(form)); } -/** Lets go of the books kept for the source project, and ends its whole-text read. */ -async function releaseProject(sourceProjectId: string): Promise { - const kept = new Set([latestBooks.get(sourceProjectId), openBooks.get(sourceProjectId)]); +/** + * Starts reading the source project's whole text afresh, ending any read already under way, so that + * {@link onBookRead}'s listeners hear again of each book holding text, and {@link onTextRead}'s hear + * once the read is done. A failure to list the books is left for {@link getTextReadStatus} to + * report. + * + * The read lasts until {@link stopTextRead}; closing the project leaves it running. + * + * @returns Once the read it replaced has stopped watching its books; the new read goes on. + */ +export function startTextRead(sourceProjectId: string): Promise { + heldReads.add(sourceProjectId); + const ended = endTextRead(sourceProjectId); + beginTextRead(sourceProjectId); + return ended; +} + +/** Ends the source project's whole-text read, leaving the books its view keeps. */ +export async function stopTextRead(sourceProjectId: string): Promise { + heldReads.delete(sourceProjectId); + await endTextRead(sourceProjectId); +} + +/** Forgets the source project's whole-text read, and stops watching each book it read. */ +async function endTextRead(sourceProjectId: string): Promise { const read = textReads.get(sourceProjectId); textReads.delete(sourceProjectId); - await Promise.all([ - ...[...kept].map((held) => held && releaseBook(sourceProjectId, held.watch)), - ...[...(read?.watches.values() ?? [])].map((stop) => stop()), - ]); + await Promise.all([...(read?.watches.values() ?? [])].map((stop) => stop())); +} + +/** Lets go of the books kept for the source project. */ +async function releaseKeptBooks(sourceProjectId: string): Promise { + const kept = new Set([latestBooks.get(sourceProjectId), openBooks.get(sourceProjectId)]); + await Promise.all([...kept].map((held) => held && releaseBook(sourceProjectId, held.watch))); } /** - * Lets go of the books kept for the source project, and of any a request under way would keep, and - * ends its whole-text read. + * Lets go of the books kept for the source project, and of any a request under way would keep. Ends + * the project's whole-text read too, except between {@link startTextRead} and {@link stopTextRead}. */ export async function closeProject(sourceProjectId: string): Promise { closeCounts.set(sourceProjectId, (closeCounts.get(sourceProjectId) ?? 0) + 1); - await releaseProject(sourceProjectId); + await Promise.all([ + releaseKeptBooks(sourceProjectId), + !heldReads.has(sourceProjectId) && endTextRead(sourceProjectId), + ]); } -/** Lets go of every book kept, and ends every whole-text read. */ +/** + * Lets go of every book kept, and ends every whole-text read, those {@link startTextRead} began + * included. + */ export async function closeAllProjects(): Promise { const sourceProjectIds = new Set([ ...latestBooks.keys(), ...openBooks.keys(), ...textReads.keys(), ]); - await Promise.all([...sourceProjectIds].map(releaseProject)); + heldReads.clear(); + await Promise.all( + [...sourceProjectIds].map((sourceProjectId) => + Promise.all([releaseKeptBooks(sourceProjectId), endTextRead(sourceProjectId)]), + ), + ); } diff --git a/src/services/draftRepository.ts b/src/services/draftRepository.ts index 080ca2ffd..77f8b5564 100644 --- a/src/services/draftRepository.ts +++ b/src/services/draftRepository.ts @@ -1,6 +1,7 @@ import type { DraftProject, TextAnalysis } from 'interlinearizer'; import type { AnalysisRepository, + ApplyOptions, ApplyResult, ChangeSet, DraftExport, @@ -187,8 +188,8 @@ export class DraftRepository extends IndexedRepository implements AnalysisReposi } /** - * Records the stored draft as clean, rewriting only its envelope. A draft never saved, or already - * clean, is left alone. + * Records the stored draft, and the draft this instance keeps, as clean, rewriting only the + * stored envelope. A stored draft never saved, or already clean, is left alone. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON; nothing is written. * @throws {Error} If the stored draft was written by a newer build, fails validation, or belongs @@ -205,11 +206,16 @@ export class DraftRepository extends IndexedRepository implements AnalysisReposi throw new Error( `Interlinearizer: stored draft for source project ${sourceProjectId} failed validation; not marking it clean`, ); - if (!parsed.dirty) return; - await storage.write( - draftKey(sourceProjectId), - JSON.stringify({ ...parsed, modelVersion: CURRENT_MODEL_VERSION, dirty: false }), - ); + if (parsed.dirty) { + await storage.write( + draftKey(sourceProjectId), + JSON.stringify({ ...parsed, modelVersion: CURRENT_MODEL_VERSION, dirty: false }), + ); + } + // An apply that keeps the mark takes it from the kept draft, which must not still read unsaved. + if (this.current) { + this.current = { ...this.current, draft: { ...this.current.draft, dirty: false } }; + } } /** @@ -253,7 +259,8 @@ export class DraftRepository extends IndexedRepository implements AnalysisReposi /** * Applies the changes to the draft this instance keeps, loading the stored draft first when it * keeps none, as after {@link exportAll} or a whole save. Only the books whose content the changes - * alter are rewritten, so an edit to a payload rewrites every book linking it. + * alter are rewritten, so an edit to a payload rewrites every book linking it. Marks the draft + * unsaved unless `options` keeps the mark. * * @throws {Error} If an id appears more than once in one record kind's changes, or the scope's * record names another source project; nothing is written. @@ -263,10 +270,12 @@ export class DraftRepository extends IndexedRepository implements AnalysisReposi * @throws If storage rejects a read or a write, as {@link replaceAll} does; this instance's draft * is left as it was. */ - async apply(changes: ChangeSet): Promise { + async apply(changes: ChangeSet, options?: ApplyOptions): Promise { const { draft: base, index } = await this.indexed(); const copied = jsonCopy(changes); - const { draft, inverse } = applyChangeSet(base, copied); + const applied = applyChangeSet(base, copied); + const { inverse } = applied; + const draft = options?.keepDirty ? { ...applied.draft, dirty: base.dirty } : applied.draft; await this.writeDraft(draft, undefined, { refuseUnreadable: true }); index.apply(copied); this.current = { draft, index }; diff --git a/src/services/editHistory.ts b/src/services/editHistory.ts index 04170ff71..d7adfe5e3 100644 --- a/src/services/editHistory.ts +++ b/src/services/editHistory.ts @@ -1,21 +1,62 @@ +import type { Book } from 'interlinearizer'; import type { AnalysisRepository, ChangeSet } from '../types/analysis-repository'; +import { mergeChangeSets } from '../utils/change-set'; import { MAX_UNDO_STEPS } from '../utils/undo-history'; +/** + * The changes that re-anchor the stored draft, as `changes` would leave it, to the current text of + * each of `books` in turn. Applies nothing. + * + * @returns The changes, or `undefined` when there are none. + */ +export type ReanchorBooks = ( + changes: ChangeSet, + books: readonly Book[], +) => Promise; + +/** What reverts or redoes one edit. */ +type Step = { + changes: ChangeSet; + /** How many passes had been recorded when the step was captured, all of which it reflects. */ + passesSeen: number; +}; + /** * A linear undo history of the edits applied through it to one repository, keeping the latest * {@link MAX_UNDO_STEPS}. Each edit is one step, and a new edit drops every step undone before it. + * + * A re-anchor pass is no step. A step holds records as they read when it was captured. So what an + * undo or redo restores is re-anchored to the current text of each book that a pass has re-anchored + * since then. */ export class EditHistory { private readonly repository: AnalysisRepository; + private readonly reanchorBooks: ReanchorBooks | undefined; + /** What reverts each edit not yet undone, latest last. */ - private readonly undoable: ChangeSet[] = []; + private readonly undoable: Step[] = []; /** What redoes each undone edit, latest last. */ - private readonly redoable: ChangeSet[] = []; + private readonly redoable: Step[] = []; - constructor(repository: AnalysisRepository) { + /** + * Each book's latest text a pass has re-anchored to, while some step predates that pass, in the + * order the passes ran. + */ + private readonly passes = new Map(); + + /** How many passes have ever been recorded. */ + private passCount = 0; + + /** + * @param repository - The repository whose edits the history applies and reverts. + * @param reanchorBooks - Gives the changes that re-anchor what an undo or redo would restore; + * absent, nothing restored is re-anchored. + */ + constructor(repository: AnalysisRepository, reanchorBooks?: ReanchorBooks) { this.repository = repository; + this.reanchorBooks = reanchorBooks; } /** @@ -25,38 +66,79 @@ export class EditHistory { */ async apply(changes: ChangeSet): Promise { const { inverse } = await this.repository.apply(changes); - this.undoable.push(inverse); + this.undoable.push({ changes: inverse, passesSeen: this.passCount }); if (this.undoable.length > MAX_UNDO_STEPS) this.undoable.shift(); this.redoable.length = 0; + this.forgetUnneededPasses(); + } + + /** + * Records that a pass has just re-anchored the stored draft to `book`'s current text, superseding + * any earlier pass over the book, so that what an undo or redo restores is re-anchored to it too. + * A missing book is passed as a book with no segments. Nothing is recorded when the book's text + * is the same text its last pass re-anchored to. + */ + recordPass(book: Book): void { + if (this.undoable.length === 0 && this.redoable.length === 0) return; + if (this.passes.get(book.bookRef)?.book.textVersion === book.textVersion) return; + this.passCount += 1; + this.passes.delete(book.bookRef); + this.passes.set(book.bookRef, { book, ordinal: this.passCount }); } /** - * Reverts the latest edit not yet undone. + * Reverts the latest edit not yet undone, re-anchoring what it restores. * - * @returns The changes that reverted it, or `undefined` when there is none. - * @throws Whatever applying those changes throws; the edit stays the next to undo. + * @returns The changes that reverted the edit and re-anchored what the undo restored, or + * `undefined` when there is none. + * @throws Whatever re-anchoring or applying those changes throws; nothing is applied, and the + * edit stays the next to undo. */ undo(): Promise { return this.step(this.undoable, this.redoable); } /** - * Redoes the latest undone edit. + * Redoes the latest undone edit, re-anchoring what it restores. * - * @returns The changes that redid it, or `undefined` when there is none. - * @throws Whatever applying those changes throws; the edit stays the next to redo. + * @returns The changes that redid the edit and re-anchored what the redo restored, or `undefined` + * when there is none. + * @throws Whatever re-anchoring or applying those changes throws; nothing is applied, and the + * edit stays the next to redo. */ redo(): Promise { return this.step(this.redoable, this.undoable); } - /** Applies the latest changes on `from`, moving what reverts them onto `to`. */ - private async step(from: ChangeSet[], to: ChangeSet[]): Promise { - const changes = from.at(-1); - if (!changes) return undefined; + /** + * Applies the latest changes on `from`, re-anchored to each book re-anchored since they were + * captured, as one apply, moving what reverts it onto `to`. + */ + private async step(from: Step[], to: Step[]): Promise { + const step = from.at(-1); + if (!step) return undefined; + const books = [...this.passes.values()] + .filter(({ ordinal }) => ordinal > step.passesSeen) + .map(({ book }) => book); + const reanchored = + books.length > 0 && this.reanchorBooks + ? await this.reanchorBooks(step.changes, books) + : undefined; + const changes = reanchored ? mergeChangeSets(step.changes, reanchored) : step.changes; const { inverse } = await this.repository.apply(changes); from.pop(); - to.push(inverse); + to.push({ changes: inverse, passesSeen: this.passCount }); + this.forgetUnneededPasses(); return changes; } + + /** Forgets each pass that every step left in the history already reflects. */ + private forgetUnneededPasses(): void { + const oldestSeen = Math.min( + ...[...this.undoable, ...this.redoable].map(({ passesSeen }) => passesSeen), + ); + this.passes.forEach(({ ordinal }, bookCode) => { + if (ordinal <= oldestSeen) this.passes.delete(bookCode); + }); + } } diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 858299a97..e1d9f222d 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -1143,7 +1143,7 @@ export function openDraftRepository( exportForCopy: () => queued((repository) => repository.exportForCopy()), replaceAll: (draft, options) => counted((repository) => repository.replaceAll(draft, options)), markClean: () => queued((repository) => repository.markClean()), - apply: (changes) => counted((repository) => repository.apply(changes)), + apply: (changes, options) => counted((repository) => repository.apply(changes, options)), getScope: () => queued((repository) => repository.getScope()), getTokenAnalyses: (ids) => queued((repository) => repository.getTokenAnalyses(ids)), getPhraseAnalyses: (ids) => queued((repository) => repository.getPhraseAnalyses(ids)), diff --git a/src/services/reanchorPasses.ts b/src/services/reanchorPasses.ts new file mode 100644 index 000000000..e524f6c7e --- /dev/null +++ b/src/services/reanchorPasses.ts @@ -0,0 +1,96 @@ +import type { Book, DraftProject, SegmentAnalysisLink } from 'interlinearizer'; +import type { AnalysisRepository, ChangeSet } from '../types/analysis-repository'; +import { emptyAnalysis } from '../types/empty-factories'; +import { booksLinkedIn } from '../utils/analysis-book'; +import { applyChangeSet, mergeChangeSets } from '../utils/change-set'; +import { reanchorChangesForBook } from '../utils/reanchor-changes'; +import { segmentationForBook } from '../utils/segmentation'; +import type { ReanchorBooks } from './editHistory'; + +/** + * The stored draft as a pass over `bookCodes` needs it: the draft's record, the links into those + * books, and the segment analyses referenced by those links or by the links in `alsoLinked`, which + * are not yet stored. Reads one call at a time, because calls for one draft must not overlap. + */ +async function readBooks( + repository: AnalysisRepository, + bookCodes: string[], + alsoLinked: readonly SegmentAnalysisLink[] = [], +): Promise { + const scope = await repository.getScope(); + const links = await repository.findLinksInBooks(bookCodes); + const segmentAnalyses = await repository.getSegmentAnalyses([ + ...new Set([...links.segmentAnalysisLinks, ...alsoLinked].map(({ analysisId }) => analysisId)), + ]); + return { ...scope, dirty: false, analysis: { ...emptyAnalysis(), ...links, segmentAnalyses } }; +} + +/** The changes that re-anchor `draft` to each of `books` in turn, `undefined` when there are none. */ +function reanchorInTurn(draft: DraftProject, books: readonly Book[]): ChangeSet | undefined { + let current = draft; + let changes: ChangeSet | undefined; + books.forEach((book) => { + const bookChanges = reanchorChangesForBook(current, book); + if (!bookChanges) return; + current = applyChangeSet(current, bookChanges).draft; + changes = changes ? mergeChangeSets(changes, bookChanges) : bookChanges; + }); + return changes; +} + +/** What a pass over one book of the stored draft found and applied. */ +export type StoredBookPass = { + /** Whether the draft linked into the book or held boundary edits in it before the pass. */ + heldAnalysis: boolean; + /** The changes applied; absent when the draft already read as re-anchored. */ + changes?: ChangeSet; +}; + +/** + * Re-anchors the stored draft to `book`'s current text, outside any undo history. The draft stays + * as saved or unsaved as it was, because a pass is bookkeeping rather than an edit. A book the text + * lacks is passed as a book with no segments. + * + * @throws Whatever reading the draft or applying the changes throws; nothing is applied. + */ +export async function reanchorStoredBook( + repository: AnalysisRepository, + book: Book, +): Promise { + const draft = await readBooks(repository, [book.bookRef]); + const heldAnalysis = + booksLinkedIn(draft.analysis).has(book.bookRef) || + segmentationForBook(draft.segmentation, book.bookRef) !== undefined; + const changes = reanchorInTurn(draft, [book]); + if (changes) await repository.apply(changes, { keepDirty: true }); + return { heldAnalysis, ...(changes && { changes }) }; +} + +/** + * Gives the {@link ReanchorBooks} for `repository`: the changes that re-anchor what an undo or redo + * would restore there, without applying them. + */ +export function reanchorRestored(repository: AnalysisRepository): ReanchorBooks { + return async (changes, books) => { + // A restored link may name a payload no stored link does, which only the step's puts reveal. + const stored = await readBooks( + repository, + books.map(({ bookRef }) => bookRef), + changes.segmentAnalysisLinks?.puts, + ); + return reanchorInTurn(applyChangeSet(stored, changes).draft, books); + }; +} + +/** + * Which of `bookCodes` the stored draft links into. + * + * @throws Whatever reading the draft throws. + */ +export async function booksLinkedAmong( + repository: AnalysisRepository, + bookCodes: string[], +): Promise { + const links = await repository.findLinksInBooks(bookCodes); + return [...booksLinkedIn({ ...emptyAnalysis(), ...links })]; +} diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 303b12015..cf1621986 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -94,6 +94,15 @@ export type ChangeSet = { [Kind in keyof TextAnalysis]?: RecordChanges; }; +/** How an apply treats the draft's unsaved mark. */ +export type ApplyOptions = { + /** + * Leave the draft as saved or unsaved as it was, for bookkeeping such as a re-anchor pass rather + * than an edit. Absent, the draft is marked unsaved. + */ + keepDirty?: boolean; +}; + /** What applying a {@link ChangeSet} tells its caller. */ export type ApplyResult = { /** Restores what the applied changes replaced; applying it undoes them. */ @@ -192,8 +201,9 @@ export interface AnalysisRepository { markClean(): Promise; /** - * Applies `changes` to the stored draft whole or not at all, marked unsaved and stamped with this - * build's model version. A draft never saved counts as an empty one. + * Applies `changes` to the stored draft whole or not at all, marked unsaved unless `options` + * keeps the mark, and stamped with this build's model version. A draft never saved counts as an + * empty one. * * @throws {ReadOnlyAnalysisError} If the repository is read-only; nothing is applied. * @throws {Error} If an id appears more than once in one record kind's changes, or the scope's @@ -205,7 +215,7 @@ export interface AnalysisRepository { * @throws If storage rejects a read or a write. The draft as this repository holds it is * unchanged, though storage may hold some of the changes until a later save. */ - apply(changes: ChangeSet): Promise; + apply(changes: ChangeSet, options?: ApplyOptions): Promise; /** * The draft's own record, as {@link AnalysisRepository.exportAll} would load it. diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 1641a0331..06fcba2be 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -364,20 +364,24 @@ declare module 'papi-shared-types' { ) => Promise; /** - * Reverts a session's latest edit not yet undone. + * Reverts a session's latest edit not yet undone, re-anchoring what it restores to the current + * text of each book re-anchored since. * - * @returns The changes that reverted it, as JSON, or `undefined` when there is none. - * @throws If the session is read-only or not open, or the changes cannot be applied (logged - * before rethrowing). + * @returns The changes that reverted the edit and re-anchored what the undo restored, as JSON, + * or `undefined` when there is none. + * @throws If the session is read-only or not open, or re-anchoring or applying the changes + * fails (logged before rethrowing). */ 'interlinearizer.undoEdit': (sessionId: string) => Promise; /** - * Redoes a session's latest undone edit. + * Redoes a session's latest undone edit, re-anchoring what it restores to the current text of + * each book re-anchored since. * - * @returns The changes that redid it, as JSON, or `undefined` when there is none. - * @throws If the session is read-only or not open, or the changes cannot be applied (logged - * before rethrowing). + * @returns The changes that redid the edit and re-anchored what the redo restored, as JSON, or + * `undefined` when there is none. + * @throws If the session is read-only or not open, or re-anchoring or applying the changes + * fails (logged before rethrowing). */ 'interlinearizer.redoEdit': (sessionId: string) => Promise; @@ -600,6 +604,9 @@ declare module 'papi-shared-types' { * with no verse text directly before it, such as the book's first or one after a heading, is * left as it is. * + * The same edit re-anchors the book's analysis to the new segments. A free translation whose + * segment changes goes stale. + * * @returns A JSON string of the changes it applied, with a scope record carrying the new * boundaries when they change. * @throws If the project has no text for the token's book, the session is read-only or not @@ -614,6 +621,9 @@ declare module 'papi-shared-types' { * Splits a segment so a new one begins at `tokenRef`. Every approved phrase the new boundary * would cut is split first, in the same edit, so that no phrase spans two segments. * + * The same edit re-anchors the book's analysis to the new segments. A free translation whose + * segment changes goes stale. + * * @returns A JSON string of the changes it applied, with a scope record carrying the new * boundaries when they change. * @throws If the project has no text for the token's book, the session is read-only or not @@ -626,6 +636,9 @@ declare module 'papi-shared-types' { * boundary does. Every approved phrase a boundary before `toRef` would cut is split first, in * the same edit. * + * The same edit re-anchors the book's analysis to the new segments. A free translation whose + * segment changes goes stale. + * * @returns A JSON string of the changes it applied, with a scope record carrying the new * boundaries when they change. * @throws If the project has no text for the book of `toRef`, the session is read-only or not diff --git a/src/utils/change-set.ts b/src/utils/change-set.ts index e11a47445..b8e71e215 100644 --- a/src/utils/change-set.ts +++ b/src/utils/change-set.ts @@ -139,3 +139,46 @@ export function applyChangeSet(draft: DraftProject, changes: ChangeSet): Applied inverse, }; } + +/** One record kind's `first` then `then`, as one; `then` wins wherever both name a record. */ +function mergeRecordChanges( + first: RecordChanges | undefined, + then: RecordChanges | undefined, +): RecordChanges | undefined { + if (!first || !then) return first ?? then; + const named = new Set([...then.puts.map(({ id }) => id), ...then.deletes]); + return { + puts: [...first.puts.filter(({ id }) => !named.has(id)), ...then.puts], + deletes: [...first.deletes.filter((id) => !named.has(id)), ...then.deletes], + }; +} + +/** + * Changes that apply as `first` and then `then` do, named by `first`'s intent. Where both name a + * record, or both carry the scope's record, `then`'s stands. + */ +export function mergeChangeSets(first: ChangeSet, then: ChangeSet): ChangeSet { + const scope = then.scope ?? first.scope; + const segmentAnalyses = mergeRecordChanges(first.segmentAnalyses, then.segmentAnalyses); + const segmentAnalysisLinks = mergeRecordChanges( + first.segmentAnalysisLinks, + then.segmentAnalysisLinks, + ); + const tokenAnalyses = mergeRecordChanges(first.tokenAnalyses, then.tokenAnalyses); + const tokenAnalysisLinks = mergeRecordChanges(first.tokenAnalysisLinks, then.tokenAnalysisLinks); + const phraseAnalyses = mergeRecordChanges(first.phraseAnalyses, then.phraseAnalyses); + const phraseAnalysisLinks = mergeRecordChanges( + first.phraseAnalysisLinks, + then.phraseAnalysisLinks, + ); + return { + intent: first.intent, + ...(scope && { scope }), + ...(segmentAnalyses && { segmentAnalyses }), + ...(segmentAnalysisLinks && { segmentAnalysisLinks }), + ...(tokenAnalyses && { tokenAnalyses }), + ...(tokenAnalysisLinks && { tokenAnalysisLinks }), + ...(phraseAnalyses && { phraseAnalyses }), + ...(phraseAnalysisLinks && { phraseAnalysisLinks }), + }; +} diff --git a/src/utils/reanchor-changes.ts b/src/utils/reanchor-changes.ts new file mode 100644 index 000000000..d4b235a3b --- /dev/null +++ b/src/utils/reanchor-changes.ts @@ -0,0 +1,74 @@ +import type { Book, DraftProject } from 'interlinearizer'; +import type { DraftContent } from '../hooks/useDraftProject'; +import type { ChangeSet, RecordChanges } from '../types/analysis-repository'; +import { scopeRecordOf } from './change-set'; +import { reanchorDraftToBook } from './reanchor-draft'; +import type { BookPass } from './undo-history'; + +/** Names the changes a re-anchor pass makes. */ +export const REANCHOR_INTENT = 'reanchor'; + +/** + * The records of `after` that differ from `before`'s record of the same id. A pass only rewrites + * records, never adding or removing one, so these are all it changed. + */ +function rewritten( + before: readonly T[], + after: readonly T[], +): RecordChanges | undefined { + const prior = new Map(before.map((record) => [record.id, record])); + const puts = after.filter((record) => prior.get(record.id) !== record); + return puts.length > 0 ? { puts, deletes: [] } : undefined; +} + +/** + * The changes that bring `draft` to the content that a re-anchor pass made of it. + * + * @returns The changes, or `undefined` when the pass made nothing new. + */ +export function changesOfReanchor( + draft: DraftProject, + { analysis, segmentation }: DraftContent, +): ChangeSet | undefined { + const tokenAnalysisLinks = rewritten( + draft.analysis.tokenAnalysisLinks, + analysis.tokenAnalysisLinks, + ); + const phraseAnalysisLinks = rewritten( + draft.analysis.phraseAnalysisLinks, + analysis.phraseAnalysisLinks, + ); + const segmentAnalysisLinks = rewritten( + draft.analysis.segmentAnalysisLinks, + analysis.segmentAnalysisLinks, + ); + const movesSplits = segmentation !== draft.segmentation; + if (!movesSplits && !tokenAnalysisLinks && !phraseAnalysisLinks && !segmentAnalysisLinks) { + return undefined; + } + return { + intent: REANCHOR_INTENT, + ...(movesSplits && { scope: { ...scopeRecordOf(draft), segmentation } }), + ...(tokenAnalysisLinks && { tokenAnalysisLinks }), + ...(phraseAnalysisLinks && { phraseAnalysisLinks }), + ...(segmentAnalysisLinks && { segmentAnalysisLinks }), + }; +} + +function changesOfPass(draft: DraftProject, pass: BookPass): ChangeSet | undefined { + return changesOfReanchor( + draft, + pass({ analysis: draft.analysis, segmentation: draft.segmentation }), + ); +} + +/** + * The changes that re-anchor `draft` to `book`'s current text, matching the WebView's pass over + * `book`. A book the text lacks is passed as a book with no segments. The draft may hold only the + * links in `book`, with the segment analyses they reference, and the changes are the same. + * + * @returns The changes, or `undefined` when the draft already reads as re-anchored. + */ +export function reanchorChangesForBook(draft: DraftProject, book: Book): ChangeSet | undefined { + return changesOfPass(draft, reanchorDraftToBook(book)); +} diff --git a/src/utils/reanchor-draft.ts b/src/utils/reanchor-draft.ts index 63ce15181..2cb589833 100644 --- a/src/utils/reanchor-draft.ts +++ b/src/utils/reanchor-draft.ts @@ -25,13 +25,12 @@ export function reanchorDraftToBook(verseBook: Book): BookPass { }; } +/** A book the project no longer has, as a book with no text. */ +export function missingBook(bookCode: string): Book { + return { id: bookCode, bookRef: bookCode, textVersion: '', segments: [], duplicateVerseIds: [] }; +} + /** Builds the pass that re-anchors a draft's content to a book the project no longer has. */ export function reanchorDraftToMissingBook(bookCode: string): BookPass { - return reanchorDraftToBook({ - id: bookCode, - bookRef: bookCode, - textVersion: '', - segments: [], - duplicateVerseIds: [], - }); + return reanchorDraftToBook(missingBook(bookCode)); }