diff --git a/AGENTS.md b/AGENTS.md index b48605d89..1b4619f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,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 starts building its repository's indexes ahead of the session's first command. Opening also starts a fresh whole-text read of the 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 to a book's edited text reports nothing itself; `reportScriptureEdit` reports the edit, with what its passes changed, as one change that names the book whole. 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. -An edit, undo or redo replies with a `CommandReply`: the `ChangeSet` it applied, and the pool buckets of the forms it touched, read by [sessionPages](src/services/sessionPages.ts). A command sent again under a `changeId` the session has applied gets its first reply and applies nothing. Each change a session applies, re-anchor passes included, is described by [describeChange](src/services/analysisChanges.ts) and goes out as the session form of `onAnalysisChanged`: its segment ranges by book, analysis ids and cross-book queries. A book the host keeps no tokenized copy of is named whole. `getBookOutline`, `getSegments` and `getTextReadStatus` also take a `{ sessionId }` address; that form segments the text by the draft's boundaries and adds the analysis that `sessionPages` reads. +An edit, undo or redo replies with a `CommandReply`: the `ChangeSet` it applied, and the pool buckets of the forms it touched, read by [sessionPages](src/services/sessionPages.ts). A command sent again under a `changeId` the session has applied gets its first reply and applies nothing. Each change a session applies, re-anchor passes included, is described by [describeChange](src/services/analysisChanges.ts) and goes out as the session form of `onAnalysisChanged`: its segment ranges by book, analysis ids and cross-book queries. A book the host keeps no tokenized copy of is named whole. `getBookOutline`, `getSegments` and `getTextReadStatus` also take a `{ sessionId }` address; that form segments the text by the draft's boundaries and adds the analysis that `sessionPages` reads. Its pages also span each phrase they touch with every member token, wherever it sits. Each undo step carries the place it was made at, or a summary of a step made at no one place, as [editSteps](src/services/editSteps.ts) derives them, and an undo or redo hands its step back. On the WebView side, [page-indexes](src/utils/page-indexes.ts) builds the token-level lookups and the boundary guard from the pages held and their spans, and finds the segment of a target whose page hasn't loaded; nothing calls it until Phase 4. 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. An unsaved-change discard copies what the WebView's discard saves, and `runDiscardParity` runs one discard through both. The discard re-anchors each book it rewrites in the same undo step. A reducer or dispatch change in Phase 3 updates its command and parity tests in the same PR. diff --git a/perf/pages/cli.ts b/perf/pages/cli.ts index 7a38664a0..33839b32b 100644 --- a/perf/pages/cli.ts +++ b/perf/pages/cli.ts @@ -37,11 +37,14 @@ import type { RecordChanges, WarningLogger, } from '../../src/types/analysis-repository'; -import type { SegmentPage } from '../../src/types/segment-pages'; +import type { OutlineSegment, SegmentPage } from '../../src/types/segment-pages'; import { isWordToken } from '../../src/types/type-guards'; import { bookOfRef } from '../../src/utils/analysis-book'; import { normalizeSurfaceForm } from '../../src/utils/analysis-identity'; -import { bookIndexesOf } from '../../src/utils/book-indexes'; +import { bookIndexesOf, buildBookIndexes } from '../../src/utils/book-indexes'; +import { buildPageIndexes, outlineRangeOf } from '../../src/utils/page-indexes'; +import { computeSplitFreeRefs, sortByDocOrder } from '../../src/utils/phrase-arc'; +import { phraseSurfaceForm } from '../../src/utils/phrase-text'; import { reanchorSegmentation, segmentationForBook } from '../../src/utils/segmentation'; import { orphanedCardCountAt, @@ -249,9 +252,12 @@ async function checkPages( repository: DraftRepository, analysis: TextAnalysis, books: readonly Book[], -): Promise<{ pages: number; mismatches: string[] }> { + viewBook: string, +): Promise<{ pages: number; mismatches: string[]; phrasesCrossing: number; jumps: number }> { const mismatches: string[] = []; let pages = 0; + let phrasesCrossing = 0; + let jumps = 0; // eslint-disable-next-line no-restricted-syntax -- books are read one at a time to bound memory for (const book of books) { const expectedFor = expectedPages(analysis, book); @@ -314,8 +320,112 @@ async function checkPages( if (differing.length > 0) mismatches.push(`${book.bookRef} ${start}: ${differing.map(([name]) => name).join(', ')}`); } + if (book.bookRef === viewBook) { + const held: SegmentPage[] = []; + for (let index = 0; index < book.segments.length; index += 2) { + // eslint-disable-next-line no-await-in-loop -- calls for one draft must not overlap + held.push(await readSegmentPage(repository, book, index, index + 1)); + } + const bookPhrases = analysis.phraseAnalysisLinks.filter( + ({ tokens }) => bookOfRef(tokens[0].tokenRef) === book.bookRef, + ); + phrasesCrossing += checkPageIndexes(book, outline.segments, held, bookPhrases, mismatches); + // A jump to a token or segment whose page is not held loads the page that holds it. + for (let index = 1; index < book.segments.length; index += 20) { + const segment = book.segments[index]; + const [token] = segment.tokens.filter(isWordToken); + const targets = token ? [token.ref, segment.id] : [segment.id]; + // eslint-disable-next-line no-restricted-syntax -- calls for one draft must not overlap + for (const target of targets) { + const range = outlineRangeOf(outline.segments, target); + // eslint-disable-next-line no-await-in-loop -- calls for one draft must not overlap + const page = range && (await readSegmentPage(repository, book, range.start, range.end)); + const landed = page && buildPageIndexes(outline.segments, [...held, page]); + const holds = + target === segment.id + ? landed?.segmentById.has(target) + : landed?.tokenSegmentMap.get(target) === segment.id; + if (!holds) mismatches.push(`${book.bookRef} jump to ${target}`); + jumps += 1; + } + } + } } - return { pages, mismatches }; + return { pages, mismatches, phrasesCrossing, jumps }; +} + +/** + * Checks that lookups built from the pages `held` read as the whole book's lookups do. The check + * covers each phrase's text, arc order, split previews and segments. It also covers the boundary + * guard on the held tokens and where each token's segment lies in the outline. + * + * @returns How many phrases that the held pages name reach a segment that no held page holds. + */ +function checkPageIndexes( + book: Book, + outline: readonly OutlineSegment[], + held: readonly SegmentPage[], + bookPhrases: readonly PhraseAnalysisLink[], + mismatches: string[], +): number { + const whole = buildBookIndexes(book.segments); + const paged = buildPageIndexes(outline, held); + const links = [ + ...new Map( + held.flatMap((page) => page.analysis?.phraseAnalysisLinks ?? []).map((l) => [l.id, l]), + ).values(), + ]; + let crossing = 0; + links.forEach((link) => { + const refs = link.tokens.map(({ tokenRef }) => tokenRef); + if (refs.some((tokenRef) => !paged.segmentById.has(whole.tokenSegmentMap.get(tokenRef) ?? ''))) + crossing += 1; + const differs = (name: string, a: unknown, b: unknown) => { + if (JSON.stringify(a) !== JSON.stringify(b)) + mismatches.push(`${book.bookRef} phrase ${link.id}: ${name}`); + }; + differs('text', phraseSurfaceForm(link.tokens, paged), phraseSurfaceForm(link.tokens, whole)); + differs( + 'arc order', + sortByDocOrder(link.tokens, paged.tokenDocOrder), + sortByDocOrder(link.tokens, whole.tokenDocOrder), + ); + differs( + 'split previews', + refs.map((tokenRef) => computeSplitFreeRefs(link, tokenRef, paged.tokenDocOrder)), + refs.map((tokenRef) => computeSplitFreeRefs(link, tokenRef, whole.tokenDocOrder)), + ); + differs( + 'segments', + refs.map((tokenRef) => paged.tokenSegmentMap.get(tokenRef)), + refs.map((tokenRef) => whole.tokenSegmentMap.get(tokenRef)), + ); + }); + const guarded = new Set(); + bookPhrases.forEach(({ tokens }) => { + const orders = tokens.flatMap(({ tokenRef }) => whole.tokenDocOrder.get(tokenRef) ?? []); + for (let i = Math.min(...orders) + 1; i <= Math.max(...orders); i += 1) + guarded.add(whole.wordRefByOrder[i]); + }); + const heldRefs = held.flatMap((page) => + page.segments.flatMap((segment) => segment.tokens.map((token) => token.ref)), + ); + if ( + JSON.stringify(heldRefs.filter((tokenRef) => guarded.has(tokenRef))) !== + JSON.stringify(heldRefs.filter((tokenRef) => paged.straddledBoundaryRefs.has(tokenRef))) + ) + mismatches.push(`${book.bookRef} boundary guard`); + const misplaced = book.segments.flatMap((segment, index) => + segment.tokens.flatMap(({ ref }) => { + const range = outlineRangeOf(outline, ref); + return range?.start === index && range.end === index + 1 ? [] : [ref]; + }), + ); + if (misplaced.length > 0) + mismatches.push( + `${book.bookRef} outline places ${misplaced.length} tokens elsewhere, ${misplaced[0]} first`, + ); + return crossing; } /** The book segmented by `delta`, its segments kept per delta so indexes over them are built once. */ @@ -739,7 +849,7 @@ async function checkTier(tier: TierSpec): Promise { const storage = memoryStorage(); await new DraftRepository(storage, draft.sourceProjectId, SILENT).replaceAll(draft); const repository = new DraftRepository(storage, draft.sourceProjectId, SILENT); - const pages = await checkPages(repository, draft.analysis, books); + const pages = await checkPages(repository, draft.analysis, books, tier.viewBook); const links = draft.analysis.tokenAnalysisLinks.length; // Each change rereads the whole draft twice, so larger tiers get fewer. @@ -747,7 +857,7 @@ async function checkTier(tier: TierSpec): Promise { const events = await checkEvents(draft, books, viewBook, steps); console.log( - `${tier.name.padEnd(15)} ${pages.pages} pages, ${pages.mismatches.length} differ ${events.changes} changes, ${events.mismatches.length} differ open ${timing.open.toFixed(0)} ms, first page ${timing.first.toFixed(0)} ms, next ${timing.next.toFixed(1)} ms`, + `${tier.name.padEnd(15)} ${pages.pages} pages, ${pages.mismatches.length} differ (${pages.phrasesCrossing} phrases reach unheld segments, ${pages.jumps} jumps) ${events.changes} changes, ${events.mismatches.length} differ open ${timing.open.toFixed(0)} ms, first page ${timing.first.toFixed(0)} ms, next ${timing.next.toFixed(1)} ms`, ); [...pages.mismatches, ...events.mismatches].slice(0, 20).forEach((m) => console.log(` ${m}`)); return pages.mismatches.length === 0 && events.mismatches.length === 0; diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx index 445d59188..28a1285f1 100644 --- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx +++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx @@ -22,7 +22,7 @@ import { AnalysisStoreProvider, useGlossDispatch } from '../../components/Analys import { SourceTextContext } from '../../components/SourceTextContext'; import { InterlinearNavProvider, useInterlinearNav } from '../../components/InterlinearNavContext'; import type { SourceText } from '../../hooks/useSourceTextReader'; -import type { StepSummary } from '../../hooks/useDraftProject'; +import type { StepSummary } from '../../types/edit-step'; import { emptyAnalysis } from '../../types/empty-factories'; import type { HeadingPlacement } from '../../utils/analysis-query'; import { defaultScrRef, FIXTURE_STAMPS, makeScrollGroupHook } from '../test-helpers'; @@ -2457,6 +2457,24 @@ describe('AnalysisCatalogPanel', () => { expect(saved.tokenAnalyses[0]).not.toHaveProperty('morphemesStale'); expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λογγ', 'ος']); }); + + it('summarizes keeping the breakdown as an edit to its analysis', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ analysis: STALE_BREAKDOWN, asOneStep }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-save')); + + expect(summaries).toStrictEqual([ + { + kind: 'catalogEdit', + form: SHARED.tokenAnalyses[0].surfaceText, + analysisId: 'ta-1', + survivingAnalysisId: 'ta-1', + }, + ]); + }); }); it('leaves the breakdown alone when the editor is canceled', async () => { diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 38648ac65..cd472600c 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -1462,6 +1462,18 @@ describe('useConfirmMorphemesDispatch', () => { const saved: TextAnalysis = onSave.mock.lastCall[0]; expect(saved.tokenAnalyses[0]).not.toHaveProperty('morphemesStale'); }); + + it('saves the kept breakdown at the token it was kept from', () => { + const onSave = jest.fn(); + const { result } = renderStoreHook(() => useConfirmMorphemesDispatch(), { + initialAnalysis: STALE_BREAKDOWN, + onSave, + }); + + act(() => result.current('ta-1', 'tok-1')); + + expect(onSave).toHaveBeenLastCalledWith(expect.anything(), 'tok-1'); + }); }); describe('useUnapprovedTokenAnalysis', () => { diff --git a/src/__tests__/components/TokenChip.test.tsx b/src/__tests__/components/TokenChip.test.tsx index c908f60a0..6edc76d45 100644 --- a/src/__tests__/components/TokenChip.test.tsx +++ b/src/__tests__/components/TokenChip.test.tsx @@ -861,7 +861,7 @@ describe('TokenChip', () => { await userEvent.click(screen.getByRole('button', { name: 'mock-edit-breakdown' })); await userEvent.click(screen.getByRole('button', { name: 'mock-confirm' })); - expect(confirm).toHaveBeenCalledWith('ta-1'); + expect(confirm).toHaveBeenCalledWith('ta-1', 'GEN 1:1:0'); }); it('offers no keeping for a breakdown that is not stale', async () => { diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index 610e8142b..ce624c3bc 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -6,9 +6,9 @@ import type { DraftProject, TextAnalysis } from 'interlinearizer'; import { FIXTURE_STAMPS, makeTokenLink } from '../test-helpers'; import useDraftProject, { type DraftContent, - type StepSummary, type UseDraftProjectResult, } from '../../hooks/useDraftProject'; +import type { StepSummary } from '../../types/edit-step'; import { emptyAnalysis } from '../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index e5cd83809..5f9537443 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -2264,7 +2264,7 @@ describe('main', () => { ) => Promise >('interlinearizer.writeAnalysisMorphemes'), confirmAnalysisMorphemes: handler< - (sessionId: string, analysisId: string) => Promise + (sessionId: string, analysisId: string, tokenRef?: string) => Promise >('interlinearizer.confirmAnalysisMorphemes'), writeAnalysisMorphemeGloss: handler< ( @@ -2739,6 +2739,201 @@ describe('main', () => { expect(bookService.heldBook).toHaveBeenCalledWith('src-project', 'GEN', undefined); }); + describe('the steps undo hands back', () => { + const tokenIn = { + tokenAnalyses: [{ id: 'ta-in', ...FIXTURE_STAMPS, surfaceText: 'In', gloss: { en: 'at' } }], + tokenAnalysisLinks: [ + { + id: 'tl-in', + analysisId: 'ta-in', + ...FIXTURE_STAMPS, + status: 'approved' as const, + token: { tokenRef: 'GEN 1:1:0', surfaceText: 'In' }, + }, + ], + }; + const inThe = [ + { tokenRef: 'GEN 1:1:0', surfaceText: 'In' }, + { tokenRef: 'GEN 1:1:3', surfaceText: 'the' }, + ]; + + /** The step that undoing the latest edit of `sessionId` hands back. */ + async function undoneStep( + undoEdit: (sessionId: string) => Promise, + sessionId: string, + ) { + return JSON.parse((await undoEdit(sessionId)) ?? '{}').step; + } + + it('places a token edit at its token', async () => { + const { openSession, writeGloss, undoEdit } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await writeGloss(sessionId, 'GEN 1:1:0', 'In', 'at'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + location: 'GEN 1:1:0', + }); + }); + + it("places a phrase edit at the phrase's first token", async () => { + const { openSession, createPhrase, deletePhrase, undoEdit } = + await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + const { changes } = JSON.parse(await createPhrase(sessionId, inThe)); + await deletePhrase(sessionId, changes.phraseAnalysisLinks.puts[0].id); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + location: 'GEN 1:1:0', + }); + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + location: 'GEN 1:1:0', + }); + }); + + it("places a reshaping that empties a phrase at the phrase's first token", async () => { + const { openSession, createPhrase, updatePhrase, undoEdit } = + await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + const { changes } = JSON.parse(await createPhrase(sessionId, inThe)); + await updatePhrase(sessionId, changes.phraseAnalysisLinks.puts[0].id, []); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + location: 'GEN 1:1:0', + }); + }); + + it('places a free-translation edit at its segment', async () => { + const { openSession, writeSegmentFreeTranslation, undoEdit } = + await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await writeSegmentFreeTranslation(sessionId, 'GEN 1:2', 'And the earth', 'x'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + location: 'GEN 1:2', + }); + }); + + it('names a catalog edit by its row and the analysis it leaves holding it', async () => { + await seedDraft(tokenIn); + const { openSession, writeAnalysisGloss, undoEdit } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await writeAnalysisGloss(sessionId, 'ta-in', 'into'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + summary: { + kind: 'catalogEdit', + form: 'In', + analysisId: 'ta-in', + survivingAnalysisId: 'ta-in', + }, + }); + }); + + it('names the analysis that an unused row was folded into', async () => { + await seedDraft({ + ...tokenIn, + tokenAnalyses: [ + ...tokenIn.tokenAnalyses, + { id: 'ta-spare', ...FIXTURE_STAMPS, surfaceText: 'In', gloss: { en: 'on' } }, + ], + }); + const { openSession, writeAnalysisGloss, undoEdit } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await writeAnalysisGloss(sessionId, 'ta-spare', 'at'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + summary: { + kind: 'catalogEdit', + form: 'In', + analysisId: 'ta-spare', + survivingAnalysisId: 'ta-in', + }, + }); + }); + + it('names a deletion by the form and uses of what it deleted', async () => { + await seedDraft(tokenIn); + const { openSession, deleteAnalyses, undoEdit } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await deleteAnalyses(sessionId, ['ta-in']); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + summary: { kind: 'catalogDelete', form: 'In', analysisId: 'ta-in', count: 1 }, + }); + }); + + it('names a gloss clear by how many analyses it cleared', async () => { + await seedDraft(tokenIn); + const { openSession, clearAnalysisGlosses, undoEdit } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await clearAnalysisGlosses(sessionId, ['ta-in']); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + summary: { kind: 'catalogClearGlossChecked', count: 1 }, + }); + }); + + describe('keeping a stale breakdown', () => { + const staleIn = { + ...tokenIn, + tokenAnalyses: tokenIn.tokenAnalyses.map((analysis) => ({ + ...analysis, + morphemes: [{ id: 'm-in', form: 'Inn', writingSystem: 'en' }], + morphemesStale: true, + })), + }; + + it('places the step at the token it was kept from', async () => { + await seedDraft(staleIn); + const { openSession, confirmAnalysisMorphemes, undoEdit } = + await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await confirmAnalysisMorphemes(sessionId, 'ta-in', 'GEN 1:1:0'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + location: 'GEN 1:1:0', + }); + }); + + it('names the catalog row when kept from no token', async () => { + await seedDraft(staleIn); + const { openSession, confirmAnalysisMorphemes, undoEdit } = + await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await confirmAnalysisMorphemes(sessionId, 'ta-in'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + summary: { + kind: 'catalogEdit', + form: 'In', + analysisId: 'ta-in', + survivingAnalysisId: 'ta-in', + }, + }); + }); + }); + + it('names a discard by what it discarded', async () => { + await seedDraft(tokenIn); + const { openSession, discardBook, undoEdit } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await discardBook(sessionId, 'GEN'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toStrictEqual({ + summary: { kind: 'discardBook', book: 'GEN' }, + }); + }); + + it('names no step for a stale edit, as the view makes it at no place', async () => { + await seedDraft(staleToken); + const { openSession, discardStaleAnalysis, undoEdit } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await discardStaleAnalysis(sessionId, 'ta-in', 'GEN 1:1:0'); + + await expect(undoneStep(undoEdit, sessionId)).resolves.toBeUndefined(); + }); + }); + it('logs the error and rethrows when an edit fails', async () => { const { writeGloss } = await activateSessionCommands(); diff --git a/src/__tests__/services/analysisSessions.test.ts b/src/__tests__/services/analysisSessions.test.ts index 6d232979b..371a2d396 100644 --- a/src/__tests__/services/analysisSessions.test.ts +++ b/src/__tests__/services/analysisSessions.test.ts @@ -909,6 +909,41 @@ describe('AnalysisSessions', () => { }); }); + it('hands back with an undo and its redo the step the edit was made as', async () => { + const { sessions } = makeSessions(); + const { sessionId } = await sessions.open(SOURCE); + await sessions.edit(sessionId, 'link', async (buffer) => linking(buffer, 'GEN 1:1:0'), { + step: { location: 'GEN 1:1:0' }, + }); + + await expect(sessions.undo(sessionId)).resolves.toMatchObject({ + step: { location: 'GEN 1:1:0' }, + }); + await expect(sessions.redo(sessionId)).resolves.toMatchObject({ + step: { location: 'GEN 1:1:0' }, + }); + }); + + it('reads the step from the changes the edit applies when told how', async () => { + const { sessions } = makeSessions(); + const { sessionId } = await sessions.open(SOURCE); + await sessions.edit(sessionId, 'link', async (buffer) => linking(buffer, 'GEN 1:1:0'), { + step: (changes) => ({ location: changes.tokenAnalysisLinks?.puts[0].token.tokenRef }), + }); + + await expect(sessions.undo(sessionId)).resolves.toMatchObject({ + step: { location: 'GEN 1:1:0' }, + }); + }); + + it('hands back no step with an undo of an edit made as none', async () => { + const { sessions } = makeSessions(); + const { sessionId } = await sessions.open(SOURCE); + await sessions.edit(sessionId, 'link', async (buffer) => linking(buffer, 'GEN 1:1:0')); + + await expect(sessions.undo(sessionId)).resolves.not.toHaveProperty('step'); + }); + it('carries the pool buckets an undo and a redo touched, and their change ids', async () => { const { sessions } = makeSessions(); const { sessionId } = await sessions.open(SOURCE); diff --git a/src/__tests__/services/commands/catalogCommands.test.ts b/src/__tests__/services/commands/catalogCommands.test.ts index 728bf94d5..6b10e73c3 100644 --- a/src/__tests__/services/commands/catalogCommands.test.ts +++ b/src/__tests__/services/commands/catalogCommands.test.ts @@ -41,7 +41,7 @@ async function bufferOver(analysis: Partial): Promise { - const commands: [string, (buffer: ChangeBuffer) => Promise][] = [ + const commands: [string, (buffer: ChangeBuffer) => Promise][] = [ [ 'writeAnalysisGloss', (buffer) => @@ -109,6 +109,53 @@ describe('catalog commands over an analysis that is not stored', () => { }); }); +describe('writeAnalysisGloss', () => { + it('returns the analysis itself when the write keeps it', async () => { + const buffer = await bufferOver(glossed); + + await expect( + writeAnalysisGloss(buffer, { + analysisId: 'ta-in', + value: 'into', + analysisLanguage: 'en', + now: NOW, + }), + ).resolves.toBe('ta-in'); + }); + + it('returns the payload that an unused analysis was folded into', async () => { + const buffer = await bufferOver({ + ...glossed, + tokenAnalyses: [ + ...(glossed.tokenAnalyses ?? []), + { id: 'ta-spare', ...FIXTURE_STAMPS, surfaceText: 'In', gloss: { en: 'on' } }, + ], + }); + + await expect( + writeAnalysisGloss(buffer, { + analysisId: 'ta-spare', + value: 'at', + analysisLanguage: 'en', + now: NOW, + }), + ).resolves.toBe('ta-in'); + }); + + it('returns nothing when the write empties the analysis', async () => { + const buffer = await bufferOver(glossed); + + await expect( + writeAnalysisGloss(buffer, { + analysisId: 'ta-in', + value: '', + analysisLanguage: 'en', + now: NOW, + }), + ).resolves.toBeUndefined(); + }); +}); + describe('deleteAnalyses', () => { it('stages removing the links to an analysis that is not stored', async () => { const buffer = await bufferOver({ diff --git a/src/__tests__/services/commands/parity.ts b/src/__tests__/services/commands/parity.ts index d15c46a62..e7cc05157 100644 --- a/src/__tests__/services/commands/parity.ts +++ b/src/__tests__/services/commands/parity.ts @@ -140,7 +140,7 @@ type DraftSeed = { * Stages `command`'s changes over a draft holding `seed`, then applies them and reloads the draft * from storage. */ -async function runOnHost(seed: DraftSeed, command: (buffer: ChangeBuffer) => Promise) { +async function runOnHost(seed: DraftSeed, command: (buffer: ChangeBuffer) => Promise) { const { storage } = memoryStorage(); const logger = silentLogger(); await new DraftRepository(storage, 'src', logger).replaceAll({ @@ -180,7 +180,7 @@ export async function runParity( analysis: TextAnalysis, analysisLanguage: string, action: UnknownAction | readonly UnknownAction[], - command: (buffer: ChangeBuffer) => Promise, + command: (buffer: ChangeBuffer) => Promise, ): Promise { const store = runInStore(analysis, analysisLanguage, [action].flat()); const { draft, changes, indexDifferences } = await runOnHost( diff --git a/src/__tests__/services/editHistory.test.ts b/src/__tests__/services/editHistory.test.ts index 21935547d..691e0bc4f 100644 --- a/src/__tests__/services/editHistory.test.ts +++ b/src/__tests__/services/editHistory.test.ts @@ -112,6 +112,18 @@ describe('EditHistory', () => { await expect(storedIds()).resolves.toStrictEqual(['a', 'b']); }); + it('returns with an undo and its redo the step the edit was applied as', async () => { + const { history } = makeHistory(); + const step = { location: 'GEN 1:1:0' }; + await history.apply(putting('a'), { step }); + await history.apply(putting('b')); + + await history.undo(); + await expect(history.undo()).resolves.toMatchObject({ step }); + await expect(history.redo()).resolves.toMatchObject({ step }); + await expect(history.redo()).resolves.not.toHaveProperty('step'); + }); + it('has nothing to undo before any edit', async () => { await expect(makeHistory().history.undo()).resolves.toBeUndefined(); }); diff --git a/src/__tests__/services/editSteps.test.ts b/src/__tests__/services/editSteps.test.ts new file mode 100644 index 000000000..895597f31 --- /dev/null +++ b/src/__tests__/services/editSteps.test.ts @@ -0,0 +1,111 @@ +/// + +import type { TextAnalysis, TokenAnalysis } from 'interlinearizer'; +import { ChangeBuffer } from '../../services/commands/changeBuffer'; +import { DraftRepository } from '../../services/draftRepository'; +import { catalogStepOf, deletionStepOf, phraseStartOf } from '../../services/editSteps'; +import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; +import { + FIXTURE_STAMPS, + makePhraseLink, + makeTokenLink, + memoryStorage, + silentLogger, +} from '../test-helpers'; + +function payload(id: string, surfaceText = 'word'): TokenAnalysis { + return { id, ...FIXTURE_STAMPS, surfaceText }; +} + +async function bufferOver(analysis: Partial) { + const { storage } = memoryStorage(); + const repository = new DraftRepository(storage, 'src', silentLogger()); + await repository.replaceAll({ + ...emptyDraft('src'), + analysis: { ...emptyAnalysis(), ...analysis }, + }); + return { buffer: new ChangeBuffer(repository), repository }; +} + +describe('catalogStepOf', () => { + const linked: Partial = { + tokenAnalyses: [payload('ta-a', 'Word'), payload('ta-b')], + tokenAnalysisLinks: [makeTokenLink('ta-a', 'GEN 1:1:0', { id: 'tl-1' })], + }; + + it("names the row's form and the analysis left holding it", async () => { + const { buffer } = await bufferOver(linked); + const stepOf = await catalogStepOf(buffer, 'catalogMerge', 'ta-a'); + + expect(stepOf('ta-b')).toStrictEqual({ + summary: { + kind: 'catalogMerge', + form: 'Word', + analysisId: 'ta-a', + survivingAnalysisId: 'ta-b', + }, + }); + }); + + it('names no surviving analysis when the edit leaves the row none', async () => { + const { buffer } = await bufferOver(linked); + const stepOf = await catalogStepOf(buffer, 'catalogEdit', 'ta-a'); + + expect(stepOf(undefined)).toStrictEqual({ + summary: { kind: 'catalogEdit', form: 'Word', analysisId: 'ta-a' }, + }); + }); +}); + +describe('deletionStepOf', () => { + const approved: Partial = { + tokenAnalyses: [payload('ta-a', 'Word'), payload('ta-b'), payload('ta-unused', 'spare')], + tokenAnalysisLinks: [ + makeTokenLink('ta-a', 'GEN 1:1:0'), + makeTokenLink('ta-a', 'GEN 1:2:0'), + makeTokenLink('ta-b', 'GEN 1:3:0'), + ], + }; + + it('names the form and how many uses a deleted analysis had', async () => { + const { buffer, repository } = await bufferOver(approved); + + await expect(deletionStepOf(buffer, repository, ['ta-a'])).resolves.toStrictEqual({ + summary: { kind: 'catalogDelete', form: 'Word', analysisId: 'ta-a', count: 2 }, + }); + }); + + it('names a deleted analysis no token used as unused', async () => { + const { buffer, repository } = await bufferOver(approved); + + await expect(deletionStepOf(buffer, repository, ['ta-unused'])).resolves.toStrictEqual({ + summary: { kind: 'catalogDeleteUnused', form: 'spare', analysisId: 'ta-unused' }, + }); + }); + + it('counts the analyses deleted together and the uses they had between them', async () => { + const { buffer, repository } = await bufferOver(approved); + + await expect( + deletionStepOf(buffer, repository, ['ta-a', 'ta-b', 'ta-unused']), + ).resolves.toStrictEqual({ + summary: { kind: 'catalogDeleteChecked', count: 3, usageCount: 3 }, + }); + }); +}); + +describe('phraseStartOf', () => { + it("places a phrase's step at its first token", async () => { + const { buffer } = await bufferOver({ + phraseAnalysisLinks: [makePhraseLink('pl', ['GEN 1:1:4', 'GEN 1:1:0'])], + }); + + await expect(phraseStartOf(buffer, 'pl')).resolves.toStrictEqual({ location: 'GEN 1:1:4' }); + }); + + it('places a step on a phrase that is not stored nowhere', async () => { + const { buffer } = await bufferOver({}); + + await expect(phraseStartOf(buffer, 'missing')).resolves.toStrictEqual({}); + }); +}); diff --git a/src/__tests__/services/sessionPages.test.ts b/src/__tests__/services/sessionPages.test.ts index 0d0bbcf83..c35725387 100644 --- a/src/__tests__/services/sessionPages.test.ts +++ b/src/__tests__/services/sessionPages.test.ts @@ -228,6 +228,24 @@ describe('readSegmentPage', () => { }); }); + it('spans each phrase touching the range with every member the book holds, wherever it sits', async () => { + const phrase = makePhraseLink('pl', [ref('God'), ref('light'), 'GEN 1:9:0']); + const repository = await repositoryOver({ + phraseAnalyses: [{ id: 'pl', ...FIXTURE_STAMPS, surfaceText: 'God light' }], + phraseAnalysisLinks: [phrase, makePhraseLink('pl-in', [ref('In'), ref('beginning')])], + }); + const [, light] = book.segments[2].tokens; + + const page = await readSegmentPage(repository, book, 1, 2); + + expect(page.analysis?.phraseSpans).toStrictEqual({ + pl: [ + { token: book.segments[1].tokens[0], segmentId: 'GEN 1:2', docOrder: 3 }, + { token: light, segmentId: 'GEN 1:3', docOrder: 6, gapText: ' ' }, + ], + }); + }); + describe('stale free translations', () => { async function pageWith(segmentId: string, start: number) { const repository = await repositoryOver(staleTranslation(segmentId)); diff --git a/src/__tests__/utils/book-indexes.test.ts b/src/__tests__/utils/book-indexes.test.ts index b8fd38497..2ad9a7189 100644 --- a/src/__tests__/utils/book-indexes.test.ts +++ b/src/__tests__/utils/book-indexes.test.ts @@ -1,6 +1,11 @@ /// -import { bookIndexesOf, buildBookIndexes } from '../../utils/book-indexes'; +import { + bookIndexesOf, + buildBookIndexes, + indexSegmentTokens, + type TokenIndexes, +} from '../../utils/book-indexes'; import { makeVerseBook } from '../test-helpers'; const BOOK = makeVerseBook([ @@ -57,3 +62,41 @@ describe('bookIndexesOf', () => { expect(bookIndexesOf([...BOOK.segments])).toStrictEqual(indexes); }); }); + +describe('indexSegmentTokens', () => { + const freshIndexes = (): TokenIndexes => ({ + tokenDocOrder: new Map(), + fullTokenOrder: new Map(), + gapTextByWordRef: new Map(), + tokenSegmentMap: new Map(), + wordTokenByRef: new Map(), + }); + + it("numbers the segment's tokens from the places it is given", () => { + const indexes = freshIndexes(); + const [, second] = BOOK.segments; + indexSegmentTokens(second, indexes, 10, 20); + + expect([...indexes.tokenDocOrder.values()]).toStrictEqual([10, 11, 12]); + expect([...indexes.fullTokenOrder.values()]).toStrictEqual([20, 21, 22]); + }); + + it('answers the places that the next segment starts at', () => { + const [first] = BOOK.segments; + + expect(indexSegmentTokens(first, freshIndexes(), 0, 0)).toStrictEqual({ + nextWord: 3, + nextToken: 4, + }); + }); + + it('reports each word token with its place', () => { + const onWord = jest.fn(); + const [first] = BOOK.segments; + indexSegmentTokens(first, freshIndexes(), 5, 0, onWord); + + expect(onWord.mock.calls).toStrictEqual( + first.tokens.filter(({ type }) => type === 'word').map(({ ref }, i) => [ref, 5 + i]), + ); + }); +}); diff --git a/src/__tests__/utils/page-indexes.test.ts b/src/__tests__/utils/page-indexes.test.ts new file mode 100644 index 000000000..5dd90f49d --- /dev/null +++ b/src/__tests__/utils/page-indexes.test.ts @@ -0,0 +1,204 @@ +/// + +import type { Book, PhraseAnalysisLink } from 'interlinearizer'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; +import type { PageAnalysis, SegmentPage } from '../../types/segment-pages'; +import { buildBookIndexes } from '../../utils/book-indexes'; +import { buildBookOutline } from '../../utils/book-outline'; +import { buildPageIndexes, outlineRangeOf } from '../../utils/page-indexes'; +import { emptyAnalysis } from '../../types/empty-factories'; +import { makePhraseLink, makeVerseBook, tokenRefOf } from '../test-helpers'; + +const BOOK = makeVerseBook([ + { sid: 'GEN 1:1', text: 'In the beginning, God created.' }, + { sid: 'GEN 1:2', text: 'And the earth was void.' }, + { sid: 'GEN 1:3', text: 'And God said, light!' }, + { sid: 'GEN 1:4', text: 'And there was light.' }, +]); +const ref = (surfaceText: string, book: Book = BOOK) => tokenRefOf(book, surfaceText); + +/** The page of `book` holding the segments at `[start, end)`, with the spans of `links`. */ +function pageOf( + book: Book, + start: number, + end: number, + links: readonly PhraseAnalysisLink[] = [], +): SegmentPage { + const indexes = buildBookIndexes(book.segments); + const analysis: PageAnalysis = { + ...emptyAnalysis(), + phraseAnalysisLinks: links, + poolBuckets: {}, + staleFreeTranslations: { bySegment: {}, orphanedAfter: {}, orphanedAhead: [] }, + phraseSpans: Object.fromEntries( + links.map((link) => [ + link.id, + link.tokens.flatMap(({ tokenRef }) => { + const token = indexes.wordTokenByRef.get(tokenRef); + const segmentId = indexes.tokenSegmentMap.get(tokenRef); + const docOrder = indexes.tokenDocOrder.get(tokenRef); + if (!token || segmentId === undefined || docOrder === undefined) return []; + const gapText = indexes.gapTextByWordRef.get(tokenRef); + return [{ token, segmentId, docOrder, ...(gapText !== undefined && { gapText }) }]; + }), + ]), + ), + }; + return { + textVersion: book.textVersion, + start, + segments: book.segments.slice(start, end), + analysis, + }; +} + +/** The entries of `map` for those of `keys` that it holds. */ +function restricted(map: ReadonlyMap, keys: Iterable): Map { + const entries: [string, V][] = []; + [...keys].forEach((key) => { + const value = map.get(key); + if (value !== undefined) entries.push([key, value]); + }); + return new Map(entries); +} + +describe('buildPageIndexes', () => { + const outline = buildBookOutline(BOOK, BOOK.segments).segments; + const whole = buildBookIndexes(BOOK.segments); + + it('places the tokens of the pages held as the whole book places them', () => { + const pages = [pageOf(BOOK, 0, 1), pageOf(BOOK, 2, 3)]; + const refs = [BOOK.segments[0], BOOK.segments[2]].flatMap((s) => s.tokens.map((t) => t.ref)); + + const indexes = buildPageIndexes(outline, pages); + + expect(indexes.tokenDocOrder).toStrictEqual(restricted(whole.tokenDocOrder, refs)); + expect(indexes.fullTokenOrder).toStrictEqual(restricted(whole.fullTokenOrder, refs)); + expect(indexes.tokenSegmentMap).toStrictEqual(restricted(whole.tokenSegmentMap, refs)); + expect(indexes.wordTokenByRef).toStrictEqual(restricted(whole.wordTokenByRef, refs)); + expect(indexes.gapTextByWordRef).toStrictEqual(restricted(whole.gapTextByWordRef, refs)); + }); + + it('places the tokens of a phrase in a segment not held as the whole book places them', () => { + const link = makePhraseLink('pl', [ref('earth'), ref('God')]); + const spanned = [ref('earth'), ref('God')]; + + const indexes = buildPageIndexes(outline, [pageOf(BOOK, 0, 1, [link])]); + + expect(restricted(indexes.tokenDocOrder, spanned)).toStrictEqual( + restricted(whole.tokenDocOrder, spanned), + ); + expect(restricted(indexes.tokenSegmentMap, spanned)).toStrictEqual( + restricted(whole.tokenSegmentMap, spanned), + ); + expect(restricted(indexes.wordTokenByRef, spanned)).toStrictEqual( + restricted(whole.wordTokenByRef, spanned), + ); + expect(restricted(indexes.gapTextByWordRef, spanned)).toStrictEqual( + restricted(whole.gapTextByWordRef, spanned), + ); + }); + + it('guards by the members the book still holds of a phrase naming one it lacks', () => { + const link = makePhraseLink('pl', ['GEN 1:9:0', ref('In'), ref('the')]); + + const indexes = buildPageIndexes(outline, [pageOf(BOOK, 0, 1, [link])]); + + expect(indexes.straddledBoundaryRefs).toStrictEqual(new Set([ref('the')])); + }); + + it('builds over pages carrying no analysis, spanning nothing', () => { + const { textVersion, start, segments } = pageOf(BOOK, 0, 1); + + const indexes = buildPageIndexes(outline, [{ textVersion, start, segments }]); + + expect(indexes.wordTokenByRef.size).toBe(5); + expect(indexes.straddledBoundaryRefs.size).toBe(0); + }); + + it('lists every segment of the outline, and the segments of the pages held', () => { + const indexes = buildPageIndexes(outline, [pageOf(BOOK, 1, 2)]); + + expect(indexes.segmentOrder).toStrictEqual(whole.segmentOrder); + expect([...indexes.outlineSegmentById.values()]).toStrictEqual(outline); + expect(indexes.segmentById).toStrictEqual(new Map([['GEN 1:2', BOOK.segments[1]]])); + }); + + it('guards the boundaries held that would cut a phrase, as the whole book does', () => { + const across = makePhraseLink('pl-across', [ref('beginning'), ref('earth')]); + const within = makePhraseLink('pl-within', [ref('said'), ref('light')]); + const pages = [pageOf(BOOK, 0, 1, [across]), pageOf(BOOK, 2, 3, [within])]; + const held = [BOOK.segments[0], BOOK.segments[2]].flatMap((s) => s.tokens.map((t) => t.ref)); + const blocked = new Set(); + [across, within].forEach(({ tokens }) => { + const orders = tokens.map(({ tokenRef }) => whole.tokenDocOrder.get(tokenRef) ?? 0); + for (let i = Math.min(...orders) + 1; i <= Math.max(...orders); i += 1) + blocked.add(whole.wordRefByOrder[i]); + }); + + const indexes = buildPageIndexes(outline, pages); + + expect(indexes.straddledBoundaryRefs).toStrictEqual( + new Set(held.filter((tokenRef) => blocked.has(tokenRef))), + ); + }); +}); + +describe('outlineRangeOf', () => { + const headed = makeVerseBook([ + { sid: 'GEN 1:1', text: 'In the ' }, + { heading: 's1', verseId: 'GEN 1:1', text: 'The Creation', charIndex: 7 }, + { sid: 'GEN 1:1', text: 'beginning God', charOffset: 7 }, + { sid: 'GEN 1:2', text: 'And the earth was void.' }, + { sid: 'GEN 1:3', text: 'And God said' }, + { sid: 'GEN 1:4', text: 'And there was light.' }, + ]); + const book = resegmentBook(headed, { + removedVerseStarts: ['GEN 1:4:0'], + addedStarts: [{ tokenRef: ref('was', headed), surfaceText: 'was' }], + }); + const outline = buildBookOutline(book, book.segments).segments; + const { tokenSegmentMap, segmentOrder } = buildBookIndexes(book.segments); + + it('finds the segment that holds each token of the book', () => { + book.segments + .flatMap((segment) => segment.tokens) + .forEach(({ ref: tokenRef }) => { + const index = segmentOrder.get(tokenSegmentMap.get(tokenRef) ?? ''); + + expect(outlineRangeOf(outline, tokenRef)).toStrictEqual({ + start: index, + end: (index ?? 0) + 1, + }); + }); + }); + + it('finds a segment by its id', () => { + expect(outlineRangeOf(outline, 'GEN 1:3')).toStrictEqual({ + start: segmentOrder.get('GEN 1:3'), + end: (segmentOrder.get('GEN 1:3') ?? 0) + 1, + }); + }); + + it('finds the segment begun mid-verse that a later verse begins in, by the verse', () => { + const verses = makeVerseBook([ + { sid: 'GEN 1:1', text: 'In the beginning' }, + { sid: 'GEN 1:2', text: 'And the earth' }, + ]); + const split = resegmentBook(verses, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [{ tokenRef: ref('beginning', verses), surfaceText: 'beginning' }], + }); + const splitOutline = buildBookOutline(split, split.segments).segments; + + expect(outlineRangeOf(splitOutline, 'GEN 1:2')).toStrictEqual({ start: 1, end: 2 }); + }); + + it('finds nothing for a token of a verse the book lacks', () => { + expect(outlineRangeOf(outline, 'GEN 1:9:0')).toBeUndefined(); + }); + + it('finds nothing for a heading the book lacks', () => { + expect(outlineRangeOf(outline, 'GEN 1:2/s1:0')).toBeUndefined(); + }); +}); diff --git a/src/__tests__/utils/phrase-arc.test.ts b/src/__tests__/utils/phrase-arc.test.ts index 06f5ee66b..0e178bb79 100644 --- a/src/__tests__/utils/phrase-arc.test.ts +++ b/src/__tests__/utils/phrase-arc.test.ts @@ -16,6 +16,7 @@ import { roundedPolyline, splitPhraseAtBoundary, SPLIT_BUTTON_WIDTH_PX, + straddledRefs, type ArcPath, } from '../../utils/phrase-arc'; import { makePhraseLink } from '../test-helpers'; @@ -1028,3 +1029,31 @@ describe('deconflictSplitButtons', () => { expect(longRun.midX).toBeGreaterThan(54); }); }); + +describe('straddledRefs', () => { + const tokenDocOrder = new Map([ + ['a', 0], + ['b', 1], + ['c', 2], + ['d', 3], + ]); + const wordRefByOrder = ['a', 'b', 'c', 'd']; + + it("names each word token after a phrase's first, up to and including its last", () => { + expect( + straddledRefs( + [makePhraseLink('p', ['a', 'c'])], + tokenDocOrder, + (order) => wordRefByOrder[order], + ), + ).toStrictEqual(new Set(['b', 'c'])); + }); + + it('skips a place that the caller holds no token at', () => { + expect( + straddledRefs([makePhraseLink('p', ['a', 'd'])], tokenDocOrder, (order) => + order === 2 ? undefined : wordRefByOrder[order], + ), + ).toStrictEqual(new Set(['b', 'd'])); + }); +}); diff --git a/src/__tests__/utils/verse-ref.test.ts b/src/__tests__/utils/verse-ref.test.ts index 2fff1bac6..655cc28e0 100644 --- a/src/__tests__/utils/verse-ref.test.ts +++ b/src/__tests__/utils/verse-ref.test.ts @@ -2,7 +2,12 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { Segment } from 'interlinearizer'; -import { firstVerseNumber, segmentContainsVerse, verseOfId } from '../../utils/verse-ref'; +import { + firstVerseNumber, + positionOf, + segmentContainsVerse, + verseOfId, +} from '../../utils/verse-ref'; import { makeVerseBook } from '../test-helpers'; /** @@ -224,3 +229,13 @@ describe('verseOfId', () => { expect(verseOfId('LUK 2:4/s1:0')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 }); }); }); + +describe('positionOf', () => { + it("reads a verse's SID as the verse at offset 0", () => { + expect(positionOf('GEN 1:2')).toStrictEqual({ verse: 'GEN 1:2', offset: 0 }); + }); + + it('reads a token ref as its verse and offset', () => { + expect(positionOf('GEN 1:2:14')).toStrictEqual({ verse: 'GEN 1:2', offset: 14 }); + }); +}); diff --git a/src/components/AnalysisCatalogPanel.tsx b/src/components/AnalysisCatalogPanel.tsx index 22f377b56..9f9526de1 100644 --- a/src/components/AnalysisCatalogPanel.tsx +++ b/src/components/AnalysisCatalogPanel.tsx @@ -13,6 +13,7 @@ import { useAnalysisReadOnly, useAnalysisRowDispatch, useCatalogRows, + useConfirmMorphemesDispatch, useGlossClearReclaims, useReportGlossEditing, useStaleLocationDispatch, @@ -41,7 +42,7 @@ import { useInterlinearNav } from './InterlinearNavContext'; import useDraftDiscardGate from '../hooks/useDraftDiscardGate'; import useRowSelection from '../hooks/useRowSelection'; import useRowWindow from '../hooks/useRowWindow'; -import type { StepSummary } from '../hooks/useDraftProject'; +import type { StepSummary } from '../types/edit-step'; import { normalizeSurfaceForm } from '../utils/analysis-identity'; import { DELETION_ANNOUNCEMENT_STRING_KEYS, @@ -416,6 +417,7 @@ export default function AnalysisCatalogPanel({ ); const rowDispatch = useAnalysisRowDispatch(); + const confirmMorphemes = useConfirmMorphemesDispatch(); const readDeletionOutcome = useAnalysisDeletionOutcome(); const readOnly = useAnalysisReadOnly(); @@ -639,6 +641,17 @@ export default function AnalysisCatalogPanel({ [asOneStep, reportEditOutcome, rowDispatch, surfaceTextOf], ); + const handleMorphemesConfirm = useCallback( + (analysisId: string) => + asOneStep(() => confirmMorphemes(analysisId), { + kind: 'catalogEdit', + form: surfaceTextOf(analysisId), + analysisId, + survivingAnalysisId: analysisId, + }), + [asOneStep, confirmMorphemes, surfaceTextOf], + ); + /** * Holds back an edit until the reader agrees to lose each breakdown they typed against a row it * would drop, a dropped record taking its draft with it. @@ -1102,6 +1115,7 @@ export default function AnalysisCatalogPanel({ } onMorphemeGlossCommit={handleMorphemeGlossCommit} onMorphemesCommit={handleMorphemesCommit} + onMorphemesConfirm={handleMorphemesConfirm} onStaleDiscard={handleStaleDiscard} onStaleReapply={handleStaleReapply} onStaleSelect={handleStaleSelect} diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index 52262bd19..53f11f4fd 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -502,17 +502,18 @@ export function useStaleMorphemesAnalysisId(tokenRef: string): string | undefine /** * Returns a stable callback that keeps an analysis's stale breakdown as it stands, clearing the - * mark for every token linked to it. Persists immediately. + * mark for every token linked to it. Persists immediately, recording the optional `tokenRef` as the + * place the confirmation was made at. * * @throws When called outside an {@link AnalysisStoreProvider}. */ -export function useConfirmMorphemesDispatch(): (analysisId: string) => void { +export function useConfirmMorphemesDispatch(): (analysisId: string, tokenRef?: string) => void { const { dispatch, save } = useAnalysisSave('useConfirmMorphemesDispatch'); return useCallback( - (analysisId: string) => { + (analysisId: string, tokenRef?: string) => { dispatch(confirmAnalysisMorphemes({ analysisId })); - save(); + save(tokenRef); }, [dispatch, save], ); diff --git a/src/components/CatalogRowEditor.tsx b/src/components/CatalogRowEditor.tsx index e5d3ff06b..b4547c3b2 100644 --- a/src/components/CatalogRowEditor.tsx +++ b/src/components/CatalogRowEditor.tsx @@ -6,11 +6,7 @@ import { MorphemeBox } from './MorphemeBox'; import { MorphemeBreakdownPopover, type MorphemeEditorLabels } from './MorphemeEditor'; import { morphemeCarriesAnnotation } from '../utils/analysis-identity'; import { resolvedOrEmpty } from '../utils/localized-strings'; -import { - useAnalysisReadOnly, - useConfirmMorphemesDispatch, - useReportGlossEditing, -} from './AnalysisStore'; +import { useAnalysisReadOnly, useReportGlossEditing } from './AnalysisStore'; /** Localized string keys the row editor renders. */ export const ROW_EDITOR_STRING_KEYS = [ @@ -54,6 +50,8 @@ type CatalogRowEditorProps = Readonly<{ onGlossCommit: (value: string) => void; /** Replaces the analysis's morpheme breakdown for every token linked to it. */ onMorphemesCommit: (forms: readonly string[]) => void; + /** Keeps the analysis's stale breakdown as it stands, for every token linked to it. */ + onMorphemesConfirm: () => void; /** Writes one morpheme's gloss for every token linked to the analysis. */ onMorphemeGlossCommit: (morphemeId: string, value: string) => void; /** @@ -194,6 +192,7 @@ export default function CatalogRowEditor({ showMorphology, onGlossCommit, onMorphemesCommit, + onMorphemesConfirm, onMorphemeGlossCommit, breakdownDraft, onBreakdownDraftChange, @@ -201,7 +200,6 @@ export default function CatalogRowEditor({ }: CatalogRowEditorProps) { const glossFieldId = useId(); const readOnly = useAnalysisReadOnly(); - const confirmMorphemes = useConfirmMorphemesDispatch(); const morphemeForms = morphemes.map((m) => m.form).join(' '); @@ -367,7 +365,7 @@ export default function CatalogRowEditor({ morphemes={morphemes} needsResetConfirm={morphemes.some(morphemeCarriesAnnotation)} onClose={() => onBreakdownDraftChange(undefined)} - onConfirm={morphemesStale ? () => confirmMorphemes(analysisId) : undefined} + onConfirm={morphemesStale ? onMorphemesConfirm : undefined} onDraftChange={(draft) => onBreakdownDraftChange(draft)} onReset={morphemes.length > 0 ? () => onMorphemesCommit([]) : undefined} onSave={(value) => onMorphemesCommit(breakdownDraftForms(value))} diff --git a/src/components/CatalogRowView.tsx b/src/components/CatalogRowView.tsx index c0b5c8c20..87d907d1f 100644 --- a/src/components/CatalogRowView.tsx +++ b/src/components/CatalogRowView.tsx @@ -67,6 +67,8 @@ type CatalogRowViewProps = Readonly<{ onGlossCommit: (analysisId: string, value: string) => void; /** Replaces this row's morpheme breakdown for every token linked to it. */ onMorphemesCommit: (analysisId: string, forms: readonly string[]) => void; + /** Keeps this row's stale breakdown as it stands, for every token linked to it. */ + onMorphemesConfirm: (analysisId: string) => void; /** Writes one of this row's morpheme glosses for every token linked to it. */ onMorphemeGlossCommit: (analysisId: string, morphemeId: string, value: string) => void; /** @@ -124,6 +126,7 @@ function CatalogRowView({ showMorphology, onGlossCommit, onMorphemesCommit, + onMorphemesConfirm, onMorphemeGlossCommit, onMergeRequest, onDeleteRequest, @@ -154,6 +157,10 @@ function CatalogRowView({ (forms: readonly string[]) => onMorphemesCommit(analysisId, forms), [analysisId, onMorphemesCommit], ); + const handleMorphemesConfirm = useCallback( + () => onMorphemesConfirm(analysisId), + [analysisId, onMorphemesConfirm], + ); const handleMorphemeGlossCommit = useCallback( (morphemeId: string, value: string) => onMorphemeGlossCommit(analysisId, morphemeId, value), [analysisId, onMorphemeGlossCommit], @@ -239,6 +246,7 @@ function CatalogRowView({ onGlossCommit={handleGlossCommit} onMorphemeGlossCommit={handleMorphemeGlossCommit} onMorphemesCommit={handleMorphemesCommit} + onMorphemesConfirm={handleMorphemesConfirm} surfaceText={row.surfaceText} usageCount={row.usageCount} /> diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index d26639ae9..8fe043efb 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -19,7 +19,11 @@ import type { PhraseMode } from '../types/phrase-mode'; import { TOOLTIP_DELAY_MS } from './tooltip-delay'; import type { ViewOptions } from '../types/view-options'; import { perfMeasure } from '../utils/perf-marks'; -import { phrasesStraddlingBoundary, splitPhraseAtBoundary } from '../utils/phrase-arc'; +import { + phrasesStraddlingBoundary, + splitPhraseAtBoundary, + straddledRefs, +} from '../utils/phrase-arc'; import SegmentListView from './SegmentListView'; import UnlinkPhraseConfirm from './modals/UnlinkPhraseConfirm'; import { FocusProvider } from './FocusStore'; @@ -115,29 +119,11 @@ export default function Interlinearizer({ const getPhraseLinkById = usePhraseLinkByIdGetter(); const phraseLinkById = usePhraseLinkByIdMap(); - /** - * Word-token refs where placing a segment boundary would cut a phrase — the not-mid-phrase UI - * guard, precomputed once per phrase-link change so boundary slots do O(1) set lookups. A - * boundary before ref `W` cuts a phrase when the phrase has tokens both strictly before and - * at-or-after `W`, i.e. every word ref strictly inside the phrase's document-order span `(min, - * max]` — including refs in the gaps of a discontiguous phrase. - */ - const straddledBoundaryRefs = useMemo>(() => { - const blocked = new Set(); - phraseLinkById.forEach((link) => { - let min = Number.POSITIVE_INFINITY; - let max = Number.NEGATIVE_INFINITY; - link.tokens.forEach((t) => { - const order = tokenDocOrder.get(t.tokenRef); - /* v8 ignore next -- phrase tokens are word tokens, which the doc-order map always contains */ - if (order === undefined) return; - if (order < min) min = order; - if (order > max) max = order; - }); - for (let i = min + 1; i <= max; i += 1) blocked.add(wordRefByOrder[i]); - }); - return blocked; - }, [phraseLinkById, tokenDocOrder, wordRefByOrder]); + /** The not-mid-phrase guard: word-token refs before which a segment boundary would cut a phrase. */ + const straddledBoundaryRefs = useMemo>( + () => straddledRefs(phraseLinkById.values(), tokenDocOrder, (order) => wordRefByOrder[order]), + [phraseLinkById, tokenDocOrder, wordRefByOrder], + ); /** * Splits every phrase that a new segment boundary before `boundaryRef` would cut, so no phrase diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 8ddcecae7..8f9f486f1 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -36,7 +36,8 @@ import { resegmentBook } from 'parsers/papi/resegmentBook'; import useLatestRef from '../hooks/useLatestRef'; import useUndoRedoKeys from '../hooks/useUndoRedoKeys'; import useWholeTextReanchor from '../hooks/useWholeTextReanchor'; -import useDraftProject, { type DraftContent, type EditStep } from '../hooks/useDraftProject'; +import useDraftProject, { type DraftContent } from '../hooks/useDraftProject'; +import type { EditStep } from '../types/edit-step'; import { formatTemplate } from '../utils/format-template'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; import useLexiconRegistry from '../hooks/useLexiconRegistry'; diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index c8d9b4f18..b2b8de0a3 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -509,7 +509,7 @@ export function TokenChip({ onConfirm={ staleMorphemesAnalysisId === undefined ? undefined - : () => confirmMorphemes(staleMorphemesAnalysisId) + : () => confirmMorphemes(staleMorphemesAnalysisId, token.ref) } onReset={hasMorphemes ? () => dispatchMorphemeDelete(token.ref) : undefined} onSave={handleMorphemeSave} diff --git a/src/components/__mocks__/AnalysisStore.tsx b/src/components/__mocks__/AnalysisStore.tsx index 653ff282f..734c03033 100644 --- a/src/components/__mocks__/AnalysisStore.tsx +++ b/src/components/__mocks__/AnalysisStore.tsx @@ -138,7 +138,7 @@ export function useStaleMorphemesAnalysisId(): string | undefined { } /** Returns a no-op dispatch for confirming a stale breakdown in mock context. */ -export function useConfirmMorphemesDispatch(): (analysisId: string) => void { +export function useConfirmMorphemesDispatch(): (analysisId: string, tokenRef?: string) => void { return () => {}; } diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index 9ebdd0133..c7f5d4a45 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -6,6 +6,7 @@ import type { TextAnalysis, } from 'interlinearizer'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { EditStep, StepSummary } from '../types/edit-step'; import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../types/model-version'; import { restoreBookInAnalysis, restoreBookInSegmentation } from '../utils/analysis-book'; @@ -94,46 +95,6 @@ function memoizePass(pass: BookPass): BookPass { return memoized; } -/** What a step made in the analysis catalog tells the reader it was. */ -type CatalogStepSummary = { - form: string; - /** The analysis whose catalog row the step acted on. */ - analysisId: string; - /** The analysis holding that row once the step is made, absent when the step leaves none. */ - survivingAnalysisId?: string; -}; - -/** What an undo step made at no one place tells the reader it was, once undone or redone. */ -export type StepSummary = Readonly< - | (CatalogStepSummary & { kind: 'catalogEdit' | 'catalogMerge' | 'catalogDeleteUnused' }) - | (CatalogStepSummary & { - kind: 'catalogDelete'; - /** How many uses the deleted analysis had, never zero. */ - count: number; - }) - | { - kind: 'catalogDeleteChecked'; - /** How many analyses were deleted. */ - count: number; - /** How many uses they had between them. */ - usageCount: number; - } - | { - kind: 'catalogClearGlossChecked'; - /** How many analyses had their gloss cleared. */ - count: number; - } - | { kind: 'discardBook'; book: string } - | { kind: 'discardAll' } ->; - -/** An undo step, as recorded. */ -export type EditStep = Readonly<{ - /** The token ref or segment id the step was made at, when it was made at one place. */ - location?: string; - summary?: StepSummary; -}>; - /** An action whose edits are being gathered into one undo step. */ type StepGroup = { /** The action's first edit: the content before it, and where it was made. */ diff --git a/src/main.ts b/src/main.ts index 4a05bab12..676e314e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,7 @@ import * as phraseCommands from './services/commands/phraseCommands'; import * as tokenCommands from './services/commands/tokenCommands'; import { startCSharpHost } from './services/csharpHost'; import type { EditOptions } from './services/editHistory'; +import { catalogStepOf, deletionStepOf, phraseStartOf } from './services/editSteps'; import { readHostPerf, setHostPerfMarks } from './services/hostPerf'; import * as projectStorage from './services/projectStorage'; import * as pt9ImportService from './services/pt9ImportService'; @@ -49,6 +50,7 @@ import type { Pt9ImportResult } from './services/pt9ImportService'; import { readBookOutline, readSegmentPage } from './services/sessionPages'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- named only for its doc links import type { CommandReply } from './types/command-reply'; +import type { EditStep } from './types/edit-step'; import { toProjectSummary, type SavedAsProject } from './types/interlinear-project-summary'; import { UnreadableBooksError, type DraftExport } from './types/analysis-repository'; import { isDraftProject } from './types/type-guards'; @@ -934,8 +936,15 @@ async function checkIndexes(sessionId: string): Promise { } } -/** The session an edit is sent to, and the change id it is sent under. */ -type EditTarget = { sessionId: string; changeId?: ChangeId | null }; +/** + * The session an edit is sent to, the change id it is sent under, and the step to record it as. A + * command may set the step while it stages, once it has read what the step names. + */ +type EditTarget = { + sessionId: string; + changeId?: ChangeId | null; + step?: EditStep; +}; /** * Runs `command` as one edit of a session's analysis. @@ -945,13 +954,17 @@ type EditTarget = { sessionId: string; changeId?: ChangeId | null }; * logged before rethrowing. */ async function runSessionEdit( - { sessionId, changeId }: EditTarget, + target: EditTarget, intent: string, command: (buffer: ChangeBuffer, session: Session) => Promise, ): Promise { + const { sessionId, changeId } = target; try { return JSON.stringify( - await sessions.edit(sessionId, intent, command, { changeId: changeId ?? undefined }), + await sessions.edit(sessionId, intent, command, { + changeId: changeId ?? undefined, + step: () => target.step, + }), ); } catch (e) { logger.error(`Interlinearizer: failed to ${intent} in session ${sessionId}`, e); @@ -983,14 +996,17 @@ function writeGloss( value: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writeGloss', (buffer, session) => - tokenCommands.writeGloss(buffer, { - ...editStamp(), - tokenRef, - surfaceText, - value, - analysisLanguage: session.analysisLanguage, - }), + return runSessionEdit( + { sessionId, changeId, step: { location: tokenRef } }, + 'writeGloss', + (buffer, session) => + tokenCommands.writeGloss(buffer, { + ...editStamp(), + tokenRef, + surfaceText, + value, + analysisLanguage: session.analysisLanguage, + }), ); } @@ -1008,13 +1024,16 @@ function approveAnalysisForToken( analysisId: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'approveAnalysisForToken', (buffer) => - tokenCommands.approveAnalysisForToken(buffer, { - ...editStamp(), - tokenRef, - surfaceText, - analysisId, - }), + return runSessionEdit( + { sessionId, changeId, step: { location: tokenRef } }, + 'approveAnalysisForToken', + (buffer) => + tokenCommands.approveAnalysisForToken(buffer, { + ...editStamp(), + tokenRef, + surfaceText, + analysisId, + }), ); } @@ -1033,14 +1052,17 @@ function writeMorphemes( writingSystem: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writeMorphemes', (buffer) => - morphemeCommands.writeMorphemes(buffer, { - ...editStamp(), - tokenRef, - surfaceText, - writingSystem, - morphemes: forms.map((form) => ({ id: crypto.randomUUID(), form })), - }), + return runSessionEdit( + { sessionId, changeId, step: { location: tokenRef } }, + 'writeMorphemes', + (buffer) => + morphemeCommands.writeMorphemes(buffer, { + ...editStamp(), + tokenRef, + surfaceText, + writingSystem, + morphemes: forms.map((form) => ({ id: crypto.randomUUID(), form })), + }), ); } @@ -1056,8 +1078,10 @@ function deleteMorphemes( tokenRef: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'deleteMorphemes', (buffer) => - morphemeCommands.deleteMorphemes(buffer, { ...editStamp(), tokenRef }), + return runSessionEdit( + { sessionId, changeId, step: { location: tokenRef } }, + 'deleteMorphemes', + (buffer) => morphemeCommands.deleteMorphemes(buffer, { ...editStamp(), tokenRef }), ); } @@ -1076,14 +1100,17 @@ function writeMorphemeGloss( value: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writeMorphemeGloss', (buffer, session) => - morphemeCommands.writeMorphemeGloss(buffer, { - ...editStamp(), - tokenRef, - morphemeId, - value, - analysisLanguage: session.analysisLanguage, - }), + return runSessionEdit( + { sessionId, changeId, step: { location: tokenRef } }, + 'writeMorphemeGloss', + (buffer, session) => + morphemeCommands.writeMorphemeGloss(buffer, { + ...editStamp(), + tokenRef, + morphemeId, + value, + analysisLanguage: session.analysisLanguage, + }), ); } @@ -1190,14 +1217,18 @@ function writeAnalysisGloss( value: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writeAnalysisGloss', (buffer, session) => - catalogCommands.writeAnalysisGloss(buffer, { - analysisId, - value, - analysisLanguage: session.analysisLanguage, - now: editStamp().now, - }), - ); + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'writeAnalysisGloss', async (buffer, session) => { + const stepOf = await catalogStepOf(buffer, 'catalogEdit', analysisId); + target.step = stepOf( + await catalogCommands.writeAnalysisGloss(buffer, { + analysisId, + value, + analysisLanguage: session.analysisLanguage, + now: editStamp().now, + }), + ); + }); } /** @@ -1211,7 +1242,10 @@ function clearAnalysisGlosses( analysisIds: string[], changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'clearAnalysisGlosses', (buffer, session) => + const step = { + summary: { kind: 'catalogClearGlossChecked', count: analysisIds.length }, + } as const; + return runSessionEdit({ sessionId, changeId, step }, 'clearAnalysisGlosses', (buffer, session) => catalogCommands.clearAnalysisGlosses(buffer, { analysisIds, analysisLanguage: session.analysisLanguage, @@ -1234,18 +1268,23 @@ function writeAnalysisMorphemes( writingSystem: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writeAnalysisMorphemes', (buffer) => - catalogCommands.writeAnalysisMorphemes(buffer, { - analysisId, - morphemes: forms.map((form) => ({ id: crypto.randomUUID(), form })), - writingSystem, - now: editStamp().now, - }), - ); + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'writeAnalysisMorphemes', async (buffer) => { + const stepOf = await catalogStepOf(buffer, 'catalogEdit', analysisId); + target.step = stepOf( + await catalogCommands.writeAnalysisMorphemes(buffer, { + analysisId, + morphemes: forms.map((form) => ({ id: crypto.randomUUID(), form })), + writingSystem, + now: editStamp().now, + }), + ); + }); } /** - * Keeps a token analysis's stale breakdown as it stands, for every token linked to it. + * Keeps a token analysis's stale breakdown as it stands, for every token linked to it. The undo + * step is placed at `tokenRef` when given, and otherwise names the analysis's catalog row. * * @returns A JSON string of the {@link CommandReply}. * @throws As {@link runSessionEdit} does. @@ -1253,11 +1292,16 @@ function writeAnalysisMorphemes( function confirmAnalysisMorphemes( sessionId: string, analysisId: string, + tokenRef?: string | null, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'confirmAnalysisMorphemes', (buffer) => - catalogCommands.confirmAnalysisMorphemes(buffer, { analysisId, now: editStamp().now }), - ); + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'confirmAnalysisMorphemes', async (buffer) => { + target.step = tokenRef + ? { location: tokenRef } + : (await catalogStepOf(buffer, 'catalogEdit', analysisId))(analysisId); + await catalogCommands.confirmAnalysisMorphemes(buffer, { analysisId, now: editStamp().now }); + }); } /** @@ -1274,15 +1318,19 @@ function writeAnalysisMorphemeGloss( value: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writeAnalysisMorphemeGloss', (buffer, session) => - catalogCommands.writeAnalysisMorphemeGloss(buffer, { - analysisId, - morphemeId, - value, - analysisLanguage: session.analysisLanguage, - now: editStamp().now, - }), - ); + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'writeAnalysisMorphemeGloss', async (buffer, session) => { + const stepOf = await catalogStepOf(buffer, 'catalogEdit', analysisId); + target.step = stepOf( + await catalogCommands.writeAnalysisMorphemeGloss(buffer, { + analysisId, + morphemeId, + value, + analysisLanguage: session.analysisLanguage, + now: editStamp().now, + }), + ); + }); } /** @@ -1296,9 +1344,11 @@ function deleteAnalyses( analysisIds: string[], changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'deleteAnalyses', (buffer) => - catalogCommands.deleteAnalyses(buffer, { analysisIds }), - ); + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'deleteAnalyses', async (buffer, session) => { + target.step = await deletionStepOf(buffer, session.repository, analysisIds); + await catalogCommands.deleteAnalyses(buffer, { analysisIds }); + }); } /** @@ -1315,15 +1365,19 @@ function mergeAnalysesInto( content: MergedContent, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'mergeAnalysesInto', (buffer, session) => - catalogCommands.mergeAnalysesInto(buffer, { - survivorAnalysisId, - mergedAnalysisIds, - content, - analysisLanguage: session.analysisLanguage, - now: editStamp().now, - }), - ); + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'mergeAnalysesInto', async (buffer, session) => { + const stepOf = await catalogStepOf(buffer, 'catalogMerge', survivorAnalysisId); + target.step = stepOf( + await catalogCommands.mergeAnalysesInto(buffer, { + survivorAnalysisId, + mergedAnalysisIds, + content, + analysisLanguage: session.analysisLanguage, + now: editStamp().now, + }), + ); + }); } /** @@ -1397,11 +1451,15 @@ function createPhrase( tokens: TokenSnapshot[], changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'createPhrase', async (buffer, session) => { - assertTokenRun(tokens); - await assertOneSegment(buffer, session, tokens); - await phraseCommands.createPhrase(buffer, { ...editStamp(), tokens }); - }); + return runSessionEdit( + { sessionId, changeId, step: { location: tokens[0]?.tokenRef } }, + 'createPhrase', + async (buffer, session) => { + assertTokenRun(tokens); + await assertOneSegment(buffer, session, tokens); + await phraseCommands.createPhrase(buffer, { ...editStamp(), tokens }); + }, + ); } /** @@ -1419,7 +1477,10 @@ function updatePhrase( tokens: TokenSnapshot[], changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'updatePhrase', async (buffer, session) => { + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'updatePhrase', async (buffer, session) => { + target.step = + tokens.length > 0 ? { location: tokens[0].tokenRef } : await phraseStartOf(buffer, phraseId); if (tokens.length > 0) await assertOneSegment(buffer, session, tokens); await phraseCommands.updatePhrase(buffer, { ...editStamp(), phraseId, tokens }); }); @@ -1437,9 +1498,11 @@ function deletePhrase( phraseId: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'deletePhrase', (buffer) => - phraseCommands.deletePhrase(buffer, { phraseId }), - ); + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'deletePhrase', async (buffer) => { + target.step = await phraseStartOf(buffer, phraseId); + await phraseCommands.deletePhrase(buffer, { phraseId }); + }); } /** @@ -1464,16 +1527,20 @@ function mergePhrases( absorbedPhraseId?: string | null, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'mergePhrases', async (buffer, session) => { - assertTokenRun(tokens); - await assertOneSegment(buffer, session, tokens); - await phraseCommands.mergePhrases(buffer, { - ...editStamp(), - targetPhraseId, - tokens, - absorbedPhraseId: absorbedPhraseId ?? undefined, - }); - }); + return runSessionEdit( + { sessionId, changeId, step: { location: tokens[0]?.tokenRef } }, + 'mergePhrases', + async (buffer, session) => { + assertTokenRun(tokens); + await assertOneSegment(buffer, session, tokens); + await phraseCommands.mergePhrases(buffer, { + ...editStamp(), + targetPhraseId, + tokens, + absorbedPhraseId: absorbedPhraseId ?? undefined, + }); + }, + ); } /** @@ -1489,14 +1556,16 @@ function writePhraseGloss( value: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writePhraseGloss', (buffer, session) => - phraseCommands.writePhraseGloss(buffer, { + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'writePhraseGloss', async (buffer, session) => { + target.step = await phraseStartOf(buffer, phraseId); + await phraseCommands.writePhraseGloss(buffer, { ...editStamp(), phraseId, value, analysisLanguage: session.analysisLanguage, - }), - ); + }); + }); } /** @@ -1512,7 +1581,9 @@ function approvePhrase( phraseId: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'approvePhrase', async (buffer, session) => { + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'approvePhrase', async (buffer, session) => { + target.step = await phraseStartOf(buffer, phraseId); const [link] = await buffer.getPhraseAnalysisLinks([phraseId]); if (link && link.status !== 'approved') await assertOneSegment(buffer, session, link.tokens); await phraseCommands.approvePhrase(buffer, { ...editStamp(), phraseId }); @@ -1543,7 +1614,9 @@ function splitPhrase( splitAfterTokenRef: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'splitPhrase', async (buffer, session) => { + const target: EditTarget = { sessionId, changeId }; + return runSessionEdit(target, 'splitPhrase', async (buffer, session) => { + target.step = await phraseStartOf(buffer, phraseId); const verseBook = await verseBookOf(session, splitAfterTokenRef); await phraseCommands.splitPhrase(buffer, { ...editStamp(), @@ -1578,15 +1651,18 @@ function writeSegmentFreeTranslation( adoptStaleAnalysisId?: string | null, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'writeSegmentFreeTranslation', (buffer, session) => - freeTranslationCommands.writeSegmentFreeTranslation(buffer, { - ...editStamp(), - segmentId, - surfaceText, - value, - analysisLanguage: session.analysisLanguage, - adoptStaleAnalysisId: adoptStaleAnalysisId ?? undefined, - }), + return runSessionEdit( + { sessionId, changeId, step: { location: segmentId } }, + 'writeSegmentFreeTranslation', + (buffer, session) => + freeTranslationCommands.writeSegmentFreeTranslation(buffer, { + ...editStamp(), + segmentId, + surfaceText, + value, + analysisLanguage: session.analysisLanguage, + adoptStaleAnalysisId: adoptStaleAnalysisId ?? undefined, + }), ); } @@ -1607,11 +1683,15 @@ function mergeSegments( secondSegmentStartRef: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'mergeSegments', async (buffer, session) => { - const verseBook = await verseBookOf(session, secondSegmentStartRef); - await boundaryCommands.mergeSegments(buffer, { verseBook, secondSegmentStartRef }); - return { reanchorTo: [verseBook] }; - }); + return runSessionEdit( + { sessionId, changeId, step: { location: secondSegmentStartRef } }, + 'mergeSegments', + async (buffer, session) => { + const verseBook = await verseBookOf(session, secondSegmentStartRef); + await boundaryCommands.mergeSegments(buffer, { verseBook, secondSegmentStartRef }); + return { reanchorTo: [verseBook] }; + }, + ); } /** @@ -1627,11 +1707,15 @@ function splitSegment( tokenRef: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'splitSegment', async (buffer, session) => { - const verseBook = await verseBookOf(session, tokenRef); - await boundaryCommands.splitSegment(buffer, { ...boundaryEditStamp(), verseBook, tokenRef }); - return { reanchorTo: [verseBook] }; - }); + return runSessionEdit( + { sessionId, changeId, step: { location: tokenRef } }, + 'splitSegment', + async (buffer, session) => { + const verseBook = await verseBookOf(session, tokenRef); + await boundaryCommands.splitSegment(buffer, { ...boundaryEditStamp(), verseBook, tokenRef }); + return { reanchorTo: [verseBook] }; + }, + ); } /** @@ -1649,16 +1733,20 @@ function moveBoundary( toRef: string, changeId?: ChangeId | null, ): Promise { - return runSessionEdit({ sessionId, changeId }, 'moveBoundary', async (buffer, session) => { - const verseBook = await verseBookOf(session, toRef); - await boundaryCommands.moveBoundary(buffer, { - ...boundaryEditStamp(), - verseBook, - fromRef, - toRef, - }); - return { reanchorTo: [verseBook] }; - }); + return runSessionEdit( + { sessionId, changeId, step: { location: toRef } }, + 'moveBoundary', + async (buffer, session) => { + const verseBook = await verseBookOf(session, toRef); + await boundaryCommands.moveBoundary(buffer, { + ...boundaryEditStamp(), + verseBook, + fromRef, + toRef, + }); + return { reanchorTo: [verseBook] }; + }, + ); } /** @@ -1691,7 +1779,7 @@ async function savedContentOf(session: Session): Promise unsavedMark(scope.interlinearProjectId !== undefined), changeId: changeId ?? undefined, + step, }, ); /* v8 ignore next 2 -- defensive: only a discard reusing another command's change id gets no flag */ @@ -1746,7 +1835,7 @@ function discardBook( changeId?: ChangeId | null, ): Promise { return runDiscard( - { sessionId, changeId }, + { sessionId, changeId, step: { summary: { kind: 'discardBook', book: bookCode } } }, 'discardBook', (buffer, stored, saved) => discardCommands.discardBook(buffer, stored, bookCode, saved), (savesToProject) => (savesToProject ? 'keep' : undefined), @@ -1763,7 +1852,7 @@ function discardBook( */ function discardAll(sessionId: string, changeId?: ChangeId | null): Promise { return runDiscard( - { sessionId, changeId }, + { sessionId, changeId, step: { summary: { kind: 'discardAll' } } }, 'discardAll', discardCommands.discardAll, () => 'clear', diff --git a/src/services/analysisSessions.ts b/src/services/analysisSessions.ts index 3ef25f3a2..422af45ae 100644 --- a/src/services/analysisSessions.ts +++ b/src/services/analysisSessions.ts @@ -9,6 +9,7 @@ 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 type { CommandReply } from '../types/command-reply'; +import type { EditStep } from '../types/edit-step'; import { booksMissingFrom } from '../utils/analysis-book'; import { isEmptyChangeSet, mergeChangeSets } from '../utils/change-set'; import { analysisHasFreeTranslations } from '../utils/free-translations'; @@ -18,7 +19,7 @@ import { describeChange, describeScriptureEdit, type AppliedChange } from './ana import { assertApprovalRule } from './commands/approvalRule'; import { ChangeBuffer } from './commands/changeBuffer'; import { settleAll } from './draftRepository'; -import { EditHistory, type EditOptions } from './editHistory'; +import { EditHistory, type EditOptions, type HistoryMove } from './editHistory'; import { reanchorRestored, reanchorStoredBook } from './reanchorPasses'; import { readTouchedPoolBuckets } from './sessionPages'; @@ -53,6 +54,11 @@ export type SessionEditOptions = { /** As {@link EditOptions}; given as a function, it is read from the session in the edit's turn. */ unsavedMark?: EditOptions['unsavedMark'] | ((session: Session) => EditOptions['unsavedMark']); changeId?: ChangeId; + /** + * The step to record the edit as, which an undo or redo of the edit hands back. A function reads + * the step from the changes that the edit applies. + */ + step?: EditStep | ((changes: ChangeSet) => EditStep | undefined); }; /** What an edit command returns besides what it stages. */ @@ -195,7 +201,7 @@ async function applyStaged( buffer: ChangeBuffer, staged: ChangeSet, reanchorTo: void | readonly Book[], - options: EditOptions | undefined, + options: Pick & Pick, ): Promise { await assertApprovalRule(buffer, session.repository); const scope = staged.scope ?? (await session.repository.getScope()); @@ -207,7 +213,11 @@ async function applyStaged( ? await reanchorRestored(session.repository)(staged, reanchorTo) : undefined; const changes = reanchored ? mergeChangeSets(staged, reanchored) : staged; - const inverse = await session.history.apply(changes, options); + const { unsavedMark, step } = options; + const inverse = await session.history.apply(changes, { + unsavedMark, + step: typeof step === 'function' ? step(changes) : step, + }); return { changes, inverse }; } @@ -415,12 +425,13 @@ export class AnalysisSessions { command: (buffer: ChangeBuffer, session: Session) => Promise, options?: SessionEditOptions, ): Promise { - const { changeId = crypto.randomUUID(), unsavedMark } = options ?? {}; + const { changeId = crypto.randomUUID(), unsavedMark, step } = options ?? {}; const reply = await this.once(sessionId, changeId, (markApplied) => this.runEdit(sessionId, (session) => noticingApplies(session, markApplied, async (): Promise => { - const editOptions: EditOptions = { + const editOptions = { unsavedMark: typeof unsavedMark === 'function' ? unsavedMark(session) : unsavedMark, + step, }; const buffer = new ChangeBuffer(session.repository); const { reanchorTo, ...extra } = (await command(buffer, session)) ?? {}; @@ -583,13 +594,15 @@ export class AnalysisSessions { private moveThroughHistory( sessionId: string, changeId: string, - move: (history: EditHistory) => Promise, + move: (history: EditHistory) => Promise, ): Promise { return this.once(sessionId, changeId, (markApplied) => this.runEdit(sessionId, (session) => noticingApplies(session, markApplied, async () => { - const applied = await move(session.history); - return applied && replyTo(this.host, session, applied, changeId); + const moved = await move(session.history); + if (!moved) return undefined; + const reply = await replyTo(this.host, session, moved, changeId); + return moved.step ? { ...reply, step: moved.step } : reply; }), ), ); diff --git a/src/services/commands/catalogCommands.ts b/src/services/commands/catalogCommands.ts index 0faa4ee32..24f0bde3c 100644 --- a/src/services/commands/catalogCommands.ts +++ b/src/services/commands/catalogCommands.ts @@ -81,18 +81,21 @@ async function removeAnalysisAndLinks(buffer: ChangeBuffer, analysisId: string): /** * Stages storing `analysis` as an edit left it. An analysis left empty goes with every token link * to it. One made identical to another payload is folded into that payload. + * + * @returns The id of the analysis that holds `analysis`'s content afterward, or `undefined` when + * the edit left `analysis` empty. */ async function settleEditedAnalysis( buffer: ChangeBuffer, analysis: TokenAnalysis, now: string, -): Promise { +): Promise { if (isEmptyTokenAnalysis(analysis)) { await removeAnalysisAndLinks(buffer, analysis.id); - return; + return undefined; } buffer.put('tokenAnalyses', analysis); - await mergeIntoIdenticalPayload(buffer, analysis, now); + return (await mergeIntoIdenticalPayload(buffer, analysis, now)) ?? analysis.id; } /** @@ -103,17 +106,20 @@ async function settleEditedAnalysis( * An analysis the write leaves empty goes with every token link to it. One the write makes * identical to another payload is folded into that payload. Nothing is staged when the analysis * isn't stored. + * + * @returns The id of the analysis that holds the edited content afterward, or `undefined` when the + * analysis isn't stored or the edit left it empty. */ export async function writeAnalysisGloss( buffer: ChangeBuffer, { analysisId, value, analysisLanguage: lang, now }: WriteAnalysisGlossRequest, -): Promise { +): Promise { const [analysis] = await buffer.getTokenAnalyses([analysisId]); - if (!analysis) return; + if (!analysis) return undefined; if (value.trim() === '') clearAnalysisGloss(analysis, lang); else analysis.gloss = { ...analysis.gloss, [lang]: value }; analysis.updatedAt = now; - await settleEditedAnalysis(buffer, analysis, now); + return settleEditedAnalysis(buffer, analysis, now); } /** @@ -125,7 +131,7 @@ export async function clearAnalysisGlosses( buffer: ChangeBuffer, { analysisIds, analysisLanguage, now }: ClearAnalysisGlossesRequest, ): Promise { - await analysisIds.reduce>( + await analysisIds.reduce>( (previous, analysisId) => previous.then(() => writeAnalysisGloss(buffer, { analysisId, value: '', analysisLanguage, now }), @@ -144,18 +150,21 @@ export async function clearAnalysisGlosses( * An analysis the re-split leaves empty goes with every token link to it. One the re-split makes * identical to another payload is folded into that payload. Nothing is staged when the analysis * isn't stored. + * + * @returns The id of the analysis that holds the edited content afterward, or `undefined` when the + * analysis isn't stored or the edit left it empty. */ export async function writeAnalysisMorphemes( buffer: ChangeBuffer, { analysisId, morphemes, writingSystem, now }: WriteAnalysisMorphemesRequest, -): Promise { +): Promise { const [analysis] = await buffer.getTokenAnalyses([analysisId]); - if (!analysis) return; + if (!analysis) return undefined; if (morphemes.length === 0) delete analysis.morphemes; else analysis.morphemes = reconcileMorphemes(analysis.morphemes, morphemes, writingSystem); delete analysis.morphemesStale; analysis.updatedAt = now; - await settleEditedAnalysis(buffer, analysis, now); + return settleEditedAnalysis(buffer, analysis, now); } /** @@ -179,14 +188,17 @@ export async function confirmAnalysisMorphemes( * * An analysis the write makes identical to another payload is folded into that payload. Nothing is * staged when the analysis isn't stored or has no such morpheme. + * + * @returns The id of the analysis that holds the edited content afterward, or `undefined` when + * nothing was staged. */ export async function writeAnalysisMorphemeGloss( buffer: ChangeBuffer, { analysisId, morphemeId, value, analysisLanguage: lang, now }: WriteAnalysisMorphemeGlossRequest, -): Promise { +): Promise { const [analysis] = await buffer.getTokenAnalyses([analysisId]); const morpheme = analysis?.morphemes?.find(({ id }) => id === morphemeId); - if (!analysis || !morpheme) return; + if (!analysis || !morpheme) return undefined; if (value.trim() === '') { if (morpheme.gloss) { delete morpheme.gloss[lang]; @@ -197,7 +209,7 @@ export async function writeAnalysisMorphemeGloss( } analysis.updatedAt = now; buffer.put('tokenAnalyses', analysis); - await mergeIntoIdenticalPayload(buffer, analysis, now); + return (await mergeIntoIdenticalPayload(buffer, analysis, now)) ?? analysis.id; } /** @@ -228,6 +240,9 @@ export async function deleteAnalyses( * A survivor the merge leaves empty goes with every token link to it. One that matches another * payload is folded into that payload, which takes the survivor's confidence. Nothing is staged * when the survivor isn't stored. + * + * @returns The id of the analysis that holds the edited content afterward, or `undefined` when the + * survivor isn't stored or the merge left it empty. */ export async function mergeAnalysesInto( buffer: ChangeBuffer, @@ -238,9 +253,9 @@ export async function mergeAnalysesInto( analysisLanguage: lang, now, }: MergeAnalysesIntoRequest, -): Promise { +): Promise { const [survivor] = await buffer.getTokenAnalyses([survivorAnalysisId]); - if (!survivor) return; + if (!survivor) return undefined; const stored = new Map( (await buffer.getTokenAnalyses([...mergedAnalysisIds])).map((ta) => [ta.id, ta]), @@ -268,7 +283,10 @@ export async function mergeAnalysesInto( if (isEmptyTokenAnalysis(survivor)) { await removeAnalysisAndLinks(buffer, survivorAnalysisId); - return; + return undefined; } - await mergeIntoIdenticalPayload(buffer, survivor, now, { settleProvenance: true }); + return ( + (await mergeIntoIdenticalPayload(buffer, survivor, now, { settleProvenance: true })) ?? + survivorAnalysisId + ); } diff --git a/src/services/commands/tokenCommands.ts b/src/services/commands/tokenCommands.ts index 03f34263e..2e3f1fa64 100644 --- a/src/services/commands/tokenCommands.ts +++ b/src/services/commands/tokenCommands.ts @@ -180,20 +180,22 @@ export type MergeIntoIdenticalOptions = { * Every link to `analysis` moves to the survivor. The survivor's `updatedAt` becomes `now` when the * merge settles its confidence or clears its stale mark. A token that links both keeps one link. * The survivor's breakdown stays stale only when both payloads' were. + * + * @returns The survivor's id, or `undefined` when no other payload is identical. */ export async function mergeIntoIdenticalPayload( buffer: ChangeBuffer, analysis: TokenAnalysis, now: string, { settleProvenance = false }: MergeIntoIdenticalOptions = {}, -): Promise { +): Promise { const candidates = await buffer.findTokenAnalysesByForm([ normalizeSurfaceForm(analysis.surfaceText), ]); const other = preferredPayload( candidates.filter((ta) => ta.id !== analysis.id && analysesAreIdentical(ta, analysis)), ); - if (!other) return; + if (!other) return undefined; const settlesStaleMark = other.morphemesStale && !analysis.morphemesStale; if (settleProvenance) { if (analysis.confidence === undefined) delete other.confidence; @@ -210,6 +212,7 @@ export async function mergeIntoIdenticalPayload( ); await coalesceLinksPerToken(buffer, other.id); buffer.delete('tokenAnalyses', analysis); + return other.id; } /** diff --git a/src/services/editHistory.ts b/src/services/editHistory.ts index 666372d40..efbaec6fc 100644 --- a/src/services/editHistory.ts +++ b/src/services/editHistory.ts @@ -1,5 +1,6 @@ import type { Book } from 'interlinearizer'; import type { AnalysisRepository, ChangeSet } from '../types/analysis-repository'; +import type { EditStep } from '../types/edit-step'; import type { AppliedChange } from './analysisChanges'; import { mergeChangeSets } from '../utils/change-set'; import { MAX_UNDO_STEPS } from '../utils/undo-history'; @@ -15,7 +16,7 @@ export type ReanchorBooks = ( books: readonly Book[], ) => Promise; -/** How an edit leaves the draft's unsaved mark. */ +/** How an edit applies. */ export type EditOptions = { /** * `'keep'` leaves the draft as saved or unsaved as it was. `'clear'` marks it saved, even when @@ -23,11 +24,18 @@ export type EditOptions = { * something marks the draft unsaved. */ unsavedMark?: 'keep' | 'clear'; + /** The step to record the edit as, which an undo or redo of the edit hands back. */ + step?: EditStep; }; +/** An undo or redo as applied, with the step that the edit was recorded as. */ +export type HistoryMove = AppliedChange & { step?: EditStep }; + /** What reverts or redoes one edit. */ type Step = { changes: ChangeSet; + /** The step that the edit was recorded as. */ + step?: EditStep; /** How many passes had been recorded when the step was captured, all of which it reflects. */ passesSeen: number; /** The state that applying the step leads to. */ @@ -107,7 +115,12 @@ export class EditHistory { ); this.appliedCount += 1; this.noteMark(wasDirty); - this.undoable.push({ changes: inverse, passesSeen: this.passCount, state: this.state }); + this.undoable.push({ + changes: inverse, + ...(options?.step && { step: options.step }), + passesSeen: this.passCount, + state: this.state, + }); if (this.undoable.length > MAX_UNDO_STEPS) this.undoable.shift(); this.redoable.length = 0; this.forgetUnneededPasses(); @@ -159,12 +172,13 @@ 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 undo restored, with what - * restores what they replaced, or `undefined` when there is none. + * restores what they replaced. The move also carries the step that the edit was recorded as. + * `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. * @throws Whatever marking the draft saved throws; the undo stays applied. */ - undo(): Promise { + undo(): Promise { return this.step(this.undoable, this.redoable); } @@ -172,12 +186,13 @@ 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 redo restored, with what - * restores what they replaced, or `undefined` when there is none. + * restores what they replaced. The move also carries the step that the edit was recorded as. + * `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. * @throws Whatever marking the draft saved throws; the redo stays applied. */ - redo(): Promise { + redo(): Promise { return this.step(this.redoable, this.undoable); } @@ -186,7 +201,7 @@ export class EditHistory { * captured, as one apply, moving what reverts it onto `to`. Marks the draft saved when the step * lands on a clean state. */ - private async step(from: Step[], to: Step[]): Promise { + private async step(from: Step[], to: Step[]): Promise { const step = from.at(-1); if (!step) return undefined; const books = [...this.passes.values()] @@ -201,11 +216,16 @@ export class EditHistory { this.appliedCount += 1; this.noteMark(wasDirty); from.pop(); - to.push({ changes: inverse, passesSeen: this.passCount, state: this.state }); + to.push({ + changes: inverse, + ...(step.step && { step: step.step }), + passesSeen: this.passCount, + state: this.state, + }); this.state = step.state; this.forgetUnneededPasses(); if (this.cleanStates.has(this.state)) await this.repository.markClean(); - return { changes, inverse }; + return { changes, inverse, ...(step.step && { step: step.step }) }; } /** Unless `wasDirty`, makes the current state the only clean one, if it isn't clean already. */ diff --git a/src/services/editSteps.ts b/src/services/editSteps.ts new file mode 100644 index 000000000..f06e496b4 --- /dev/null +++ b/src/services/editSteps.ts @@ -0,0 +1,70 @@ +import type { AnalysisRepository } from '../types/analysis-repository'; +import type { EditStep } from '../types/edit-step'; +import type { ChangeBuffer } from './commands/changeBuffer'; + +/** + * Reads, ahead of a catalog edit or merge on the row of `analysisId`, what the edit's step will + * summarize. + * + * @returns A function that takes the id of the analysis that holds the row after the edit, or + * `undefined` when the edit removed the row. It builds a step that names the row's form, + * `analysisId` and any such surviving analysis. + */ +export async function catalogStepOf( + buffer: ChangeBuffer, + kind: 'catalogEdit' | 'catalogMerge', + analysisId: string, +): Promise<(survivingAnalysisId: string | undefined) => EditStep> { + const [analysis] = await buffer.getTokenAnalyses([analysisId]); + /* v8 ignore next -- an edit of an analysis not stored stages nothing, so records no step */ + const form = analysis?.surfaceText ?? ''; + return (survivingAnalysisId) => ({ + summary: { + kind, + form, + analysisId, + ...(survivingAnalysisId !== undefined && { survivingAnalysisId }), + }, + }); +} + +/** + * Reads, ahead of a deletion of analyses, the step that summarizes it. One analysis is summarized + * by its form and how many uses it had. Several are summarized by their count and their total + * uses. + * + * @param buffer - The deletion's change buffer, read before the deletion stages. + * @param repository - The session's repository, holding the draft as the deletion finds it. + * @param analysisIds - The analyses to delete. + */ +export async function deletionStepOf( + buffer: ChangeBuffer, + repository: AnalysisRepository, + analysisIds: readonly string[], +): Promise { + const counts = await repository.countApprovals([...analysisIds]); + if (analysisIds.length > 1) { + const usageCount = Object.values(counts).reduce((sum, count) => sum + count, 0); + return { summary: { kind: 'catalogDeleteChecked', count: analysisIds.length, usageCount } }; + } + const [analysisId] = analysisIds; + const [analysis] = await buffer.getTokenAnalyses([analysisId]); + /* v8 ignore next -- deleting an analysis not stored stages nothing, so records no step */ + const form = analysis?.surfaceText ?? ''; + const count = counts[analysisId] ?? 0; + return { + summary: + count === 0 + ? { kind: 'catalogDeleteUnused', form, analysisId } + : { kind: 'catalogDelete', form, analysisId, count }, + }; +} + +/** + * The step of an edit to the phrase occurrence `phraseId`, located at its first token. The step has + * no location when the occurrence is not stored. + */ +export async function phraseStartOf(buffer: ChangeBuffer, phraseId: string): Promise { + const [link] = await buffer.getPhraseAnalysisLinks([phraseId]); + return link ? { location: link.tokens[0].tokenRef } : {}; +} diff --git a/src/services/sessionPages.ts b/src/services/sessionPages.ts index fd526aaec..f211d6f4d 100644 --- a/src/services/sessionPages.ts +++ b/src/services/sessionPages.ts @@ -1,5 +1,6 @@ import type { Book, + PhraseAnalysisLink, Segment, SegmentAnalysis, SegmentAnalysisLink, @@ -9,12 +10,14 @@ import type { AnalysisRepository } from '../types/analysis-repository'; import type { AppliedChange } from './analysisChanges'; import type { BookOutline, + PhraseSpanToken, PlacedFreeTranslations, PoolRead, SegmentPage, } from '../types/segment-pages'; import { isWordToken } from '../types/type-guards'; import { normalizeSurfaceForm } from '../utils/analysis-identity'; +import { bookIndexesOf } from '../utils/book-indexes'; import { buildBookOutline } from '../utils/book-outline'; import { touched } from '../utils/change-set'; import { @@ -119,6 +122,29 @@ function placedAmong( return { bySegment, orphanedAfter, orphanedAhead }; } +/** For each of `links`, by id, the word tokens of the link that `book` holds, with where each sits. */ +function spansOf( + book: Book, + links: readonly PhraseAnalysisLink[], +): Record { + const { wordTokenByRef, tokenSegmentMap, tokenDocOrder, gapTextByWordRef } = bookIndexesOf( + book.segments, + ); + return Object.fromEntries( + links.map(({ id, tokens }) => [ + id, + tokens.flatMap(({ tokenRef }): PhraseSpanToken[] => { + const token = wordTokenByRef.get(tokenRef); + const segmentId = tokenSegmentMap.get(tokenRef); + const docOrder = tokenDocOrder.get(tokenRef); + if (!token || segmentId === undefined || docOrder === undefined) return []; + const gapText = gapTextByWordRef.get(tokenRef); + return [{ token, segmentId, docOrder, ...(gapText !== undefined && { gapText }) }]; + }), + ]), + ); +} + /** * Outlines a book, counting the orphaned free-translation cards that the view shows beside each * segment. Each count takes in the cards after its segment; the first also takes in those ahead of @@ -205,6 +231,7 @@ export async function readSegmentPage( ]), poolBuckets, staleFreeTranslations, + phraseSpans: spansOf(book, phraseAnalysisLinks), }, }; } diff --git a/src/types/command-reply.ts b/src/types/command-reply.ts index 3837caa9b..ebcbef3fb 100644 --- a/src/types/command-reply.ts +++ b/src/types/command-reply.ts @@ -1,5 +1,6 @@ import type { ChangeId } from 'interlinearizer'; import type { ChangeSet } from './analysis-repository'; +import type { EditStep } from './edit-step'; import type { PoolRead } from './segment-pages'; /** @@ -12,4 +13,9 @@ export type CommandReply = { changeId: ChangeId; /** The changes applied, naming the edit's intent. */ changes: ChangeSet; + /** + * For an undo or redo, the step that the edit was recorded as; absent when it was recorded with + * none. + */ + step?: EditStep; } & PoolRead; diff --git a/src/types/edit-step.ts b/src/types/edit-step.ts new file mode 100644 index 000000000..0f5ac3fe2 --- /dev/null +++ b/src/types/edit-step.ts @@ -0,0 +1,39 @@ +/** What a step made in the analysis catalog tells the reader it was. */ +type CatalogStepSummary = { + form: string; + /** The analysis whose catalog row the step acted on. */ + analysisId: string; + /** The analysis holding that row once the step is made, absent when the step leaves none. */ + survivingAnalysisId?: string; +}; + +/** What an undo step made at no one place tells the reader it was, once undone or redone. */ +export type StepSummary = Readonly< + | (CatalogStepSummary & { kind: 'catalogEdit' | 'catalogMerge' | 'catalogDeleteUnused' }) + | (CatalogStepSummary & { + kind: 'catalogDelete'; + /** How many uses the deleted analysis had, never zero. */ + count: number; + }) + | { + kind: 'catalogDeleteChecked'; + /** How many analyses were deleted. */ + count: number; + /** How many uses they had between them. */ + usageCount: number; + } + | { + kind: 'catalogClearGlossChecked'; + /** How many analyses had their gloss cleared. */ + count: number; + } + | { kind: 'discardBook'; book: string } + | { kind: 'discardAll' } +>; + +/** An undo step, as recorded. */ +export type EditStep = Readonly<{ + /** The token ref or segment id the step was made at, when it was made at one place. */ + location?: string; + summary?: StepSummary; +}>; diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 9c22b6525..6ec78aa24 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -385,8 +385,8 @@ declare module 'papi-shared-types' { * was saved with, and unsaved otherwise. * * @returns A JSON string of the `CommandReply`: the changes that reverted the edit and - * re-anchored what the undo restored, with the pool buckets they touched. `undefined` when - * there is none. + * re-anchored what the undo restored, with the pool buckets that they touched. The reply also + * carries the step that the edit was recorded as. `undefined` when there is none. * @throws If the session is read-only or not open, or re-anchoring, applying the changes or * marking the draft saved fails (logged before rethrowing). */ @@ -401,8 +401,8 @@ declare module 'papi-shared-types' { * saved with, and unsaved otherwise. * * @returns A JSON string of the `CommandReply`: the changes that redid the edit and re-anchored - * what the redo restored, with the pool buckets they touched. `undefined` when there is - * none. + * what the redo restored, with the pool buckets that they touched. The reply also carries the + * step that the edit was recorded as. `undefined` when there is none. * @throws If the session is read-only or not open, or re-anchoring, applying the changes or * marking the draft saved fails (logged before rethrowing). */ @@ -615,7 +615,8 @@ declare module 'papi-shared-types' { ) => Promise; /** - * Keeps a token analysis's stale breakdown as it stands, for every token linked to it. + * Keeps a token analysis's stale breakdown as it stands, for every token linked to it. The undo + * step is placed at `tokenRef` when given, and otherwise names the analysis's catalog row. * * @returns A JSON string of the `CommandReply`: the changes it applied, with the pool buckets * they touched. @@ -625,6 +626,7 @@ declare module 'papi-shared-types' { 'interlinearizer.confirmAnalysisMorphemes': ( sessionId: string, analysisId: string, + tokenRef?: string | null, changeId?: import('interlinearizer').ChangeId | null, ) => Promise; diff --git a/src/types/segment-pages.ts b/src/types/segment-pages.ts index f74b06cac..cda20e751 100644 --- a/src/types/segment-pages.ts +++ b/src/types/segment-pages.ts @@ -6,6 +6,7 @@ import type { SegmentAnalysis, SegmentAnalysisLink, SegmentHeading, + Token, TokenAnalysis, TokenAnalysisLink, VerseStart, @@ -58,6 +59,19 @@ export type PoolRead = { poolAnalyses: TokenAnalysis[]; }; +/** A word token of a phrase that touches a page, with where it sits in its book. */ +export type PhraseSpanToken = Readonly<{ + token: Token & { type: 'word' }; + segmentId: string; + /** Its place among the book's word tokens, in document order. */ + docOrder: number; + /** + * The baseline text separating it from the previous word token of its segment, verbatim; absent + * for a segment's first word token. + */ + gapText?: string; +}>; + /** Stale free translations by where the view shows them, each named by its payload's id. */ export type PlacedFreeTranslations = Readonly<{ /** @@ -92,6 +106,11 @@ export type PageAnalysis = Readonly<{ poolBuckets: PoolBuckets; /** The stale free translations placed among the segments. */ staleFreeTranslations: PlacedFreeTranslations; + /** + * The word tokens of each phrase link, by its id, in the order the link stores them, wherever + * they sit in the book. A token the book no longer holds is left out. + */ + phraseSpans: Readonly>; }>; /** A run of a book's segments, at the indexes its outline lists them at. */ diff --git a/src/utils/book-indexes.ts b/src/utils/book-indexes.ts index 7152726d0..2afd49896 100644 --- a/src/utils/book-indexes.ts +++ b/src/utils/book-indexes.ts @@ -39,6 +39,50 @@ export interface BookIndexes { wordRefByOrder: readonly string[]; } +/** The writable token lookups of {@link BookIndexes}, filled while segments are indexed. */ +export type TokenIndexes = { + tokenDocOrder: Map; + fullTokenOrder: Map; + gapTextByWordRef: Map; + tokenSegmentMap: Map; + wordTokenByRef: Map; +}; + +/** + * Records a segment's tokens in `indexes`, numbering its word tokens from `firstWord` and all its + * tokens from `firstToken`. Calls `onWord` with each word token's ref and its place among the + * book's word tokens. Returns the places where the next segment's word numbering and token + * numbering start. + */ +export function indexSegmentTokens( + segment: Segment, + indexes: TokenIndexes, + firstWord: number, + firstToken: number, + onWord?: (ref: string, wordOrder: number) => void, +): { nextWord: number; nextToken: number } { + let wordOrder = firstWord; + let tokenOrder = firstToken; + let previousWord: Token | undefined; + segment.tokens.forEach((token) => { + indexes.tokenSegmentMap.set(token.ref, segment.id); + indexes.fullTokenOrder.set(token.ref, tokenOrder); + tokenOrder += 1; + if (!isWordToken(token)) return; + indexes.tokenDocOrder.set(token.ref, wordOrder); + onWord?.(token.ref, wordOrder); + wordOrder += 1; + indexes.wordTokenByRef.set(token.ref, token); + if (previousWord) + indexes.gapTextByWordRef.set( + token.ref, + segment.baselineText.slice(previousWord.charEnd, token.charStart), + ); + previousWord = token; + }); + return { nextWord: wordOrder, nextToken: tokenOrder }; +} + /** * Builds the book-wide lookup indexes over a book's `segments`. Every segmentation of one text * gives the same token orders; custom boundaries regroup a book's tokens without reordering them. @@ -46,44 +90,23 @@ export interface BookIndexes { export function buildBookIndexes(segments: readonly Segment[]): BookIndexes { const segmentById = new Map(); const segmentOrder = new Map(); - const tokenDocOrder = new Map(); - const fullTokenOrder = new Map(); - const gapTextByWordRef = new Map(); - const tokenSegmentMap = new Map(); - const wordTokenByRef = new Map(); + const indexes: TokenIndexes = { + tokenDocOrder: new Map(), + fullTokenOrder: new Map(), + gapTextByWordRef: new Map(), + tokenSegmentMap: new Map(), + wordTokenByRef: new Map(), + }; const wordRefByOrder: string[] = []; - let tokenIndex = 0; - segments.forEach((seg, segIndex) => { - segmentById.set(seg.id, seg); - segmentOrder.set(seg.id, segIndex); - let prevWord: Token | undefined; - seg.tokens.forEach((token) => { - tokenSegmentMap.set(token.ref, seg.id); - fullTokenOrder.set(token.ref, tokenIndex); - tokenIndex += 1; - if (isWordToken(token)) { - tokenDocOrder.set(token.ref, wordRefByOrder.length); - wordRefByOrder.push(token.ref); - wordTokenByRef.set(token.ref, token); - if (prevWord) - gapTextByWordRef.set( - token.ref, - seg.baselineText.slice(prevWord.charEnd, token.charStart), - ); - prevWord = token; - } - }); + let next = { nextWord: 0, nextToken: 0 }; + segments.forEach((segment, index) => { + segmentById.set(segment.id, segment); + segmentOrder.set(segment.id, index); + next = indexSegmentTokens(segment, indexes, next.nextWord, next.nextToken, (ref) => + wordRefByOrder.push(ref), + ); }); - return { - segmentById, - segmentOrder, - tokenDocOrder, - fullTokenOrder, - gapTextByWordRef, - tokenSegmentMap, - wordTokenByRef, - wordRefByOrder, - }; + return { segmentById, segmentOrder, ...indexes, wordRefByOrder }; } const bookIndexesCache = new WeakMap(); diff --git a/src/utils/page-indexes.ts b/src/utils/page-indexes.ts new file mode 100644 index 000000000..495df6724 --- /dev/null +++ b/src/utils/page-indexes.ts @@ -0,0 +1,131 @@ +import type { PhraseAnalysisLink, Segment, SegmentRange, Token } from 'interlinearizer'; +import type { OutlineSegment, SegmentPage } from '../types/segment-pages'; +import { indexSegmentTokens, type TokenIndexes } from './book-indexes'; +import { straddledRefs } from './phrase-arc'; +import { positionOf } from './verse-ref'; + +/** + * The lookups a view reads tokens and segments through, built from the pages it holds and the + * phrase spans they carry rather than from the whole book. Each holds what the whole book's lookups + * hold for the tokens it covers. + */ +export type PageIndexes = Readonly<{ + /** Every segment of the outline, by id. */ + outlineSegmentById: ReadonlyMap; + /** The outline index of every segment, by id. */ + segmentOrder: ReadonlyMap; + /** The segments of the pages held, by id. */ + segmentById: ReadonlyMap; + /** Each word token held, or spanned by a phrase, to its place among the book's word tokens. */ + tokenDocOrder: ReadonlyMap; + /** Each token held, punctuation included, to its place among the book's tokens. */ + fullTokenOrder: ReadonlyMap; + /** + * Each word token held, or spanned by a phrase, to the baseline text separating it from the + * previous word token of its segment. A segment's first word token has no entry. + */ + gapTextByWordRef: ReadonlyMap; + /** Each token held, or spanned by a phrase, to the id of its segment. */ + tokenSegmentMap: ReadonlyMap; + /** Each word token held, or spanned by a phrase, by ref. */ + wordTokenByRef: ReadonlyMap; + /** + * The refs of held word tokens before which a segment boundary would cut a phrase that the pages + * name. For each such phrase, those are the held word tokens after its first, up to and including + * its last, including those in the gaps of a discontiguous phrase. + */ + straddledBoundaryRefs: ReadonlySet; +}>; + +/** + * Builds a view's lookups from the pages that it holds. + * + * @param outline - The outline of the book that the pages belong to. + * @param pages - The pages held, each cut from the same text and boundaries as `outline`. + */ +export function buildPageIndexes( + outline: readonly OutlineSegment[], + pages: readonly SegmentPage[], +): PageIndexes { + const outlineSegmentById = new Map(); + const segmentOrder = new Map(); + const firstWordAt: number[] = []; + const firstTokenAt: number[] = []; + let words = 0; + let tokens = 0; + outline.forEach((segment, index) => { + outlineSegmentById.set(segment.id, segment); + segmentOrder.set(segment.id, index); + firstWordAt.push(words); + firstTokenAt.push(tokens); + words += segment.wordTokenCount; + tokens += segment.tokenCount; + }); + + const segmentById = new Map(); + const indexes: TokenIndexes = { + tokenDocOrder: new Map(), + fullTokenOrder: new Map(), + gapTextByWordRef: new Map(), + tokenSegmentMap: new Map(), + wordTokenByRef: new Map(), + }; + const heldWordByOrder = new Map(); + const phraseLinks = new Map(); + pages.forEach(({ start, segments, analysis }) => { + segments.forEach((segment, offset) => { + segmentById.set(segment.id, segment); + indexSegmentTokens( + segment, + indexes, + firstWordAt[start + offset], + firstTokenAt[start + offset], + (ref, wordOrder) => heldWordByOrder.set(wordOrder, ref), + ); + }); + Object.values(analysis?.phraseSpans ?? {}) + .flat() + .forEach(({ token, segmentId, docOrder, gapText }) => { + indexes.tokenSegmentMap.set(token.ref, segmentId); + indexes.tokenDocOrder.set(token.ref, docOrder); + indexes.wordTokenByRef.set(token.ref, token); + if (gapText !== undefined) indexes.gapTextByWordRef.set(token.ref, gapText); + }); + analysis?.phraseAnalysisLinks.forEach((link) => phraseLinks.set(link.id, link)); + }); + + return { + outlineSegmentById, + segmentOrder, + segmentById, + ...indexes, + straddledBoundaryRefs: straddledRefs(phraseLinks.values(), indexes.tokenDocOrder, (order) => + heldWordByOrder.get(order), + ), + }; +} + +/** + * The outline range to read for the segment id or token ref `ref` before its page has loaded; + * `undefined` when the outline holds no such segment or verse. + */ +export function outlineRangeOf( + outline: readonly OutlineSegment[], + ref: string, +): SegmentRange | undefined { + const { verse, offset } = positionOf(ref); + const chapterAndVerse = verse.slice(verse.indexOf(' ') + 1); + const holds = (segment: OutlineSegment) => { + if (segment.id === ref || segment.id === verse) return true; + if (segment.heading) return false; + if (segment.id.startsWith(`${verse}:`)) + return Number(segment.id.slice(verse.length + 1)) <= offset; + return segment.verseStarts.some( + ({ chapter, number, isContinuation }) => + !isContinuation && `${chapter}:${number}` === chapterAndVerse, + ); + }; + const index = outline.findIndex((segment) => segment.id === ref); + const found = index === -1 ? outline.findLastIndex(holds) : index; + return found === -1 ? undefined : { start: found, end: found + 1 }; +} diff --git a/src/utils/phrase-arc.ts b/src/utils/phrase-arc.ts index 12cbad2d0..79c6f30ec 100644 --- a/src/utils/phrase-arc.ts +++ b/src/utils/phrase-arc.ts @@ -1,6 +1,33 @@ import type { PhraseAnalysisLink, TokenSnapshot } from 'interlinearizer'; import type { PhraseMode } from '../types/phrase-mode'; +/** + * The refs of the word tokens before which a segment boundary would cut one of the phrases in + * `links`. For each phrase, those are the word tokens after its first, up to and including its + * last, including those in the gaps of a discontiguous phrase. + * + * @param links - The phrases to check. + * @param tokenDocOrder - Each word token's place in document order. + * @param wordAt - The ref of the word token at a place in document order, or `undefined` where the + * caller holds none. + */ +export function straddledRefs( + links: Iterable, + tokenDocOrder: ReadonlyMap, + wordAt: (order: number) => string | undefined, +): Set { + const straddled = new Set(); + // eslint-disable-next-line no-restricted-syntax -- an Iterable has no forEach + for (const { tokens } of links) { + const orders = tokens.flatMap(({ tokenRef }) => tokenDocOrder.get(tokenRef) ?? []); + for (let order = Math.min(...orders) + 1; order <= Math.max(...orders); order += 1) { + const ref = wordAt(order); + if (ref !== undefined) straddled.add(ref); + } + } + return straddled; +} + // #region Constants /** diff --git a/src/utils/stale-free-translations.ts b/src/utils/stale-free-translations.ts index e63d732be..103ef763b 100644 --- a/src/utils/stale-free-translations.ts +++ b/src/utils/stale-free-translations.ts @@ -2,7 +2,7 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book, Segment } from 'interlinearizer'; import { bookOfRef } from './analysis-book'; import { verseOfTokenRef } from './reanchor-analysis'; -import { segmentContainsVerse, verseLabelCovers, verseOfId } from './verse-ref'; +import { positionOf, segmentContainsVerse, verseLabelCovers, verseOfId } from './verse-ref'; /** A free translation whose segment's text has changed since it was written. */ export type StaleFreeTranslation = Readonly<{ @@ -33,18 +33,6 @@ type Place = Readonly<{ order: number; }>; -/** - * Reads the verse and character offset a segment id names: a segment begun at a verse is named by - * the verse's SID, and one begun mid-verse by its first token's ref, one `:`-separated part - * longer. - */ -function positionOf(segmentId: string): { verse: string; offset: number } { - const refPart = segmentId.slice(segmentId.indexOf(' ') + 1); - if (refPart.split(':').length < 3) return { verse: segmentId, offset: 0 }; - const verse = verseOfTokenRef(segmentId); - return { verse, offset: Number(segmentId.slice(verse.length + 1)) }; -} - /** * Indexes the book's tokens by the verse their refs name, each list in document order, and each * verse holding no tokens by its segment alone. diff --git a/src/utils/verse-ref.ts b/src/utils/verse-ref.ts index 37614426c..21925a192 100644 --- a/src/utils/verse-ref.ts +++ b/src/utils/verse-ref.ts @@ -1,6 +1,7 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { ScriptureRef } from 'interlinearizer'; import type { OutlineSegment } from '../types/segment-pages'; +import { verseOfTokenRef } from './reanchor-analysis'; /** * Whether the two refs name the same verse, bridging {@link ScriptureRef}'s `chapter`/`verse` field @@ -81,6 +82,18 @@ export function toSerializedVerseRef(ref: ScriptureRef): SerializedVerseRef { return { book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse }; } +/** + * Reads the verse and character offset that a segment id or token ref names. A verse's SID names + * offset `0`. A token ref, or the id of a segment that begins mid-verse, is the SID followed by `:` + * and the offset. + */ +export function positionOf(id: string): { verse: string; offset: number } { + const refPart = id.slice(id.indexOf(' ') + 1); + if (refPart.split(':').length < 3) return { verse: id, offset: 0 }; + const verse = verseOfTokenRef(id); + return { verse, offset: Number(id.slice(verse.length + 1)) }; +} + /** The verse a token ref or segment id names, a heading's being the verse it is filed under. */ export function verseOfId(id: string): SerializedVerseRef { const space = id.indexOf(' ');