From ec25bc01f0fea592e79ca9c525aa34103f8a93aa Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 15:03:06 -0400 Subject: [PATCH 01/25] Pass each book the whole-text read files to listeners 3j re-anchors each linked book as the read delivers it, so the read now hands its tokenized text to onBookRead listeners, ahead of the edit report for a reread. onTextRead and getTextBookIds name the books holding text once every book has read, for staling links into the rest. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/bookService.test.ts | 219 +++++++++++++++++++++ src/services/bookService.ts | 101 ++++++++-- 2 files changed, 304 insertions(+), 16 deletions(-) diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index 23d7d2c92..5d893e2dc 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -11,12 +11,15 @@ import { getBookOutline, getBookSegments, getSegments, + getTextBookIds, getTextReadStatus, getTokenizedBook, getVerseBook, getWritingSystem, isFormInText, + onBookRead, onBookTextChanged, + onTextRead, } from '../../services/bookService'; import type { BookOutline, SegmentPage } from '../../types/segment-pages'; import type { TextReadStatus } from '../../types/text-read-status'; @@ -1230,6 +1233,222 @@ 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; + } + + 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 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 and getTextBookIds', () => { + 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']); + expect(getTextBookIds('src')?.sort()).toEqual(['GEN', 'LEV']); + }); + + it('knows no books holding text while some 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(); + expect(getTextBookIds('src')).toBeUndefined(); + await finishReading(project); + }); + + it('knows no books holding text for a project whose text is not being read', () => { + expect(getTextBookIds('src')).toBeUndefined(); + }); + + 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(); + expect(getTextBookIds('src')).toBeUndefined(); + + delivery = usj('GEN'); + subscriptionsTo(project, 'GEN').at(-1)?.deliver(delivery); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(getTextBookIds('src')?.sort()).toEqual(['EXO', 'GEN']); + }); + + 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('getTextReadStatus', () => { it('answers once the books are known, before any is read', async () => { const project = serveProject(); diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 91042b05c..c0f681249 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -72,6 +72,8 @@ 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>; @@ -92,6 +94,16 @@ function reportEdit(sourceProjectId: string, bookId: string): void { bookTextListeners.forEach((listener) => listener(sourceProjectId, bookId)); } +/** Receives the source project and tokenized text of each book a whole-text read files. */ +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. @@ -285,6 +297,38 @@ export function onBookTextChanged(listener: BookTextListener): () => boolean { return () => bookTextListeners.delete(listener); } +/** + * Calls `listener` with each book that a source project's whole-text read files with text, until + * the returned function is called: as the read first reaches the book, and again each time the + * book's text changes. For a change, `listener` is called before {@link onBookTextChanged}'s + * listeners hear of it. A book whose read fails, or that has 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's text that hold text, until the returned + * function is called: once its whole-text read has read every book with none failed, and again + * whenever a book's text appears or goes, or its failed read is the last to read again. + */ +export function onTextRead(listener: TextReadListener): () => boolean { + textReadListeners.add(listener); + return () => textReadListeners.delete(listener); +} + +/** + * The books of the source project's text that hold text, as {@link onTextRead} last reported them. + * + * @returns `undefined` until the project's whole-text read has read every book, and while any + * book's latest read has failed. + */ +export function getTextBookIds(sourceProjectId: string): string[] | undefined { + const read = textReads.get(sourceProjectId); + return read && textBookIdsOf(read); +} + /** * 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 +440,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 +463,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 +478,37 @@ 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 clears a failure. */ async function rereadTextBook( sourceProjectId: string, @@ -454,9 +516,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,7 +555,9 @@ 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. */ @@ -502,6 +570,7 @@ function startTextRead(sourceProjectId: string): TextRead { isRead: false, readBookIds: new Set(), failedBookIds: new Set(), + textBookIds: new Set(), forms: new FormIndex(), watches: new Map(), }; From b70a768a30fc43eedc26a25c11fd5a33174775f3 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 15:05:41 -0400 Subject: [PATCH 02/25] Build a re-anchor pass's changes as a change set The host's pass runs the WebView's own pass over a draft and puts each link it rewrote, with the draft's record when it moved a split. Parity tests check the result against the WebView pass on its drift and missing-book cases, from the whole draft and from one book's analysis. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/utils/reanchor-changes.test.ts | 367 +++++++++++++++++++ src/utils/reanchor-changes.ts | 77 ++++ 2 files changed, 444 insertions(+) create mode 100644 src/__tests__/utils/reanchor-changes.test.ts create mode 100644 src/utils/reanchor-changes.ts diff --git a/src/__tests__/utils/reanchor-changes.test.ts b/src/__tests__/utils/reanchor-changes.test.ts new file mode 100644 index 000000000..4536bbbcb --- /dev/null +++ b/src/__tests__/utils/reanchor-changes.test.ts @@ -0,0 +1,367 @@ +/// + +import type { + AssignmentStatus, + Book, + DraftProject, + SegmentationDelta, + TextAnalysis, +} from 'interlinearizer'; +import type { DraftContent } from '../../hooks/useDraftProject'; +import type { ChangeSet } from '../../types/analysis-repository'; +import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; +import { splitAnalysisByBook } from '../../utils/analysis-book'; +import { applyChangeSet } from '../../utils/change-set'; +import { + REANCHOR_INTENT, + reanchorChangesForBook, + reanchorChangesForMissingBook, +} from '../../utils/reanchor-changes'; +import { reanchorDraftToBook, reanchorDraftToMissingBook } from '../../utils/reanchor-draft'; +import type { BookPass } from '../../utils/undo-history'; +import { FIXTURE_STAMPS, makePhraseLink, makeTokenLink, makeVerseBook } from '../test-helpers'; + +beforeAll(() => { + // Both passes stamp what they rewrite with the time, so they must run at one instant to compare. + jest.useFakeTimers({ now: new Date('2026-02-01T00:00:00.000Z') }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +/** 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 }))); +} + +/** The ref of the `occurrence`th token of `book` reading `surfaceText`. */ +function refOf(book: Book, surfaceText: string, occurrence = 0): string { + const token = book.segments + .flatMap((segment) => segment.tokens) + .filter((t) => t.surfaceText === surfaceText)[occurrence]; + if (!token) throw new Error(`fixture missing token ${surfaceText}`); + return token.ref; +} + +/** An analysis glossing each of `words` where `book` has it, each link of `status`. */ +function glossing( + book: Book, + words: string[], + status: AssignmentStatus = 'approved', +): TextAnalysis { + return { + ...emptyAnalysis(), + tokenAnalyses: words.map((word) => ({ + ...FIXTURE_STAMPS, + id: `ta-${word}`, + surfaceText: word, + gloss: { und: `${word} gloss` }, + })), + tokenAnalysisLinks: words.map((word) => + makeTokenLink(`ta-${word}`, refOf(book, word), { surfaceText: word, status }), + ), + }; +} + +/** An analysis grouping `words`, in order, into one phrase where `book` has them. */ +function phrasing(book: Book, words: string[]): TextAnalysis { + return { + ...emptyAnalysis(), + phraseAnalyses: [{ ...FIXTURE_STAMPS, id: 'ph-1', surfaceText: words.join(' ') }], + phraseAnalysisLinks: [ + makePhraseLink( + 'ph-1', + words.map((word) => refOf(book, word)), + words, + ), + ], + }; +} + +/** An analysis translating the segment `segmentId`, written against `surfaceText`. */ +function translating( + segmentId: string, + surfaceText: string, + status: AssignmentStatus = 'approved', +): TextAnalysis { + return { + ...emptyAnalysis(), + segmentAnalyses: [ + { ...FIXTURE_STAMPS, id: 'sa-1', surfaceText, freeTranslation: { en: 'a translation' } }, + ], + segmentAnalysisLinks: [ + { ...FIXTURE_STAMPS, id: 'sl-1', analysisId: 'sa-1', status, segmentId }, + ], + }; +} + +/** Every record of each analysis, in order. */ +function combined(...analyses: TextAnalysis[]): TextAnalysis { + const all = emptyAnalysis(); + analyses.forEach((analysis) => { + all.tokenAnalyses.push(...analysis.tokenAnalyses); + all.tokenAnalysisLinks.push(...analysis.tokenAnalysisLinks); + all.phraseAnalyses.push(...analysis.phraseAnalyses); + all.phraseAnalysisLinks.push(...analysis.phraseAnalysisLinks); + all.segmentAnalyses.push(...analysis.segmentAnalyses); + all.segmentAnalysisLinks.push(...analysis.segmentAnalysisLinks); + }); + return all; +} + +function draftOf(analysis: TextAnalysis, segmentation?: SegmentationDelta): DraftProject { + return { ...emptyDraft('src'), analysis, ...(segmentation && { segmentation }) }; +} + +/** `draft` holding only its analysis in `bookCode`, as a lookup of the book's links reads it. */ +function bookOnly(draft: DraftProject, bookCode: string): DraftProject { + return { + ...draft, + analysis: splitAnalysisByBook(draft.analysis).get(bookCode) ?? emptyAnalysis(), + }; +} + +/** Checks that `changes` bring `draft` to what the WebView's `pass` makes of it. */ +function expectParity( + draft: DraftProject, + changes: ChangeSet | undefined, + pass: BookPass, +): DraftProject { + const expected = pass({ analysis: draft.analysis, segmentation: draft.segmentation }); + const applied = changes ? applyChangeSet(draft, changes).draft : draft; + expect(applied.analysis).toEqual(expected.analysis); + expect(applied.segmentation).toEqual(expected.segmentation); + return applied; +} + +/** The draft-level counterparts of the drift cases in `reanchor-analysis.test.ts`. */ +const DRIFT_CASES: [string, () => { draft: DraftProject; book: Book }][] = [ + [ + 'a word is inserted before a gloss', + () => ({ + draft: draftOf(glossing(genesis('it was unbelievable'), ['unbelievable'])), + book: genesis('it was and unbelievable'), + }), + ], + [ + 'a glossed word is deleted', + () => ({ + draft: draftOf(glossing(genesis('alpha beta gamma'), ['beta'])), + book: genesis('alpha gamma'), + }), + ], + [ + 'a repeated word loses an occurrence', + () => { + const before = genesis('beta alpha beta'); + const analysis = glossing(before, ['alpha']); + analysis.tokenAnalysisLinks.push( + makeTokenLink('ta-alpha', refOf(before, 'beta', 1), { surfaceText: 'beta', id: 'tl-b' }), + ); + return { draft: draftOf(analysis), book: genesis('beta alpha') }; + }, + ], + [ + "a stale gloss's word comes back at a shifted ref", + () => ({ + draft: draftOf(glossing(genesis('alpha beta'), ['beta'], 'stale')), + book: genesis('new alpha beta'), + }), + ], + [ + 'a rejected gloss shifts with its word', + () => ({ + draft: draftOf(glossing(genesis('alpha beta'), ['beta'], 'rejected')), + book: genesis('new alpha beta'), + }), + ], + [ + 'a phrase shifts with a word inserted before it', + () => ({ + draft: draftOf(phrasing(genesis('alpha beta gamma'), ['beta', 'gamma'])), + book: genesis('new alpha beta gamma'), + }), + ], + [ + 'a phrase loses a word', + () => ({ + draft: draftOf(phrasing(genesis('alpha beta gamma'), ['beta', 'gamma'])), + book: genesis('alpha gamma'), + }), + ], + [ + "a free translation's segment gains a word", + () => ({ + draft: draftOf(translating('GEN 1:1', 'alpha beta')), + book: genesis('alpha beta gamma'), + }), + ], + [ + "a stale free translation's segment reads as it did again", + () => ({ + draft: draftOf(translating('GEN 1:1', 'alpha beta', 'stale')), + book: genesis('alpha beta'), + }), + ], + [ + 'a split and its piece translation shift with a word inserted before them', + () => { + const before = genesis('alpha beta'); + const split = refOf(before, 'beta'); + return { + draft: draftOf(translating(split, 'beta'), { + removedVerseStarts: [], + addedStarts: [{ tokenRef: split, surfaceText: 'beta' }], + }), + book: genesis('alpha and beta'), + }; + }, + ], + [ + 'a verse holding a gloss is removed', + () => ({ + draft: draftOf(glossing(genesis('alpha', 'beta'), ['beta'])), + book: genesis('alpha'), + }), + ], + [ + 'a gloss shifts beside glosses in another book', + () => { + const exodus = makeVerseBook([{ sid: 'EXO 1:1', text: 'delta epsilon' }]); + return { + draft: draftOf( + combined( + glossing(exodus, ['epsilon']), + glossing(genesis('alpha beta'), ['beta']), + translating('EXO 1:1', 'delta epsilon'), + ), + ), + book: genesis('new alpha beta'), + }; + }, + ], +]; + +describe('reanchorChangesForBook', () => { + it.each(DRIFT_CASES)('matches the WebView pass when %s', (_, make) => { + const { draft, book } = make(); + + const changes = reanchorChangesForBook(draft, book); + + expect(changes).toBeDefined(); + expectParity(draft, changes, reanchorDraftToBook(book)); + }); + + it.each(DRIFT_CASES)('makes the same changes from the book alone when %s', (_, make) => { + const { draft, book } = make(); + + expect(reanchorChangesForBook(bookOnly(draft, book.bookRef), book)).toEqual( + reanchorChangesForBook(draft, book), + ); + }); + + it.each(DRIFT_CASES)('changes nothing more once applied when %s', (_, make) => { + const { draft, book } = make(); + const changes = reanchorChangesForBook(draft, book); + if (!changes) throw new Error('expected changes'); + + expect(reanchorChangesForBook(applyChangeSet(draft, changes).draft, book)).toBeUndefined(); + }); + + it('names the re-anchor as its intent', () => { + const [[, make]] = DRIFT_CASES; + const { draft, book } = make(); + + expect(reanchorChangesForBook(draft, book)?.intent).toBe(REANCHOR_INTENT); + }); + + it('puts only the links the pass rewrote', () => { + const before = genesis('alpha beta', 'gamma'); + const draft = draftOf(glossing(before, ['beta', 'gamma'])); + + const changes = reanchorChangesForBook(draft, genesis('new alpha beta', 'gamma')); + + 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 record with the split it moved", () => { + const splitCase = DRIFT_CASES.find(([name]) => name.startsWith('a split')); + if (!splitCase) throw new Error('expected a split case'); + const { draft, book } = splitCase[1](); + const named = { ...draft, analysisLanguages: ['fr'], suggestedName: 'Named' }; + + const scope = reanchorChangesForBook(named, book)?.scope; + + expect(scope).toEqual( + expect.objectContaining({ analysisLanguages: ['fr'], suggestedName: 'Named' }), + ); + 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'); + const draft = draftOf(combined(glossing(book, ['beta']), translating('GEN 1:1', 'alpha')), { + removedVerseStarts: [], + addedStarts: [{ tokenRef: refOf(book, 'beta'), surfaceText: 'beta' }], + }); + + expect(reanchorChangesForBook(draft, book)).toBeUndefined(); + }); +}); + +/** The draft-level counterparts of the missing-book cases in `reanchor-draft.test.ts`. */ +const MISSING_BOOK_CASES: [string, () => DraftProject][] = [ + ['an approval is in the book', () => draftOf(glossing(genesis('alpha beta'), ['beta']))], + ['a free translation is in the book', () => draftOf(translating('GEN 1:1', 'alpha beta'))], + [ + 'a phrase is in the book beside an approval in another', + () => + draftOf( + combined( + phrasing(genesis('alpha beta'), ['alpha', 'beta']), + glossing(makeVerseBook([{ sid: 'EXO 1:1', text: 'delta' }]), ['delta']), + ), + ), + ], +]; + +describe('reanchorChangesForMissingBook', () => { + it.each(MISSING_BOOK_CASES)('matches the WebView pass when %s', (_, make) => { + const draft = make(); + + const changes = reanchorChangesForMissingBook(draft, 'GEN'); + + expect(changes).toBeDefined(); + const applied = expectParity(draft, changes, reanchorDraftToMissingBook('GEN')); + expect( + [ + ...applied.analysis.tokenAnalysisLinks, + ...applied.analysis.phraseAnalysisLinks, + ...applied.analysis.segmentAnalysisLinks, + ] + .filter((link) => link.status === 'stale') + .map(({ id }) => id), + ).not.toEqual([]); + }); + + it.each(MISSING_BOOK_CASES)('makes the same changes from the book alone when %s', (_, make) => { + const draft = make(); + + expect(reanchorChangesForMissingBook(bookOnly(draft, 'GEN'), 'GEN')).toEqual( + reanchorChangesForMissingBook(draft, 'GEN'), + ); + }); + + it("leaves another book's approvals alone", () => { + const draft = draftOf(glossing(genesis('alpha beta'), ['beta'])); + + expect(reanchorChangesForMissingBook(draft, 'EXO')).toBeUndefined(); + }); +}); diff --git a/src/utils/reanchor-changes.ts b/src/utils/reanchor-changes.ts new file mode 100644 index 000000000..f4e36c136 --- /dev/null +++ b/src/utils/reanchor-changes.ts @@ -0,0 +1,77 @@ +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, reanchorDraftToMissingBook } 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 what `pass` makes of it, `undefined` when it makes nothing new. */ +function changesOfPass(draft: DraftProject, pass: BookPass): ChangeSet | undefined { + const { analysis, segmentation } = pass({ + analysis: draft.analysis, + segmentation: draft.segmentation, + }); + 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 }), + }; +} + +/** + * The changes that re-anchor `draft` to `book`'s current text, as {@link reanchorDraftToBook} does. + * The draft may hold only the analysis in `book`, with the payloads its links 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)); +} + +/** + * The changes that re-anchor `draft` to a book the text lacks, as {@link reanchorDraftToMissingBook} + * does. The draft may hold only the analysis in the book, as for {@link reanchorChangesForBook}. + * + * @returns The changes, or `undefined` when the draft already reads as re-anchored. + */ +export function reanchorChangesForMissingBook( + draft: DraftProject, + bookCode: string, +): ChangeSet | undefined { + return changesOfPass(draft, reanchorDraftToMissingBook(bookCode)); +} From c38b1a720f30e4b993c48c7a1d2e07b4f37b7056 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 15:15:48 -0400 Subject: [PATCH 03/25] Re-anchor what undo and redo restore to the current text EditHistory records each pass a session runs while a step predates it, and after an undo or redo re-anchors what it restored to the latest text of each book passed over since that step was captured, as the WebView's history does. The undo or redo returns its changes merged with the re-anchor's. A pass is no step, and a pass no held step predates is forgotten. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/editHistory.test.ts | 209 ++++++++++++++++++++- src/__tests__/utils/change-set.test.ts | 79 +++++++- src/services/editHistory.ts | 104 ++++++++-- src/utils/change-set.ts | 43 +++++ 4 files changed, 415 insertions(+), 20 deletions(-) diff --git a/src/__tests__/services/editHistory.test.ts b/src/__tests__/services/editHistory.test.ts index 9a9693cb4..bc63fb41d 100644 --- a/src/__tests__/services/editHistory.test.ts +++ b/src/__tests__/services/editHistory.test.ts @@ -1,11 +1,19 @@ /// -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 { 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, +} from '../test-helpers'; function payload(id: string): TokenAnalysis { return { id, ...FIXTURE_STAMPS, surfaceText: id }; @@ -135,3 +143,198 @@ 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); + } + + /** The ref of the token reading `surfaceText` in `book`. */ + function refOf(book: Book, surfaceText: string): string { + const token = book.segments[0].tokens.find((t) => t.surfaceText === surfaceText); + if (!token) throw new Error(`fixture missing token ${surfaceText}`); + return token.ref; + } + + /** A link to the payload `analysisId` on the word `beta` where `book` has it. */ + function betaLink(book: Book, analysisId: string): TokenAnalysisLink { + return makeTokenLink(analysisId, refOf(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>((books) => + books.reduce>(async (previous, book) => { + const applied = await previous; + const changes = reanchorChangesForBook((await repository.exportAll()).draft, book); + if (!changes) return applied; + await repository.apply(changes); + return applied ? mergeChangeSets(applied, changes) : changes; + }, Promise.resolve(undefined)), + ); + const history = new EditHistory(repository, reanchorBooks); + const pass = async (book: Book) => { + await reanchorBooks([book]); + history.recordPass(book); + reanchorBooks.mockClear(); + }; + const storedLink = async () => + (await new DraftRepository(storage, 'src', silentLogger()).exportAll()).draft.analysis + .tokenAnalysisLinks[0]; + return { history, 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(refOf(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(refOf(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: refOf(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([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][0].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('undoes the edit when re-anchoring what it restores fails', async () => { + const { history, reanchorBooks, pass, storedLink } = 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')); + reanchorBooks.mockRejectedValueOnce(new Error('disk full')); + + await expect(history.undo()).rejects.toThrow('disk full'); + + expect((await storedLink()).analysisId).toBe('ta-1'); + await history.redo(); + expect((await storedLink()).analysisId).toBe('ta-2'); + }); + + 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__/utils/change-set.test.ts b/src/__tests__/utils/change-set.test.ts index b0894f579..3ed1403ad 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,75 @@ 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, or the earlier one when only it has 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); + 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/services/editHistory.ts b/src/services/editHistory.ts index 04170ff71..a28bc61d3 100644 --- a/src/services/editHistory.ts +++ b/src/services/editHistory.ts @@ -1,21 +1,58 @@ +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'; +/** + * Re-anchors the stored draft to the current text of each of `books`, in turn, outside any undo + * history. + * + * @returns The changes it applied, or `undefined` when it applied none. + */ +export type ReanchorBooks = (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. What an undo or redo restores is re-anchored to the current text of + * each book a pass has re-anchored since the step was captured, since the step holds records as + * they read before that text. */ 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[] = []; + + /** + * Each book's latest text a pass has re-anchored to, while some step predates it, in the order + * the passes ran. + */ + private readonly passes = new Map(); + + /** How many passes have ever been recorded. */ + private passCount = 0; - constructor(repository: AnalysisRepository) { + /** + * @param reanchorBooks - Re-anchors what an undo or redo restores; absent, nothing restored is + * re-anchored. + */ + constructor(repository: AnalysisRepository, reanchorBooks?: ReanchorBooks) { this.repository = repository; + this.reanchorBooks = reanchorBooks; } /** @@ -25,38 +62,73 @@ 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(); } /** - * Reverts the latest edit not yet undone. + * 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. A pass over text the book's last pass read + * records nothing. + */ + 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, 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 it, with those that re-anchored it, or `undefined` when + * there is none. + * @throws Whatever applying the reverting changes throws; the edit stays the next to undo. + * @throws Whatever re-anchoring throws; the edit is undone all the same. */ 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 it, with those that re-anchored it, or `undefined` when there + * is none. + * @throws Whatever applying the redoing changes throws; the edit stays the next to redo. + * @throws Whatever re-anchoring throws; the edit is redone all the same. */ 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; - const { inverse } = await this.repository.apply(changes); + private async step(from: Step[], to: Step[]): Promise { + const step = from.at(-1); + if (!step) return undefined; + const { inverse } = await this.repository.apply(step.changes); from.pop(); - to.push(inverse); - return changes; + to.push({ changes: inverse, passesSeen: this.passCount }); + const books = [...this.passes.values()] + .filter(({ ordinal }) => ordinal > step.passesSeen) + .map(({ book }) => book); + this.forgetUnneededPasses(); + const reanchored = + books.length > 0 && this.reanchorBooks ? await this.reanchorBooks(books) : undefined; + return reanchored ? mergeChangeSets(step.changes, reanchored) : step.changes; + } + + /** Forgets each pass that every step still held 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/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 }), + }; +} From f6f9a4c399ab073729c4b8360c8b89080830333e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 15:35:55 -0400 Subject: [PATCH 04/25] Check host re-anchor parity on every WebView pass case Each case of reanchor-analysis.test.ts and reanchor-draft.test.ts now also checks that the host's change set brings the draft to what the WebView's pass gave, and that the pass over the book's analysis alone makes the same changes. Five fixtures that gave two links one id now give each its own, as the host's records need. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/host-reanchor-parity.ts | 38 ++ src/__tests__/utils/reanchor-analysis.test.ts | 30 +- src/__tests__/utils/reanchor-changes.test.ts | 343 +++--------------- src/__tests__/utils/reanchor-draft.test.ts | 43 ++- src/utils/reanchor-changes.ts | 22 +- 5 files changed, 162 insertions(+), 314 deletions(-) create mode 100644 src/__tests__/host-reanchor-parity.ts diff --git a/src/__tests__/host-reanchor-parity.ts b/src/__tests__/host-reanchor-parity.ts new file mode 100644 index 000000000..ee21bbfba --- /dev/null +++ b/src/__tests__/host-reanchor-parity.ts @@ -0,0 +1,38 @@ +/// + +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 `bookCode` gives what the WebView's `pass` made of `content`, + * `reanchored`: the changes it applies bring the draft to `reanchored`, and the pass over only the + * book's analysis, as the host reads it, makes the same changes. `pass` must stamp every run + * alike. + */ +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); + + const bookAlone: DraftContent = { + analysis: splitAnalysisByBook(content.analysis).get(bookCode) ?? emptyAnalysis(), + segmentation: content.segmentation, + }; + expect(changesOfReanchor(draftHolding(bookAlone), pass(bookAlone))).toEqual(changes); +} 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 index 4536bbbcb..a2e179490 100644 --- a/src/__tests__/utils/reanchor-changes.test.ts +++ b/src/__tests__/utils/reanchor-changes.test.ts @@ -1,287 +1,63 @@ /// -import type { - AssignmentStatus, - Book, - DraftProject, - SegmentationDelta, - TextAnalysis, -} from 'interlinearizer'; -import type { DraftContent } from '../../hooks/useDraftProject'; -import type { ChangeSet } from '../../types/analysis-repository'; +import type { Book, DraftProject, SegmentationDelta, TextAnalysis } from 'interlinearizer'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; -import { splitAnalysisByBook } from '../../utils/analysis-book'; -import { applyChangeSet } from '../../utils/change-set'; import { REANCHOR_INTENT, reanchorChangesForBook, reanchorChangesForMissingBook, } from '../../utils/reanchor-changes'; -import { reanchorDraftToBook, reanchorDraftToMissingBook } from '../../utils/reanchor-draft'; -import type { BookPass } from '../../utils/undo-history'; -import { FIXTURE_STAMPS, makePhraseLink, makeTokenLink, makeVerseBook } from '../test-helpers'; +import { FIXTURE_STAMPS, makeTokenLink, makeVerseBook } from '../test-helpers'; -beforeAll(() => { - // Both passes stamp what they rewrite with the time, so they must run at one instant to compare. - jest.useFakeTimers({ now: new Date('2026-02-01T00:00:00.000Z') }); -}); - -afterAll(() => { - jest.useRealTimers(); -}); +// The cases of `reanchor-analysis.test.ts` and `reanchor-draft.test.ts` check these changes against +// the WebView's passes; these check what the changes carry. /** 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 }))); } -/** The ref of the `occurrence`th token of `book` reading `surfaceText`. */ -function refOf(book: Book, surfaceText: string, occurrence = 0): string { - const token = book.segments - .flatMap((segment) => segment.tokens) - .filter((t) => t.surfaceText === surfaceText)[occurrence]; +/** The ref of the first token of `book` reading `surfaceText`. */ +function refOf(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; } -/** An analysis glossing each of `words` where `book` has it, each link of `status`. */ -function glossing( - book: Book, - words: string[], - status: AssignmentStatus = 'approved', -): TextAnalysis { +/** 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, - gloss: { und: `${word} gloss` }, })), tokenAnalysisLinks: words.map((word) => - makeTokenLink(`ta-${word}`, refOf(book, word), { surfaceText: word, status }), + makeTokenLink(`ta-${word}`, refOf(book, word), { surfaceText: word }), ), }; } -/** An analysis grouping `words`, in order, into one phrase where `book` has them. */ -function phrasing(book: Book, words: string[]): TextAnalysis { - return { - ...emptyAnalysis(), - phraseAnalyses: [{ ...FIXTURE_STAMPS, id: 'ph-1', surfaceText: words.join(' ') }], - phraseAnalysisLinks: [ - makePhraseLink( - 'ph-1', - words.map((word) => refOf(book, word)), - words, - ), - ], - }; -} - -/** An analysis translating the segment `segmentId`, written against `surfaceText`. */ -function translating( - segmentId: string, - surfaceText: string, - status: AssignmentStatus = 'approved', -): TextAnalysis { - return { - ...emptyAnalysis(), - segmentAnalyses: [ - { ...FIXTURE_STAMPS, id: 'sa-1', surfaceText, freeTranslation: { en: 'a translation' } }, - ], - segmentAnalysisLinks: [ - { ...FIXTURE_STAMPS, id: 'sl-1', analysisId: 'sa-1', status, segmentId }, - ], - }; -} - -/** Every record of each analysis, in order. */ -function combined(...analyses: TextAnalysis[]): TextAnalysis { - const all = emptyAnalysis(); - analyses.forEach((analysis) => { - all.tokenAnalyses.push(...analysis.tokenAnalyses); - all.tokenAnalysisLinks.push(...analysis.tokenAnalysisLinks); - all.phraseAnalyses.push(...analysis.phraseAnalyses); - all.phraseAnalysisLinks.push(...analysis.phraseAnalysisLinks); - all.segmentAnalyses.push(...analysis.segmentAnalyses); - all.segmentAnalysisLinks.push(...analysis.segmentAnalysisLinks); - }); - return all; -} - function draftOf(analysis: TextAnalysis, segmentation?: SegmentationDelta): DraftProject { return { ...emptyDraft('src'), analysis, ...(segmentation && { segmentation }) }; } -/** `draft` holding only its analysis in `bookCode`, as a lookup of the book's links reads it. */ -function bookOnly(draft: DraftProject, bookCode: string): DraftProject { +/** A split before "beta" in Genesis 1:1 reading `alpha beta`. */ +function splitBeforeBeta(): SegmentationDelta { return { - ...draft, - analysis: splitAnalysisByBook(draft.analysis).get(bookCode) ?? emptyAnalysis(), + removedVerseStarts: [], + addedStarts: [{ tokenRef: refOf(genesis('alpha beta'), 'beta'), surfaceText: 'beta' }], }; } -/** Checks that `changes` bring `draft` to what the WebView's `pass` makes of it. */ -function expectParity( - draft: DraftProject, - changes: ChangeSet | undefined, - pass: BookPass, -): DraftProject { - const expected = pass({ analysis: draft.analysis, segmentation: draft.segmentation }); - const applied = changes ? applyChangeSet(draft, changes).draft : draft; - expect(applied.analysis).toEqual(expected.analysis); - expect(applied.segmentation).toEqual(expected.segmentation); - return applied; -} - -/** The draft-level counterparts of the drift cases in `reanchor-analysis.test.ts`. */ -const DRIFT_CASES: [string, () => { draft: DraftProject; book: Book }][] = [ - [ - 'a word is inserted before a gloss', - () => ({ - draft: draftOf(glossing(genesis('it was unbelievable'), ['unbelievable'])), - book: genesis('it was and unbelievable'), - }), - ], - [ - 'a glossed word is deleted', - () => ({ - draft: draftOf(glossing(genesis('alpha beta gamma'), ['beta'])), - book: genesis('alpha gamma'), - }), - ], - [ - 'a repeated word loses an occurrence', - () => { - const before = genesis('beta alpha beta'); - const analysis = glossing(before, ['alpha']); - analysis.tokenAnalysisLinks.push( - makeTokenLink('ta-alpha', refOf(before, 'beta', 1), { surfaceText: 'beta', id: 'tl-b' }), - ); - return { draft: draftOf(analysis), book: genesis('beta alpha') }; - }, - ], - [ - "a stale gloss's word comes back at a shifted ref", - () => ({ - draft: draftOf(glossing(genesis('alpha beta'), ['beta'], 'stale')), - book: genesis('new alpha beta'), - }), - ], - [ - 'a rejected gloss shifts with its word', - () => ({ - draft: draftOf(glossing(genesis('alpha beta'), ['beta'], 'rejected')), - book: genesis('new alpha beta'), - }), - ], - [ - 'a phrase shifts with a word inserted before it', - () => ({ - draft: draftOf(phrasing(genesis('alpha beta gamma'), ['beta', 'gamma'])), - book: genesis('new alpha beta gamma'), - }), - ], - [ - 'a phrase loses a word', - () => ({ - draft: draftOf(phrasing(genesis('alpha beta gamma'), ['beta', 'gamma'])), - book: genesis('alpha gamma'), - }), - ], - [ - "a free translation's segment gains a word", - () => ({ - draft: draftOf(translating('GEN 1:1', 'alpha beta')), - book: genesis('alpha beta gamma'), - }), - ], - [ - "a stale free translation's segment reads as it did again", - () => ({ - draft: draftOf(translating('GEN 1:1', 'alpha beta', 'stale')), - book: genesis('alpha beta'), - }), - ], - [ - 'a split and its piece translation shift with a word inserted before them', - () => { - const before = genesis('alpha beta'); - const split = refOf(before, 'beta'); - return { - draft: draftOf(translating(split, 'beta'), { - removedVerseStarts: [], - addedStarts: [{ tokenRef: split, surfaceText: 'beta' }], - }), - book: genesis('alpha and beta'), - }; - }, - ], - [ - 'a verse holding a gloss is removed', - () => ({ - draft: draftOf(glossing(genesis('alpha', 'beta'), ['beta'])), - book: genesis('alpha'), - }), - ], - [ - 'a gloss shifts beside glosses in another book', - () => { - const exodus = makeVerseBook([{ sid: 'EXO 1:1', text: 'delta epsilon' }]); - return { - draft: draftOf( - combined( - glossing(exodus, ['epsilon']), - glossing(genesis('alpha beta'), ['beta']), - translating('EXO 1:1', 'delta epsilon'), - ), - ), - book: genesis('new alpha beta'), - }; - }, - ], -]; - describe('reanchorChangesForBook', () => { - it.each(DRIFT_CASES)('matches the WebView pass when %s', (_, make) => { - const { draft, book } = make(); - - const changes = reanchorChangesForBook(draft, book); - - expect(changes).toBeDefined(); - expectParity(draft, changes, reanchorDraftToBook(book)); - }); - - it.each(DRIFT_CASES)('makes the same changes from the book alone when %s', (_, make) => { - const { draft, book } = make(); - - expect(reanchorChangesForBook(bookOnly(draft, book.bookRef), book)).toEqual( - reanchorChangesForBook(draft, book), - ); - }); - - it.each(DRIFT_CASES)('changes nothing more once applied when %s', (_, make) => { - const { draft, book } = make(); - const changes = reanchorChangesForBook(draft, book); - if (!changes) throw new Error('expected changes'); - - expect(reanchorChangesForBook(applyChangeSet(draft, changes).draft, book)).toBeUndefined(); - }); - - it('names the re-anchor as its intent', () => { - const [[, make]] = DRIFT_CASES; - const { draft, book } = make(); - - expect(reanchorChangesForBook(draft, book)?.intent).toBe(REANCHOR_INTENT); - }); - - it('puts only the links the pass rewrote', () => { - const before = genesis('alpha beta', 'gamma'); - const draft = draftOf(glossing(before, ['beta', 'gamma'])); + 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', ]); @@ -290,16 +66,25 @@ describe('reanchorChangesForBook', () => { expect(changes?.scope).toBeUndefined(); }); - it("carries the draft's record with the split it moved", () => { - const splitCase = DRIFT_CASES.find(([name]) => name.startsWith('a split')); - if (!splitCase) throw new Error('expected a split case'); - const { draft, book } = splitCase[1](); - const named = { ...draft, analysisLanguages: ['fr'], suggestedName: 'Named' }; + 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(named, book)?.scope; + const scope = reanchorChangesForBook(draft, genesis('alpha and beta'))?.scope; expect(scope).toEqual( - expect.objectContaining({ analysisLanguages: ['fr'], suggestedName: 'Named' }), + 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'); @@ -307,61 +92,27 @@ describe('reanchorChangesForBook', () => { it('changes nothing when the book reads as the draft was written against', () => { const book = genesis('alpha beta'); - const draft = draftOf(combined(glossing(book, ['beta']), translating('GEN 1:1', 'alpha')), { - removedVerseStarts: [], - addedStarts: [{ tokenRef: refOf(book, 'beta'), surfaceText: 'beta' }], - }); - expect(reanchorChangesForBook(draft, book)).toBeUndefined(); + expect( + reanchorChangesForBook(draftOf(glossing(book, ['beta']), splitBeforeBeta()), book), + ).toBeUndefined(); }); }); -/** The draft-level counterparts of the missing-book cases in `reanchor-draft.test.ts`. */ -const MISSING_BOOK_CASES: [string, () => DraftProject][] = [ - ['an approval is in the book', () => draftOf(glossing(genesis('alpha beta'), ['beta']))], - ['a free translation is in the book', () => draftOf(translating('GEN 1:1', 'alpha beta'))], - [ - 'a phrase is in the book beside an approval in another', - () => - draftOf( - combined( - phrasing(genesis('alpha beta'), ['alpha', 'beta']), - glossing(makeVerseBook([{ sid: 'EXO 1:1', text: 'delta' }]), ['delta']), - ), - ), - ], -]; - describe('reanchorChangesForMissingBook', () => { - it.each(MISSING_BOOK_CASES)('matches the WebView pass when %s', (_, make) => { - const draft = make(); - - const changes = reanchorChangesForMissingBook(draft, 'GEN'); - - expect(changes).toBeDefined(); - const applied = expectParity(draft, changes, reanchorDraftToMissingBook('GEN')); - expect( - [ - ...applied.analysis.tokenAnalysisLinks, - ...applied.analysis.phraseAnalysisLinks, - ...applied.analysis.segmentAnalysisLinks, - ] - .filter((link) => link.status === 'stale') - .map(({ id }) => id), - ).not.toEqual([]); - }); - - it.each(MISSING_BOOK_CASES)('makes the same changes from the book alone when %s', (_, make) => { - const draft = make(); - - expect(reanchorChangesForMissingBook(bookOnly(draft, 'GEN'), 'GEN')).toEqual( - reanchorChangesForMissingBook(draft, 'GEN'), + it('puts the links it stales, named as a re-anchor', () => { + const changes = reanchorChangesForMissingBook( + draftOf(glossing(genesis('alpha beta'), ['beta'])), + 'GEN', ); - }); - it("leaves another book's approvals alone", () => { - const draft = draftOf(glossing(genesis('alpha beta'), ['beta'])); + expect(changes?.intent).toBe(REANCHOR_INTENT); + expect(changes?.tokenAnalysisLinks?.puts.map(({ status }) => status)).toEqual(['stale']); + }); - expect(reanchorChangesForMissingBook(draft, 'EXO')).toBeUndefined(); + it("changes nothing for another book's links", () => { + expect( + reanchorChangesForMissingBook(draftOf(glossing(genesis('alpha beta'), ['beta'])), '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/utils/reanchor-changes.ts b/src/utils/reanchor-changes.ts index f4e36c136..eea610e45 100644 --- a/src/utils/reanchor-changes.ts +++ b/src/utils/reanchor-changes.ts @@ -21,12 +21,15 @@ function rewritten( return puts.length > 0 ? { puts, deletes: [] } : undefined; } -/** The changes that bring `draft` to what `pass` makes of it, `undefined` when it makes nothing new. */ -function changesOfPass(draft: DraftProject, pass: BookPass): ChangeSet | undefined { - const { analysis, segmentation } = pass({ - analysis: draft.analysis, - segmentation: draft.segmentation, - }); +/** + * The changes that bring `draft` to `reanchored`, the content 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, @@ -52,6 +55,13 @@ function changesOfPass(draft: DraftProject, pass: BookPass): Chang }; } +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, as {@link reanchorDraftToBook} does. * The draft may hold only the analysis in `book`, with the payloads its links reference, and the From b540b271e14ef13b0a192e1d156c7b70b6e9b957 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 15:58:05 -0400 Subject: [PATCH 05/25] Compare the host's re-anchor with the WebView's in the bench perf:bench now re-anchors every book of each tier both ways, the host's from the book's analysis alone, and reports books changed by each and books where they disagree, ignoring the time each pass stamps. On every tier they agree. The tiers' cross-verse phrases stale on first arrival: 1 book changes on small and medium, 67 on large-loadable, 72 on large. Part of #449. Co-Authored-By: Claude Opus 5.5 --- perf/bench/cli.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/perf/bench/cli.ts b/perf/bench/cli.ts index ff22d26a8..5f3e0ce6b 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,60 @@ function benchTier(tier: TierSpec, allBooks: () => Book[]): Record ({ + ...draft, + analysis: byBook.get(bookCode) ?? emptyAnalysis(), + }); + 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 +255,12 @@ function benchTier(tier: TierSpec, allBooks: () => Book[]): Record Date: Fri, 23 Oct 2026 16:17:52 -0400 Subject: [PATCH 06/25] Apply an undo or redo and its re-anchor as one The history now asks for the re-anchor of what a step would restore before applying anything, and applies the step merged with it in one apply, so a failure applies nothing and leaves the step where it was, and the inverse a redo replays covers the re-anchor's changes too. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/editHistory.test.ts | 81 ++++++++++++++++------ src/services/editHistory.ts | 43 +++++++----- 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/src/__tests__/services/editHistory.test.ts b/src/__tests__/services/editHistory.test.ts index bc63fb41d..654ebd80f 100644 --- a/src/__tests__/services/editHistory.test.ts +++ b/src/__tests__/services/editHistory.test.ts @@ -4,7 +4,7 @@ import type { Book, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; import { DraftRepository } from '../../services/draftRepository'; import { EditHistory, type ReanchorBooks } from '../../services/editHistory'; import type { ChangeSet } from '../../types/analysis-repository'; -import { mergeChangeSets } from '../../utils/change-set'; +import { applyChangeSet, mergeChangeSets } from '../../utils/change-set'; import { reanchorChangesForBook } from '../../utils/reanchor-changes'; import { MAX_UNDO_STEPS } from '../../utils/undo-history'; import { @@ -177,25 +177,29 @@ describe('EditHistory re-anchoring', () => { function makeReanchoringHistory() { const { storage } = memoryStorage(); const repository = new DraftRepository(storage, 'src', silentLogger()); - const reanchorBooks = jest.fn, Parameters>((books) => - books.reduce>(async (previous, book) => { - const applied = await previous; - const changes = reanchorChangesForBook((await repository.exportAll()).draft, book); - if (!changes) return applied; - await repository.apply(changes); - return applied ? mergeChangeSets(applied, changes) : changes; - }, Promise.resolve(undefined)), + 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) => { - await reanchorBooks([book]); + const changes = reanchorChangesForBook((await repository.exportAll()).draft, book); + if (changes) await repository.apply(changes); history.recordPass(book); - reanchorBooks.mockClear(); }; const storedLink = async () => (await new DraftRepository(storage, 'src', silentLogger()).exportAll()).draft.analysis .tokenAnalysisLinks[0]; - return { history, reanchorBooks, pass, storedLink }; + return { history, repository, reanchorBooks, pass, storedLink }; } it('undoes an edit to the text a pass re-anchored to since', async () => { @@ -282,7 +286,7 @@ describe('EditHistory re-anchoring', () => { await history.undo(); - expect(reanchorBooks).toHaveBeenCalledWith([exodus, latest]); + expect(reanchorBooks).toHaveBeenCalledWith(expect.anything(), [exodus, latest]); }); it('records nothing for a pass over text the book last read', async () => { @@ -295,7 +299,7 @@ describe('EditHistory re-anchoring', () => { await history.undo(); - expect(reanchorBooks.mock.calls[0][0].map(({ bookRef }) => bookRef)).toEqual(['GEN', 'EXO']); + 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 () => { @@ -311,19 +315,56 @@ describe('EditHistory re-anchoring', () => { expect(reanchorBooks).not.toHaveBeenCalled(); }); - it('undoes the edit when re-anchoring what it restores fails', async () => { + 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(genesis('new alpha beta')); - reanchorBooks.mockRejectedValueOnce(new Error('disk full')); + await pass(after); + reanchorBooks.mockRejectedValueOnce(new Error('busy')); - await expect(history.undo()).rejects.toThrow('disk full'); + await expect(history.undo()).rejects.toThrow('busy'); - expect((await storedLink()).analysisId).toBe('ta-1'); - await history.redo(); expect((await storedLink()).analysisId).toBe('ta-2'); + await history.undo(); + expect(await storedLink()).toEqual( + expect.objectContaining({ + analysisId: 'ta-1', + token: { tokenRef: refOf(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 () => { diff --git a/src/services/editHistory.ts b/src/services/editHistory.ts index a28bc61d3..05edb04ac 100644 --- a/src/services/editHistory.ts +++ b/src/services/editHistory.ts @@ -4,12 +4,15 @@ import { mergeChangeSets } from '../utils/change-set'; import { MAX_UNDO_STEPS } from '../utils/undo-history'; /** - * Re-anchors the stored draft to the current text of each of `books`, in turn, outside any 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 it applied, or `undefined` when it applied none. + * @returns The changes, or `undefined` when there are none. */ -export type ReanchorBooks = (books: readonly Book[]) => Promise; +export type ReanchorBooks = ( + changes: ChangeSet, + books: readonly Book[], +) => Promise; /** What reverts or redoes one edit. */ type Step = { @@ -47,8 +50,8 @@ export class EditHistory { private passCount = 0; /** - * @param reanchorBooks - Re-anchors what an undo or redo restores; absent, nothing restored is - * re-anchored. + * @param reanchorBooks - Re-anchors what an undo or redo would restore; absent, nothing restored + * is re-anchored. */ constructor(repository: AnalysisRepository, reanchorBooks?: ReanchorBooks) { this.repository = repository; @@ -87,8 +90,8 @@ export class EditHistory { * * @returns The changes that reverted it, with those that re-anchored it, or `undefined` when * there is none. - * @throws Whatever applying the reverting changes throws; the edit stays the next to undo. - * @throws Whatever re-anchoring throws; the edit is undone all the same. + * @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); @@ -99,27 +102,33 @@ export class EditHistory { * * @returns The changes that redid it, with those that re-anchored it, or `undefined` when there * is none. - * @throws Whatever applying the redoing changes throws; the edit stays the next to redo. - * @throws Whatever re-anchoring throws; the edit is redone all the same. + * @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`. */ + /** + * Applies the latest changes on `from`, re-anchored to each book passed over 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 { inverse } = await this.repository.apply(step.changes); - from.pop(); - to.push({ changes: inverse, passesSeen: this.passCount }); const books = [...this.passes.values()] .filter(({ ordinal }) => ordinal > step.passesSeen) .map(({ book }) => book); - this.forgetUnneededPasses(); const reanchored = - books.length > 0 && this.reanchorBooks ? await this.reanchorBooks(books) : undefined; - return reanchored ? mergeChangeSets(step.changes, reanchored) : step.changes; + 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({ changes: inverse, passesSeen: this.passCount }); + this.forgetUnneededPasses(); + return changes; } /** Forgets each pass that every step still held already reflects. */ From 0831194cfe1f5a7417691ed391a1a77858887fed Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 16:20:49 -0400 Subject: [PATCH 07/25] Pass the open book's edited text on before reporting the edit While the whole-text read has yet to reach the open book, its edit was reported at once, so a pass could not run before the view's refetch. The edit now waits for the book's new text, hands it to onBookRead's listeners, then is reported; the view's refetch waited on that tokenizing already. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/bookService.test.ts | 62 ++++++++++++++++++++++ src/services/bookService.ts | 23 ++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index 5d893e2dc..7737d2ecf 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -758,6 +758,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'); @@ -769,6 +770,7 @@ describe('onBookTextChanged', () => { await getBookOutline('src', 'GEN'); project.subscriptions[0].deliver(usj('GEN', 'v2')); + await settle(); listeners.forEach((listener) => expect(listener).toHaveBeenCalledTimes(1)); }); @@ -822,6 +824,7 @@ describe('onBookTextChanged', () => { await settle(); subscriptionsTo(project, 'GEN')[1].deliver(usj('GEN', 'v3')); + await settle(); expect(listener).toHaveBeenCalledTimes(2); }); @@ -857,6 +860,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'); @@ -909,6 +913,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'); @@ -927,6 +932,7 @@ describe('onBookTextChanged', () => { await earlier; subscriptionsTo(project, 'EXO')[0].deliver(usj('EXO', 'v2')); + await settle(); expect(listener).toHaveBeenCalledWith('src', 'EXO'); }); @@ -1243,6 +1249,10 @@ describe('onBookRead', () => { return listener; } + beforeEach(() => { + segmentStandIns(); + }); + afterEach(() => { unsubscribers.splice(0).forEach((unsubscribe) => unsubscribe()); }); @@ -1295,6 +1305,58 @@ describe('onBookRead', () => { 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); diff --git a/src/services/bookService.ts b/src/services/bookService.ts index c0f681249..bc48c4c74 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -177,12 +177,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) => @@ -197,6 +198,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 @@ -300,8 +314,9 @@ export function onBookTextChanged(listener: BookTextListener): () => boolean { /** * Calls `listener` with each book that a source project's whole-text read files with text, until * the returned function is called: as the read first reaches the book, and again each time the - * book's text changes. For a change, `listener` is called before {@link onBookTextChanged}'s - * listeners hear of it. A book whose read fails, or that has no text, is not passed. + * book's text changes. The open book's edited text is passed too while the read has yet to reach + * the book. For a change, `listener` is called before {@link onBookTextChanged}'s listeners hear of + * it. A book whose read fails, or that has no text, is not passed. */ export function onBookRead(listener: BookReadListener): () => boolean { bookReadListeners.add(listener); From bb59476e4d39725a4006d3eb43b27fe36c2e51f3 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 16:56:14 -0400 Subject: [PATCH 08/25] Let a session start and stop the whole-text read startTextRead reads the text afresh, ending any read under way, so a session opened after an earlier read still hears of every book; it doesn't wait on the book list, leaving a failure for the status to report. stopTextRead ends the read and leaves the view's books kept. getTextBookIds goes, since a fresh read reports to onTextRead. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/bookService.test.ts | 101 ++++++++++++++++++--- src/services/bookService.ts | 44 +++++---- 2 files changed, 118 insertions(+), 27 deletions(-) diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index 7737d2ecf..c9f82bd62 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -11,7 +11,6 @@ import { getBookOutline, getBookSegments, getSegments, - getTextBookIds, getTextReadStatus, getTokenizedBook, getVerseBook, @@ -20,6 +19,8 @@ import { onBookRead, onBookTextChanged, onTextRead, + startTextRead, + stopTextRead, } from '../../services/bookService'; import type { BookOutline, SegmentPage } from '../../types/segment-pages'; import type { TextReadStatus } from '../../types/text-read-status'; @@ -1402,7 +1403,7 @@ describe('onBookRead', () => { }); }); -describe('onTextRead and getTextBookIds', () => { +describe('onTextRead', () => { const unsubscribers: Array<() => boolean> = []; /** A listener for the books holding text, registered for the test and removed once it ends. */ @@ -1427,10 +1428,9 @@ describe('onTextRead and getTextBookIds', () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener.mock.calls[0][0]).toBe('src'); expect([...listener.mock.calls[0][1]].sort()).toEqual(['GEN', 'LEV']); - expect(getTextBookIds('src')?.sort()).toEqual(['GEN', 'LEV']); }); - it('knows no books holding text while some are still being read', async () => { + it('reports nothing while some books are still being read', async () => { const project = serveProject(); project.booksPresent = '11'; const listener = listenForText(); @@ -1441,14 +1441,9 @@ describe('onTextRead and getTextBookIds', () => { await settle(); expect(listener).not.toHaveBeenCalled(); - expect(getTextBookIds('src')).toBeUndefined(); await finishReading(project); }); - it('knows no books holding text for a project whose text is not being read', () => { - expect(getTextBookIds('src')).toBeUndefined(); - }); - 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))); @@ -1458,14 +1453,12 @@ describe('onTextRead and getTextBookIds', () => { await settle(); expect(listener).not.toHaveBeenCalled(); - expect(getTextBookIds('src')).toBeUndefined(); delivery = usj('GEN'); subscriptionsTo(project, 'GEN').at(-1)?.deliver(delivery); await settle(); expect(listener).toHaveBeenCalledTimes(1); - expect(getTextBookIds('src')?.sort()).toEqual(['EXO', 'GEN']); }); it('reports again when an edit takes away the text of a book', async () => { @@ -1511,6 +1504,92 @@ describe('onTextRead and getTextBookIds', () => { }); }); +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(); + }); +}); + describe('getTextReadStatus', () => { it('answers once the books are known, before any is read', async () => { const project = serveProject(); diff --git a/src/services/bookService.ts b/src/services/bookService.ts index bc48c4c74..8d532c5a3 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -333,17 +333,6 @@ export function onTextRead(listener: TextReadListener): () => boolean { return () => textReadListeners.delete(listener); } -/** - * The books of the source project's text that hold text, as {@link onTextRead} last reported them. - * - * @returns `undefined` until the project's whole-text read has read every book, and while any - * book's latest read has failed. - */ -export function getTextBookIds(sourceProjectId: string): string[] | undefined { - const read = textReads.get(sourceProjectId); - return read && textBookIdsOf(read); -} - /** * 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 @@ -576,7 +565,7 @@ async function readText(sourceProjectId: string, read: TextRead): Promise } /** 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), @@ -605,7 +594,7 @@ function startTextRead(sourceProjectId: string): TextRead { * 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 = { @@ -627,14 +616,37 @@ export function isFormInText(sourceProjectId: string, form: string): boolean | u return read.forms.has(normalizeSurfaceForm(form)); } +/** + * Starts reading the source project's whole text afresh, ending any read already under way, so that + * {@link onBookRead}'s listeners hear of every book again, and {@link onTextRead}'s once the read is + * done. A failure to list the books is left for {@link getTextReadStatus} to report. + * + * @returns Once the read it replaced has stopped watching its books; the new read goes on. + */ +export function startTextRead(sourceProjectId: string): Promise { + 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 { + 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([...(read?.watches.values() ?? [])].map((stop) => stop())); +} + /** 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)]); - 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()), + endTextRead(sourceProjectId), ]); } From 4d2ca43a9b75987a3dbe88c17994a1f4e0cd528c Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 23 Oct 2026 17:21:23 -0400 Subject: [PATCH 09/25] Re-anchor each book the session's read delivers Opening a session starts its whole-text read and closing it ends it. Each book the read delivers, and each edited book, re-anchors in the session's turn through the repository's lookups, as no undo step but recorded for undo and redo; books the text lacks stale once the read is done. onAnalysisChanged for an edit waits for the session's turn, so a refetch sees re-anchored links. A read-only session isn't re-anchored. Closes #449. Co-Authored-By: Claude Opus 5.5 --- perf/bench/cli.ts | 20 +- src/__tests__/host-reanchor-parity.ts | 13 +- src/__tests__/main.test.ts | 105 ++++++++- .../services/analysisSessions.parity.test.ts | 2 + .../services/analysisSessions.test.ts | 219 +++++++++++++++++- src/__tests__/services/reanchorPasses.test.ts | 185 +++++++++++++++ src/main.ts | 37 ++- src/services/analysisSessions.ts | 82 ++++++- src/services/reanchorPasses.ts | 77 ++++++ src/utils/reanchor-changes.ts | 4 +- src/utils/reanchor-draft.ts | 13 +- 11 files changed, 725 insertions(+), 32 deletions(-) create mode 100644 src/__tests__/services/reanchorPasses.test.ts create mode 100644 src/services/reanchorPasses.ts diff --git a/perf/bench/cli.ts b/perf/bench/cli.ts index 5f3e0ce6b..bdebcfb58 100644 --- a/perf/bench/cli.ts +++ b/perf/bench/cli.ts @@ -179,14 +179,24 @@ function benchTier(tier: TierSpec, allBooks: () => Book[]): Record ({ - ...draft, - analysis: byBook.get(bookCode) ?? emptyAnalysis(), - }); + // As the host reads a book: its links, and the segment analyses they reference. + const bookAlone = (bookCode: string): DraftProject => { + 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] => [ diff --git a/src/__tests__/host-reanchor-parity.ts b/src/__tests__/host-reanchor-parity.ts index ee21bbfba..e08546f89 100644 --- a/src/__tests__/host-reanchor-parity.ts +++ b/src/__tests__/host-reanchor-parity.ts @@ -15,8 +15,7 @@ function draftHolding({ analysis, segmentation }: DraftContent): DraftProject { /** * Checks that the host's re-anchor of `bookCode` gives what the WebView's `pass` made of `content`, * `reanchored`: the changes it applies bring the draft to `reanchored`, and the pass over only the - * book's analysis, as the host reads it, makes the same changes. `pass` must stamp every run - * alike. + * book's links, as the host reads them, makes the same changes. `pass` must stamp every run alike. */ export function expectHostReanchorParity( content: DraftContent, @@ -30,8 +29,16 @@ export function expectHostReanchorParity( 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: splitAnalysisByBook(content.analysis).get(bookCode) ?? emptyAnalysis(), + 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..b95f9eb64 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -296,6 +296,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 +321,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 +337,113 @@ 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 = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [], + duplicateVerseIds: [], + }; + + 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), + ); }); }); 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..7237201bc 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,14 @@ 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, +} from '../test-helpers'; const SOURCE = 'src-project'; const OTHER_SOURCE = 'other-project'; @@ -67,6 +74,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 +613,212 @@ 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 }; + } + + function refOf(book: Book, surfaceText: string): string { + const token = book.segments[0].tokens.find((t) => t.surfaceText === surfaceText); + if (!token) throw new Error(`fixture missing token ${surfaceText}`); + return token.ref; + } + + /** A link to the payload `analysisId` on "beta" where `book` has it. */ + function betaLink(book: Book, analysisId: string) { + return makeTokenLink(analysisId, refOf(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(refOf(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(); + }); + + 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('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(refOf(BEFORE, 'beta')); + command.resolve(); + await Promise.all([running, reanchoring]); + + expect((await storedLink(storage)).token.tokenRef).toBe(refOf(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/reanchorPasses.test.ts b/src/__tests__/services/reanchorPasses.test.ts new file mode 100644 index 000000000..8681c9419 --- /dev/null +++ b/src/__tests__/services/reanchorPasses.test.ts @@ -0,0 +1,185 @@ +/// + +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, +} from '../test-helpers'; + +const SOURCE = 'src'; + +/** A book of one verse reading `text`. */ +function verseBook(sid: string, text: string): Book { + return makeVerseBook([{ sid, text }]); +} + +/** The ref of the token reading `surfaceText` in `book`. */ +function refOf(book: Book, surfaceText: string): string { + const token = book.segments[0].tokens.find((t) => t.surfaceText === surfaceText); + if (!token) throw new Error(`fixture missing token ${surfaceText}`); + return token.ref; +} + +/** 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}`, refOf(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(refOf(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('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.toBeUndefined(); + + 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: refOf(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: refOf(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'); + }); +}); + +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', refOf(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: refOf(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('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/main.ts b/src/main.ts index 3200325c7..a990ecdf2 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 @@ -2634,12 +2636,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 +2777,8 @@ export async function activate(context: ExecutionActivationContext): Promise; + /** + * Starts reading the source project's whole text afresh, without waiting on it, so that every + * book reaches {@link AnalysisSessions.reanchorBook} and the books holding text reach + * {@link AnalysisSessions.reanchorMissingBooks}. + */ + startTextRead(sourceProjectId: string): Promise; + /** Ends the source project's whole-text read. */ + stopTextRead(sourceProjectId: string): Promise; +} + +/** Re-anchors the session's draft to `book`, recording the pass in its history. */ +async function reanchorNow(session: Session, book: Book): Promise { + await reanchorStoredBook(session.repository, book); + session.history.recordPass(book); } type OpenSession = { @@ -100,7 +118,8 @@ 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. + * holds, with nothing to undo, and starts reading the source's whole text afresh, which the + * session's close ends. * * 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, @@ -262,6 +282,57 @@ export class AnalysisSessions { 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, so that 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 every + * book it links into that `textBookIds` leaves out, which the text lacks. + * + * @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 +358,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 +368,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/reanchorPasses.ts b/src/services/reanchorPasses.ts new file mode 100644 index 000000000..398ad6ca3 --- /dev/null +++ b/src/services/reanchorPasses.ts @@ -0,0 +1,77 @@ +import type { Book, DraftProject } 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 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 those links reference. Reads one call at a time, since calls for + * one draft must not overlap. + */ +async function readBooks( + repository: AnalysisRepository, + bookCodes: string[], +): Promise { + const scope = await repository.getScope(); + const links = await repository.findLinksInBooks(bookCodes); + const segmentAnalyses = await repository.getSegmentAnalyses([ + ...new Set(links.segmentAnalysisLinks.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; +} + +/** + * Re-anchors the stored draft to `book`'s current text, outside any undo history. A book the text + * lacks is passed as a book with no segments. + * + * @returns The changes applied, or `undefined` when the draft already read as re-anchored. + * @throws Whatever reading the draft or applying the changes throws; nothing is applied. + */ +export async function reanchorStoredBook( + repository: AnalysisRepository, + book: Book, +): Promise { + const changes = reanchorInTurn(await readBooks(repository, [book.bookRef]), [book]); + if (changes) await repository.apply(changes); + return changes; +} + +/** Re-anchors what an undo or redo would restore in `repository`, for an {@link EditHistory}. */ +export function reanchorRestored(repository: AnalysisRepository): ReanchorBooks { + return async (changes, books) => { + const stored = await readBooks( + repository, + books.map(({ bookRef }) => bookRef), + ); + 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/utils/reanchor-changes.ts b/src/utils/reanchor-changes.ts index eea610e45..b6e7d729a 100644 --- a/src/utils/reanchor-changes.ts +++ b/src/utils/reanchor-changes.ts @@ -64,7 +64,7 @@ function changesOfPass(draft: DraftProject, pass: BookPass): Chang /** * The changes that re-anchor `draft` to `book`'s current text, as {@link reanchorDraftToBook} does. - * The draft may hold only the analysis in `book`, with the payloads its links reference, and the + * 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. @@ -75,7 +75,7 @@ export function reanchorChangesForBook(draft: DraftProject, book: Book): ChangeS /** * The changes that re-anchor `draft` to a book the text lacks, as {@link reanchorDraftToMissingBook} - * does. The draft may hold only the analysis in the book, as for {@link reanchorChangesForBook}. + * does. The draft may hold only the links in the book, as for {@link reanchorChangesForBook}. * * @returns The changes, or `undefined` when the draft already reads as re-anchored. */ 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)); } From 664b267072b283747a878b8c0d640af56634fcc2 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 26 Oct 2026 14:14:17 -0400 Subject: [PATCH 10/25] Compare a restored translation with its payload An undo or redo can restore a segment link whose payload no stored link names, as when an edit deleted only the link. The pass over what the step restores read only the payloads stored links name, so it found no baseline for such a link and kept its translation approved over changed text. It now also reads the payloads the step's segment links name. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/reanchorPasses.test.ts | 40 +++++++++++++++++++ src/services/reanchorPasses.ts | 13 ++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/__tests__/services/reanchorPasses.test.ts b/src/__tests__/services/reanchorPasses.test.ts index 8681c9419..ad9b46e5d 100644 --- a/src/__tests__/services/reanchorPasses.test.ts +++ b/src/__tests__/services/reanchorPasses.test.ts @@ -167,6 +167,46 @@ describe('reanchorRestored', () => { ]); }); + 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(); diff --git a/src/services/reanchorPasses.ts b/src/services/reanchorPasses.ts index 398ad6ca3..678359c67 100644 --- a/src/services/reanchorPasses.ts +++ b/src/services/reanchorPasses.ts @@ -1,4 +1,4 @@ -import type { Book, DraftProject } from 'interlinearizer'; +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'; @@ -8,17 +8,20 @@ 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 those links reference. Reads one call at a time, since calls for - * one draft must not overlap. + * books, and the segment analyses those links reference, along with those `alsoLinked` names. Reads + * one call at a time, since calls for one draft must not overlap. + * + * @param alsoLinked - Segment links not yet stored, whose payloads the pass will compare against. */ 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.map(({ analysisId }) => analysisId)), + ...new Set([...links.segmentAnalysisLinks, ...alsoLinked].map(({ analysisId }) => analysisId)), ]); return { ...scope, dirty: false, analysis: { ...emptyAnalysis(), ...links, segmentAnalyses } }; } @@ -55,9 +58,11 @@ export async function reanchorStoredBook( /** Re-anchors what an undo or redo would restore in `repository`, for an {@link EditHistory}. */ 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); }; From c4d375bcf5f1f954694202d485015b97d06b0120 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 26 Oct 2026 14:14:17 -0400 Subject: [PATCH 11/25] Leave the unsaved mark as it was after a re-anchor pass A pass is bookkeeping rather than an edit, as the WebView's reanchorBook treats it, but applying its changes marked the draft unsaved, so opening a session on a saved draft could leave it unsaved. apply now takes keepDirty, which the stored-book pass passes; edits, undo and redo still mark the draft unsaved. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/analysis-repository-conformance.ts | 16 ++++++++++++++++ src/__tests__/services/reanchorPasses.test.ts | 11 +++++++++++ src/services/draftRepository.ts | 7 +++++-- src/services/projectStorage.ts | 2 +- src/services/reanchorPasses.ts | 7 ++++--- src/types/analysis-repository.ts | 16 +++++++++++++--- 6 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/__tests__/analysis-repository-conformance.ts b/src/__tests__/analysis-repository-conformance.ts index a2c6642a5..3693121df 100644 --- a/src/__tests__/analysis-repository-conformance.ts +++ b/src/__tests__/analysis-repository-conformance.ts @@ -1153,6 +1153,22 @@ export function describeAnalysisRepositoryConformance( expect(dirty).toBe(true); }); + it.each([false, true])( + 'leaves a draft whose unsaved mark is %s as it was when asked to keep the mark', + async (dirty) => { + 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('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__/services/reanchorPasses.test.ts b/src/__tests__/services/reanchorPasses.test.ts index ad9b46e5d..149c1c892 100644 --- a/src/__tests__/services/reanchorPasses.test.ts +++ b/src/__tests__/services/reanchorPasses.test.ts @@ -82,6 +82,17 @@ describe('reanchorStoredBook', () => { 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'); diff --git a/src/services/draftRepository.ts b/src/services/draftRepository.ts index 080ca2ffd..afeecbcd8 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, @@ -263,10 +264,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/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 index 678359c67..0abf761da 100644 --- a/src/services/reanchorPasses.ts +++ b/src/services/reanchorPasses.ts @@ -40,8 +40,9 @@ function reanchorInTurn(draft: DraftProject, books: readonly Book[]): ChangeSet } /** - * Re-anchors the stored draft to `book`'s current text, outside any undo history. A book the text - * lacks is passed as a book with no segments. + * Re-anchors the stored draft to `book`'s current text, outside any undo history and leaving the + * draft as saved or unsaved as it was, since a pass is bookkeeping rather than an edit. A book the + * text lacks is passed as a book with no segments. * * @returns The changes applied, or `undefined` when the draft already read as re-anchored. * @throws Whatever reading the draft or applying the changes throws; nothing is applied. @@ -51,7 +52,7 @@ export async function reanchorStoredBook( book: Book, ): Promise { const changes = reanchorInTurn(await readBooks(repository, [book.bookRef]), [book]); - if (changes) await repository.apply(changes); + if (changes) await repository.apply(changes, { keepDirty: true }); return changes; } 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. From bd8e259902c3ed1e25c607d3e2ee7f4bde15bbe6 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 26 Oct 2026 14:42:25 -0400 Subject: [PATCH 12/25] Clear the kept draft's unsaved mark when marking it clean markClean cleared the stored mark but left the draft the repository keeps marked unsaved, and an apply that keeps the mark takes it from that draft, so a re-anchor pass after a project save wrote the stale mark back. Part of #449. Co-Authored-By: Claude Opus 5.5 --- .../analysis-repository-conformance.ts | 12 ++++++++++++ src/services/draftRepository.ts | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/__tests__/analysis-repository-conformance.ts b/src/__tests__/analysis-repository-conformance.ts index 3693121df..bd2ffdade 100644 --- a/src/__tests__/analysis-repository-conformance.ts +++ b/src/__tests__/analysis-repository-conformance.ts @@ -1169,6 +1169,18 @@ export function describeAnalysisRepositoryConformance( }, ); + 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/services/draftRepository.ts b/src/services/draftRepository.ts index afeecbcd8..b38d8ccd2 100644 --- a/src/services/draftRepository.ts +++ b/src/services/draftRepository.ts @@ -188,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 @@ -206,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 } }; + } } /** From b72aed6b69e1e3635fd04091bff70849d70d4373 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 26 Oct 2026 14:42:25 -0400 Subject: [PATCH 13/25] Fold readBooks' parameter note into its summary Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/services/reanchorPasses.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/services/reanchorPasses.ts b/src/services/reanchorPasses.ts index 0abf761da..d1414114b 100644 --- a/src/services/reanchorPasses.ts +++ b/src/services/reanchorPasses.ts @@ -8,10 +8,8 @@ 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 those links reference, along with those `alsoLinked` names. Reads - * one call at a time, since calls for one draft must not overlap. - * - * @param alsoLinked - Segment links not yet stored, whose payloads the pass will compare against. + * 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, since calls for one draft must not overlap. */ async function readBooks( repository: AnalysisRepository, From eced113b2c08dc038477310ed2360430b4c32d19 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 26 Oct 2026 16:20:41 -0400 Subject: [PATCH 14/25] Tidy the re-anchor tests and docs from review The tests share tokenRefOf and GEN_1_1_BOOK from the test helpers. reanchorChangesForMissingBook goes, since nothing outside its tests called it; its cases pass missingBook to reanchorChangesForBook. The docs the review flagged are reworded to parse on a first read, document every constructor parameter, and state guarantees without naming callees or consumers. Part of #449. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/main.test.ts | 9 +---- .../services/analysisSessions.test.ts | 18 ++++----- src/__tests__/services/editHistory.test.ts | 21 +++++----- src/__tests__/services/reanchorPasses.test.ts | 20 ++++------ src/__tests__/test-helpers.ts | 11 ++++++ src/__tests__/utils/reanchor-changes.test.ts | 39 +++++++------------ src/services/analysisSessions.ts | 11 +++--- src/services/bookService.ts | 21 +++++----- src/services/editHistory.ts | 17 ++++---- src/services/reanchorPasses.ts | 13 ++++--- src/utils/reanchor-changes.ts | 23 +++-------- 11 files changed, 92 insertions(+), 111 deletions(-) diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index b95f9eb64..2df19d76e 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, @@ -382,13 +383,7 @@ describe('main', () => { }); describe('re-anchoring', () => { - const book = { - id: 'GEN', - bookRef: 'GEN', - textVersion: 'v1', - segments: [], - duplicateVerseIds: [], - }; + const book = GEN_1_1_BOOK; it("re-anchors each book read to the source's session", async () => { const reanchorBook = jest diff --git a/src/__tests__/services/analysisSessions.test.ts b/src/__tests__/services/analysisSessions.test.ts index 7237201bc..8c5c90554 100644 --- a/src/__tests__/services/analysisSessions.test.ts +++ b/src/__tests__/services/analysisSessions.test.ts @@ -21,6 +21,7 @@ import { makeVerseBook, memoryStorage, silentLogger, + tokenRefOf, } from '../test-helpers'; const SOURCE = 'src-project'; @@ -654,15 +655,12 @@ describe('AnalysisSessions', () => { return { ...makeVerseBook([{ sid: 'GEN 1:1', text }]), textVersion: text }; } - function refOf(book: Book, surfaceText: string): string { - const token = book.segments[0].tokens.find((t) => t.surfaceText === surfaceText); - if (!token) throw new Error(`fixture missing token ${surfaceText}`); - return token.ref; - } - /** A link to the payload `analysisId` on "beta" where `book` has it. */ function betaLink(book: Book, analysisId: string) { - return makeTokenLink(analysisId, refOf(book, 'beta'), { surfaceText: 'beta', id: 'tl-beta' }); + return makeTokenLink(analysisId, tokenRefOf(book, 'beta'), { + surfaceText: 'beta', + id: 'tl-beta', + }); } const BEFORE = genesis('alpha beta'); @@ -694,7 +692,7 @@ describe('AnalysisSessions', () => { await sessions.reanchorBook(SOURCE, AFTER); - expect((await storedLink(storage)).token.tokenRef).toBe(refOf(AFTER, 'beta')); + expect((await storedLink(storage)).token.tokenRef).toBe(tokenRefOf(AFTER, 'beta')); }); it('adds no undo step', async () => { @@ -747,11 +745,11 @@ describe('AnalysisSessions', () => { const reanchoring = sessions.reanchorBook(SOURCE, AFTER); await flushPromises(); - expect((await storedLink(storage)).token.tokenRef).toBe(refOf(BEFORE, 'beta')); + expect((await storedLink(storage)).token.tokenRef).toBe(tokenRefOf(BEFORE, 'beta')); command.resolve(); await Promise.all([running, reanchoring]); - expect((await storedLink(storage)).token.tokenRef).toBe(refOf(AFTER, 'beta')); + expect((await storedLink(storage)).token.tokenRef).toBe(tokenRefOf(AFTER, 'beta')); }); it('does nothing for a source with no session open', async () => { diff --git a/src/__tests__/services/editHistory.test.ts b/src/__tests__/services/editHistory.test.ts index 654ebd80f..2d526a2dd 100644 --- a/src/__tests__/services/editHistory.test.ts +++ b/src/__tests__/services/editHistory.test.ts @@ -13,6 +13,7 @@ import { makeVerseBook, memoryStorage, silentLogger, + tokenRefOf, } from '../test-helpers'; function payload(id: string): TokenAnalysis { @@ -154,16 +155,12 @@ describe('EditHistory re-anchoring', () => { return verseBook('GEN 1:1', text); } - /** The ref of the token reading `surfaceText` in `book`. */ - function refOf(book: Book, surfaceText: string): string { - const token = book.segments[0].tokens.find((t) => t.surfaceText === surfaceText); - if (!token) throw new Error(`fixture missing token ${surfaceText}`); - return token.ref; - } - /** A link to the payload `analysisId` on the word `beta` where `book` has it. */ function betaLink(book: Book, analysisId: string): TokenAnalysisLink { - return makeTokenLink(analysisId, refOf(book, 'beta'), { surfaceText: 'beta', id: 'tl-beta' }); + return makeTokenLink(analysisId, tokenRefOf(book, 'beta'), { + surfaceText: 'beta', + id: 'tl-beta', + }); } function linking(link: TokenAnalysisLink): ChangeSet { @@ -214,7 +211,7 @@ describe('EditHistory re-anchoring', () => { const restored = await storedLink(); expect(restored.analysisId).toBe('ta-1'); - expect(restored.token.tokenRef).toBe(refOf(after, 'beta')); + expect(restored.token.tokenRef).toBe(tokenRefOf(after, 'beta')); }); it('redoes an edit to the text a pass re-anchored to since the undo', async () => { @@ -230,7 +227,7 @@ describe('EditHistory re-anchoring', () => { const restored = await storedLink(); expect(restored.analysisId).toBe('ta-2'); - expect(restored.token.tokenRef).toBe(refOf(after, 'beta')); + expect(restored.token.tokenRef).toBe(tokenRefOf(after, 'beta')); }); it('returns the changes that reverted the edit with those that re-anchored them', async () => { @@ -247,7 +244,7 @@ describe('EditHistory re-anchoring', () => { expect(undone?.tokenAnalysisLinks?.puts).toEqual([ expect.objectContaining({ analysisId: 'ta-1', - token: { tokenRef: refOf(after, 'beta'), surfaceText: 'beta' }, + token: { tokenRef: tokenRefOf(after, 'beta'), surfaceText: 'beta' }, }), ]); }); @@ -331,7 +328,7 @@ describe('EditHistory re-anchoring', () => { expect(await storedLink()).toEqual( expect.objectContaining({ analysisId: 'ta-1', - token: { tokenRef: refOf(after, 'beta'), surfaceText: 'beta' }, + token: { tokenRef: tokenRefOf(after, 'beta'), surfaceText: 'beta' }, }), ); }); diff --git a/src/__tests__/services/reanchorPasses.test.ts b/src/__tests__/services/reanchorPasses.test.ts index 149c1c892..05410be3e 100644 --- a/src/__tests__/services/reanchorPasses.test.ts +++ b/src/__tests__/services/reanchorPasses.test.ts @@ -15,6 +15,7 @@ import { makeVerseBook, memoryStorage, silentLogger, + tokenRefOf, } from '../test-helpers'; const SOURCE = 'src'; @@ -24,20 +25,13 @@ function verseBook(sid: string, text: string): Book { return makeVerseBook([{ sid, text }]); } -/** The ref of the token reading `surfaceText` in `book`. */ -function refOf(book: Book, surfaceText: string): string { - const token = book.segments[0].tokens.find((t) => t.surfaceText === surfaceText); - if (!token) throw new Error(`fixture missing token ${surfaceText}`); - return token.ref; -} - /** 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}`, refOf(book, word), { surfaceText: word, id: linkId }), + makeTokenLink(`ta-${word}`, tokenRefOf(book, word), { surfaceText: word, id: linkId }), ], }; } @@ -77,7 +71,7 @@ describe('reanchorStoredBook', () => { const changes = await reanchorStoredBook(repository, edited); const draft = await stored(); - expect(linkNamed(draft, 'tl-gen').token.tokenRef).toBe(refOf(edited, 'beta')); + 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']); }); @@ -110,7 +104,7 @@ describe('reanchorStoredBook', () => { suggestedName: 'Named', segmentation: { removedVerseStarts: [], - addedStarts: [{ tokenRef: refOf(before, 'beta'), surfaceText: 'beta' }], + addedStarts: [{ tokenRef: tokenRefOf(before, 'beta'), surfaceText: 'beta' }], }, }); const edited = verseBook('GEN 1:1', 'alpha and beta'); @@ -119,7 +113,7 @@ describe('reanchorStoredBook', () => { const draft = await stored(); expect(draft.segmentation?.addedStarts).toEqual([ - { tokenRef: refOf(edited, 'beta'), surfaceText: 'beta' }, + { tokenRef: tokenRefOf(edited, 'beta'), surfaceText: 'beta' }, ]); expect(draft.suggestedName).toBe('Named'); }); @@ -144,7 +138,7 @@ describe('reanchorRestored', () => { intent: 'undo', tokenAnalysisLinks: { puts: [ - makeTokenLink('ta-beta', refOf(before, 'beta'), { surfaceText: 'beta', id: 'tl-2' }), + makeTokenLink('ta-beta', tokenRefOf(before, 'beta'), { surfaceText: 'beta', id: 'tl-2' }), ], deletes: [], }, @@ -157,7 +151,7 @@ describe('reanchorRestored', () => { expect.objectContaining({ id: 'tl-gen' }), expect.objectContaining({ id: 'tl-2', - token: { tokenRef: refOf(edited, 'beta'), surfaceText: 'beta' }, + token: { tokenRef: tokenRefOf(edited, 'beta'), surfaceText: 'beta' }, }), ]); expect(apply).not.toHaveBeenCalled(); 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/reanchor-changes.test.ts b/src/__tests__/utils/reanchor-changes.test.ts index a2e179490..2ff153bf8 100644 --- a/src/__tests__/utils/reanchor-changes.test.ts +++ b/src/__tests__/utils/reanchor-changes.test.ts @@ -2,28 +2,18 @@ import type { Book, DraftProject, SegmentationDelta, TextAnalysis } from 'interlinearizer'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; -import { - REANCHOR_INTENT, - reanchorChangesForBook, - reanchorChangesForMissingBook, -} from '../../utils/reanchor-changes'; -import { FIXTURE_STAMPS, makeTokenLink, makeVerseBook } from '../test-helpers'; +import { REANCHOR_INTENT, reanchorChangesForBook } from '../../utils/reanchor-changes'; +import { missingBook } from '../../utils/reanchor-draft'; +import { FIXTURE_STAMPS, makeTokenLink, makeVerseBook, tokenRefOf } from '../test-helpers'; -// The cases of `reanchor-analysis.test.ts` and `reanchor-draft.test.ts` check these changes against -// the WebView's passes; these check what the changes carry. +// These check what the changes carry. Each case of the WebView's passes checks that the changes +// match the pass's result. /** 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 }))); } -/** The ref of the first token of `book` reading `surfaceText`. */ -function refOf(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; -} - /** An analysis glossing each of `words` where `book` has it. */ function glossing(book: Book, words: string[]): TextAnalysis { return { @@ -34,7 +24,7 @@ function glossing(book: Book, words: string[]): TextAnalysis { surfaceText: word, })), tokenAnalysisLinks: words.map((word) => - makeTokenLink(`ta-${word}`, refOf(book, word), { surfaceText: word }), + makeTokenLink(`ta-${word}`, tokenRefOf(book, word), { surfaceText: word }), ), }; } @@ -47,7 +37,7 @@ function draftOf(analysis: TextAnalysis, segmentation?: SegmentationDelta): Draf function splitBeforeBeta(): SegmentationDelta { return { removedVerseStarts: [], - addedStarts: [{ tokenRef: refOf(genesis('alpha beta'), 'beta'), surfaceText: 'beta' }], + addedStarts: [{ tokenRef: tokenRefOf(genesis('alpha beta'), 'beta'), surfaceText: 'beta' }], }; } @@ -97,22 +87,23 @@ describe('reanchorChangesForBook', () => { reanchorChangesForBook(draftOf(glossing(book, ['beta']), splitBeforeBeta()), book), ).toBeUndefined(); }); -}); -describe('reanchorChangesForMissingBook', () => { - it('puts the links it stales, named as a re-anchor', () => { - const changes = reanchorChangesForMissingBook( + 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'])), - 'GEN', + 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", () => { + it("changes nothing for another book's links when a book the text lacks is passed", () => { expect( - reanchorChangesForMissingBook(draftOf(glossing(genesis('alpha beta'), ['beta'])), 'EXO'), + reanchorChangesForBook( + draftOf(glossing(genesis('alpha beta'), ['beta'])), + missingBook('EXO'), + ), ).toBeUndefined(); }); }); diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index 454009ff7..3fdec382d 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -117,9 +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, and starts reading the source's whole text afresh, which the - * session's close ends. + * 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 @@ -295,8 +295,9 @@ export class AnalysisSessions { } /** - * Re-anchors the draft of the source project's session, as {@link reanchorBook} does, to every - * book it links into that `textBookIds` leaves out, which the text lacks. + * 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 one the draft links into that `textBookIds` does not + * list. * * @throws Whatever reading the draft or applying the changes throws; the books before stay * re-anchored. diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 8d532c5a3..58a188fe4 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -94,7 +94,7 @@ function reportEdit(sourceProjectId: string, bookId: string): void { bookTextListeners.forEach((listener) => listener(sourceProjectId, bookId)); } -/** Receives the source project and tokenized text of each book a whole-text read files. */ +/** Receives the source project and tokenized text of each book that a whole-text read takes in. */ type BookReadListener = (sourceProjectId: string, book: Book) => void; const bookReadListeners = new Set(); @@ -312,11 +312,12 @@ export function onBookTextChanged(listener: BookTextListener): () => boolean { } /** - * Calls `listener` with each book that a source project's whole-text read files with text, until - * the returned function is called: as the read first reaches the book, and again each time the - * book's text changes. The open book's edited text is passed too while the read has yet to reach - * the book. For a change, `listener` is called before {@link onBookTextChanged}'s listeners hear of - * it. A book whose read fails, or that has no text, is not passed. + * 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); @@ -324,9 +325,11 @@ export function onBookRead(listener: BookReadListener): () => boolean { } /** - * Calls `listener` with the books of a source project's text that hold text, until the returned - * function is called: once its whole-text read has read every book with none failed, and again - * whenever a book's text appears or goes, or its failed read is the last to read again. + * Calls `listener` with the books of a source project that hold text, until the returned function + * is called. It is first called once the whole-text read has read every book with none failed. + * + * It is called again whenever a book gains or loses its text. It 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); diff --git a/src/services/editHistory.ts b/src/services/editHistory.ts index 05edb04ac..ac1ca17ba 100644 --- a/src/services/editHistory.ts +++ b/src/services/editHistory.ts @@ -25,9 +25,9 @@ type Step = { * 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. What an undo or redo restores is re-anchored to the current text of - * each book a pass has re-anchored since the step was captured, since the step holds records as - * they read before that text. + * 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; @@ -50,8 +50,9 @@ export class EditHistory { private passCount = 0; /** - * @param reanchorBooks - Re-anchors what an undo or redo would restore; absent, nothing restored - * is re-anchored. + * @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; @@ -74,8 +75,8 @@ export class EditHistory { /** * 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. A pass over text the book's last pass read - * records nothing. + * 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; @@ -131,7 +132,7 @@ export class EditHistory { return changes; } - /** Forgets each pass that every step still held already reflects. */ + /** Forgets each pass that every undo and redo step still held already reflects. */ private forgetUnneededPasses(): void { const oldestSeen = Math.min( ...[...this.undoable, ...this.redoable].map(({ passesSeen }) => passesSeen), diff --git a/src/services/reanchorPasses.ts b/src/services/reanchorPasses.ts index d1414114b..71d47fc27 100644 --- a/src/services/reanchorPasses.ts +++ b/src/services/reanchorPasses.ts @@ -9,7 +9,7 @@ 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, since calls for one draft must not overlap. + * are not yet stored. Reads one call at a time, because calls for one draft must not overlap. */ async function readBooks( repository: AnalysisRepository, @@ -38,9 +38,9 @@ function reanchorInTurn(draft: DraftProject, books: readonly Book[]): ChangeSet } /** - * Re-anchors the stored draft to `book`'s current text, outside any undo history and leaving the - * draft as saved or unsaved as it was, since a pass is bookkeeping rather than an edit. A book the - * text lacks is passed as a book with no segments. + * 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. * * @returns The changes applied, or `undefined` when the draft already read as re-anchored. * @throws Whatever reading the draft or applying the changes throws; nothing is applied. @@ -54,7 +54,10 @@ export async function reanchorStoredBook( return changes; } -/** Re-anchors what an undo or redo would restore in `repository`, for an {@link EditHistory}. */ +/** + * 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. diff --git a/src/utils/reanchor-changes.ts b/src/utils/reanchor-changes.ts index b6e7d729a..d4b235a3b 100644 --- a/src/utils/reanchor-changes.ts +++ b/src/utils/reanchor-changes.ts @@ -2,7 +2,7 @@ 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, reanchorDraftToMissingBook } from './reanchor-draft'; +import { reanchorDraftToBook } from './reanchor-draft'; import type { BookPass } from './undo-history'; /** Names the changes a re-anchor pass makes. */ @@ -22,7 +22,7 @@ function rewritten( } /** - * The changes that bring `draft` to `reanchored`, the content a re-anchor pass made of it. + * 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. */ @@ -63,25 +63,12 @@ function changesOfPass(draft: DraftProject, pass: BookPass): Chang } /** - * The changes that re-anchor `draft` to `book`'s current text, as {@link reanchorDraftToBook} does. - * The draft may hold only the links in `book`, with the segment analyses they reference, and the - * changes are the same. + * 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)); } - -/** - * The changes that re-anchor `draft` to a book the text lacks, as {@link reanchorDraftToMissingBook} - * does. The draft may hold only the links in the book, as for {@link reanchorChangesForBook}. - * - * @returns The changes, or `undefined` when the draft already reads as re-anchored. - */ -export function reanchorChangesForMissingBook( - draft: DraftProject, - bookCode: string, -): ChangeSet | undefined { - return changesOfPass(draft, reanchorDraftToMissingBook(bookCode)); -} From 1b760fab35d959e0aa48183e12e906c33463d2b0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 26 Oct 2026 17:35:09 -0400 Subject: [PATCH 15/25] Plan a pass over every book the read delivers The host's pass runs on each book the whole-text read delivers, not only the books the draft links into, so the stored splits of a book without links stay current once 4a turns the host on. Part of #449. Co-Authored-By: Claude Opus 5.5 --- docs/data-layer-plan/plan.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/data-layer-plan/plan.md b/docs/data-layer-plan/plan.md index 445cbf815..794e8f628 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, 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 | | **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 | From 919caf4f8e61e0b6e686f6137bafc179bcef8af8 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:04:43 -0400 Subject: [PATCH 16/25] Keep a session's whole-text read running when its tab closes Closing the project's tab ended the read a session started, so edits made while the session stayed open got no re-anchor pass. A read that startTextRead began now lasts until stopTextRead or closeAllProjects. Also corrects the read-lifetime and listener docs in BookService. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/bookService.test.ts | 55 ++++++++++++++++++++ src/services/bookService.ts | 60 ++++++++++++++-------- 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index c9f82bd62..8aa71a1b7 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -1588,6 +1588,61 @@ describe('startTextRead and stopTextRead', () => { 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', () => { diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 58a188fe4..7aaeb993e 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -80,11 +80,15 @@ interface TextRead { } /** - * 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 lasts until {@link stopTextRead}, until the project closes while no + * session holds its read, or until its books could not be listed. */ const textReads = new Map(); +/** The source projects whose read a session holds, from {@link startTextRead} to {@link stopTextRead}. */ +const sessionReads = new Set(); + /** Receives the source project and book of each edit to a watched book's text. */ type BookTextListener = (sourceProjectId: string, bookId: string) => void; @@ -94,7 +98,10 @@ 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. */ +/** + * 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(); @@ -326,10 +333,11 @@ export function onBookRead(listener: BookReadListener): () => boolean { /** * Calls `listener` with the books of a source project that hold text, until the returned function - * is called. It is first called once the whole-text read has read every book with none failed. + * is called. `listener` is first called once the whole-text read has read every book with none + * failed. * - * It is called again whenever a book gains or loses its text. It is also called when a retried read - * succeeds and no other book's read is still failing. + * `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); @@ -589,8 +597,8 @@ function beginTextRead(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 @@ -621,12 +629,16 @@ export function isFormInText(sourceProjectId: string, form: string): boolean | u /** * Starts reading the source project's whole text afresh, ending any read already under way, so that - * {@link onBookRead}'s listeners hear of every book again, and {@link onTextRead}'s once the read is - * done. A failure to list the books is left for {@link getTextReadStatus} to report. + * {@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 session holds the read until {@link stopTextRead}, so 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 { + sessionReads.add(sourceProjectId); const ended = endTextRead(sourceProjectId); beginTextRead(sourceProjectId); return ended; @@ -634,6 +646,7 @@ export function startTextRead(sourceProjectId: string): Promise { /** Ends the source project's whole-text read, leaving the books its view keeps. */ export async function stopTextRead(sourceProjectId: string): Promise { + sessionReads.delete(sourceProjectId); await endTextRead(sourceProjectId); } @@ -644,30 +657,35 @@ async function endTextRead(sourceProjectId: string): Promise { await Promise.all([...(read?.watches.values() ?? [])].map((stop) => stop())); } -/** Lets go of the books kept for the source project, and ends its whole-text read. */ -async function releaseProject(sourceProjectId: string): Promise { +/** 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)), - endTextRead(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, unless a session holds that read. */ export async function closeProject(sourceProjectId: string): Promise { closeCounts.set(sourceProjectId, (closeCounts.get(sourceProjectId) ?? 0) + 1); - await releaseProject(sourceProjectId); + await Promise.all([ + releaseKeptBooks(sourceProjectId), + !sessionReads.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, including those sessions hold. */ export async function closeAllProjects(): Promise { const sourceProjectIds = new Set([ ...latestBooks.keys(), ...openBooks.keys(), ...textReads.keys(), ]); - await Promise.all([...sourceProjectIds].map(releaseProject)); + sessionReads.clear(); + await Promise.all( + [...sourceProjectIds].map((sourceProjectId) => + Promise.all([releaseKeptBooks(sourceProjectId), endTextRead(sourceProjectId)]), + ), + ); } From a9e09ce8fd5c7b8836abdd37c07a7a5616fc5ba1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:04:44 -0400 Subject: [PATCH 17/25] Correct the undo, redo and re-anchor docs from review Undo and redo docs cover the re-anchor changes they return and what can throw; startTextRead's doc no longer claims every book reaches a pass; AGENTS.md describes how passes and the whole-text read fit sessions. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/__tests__/utils/reanchor-changes.test.ts | 3 --- src/main.ts | 20 +++++++++------- src/services/analysisSessions.ts | 25 +++++++++++++------- src/services/draftRepository.ts | 3 ++- src/services/editHistory.ts | 14 +++++------ src/types/interlinearizer.d.ts | 20 +++++++++------- 7 files changed, 50 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 02059596a..bbacb8f0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ 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. diff --git a/src/__tests__/utils/reanchor-changes.test.ts b/src/__tests__/utils/reanchor-changes.test.ts index 2ff153bf8..d0c6d7049 100644 --- a/src/__tests__/utils/reanchor-changes.test.ts +++ b/src/__tests__/utils/reanchor-changes.test.ts @@ -6,9 +6,6 @@ import { REANCHOR_INTENT, reanchorChangesForBook } from '../../utils/reanchor-ch import { missingBook } from '../../utils/reanchor-draft'; import { FIXTURE_STAMPS, makeTokenLink, makeVerseBook, tokenRefOf } from '../test-helpers'; -// These check what the changes carry. Each case of the WebView's passes checks that the changes -// match the pass's result. - /** 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 }))); diff --git a/src/main.ts b/src/main.ts index a990ecdf2..68fa92b98 100644 --- a/src/main.ts +++ b/src/main.ts @@ -785,11 +785,13 @@ async function setAnalysisLanguage(sessionId: string, analysisLanguage: string): } /** - * 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 passed over 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. The error is - * logged before rethrowing. + * @returns The changes that reverted the edit and re-anchored what the edit 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. + * The error is logged before rethrowing. */ async function undoEdit(sessionId: string): Promise { try { @@ -802,11 +804,13 @@ async function 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 passed over 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. The error is - * logged before rethrowing. + * @returns The changes that redid the edit and re-anchored what the edit 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. + * The error is logged before rethrowing. */ async function redoEdit(sessionId: string): Promise { try { diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index 3fdec382d..1a5f44ed5 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -70,9 +70,10 @@ export interface SessionHost { /** The platform's interface languages, most preferred first. */ getInterfaceLanguages(): Promise; /** - * Starts reading the source project's whole text afresh, without waiting on it, so that every - * book reaches {@link AnalysisSessions.reanchorBook} and the books holding text reach - * {@link AnalysisSessions.reanchorMissingBooks}. + * Starts reading the source project's whole text afresh, without waiting on the read. Each book + * holding text reaches {@link AnalysisSessions.reanchorBook} once read. Once every book is read + * with none failed, the books holding text reach {@link AnalysisSessions.reanchorMissingBooks}. A + * book that fails to read reaches neither until a later read of that book succeeds. */ startTextRead(sourceProjectId: string): Promise; /** Ends the source project's whole-text read. */ @@ -259,24 +260,30 @@ export class AnalysisSessions { } /** - * 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 passed over 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 edit 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 passed over 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 edit 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()); diff --git a/src/services/draftRepository.ts b/src/services/draftRepository.ts index b38d8ccd2..bc6db6737 100644 --- a/src/services/draftRepository.ts +++ b/src/services/draftRepository.ts @@ -259,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 the options ask to leave its unsaved mark as it was. * * @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. diff --git a/src/services/editHistory.ts b/src/services/editHistory.ts index ac1ca17ba..c7e1c1422 100644 --- a/src/services/editHistory.ts +++ b/src/services/editHistory.ts @@ -41,8 +41,8 @@ export class EditHistory { private readonly redoable: Step[] = []; /** - * Each book's latest text a pass has re-anchored to, while some step predates it, in the order - * the passes ran. + * 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(); @@ -89,8 +89,8 @@ export class EditHistory { /** * Reverts the latest edit not yet undone, re-anchoring what it restores. * - * @returns The changes that reverted it, with those that re-anchored it, or `undefined` when - * there is none. + * @returns The changes that reverted the edit and re-anchored what the edit 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. */ @@ -101,8 +101,8 @@ export class EditHistory { /** * Redoes the latest undone edit, re-anchoring what it restores. * - * @returns The changes that redid it, with those that re-anchored it, or `undefined` when there - * is none. + * @returns The changes that redid the edit and re-anchored what the edit 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. */ @@ -132,7 +132,7 @@ export class EditHistory { return changes; } - /** Forgets each pass that every undo and redo step still held already reflects. */ + /** 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), diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 1641a0331..c3cb705e0 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 passed over 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 edit 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 passed over 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 edit 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; From a92c4d46d5cc623d62db26ac8f3987569f11bfff Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:43:37 -0400 Subject: [PATCH 18/25] Give the boundary undo test's session host the text-read members Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/commands/boundaryCommands.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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(); From 17ea50a18e662ca817b34da907caaf5a1438bb82 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:43:37 -0400 Subject: [PATCH 19/25] Re-anchor a boundary edit's book in the edit's undo step A boundary command now returns its book, and the session re-anchors the draft, as the staged changes leave it, to that book's text before applying both as one step. This stales a free translation whose segment the edit changes, as the WebView's pass does after a boundary edit, and an undo restores the links with the boundaries. Also trims docs that repeated their owner's: the main.ts wrappers no longer restate the re-anchor details the declarations carry, and BookService's read docs describe startTextRead and stopTextRead rather than sessions. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/__tests__/main.test.ts | 32 +++++++++++ .../services/analysisSessions.test.ts | 32 +++++++++++ src/main.ts | 57 +++++++++---------- src/services/analysisSessions.ts | 26 ++++++--- src/services/bookService.ts | 28 +++++---- src/services/draftRepository.ts | 2 +- src/types/interlinearizer.d.ts | 9 +++ 8 files changed, 135 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bbacb8f0f..79e613ea6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ Saved projects are listed through [ProjectSummaries](src/services/projectSummari [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/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 2df19d76e..8e4e90e63 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -2844,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.test.ts b/src/__tests__/services/analysisSessions.test.ts index 8c5c90554..eac39b268 100644 --- a/src/__tests__/services/analysisSessions.test.ts +++ b/src/__tests__/services/analysisSessions.test.ts @@ -722,6 +722,38 @@ describe('AnalysisSessions', () => { ); }); + 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(); diff --git a/src/main.ts b/src/main.ts index 68fa92b98..24d44e7aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -785,13 +785,11 @@ async function setAnalysisLanguage(sessionId: string, analysisLanguage: string): } /** - * Reverts a session's latest edit not yet undone, re-anchoring what it restores to the current text - * of each book passed over since. + * Reverts a session's latest edit not yet undone. * - * @returns The changes that reverted the edit and re-anchored what the edit 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. - * 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 { @@ -804,13 +802,11 @@ async function undoEdit(sessionId: string): Promise { } /** - * Redoes a session's latest undone edit, re-anchoring what it restores to the current text of each - * book passed over since. + * Redoes a session's latest undone edit. * - * @returns The changes that redid the edit and re-anchored what the edit 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. - * 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 { @@ -850,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)); @@ -1245,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]; + }); } /** @@ -1262,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]; + }); } /** @@ -1281,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]; + }); } /** diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index 1a5f44ed5..bf13b35bb 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -3,7 +3,7 @@ import type { Book } from 'interlinearizer'; import type { AnalysisRepository, ChangeSet } from '../types/analysis-repository'; import { ReadOnlyAnalysisError } from '../types/read-only-analysis-error'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; -import { isEmptyChangeSet } from '../utils/change-set'; +import { isEmptyChangeSet, mergeChangeSets } from '../utils/change-set'; import { analysisHasFreeTranslations } from '../utils/free-translations'; import { missingBook } from '../utils/reanchor-draft'; import { assertApprovalRule } from './commands/approvalRule'; @@ -231,29 +231,37 @@ 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; }); diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 7aaeb993e..737ab100f 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -81,13 +81,16 @@ interface TextRead { /** * Each source project's whole-text read. A read starts with the first status asked for, or with - * {@link startTextRead}. It lasts until {@link stopTextRead}, until the project closes while no - * session holds its read, or until its books could not be listed. + * {@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 whose read a session holds, from {@link startTextRead} to {@link stopTextRead}. */ -const sessionReads = new Set(); +/** + * 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; @@ -633,12 +636,12 @@ export function isFormInText(sourceProjectId: string, form: string): boolean | u * once the read is done. A failure to list the books is left for {@link getTextReadStatus} to * report. * - * The session holds the read until {@link stopTextRead}, so closing the project leaves it running. + * 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 { - sessionReads.add(sourceProjectId); + heldReads.add(sourceProjectId); const ended = endTextRead(sourceProjectId); beginTextRead(sourceProjectId); return ended; @@ -646,7 +649,7 @@ export function startTextRead(sourceProjectId: string): Promise { /** Ends the source project's whole-text read, leaving the books its view keeps. */ export async function stopTextRead(sourceProjectId: string): Promise { - sessionReads.delete(sourceProjectId); + heldReads.delete(sourceProjectId); await endTextRead(sourceProjectId); } @@ -665,24 +668,27 @@ async function releaseKeptBooks(sourceProjectId: string): Promise { /** * 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, unless a session holds that read. + * 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 Promise.all([ releaseKeptBooks(sourceProjectId), - !sessionReads.has(sourceProjectId) && endTextRead(sourceProjectId), + !heldReads.has(sourceProjectId) && endTextRead(sourceProjectId), ]); } -/** Lets go of every book kept, and ends every whole-text read, including those sessions hold. */ +/** + * 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(), ]); - sessionReads.clear(); + 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 bc6db6737..77f8b5564 100644 --- a/src/services/draftRepository.ts +++ b/src/services/draftRepository.ts @@ -260,7 +260,7 @@ 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. Marks the draft - * unsaved, unless the options ask to leave its unsaved mark as it was. + * 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. diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index c3cb705e0..b16b98bdc 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -604,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 @@ -618,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 @@ -630,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 From 65aaaa5a46fbd8ec0db8a76e65b9c7baa70eb718 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:41:13 -0400 Subject: [PATCH 20/25] Address review: parse-safe docs and one test per scope side Co-Authored-By: Claude Opus 5.5 --- src/__tests__/host-reanchor-parity.ts | 11 ++++++++--- src/__tests__/utils/change-set.test.ts | 7 ++++++- src/services/analysisSessions.ts | 11 +++++------ src/services/editHistory.ts | 6 +++--- src/types/interlinearizer.d.ts | 8 ++++---- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/__tests__/host-reanchor-parity.ts b/src/__tests__/host-reanchor-parity.ts index e08546f89..05509cf50 100644 --- a/src/__tests__/host-reanchor-parity.ts +++ b/src/__tests__/host-reanchor-parity.ts @@ -13,9 +13,14 @@ function draftHolding({ analysis, segmentation }: DraftContent): DraftProject { } /** - * Checks that the host's re-anchor of `bookCode` gives what the WebView's `pass` made of `content`, - * `reanchored`: the changes it applies bring the draft to `reanchored`, and the pass over only the - * book's links, as the host reads them, makes the same changes. `pass` must stamp every run alike. + * 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, diff --git a/src/__tests__/utils/change-set.test.ts b/src/__tests__/utils/change-set.test.ts index 3ed1403ad..16a67f539 100644 --- a/src/__tests__/utils/change-set.test.ts +++ b/src/__tests__/utils/change-set.test.ts @@ -236,13 +236,18 @@ describe('mergeChangeSets', () => { expect(mergeChangeSets({ intent: 'first' }, { intent: 'then' }).intent).toBe('first'); }); - it("keeps the later scope's record, or the earlier one when only it has one", () => { + 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); }); diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index bf13b35bb..4fd2f3068 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -269,9 +269,9 @@ export class AnalysisSessions { /** * Reverts the session's latest edit not yet undone, re-anchoring what it restores to the current - * text of each book passed over since. + * text of each book re-anchored since. * - * @returns The changes that reverted the edit and re-anchored what the edit restored, or + * @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. @@ -284,9 +284,9 @@ export class AnalysisSessions { /** * Redoes the session's latest undone edit, re-anchoring what it restores to the current text of - * each book passed over since. + * each book re-anchored since. * - * @returns The changes that redid the edit and re-anchored what the edit restored, or `undefined` + * @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. @@ -311,8 +311,7 @@ export class AnalysisSessions { /** * 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 one the draft links into that `textBookIds` does not - * list. + * 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. diff --git a/src/services/editHistory.ts b/src/services/editHistory.ts index c7e1c1422..d7adfe5e3 100644 --- a/src/services/editHistory.ts +++ b/src/services/editHistory.ts @@ -89,7 +89,7 @@ export class EditHistory { /** * Reverts the latest edit not yet undone, re-anchoring what it restores. * - * @returns The changes that reverted the edit and re-anchored what the edit restored, or + * @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. @@ -101,7 +101,7 @@ export class EditHistory { /** * Redoes the latest undone edit, re-anchoring what it restores. * - * @returns The changes that redid the edit and re-anchored what the edit restored, or `undefined` + * @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. @@ -111,7 +111,7 @@ export class EditHistory { } /** - * Applies the latest changes on `from`, re-anchored to each book passed over since they were + * 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 { diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index b16b98bdc..06fcba2be 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -365,9 +365,9 @@ declare module 'papi-shared-types' { /** * Reverts a session's latest edit not yet undone, re-anchoring what it restores to the current - * text of each book passed over since. + * text of each book re-anchored since. * - * @returns The changes that reverted the edit and re-anchored what the edit restored, as JSON, + * @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). @@ -376,9 +376,9 @@ declare module 'papi-shared-types' { /** * Redoes a session's latest undone edit, re-anchoring what it restores to the current text of - * each book passed over since. + * each book re-anchored since. * - * @returns The changes that redid the edit and re-anchored what the edit restored, as JSON, or + * @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). From c3bf185b20130e892b8c4e0c001c215aa4bcf2df Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:47:04 -0400 Subject: [PATCH 21/25] Record re-anchor passes only over books the draft links into Co-Authored-By: Claude Opus 5.5 --- src/__tests__/services/analysisSessions.test.ts | 14 ++++++++++++++ src/services/analysisSessions.ts | 9 ++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/__tests__/services/analysisSessions.test.ts b/src/__tests__/services/analysisSessions.test.ts index eac39b268..7a9c9252b 100644 --- a/src/__tests__/services/analysisSessions.test.ts +++ b/src/__tests__/services/analysisSessions.test.ts @@ -703,6 +703,20 @@ describe('AnalysisSessions', () => { await expect(sessions.undo(sessionId)).resolves.toBeUndefined(); }); + it('records no pass over a book the draft does not link into', async () => { + const { sessions, saveDraft } = makeSessions(); + await saveDraft(emptyDraft(SOURCE)); + const { sessionId } = await sessions.open(SOURCE); + const recordPass = jest.fn(); + await sessions.run(sessionId, async (session) => { + jest.spyOn(session.history, 'recordPass').mockImplementation(recordPass); + }); + + await sessions.reanchorBook(SOURCE, AFTER); + + expect(recordPass).not.toHaveBeenCalled(); + }); + it('undoes and redoes edits re-anchored to the current text', async () => { const { sessions, sessionId, storage } = await sessionGlossingBeta(); const relinking = (analysisId: string) => ({ diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index 4fd2f3068..24b0511b8 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -300,13 +300,16 @@ export class AnalysisSessions { /** * 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, so that 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. + * A book the text lacks is passed as a book with no segments. Does nothing for a book the draft + * does not link into, or 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)); + return this.runOnWritable(sourceProjectId, async (session) => { + const linked = await booksLinkedAmong(session.repository, [book.bookRef]); + if (linked.length > 0) await reanchorNow(session, book); + }); } /** From fedb7f39153ec5503be402850bbed570434efa3b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:50:35 -0400 Subject: [PATCH 22/25] Keep re-anchoring every book; record the pass only over linked ones Co-Authored-By: Claude Opus 5.5 --- .../services/analysisSessions.test.ts | 29 +++++++++++++++++-- src/services/analysisSessions.ts | 19 ++++++------ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/__tests__/services/analysisSessions.test.ts b/src/__tests__/services/analysisSessions.test.ts index 7a9c9252b..50f19a6d4 100644 --- a/src/__tests__/services/analysisSessions.test.ts +++ b/src/__tests__/services/analysisSessions.test.ts @@ -703,10 +703,33 @@ describe('AnalysisSessions', () => { 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 no pass over a book the draft does not link into', async () => { - const { sessions, saveDraft } = makeSessions(); - await saveDraft(emptyDraft(SOURCE)); - const { sessionId } = await sessions.open(SOURCE); + const { sessions, sessionId } = await sessionSplittingBeta(); const recordPass = jest.fn(); await sessions.run(sessionId, async (session) => { jest.spyOn(session.history, 'recordPass').mockImplementation(recordPass); diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index 24b0511b8..a22fb3c0b 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -80,10 +80,14 @@ export interface SessionHost { stopTextRead(sourceProjectId: string): Promise; } -/** Re-anchors the session's draft to `book`, recording the pass in its history. */ +/** + * Re-anchors the session's draft to `book`. Records the pass in the session's history only when the + * draft links into the book, so an undo or redo re-anchors to no book without links. + */ async function reanchorNow(session: Session, book: Book): Promise { + const linked = await booksLinkedAmong(session.repository, [book.bookRef]); await reanchorStoredBook(session.repository, book); - session.history.recordPass(book); + if (linked.length > 0) session.history.recordPass(book); } type OpenSession = { @@ -299,17 +303,14 @@ export class AnalysisSessions { /** * 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, so that 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 for a book the draft - * does not link into, or when the source has no session open, or only a read-only one. + * turn and as no undo step. Where the draft links into the book, 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, async (session) => { - const linked = await booksLinkedAmong(session.repository, [book.bookRef]); - if (linked.length > 0) await reanchorNow(session, book); - }); + return this.runOnWritable(sourceProjectId, (session) => reanchorNow(session, book)); } /** From 6db3a45a9fab501b6de258a48556431292da537f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:52:10 -0400 Subject: [PATCH 23/25] Disambiguate what clears a failure in rereadTextBook's doc Co-Authored-By: Claude Opus 5.5 --- src/services/bookService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 737ab100f..3375e95c8 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -526,7 +526,8 @@ function reportTextRead(sourceProjectId: string, read: TextRead): void { /** * 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. Reports the - * books holding text first when the read changes which they are, or clears a failure. + * books holding text first when the read changes which they are, or when the read clears a + * failure. */ async function rereadTextBook( sourceProjectId: string, From 8154e059613a7df2dcc836e5fca5d36d68d4fd57 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 15:57:32 -0400 Subject: [PATCH 24/25] Record undo passes over books the draft only splits, from the pass's read A pass recorded for undo only over linked books, so undoing a boundary edit in a book the draft held no links in left the restored splits on the book's old text, where main's always-recorded open-book pass moves them. The pass now reports whether the draft linked into the book or held boundary edits in it, from the read it already makes, and the session records the pass on either, dropping the extra links lookup per book. Co-Authored-By: Claude Opus 5.5 --- .../services/analysisSessions.test.ts | 25 +++++++++++++----- src/__tests__/services/reanchorPasses.test.ts | 26 +++++++++++++++++-- src/services/analysisSessions.ts | 17 ++++++------ src/services/reanchorPasses.ts | 20 +++++++++++--- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/__tests__/services/analysisSessions.test.ts b/src/__tests__/services/analysisSessions.test.ts index 50f19a6d4..bac539be9 100644 --- a/src/__tests__/services/analysisSessions.test.ts +++ b/src/__tests__/services/analysisSessions.test.ts @@ -728,16 +728,29 @@ describe('AnalysisSessions', () => { ]); }); - it('records no pass over a book the draft does not link into', async () => { + it('records the pass over a book the draft only splits', async () => { const { sessions, sessionId } = await sessionSplittingBeta(); - const recordPass = jest.fn(); - await sessions.run(sessionId, async (session) => { - jest.spyOn(session.history, 'recordPass').mockImplementation(recordPass); - }); + const recordPass = await sessions.run(sessionId, async (session) => + jest.spyOn(session.history, 'recordPass'), + ); await sessions.reanchorBook(SOURCE, AFTER); - expect(recordPass).not.toHaveBeenCalled(); + 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 () => { diff --git a/src/__tests__/services/reanchorPasses.test.ts b/src/__tests__/services/reanchorPasses.test.ts index 05410be3e..551e94afa 100644 --- a/src/__tests__/services/reanchorPasses.test.ts +++ b/src/__tests__/services/reanchorPasses.test.ts @@ -68,7 +68,7 @@ describe('reanchorStoredBook', () => { const { repository, stored } = await storedDraft(); const edited = verseBook('GEN 1:1', 'alpha and beta'); - const changes = await reanchorStoredBook(repository, edited); + const { changes } = await reanchorStoredBook(repository, edited); const draft = await stored(); expect(linkNamed(draft, 'tl-gen').token.tokenRef).toBe(tokenRefOf(edited, 'beta')); @@ -93,7 +93,7 @@ describe('reanchorStoredBook', () => { await expect( reanchorStoredBook(repository, verseBook('GEN 1:1', 'alpha beta')), - ).resolves.toBeUndefined(); + ).resolves.toStrictEqual({ heldAnalysis: true }); expect(apply).not.toHaveBeenCalled(); }); @@ -127,6 +127,28 @@ describe('reanchorStoredBook', () => { 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', () => { diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index a22fb3c0b..56505797f 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -81,13 +81,13 @@ export interface SessionHost { } /** - * Re-anchors the session's draft to `book`. Records the pass in the session's history only when the - * draft links into the book, so an undo or redo re-anchors to no book without links. + * 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 linked = await booksLinkedAmong(session.repository, [book.bookRef]); - await reanchorStoredBook(session.repository, book); - if (linked.length > 0) session.history.recordPass(book); + const { heldAnalysis } = await reanchorStoredBook(session.repository, book); + if (heldAnalysis) session.history.recordPass(book); } type OpenSession = { @@ -303,9 +303,10 @@ export class AnalysisSessions { /** * 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, 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. + * 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. */ diff --git a/src/services/reanchorPasses.ts b/src/services/reanchorPasses.ts index 71d47fc27..e524f6c7e 100644 --- a/src/services/reanchorPasses.ts +++ b/src/services/reanchorPasses.ts @@ -4,6 +4,7 @@ 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'; /** @@ -37,21 +38,32 @@ function reanchorInTurn(draft: DraftProject, books: readonly Book[]): ChangeSet 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. * - * @returns The changes applied, or `undefined` when the draft already read as re-anchored. * @throws Whatever reading the draft or applying the changes throws; nothing is applied. */ export async function reanchorStoredBook( repository: AnalysisRepository, book: Book, -): Promise { - const changes = reanchorInTurn(await readBooks(repository, [book.bookRef]), [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 changes; + return { heldAnalysis, ...(changes && { changes }) }; } /** From 635e24f906dde38f22d4d22a49196afd045808d0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 27 Oct 2026 16:05:23 -0400 Subject: [PATCH 25/25] Send onAnalysisChanged only after a pass for new text, per the plan The 3j row now says only a pass for new text sends the event, as the Phase 3 prose does. SessionHost.startTextRead's doc no longer names the session methods main.ts wires the read's books to. Co-Authored-By: Claude Opus 5.5 --- docs/data-layer-plan/plan.md | 2 +- src/services/analysisSessions.ts | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/data-layer-plan/plan.md b/docs/data-layer-plan/plan.md index 794e8f628..3c2d21cb3 100644 --- a/docs/data-layer-plan/plan.md +++ b/docs/data-layer-plan/plan.md @@ -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, 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/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index 56505797f..f4a54a8ed 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -69,12 +69,7 @@ export interface SessionHost { openPt9ImportRepository(interlinearProjectId: string): AnalysisRepository; /** The platform's interface languages, most preferred first. */ getInterfaceLanguages(): Promise; - /** - * Starts reading the source project's whole text afresh, without waiting on the read. Each book - * holding text reaches {@link AnalysisSessions.reanchorBook} once read. Once every book is read - * with none failed, the books holding text reach {@link AnalysisSessions.reanchorMissingBooks}. A - * book that fails to read reaches neither until a later read of that book succeeds. - */ + /** 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;