From 734e211ec331bab650e3272d2d0b6119e2acc07b Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Wed, 2 Sep 2026 17:37:00 -0600 Subject: [PATCH 1/3] Warn when the source text drops persisted segment boundaries --- contributions/localizedStrings.json | 1 + .../components/InterlinearizerLoader.test.tsx | 129 ++++++++++++++++++ src/__tests__/utils/segmentation.test.ts | 30 ++++ src/components/InterlinearizerLoader.tsx | 45 +++++- src/utils/segmentation.ts | 16 +++ 5 files changed, 220 insertions(+), 1 deletion(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 5f2918ac..3261d90e 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -101,6 +101,7 @@ "%interlinearizer_boundaryControl_merge%": "Join these two segments", "%interlinearizer_boundaryControl_mergeAltHint%": "Join these two segments. Hold {key} and click between words to split.", "%interlinearizer_boundaryControl_split%": "Split segment here", + "%interlinearizer_segmentation_lostBoundaries%": "The source text changed, so {count} of your segment boundaries no longer fit it and aren't shown. They'll come back on their own if the text is restored.", "%interlinearizer_phraseBox_glossLabel%": "Phrase gloss", "%interlinearizer_phraseBox_edit%": "Edit phrase", "%interlinearizer_phraseBox_unlink%": "Unlink phrase", diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index f6406c5a..ab342462 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -2534,6 +2534,135 @@ describe('InterlinearizerLoader', () => { }); }); + describe('lost segment boundaries', () => { + /** A two-verse book the deltas below anchor into. */ + const TWO_VERSE_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + makeSegment('GEN 1:1', 'Alpha beta.', [ + makeWordToken('GEN 1:1:0', 'Alpha'), + makeWordToken('GEN 1:1:6', 'beta', 6), + ]), + makeSegment('GEN 1:2', 'Gamma.', [makeWordToken('GEN 1:2:0', 'Gamma')]), + ], + }; + + /** + * Renders the loader on {@link TWO_VERSE_BOOK} with the given persisted boundary delta, + * returning a `rerenderNow` that re-invokes the _same_ loader instance — the book-data mock + * mutates hook output rather than React state, so a rerender is what picks up a changed book. + */ + async function renderWithSegmentation( + segmentation: DraftProject['segmentation'], + ): Promise<{ rerenderNow: () => void }> { + mockBookData({ book: TWO_VERSE_BOOK }); + mockSendCommand.mockResolvedValue( + JSON.stringify({ ...emptyDraft(testProjectId), segmentation }), + ); + const scrollGroupHook = makeScrollGroupHook(); + const webViewState = makeWebViewState(); + const buildUi = () => ( + true)} + /> + ); + let view: ReturnType | undefined; + await act(async () => { + view = render(buildUi()); + }); + return { rerenderNow: () => view?.rerender(buildUi()) }; + } + + it('warns with the lost-anchor count when the source no longer has the anchored tokens', async () => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + await renderWithSegmentation({ + removedVerseStarts: ['GEN 1:9:0'], + addedStarts: ['GEN 1:1:99'], + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ + message: '%interlinearizer_segmentation_lostBoundaries%', + severity: 'warning', + }); + }); + + it('does not warn when every anchor still resolves', async () => { + await renderWithSegmentation({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: ['GEN 1:1:6'], + }); + + expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); + }); + + it('does not warn for the default segmentation', async () => { + await renderWithSegmentation(undefined); + + expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); + }); + + it('warns once per tokenization, not once per re-render of the same book', async () => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + const view = await renderWithSegmentation({ + removedVerseStarts: ['GEN 1:9:0'], + addedStarts: [], + }); + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(1); + + // A duplicate GetText re-fetch hands the loader back the same `verseBook` reference, so the + // re-render it causes must not re-warn about a loss already reported. + await act(async () => { + view.rerenderNow(); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(1); + }); + + it('warns again on a new tokenization that still loses anchors', async () => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + const view = await renderWithSegmentation({ + removedVerseStarts: ['GEN 1:9:0'], + addedStarts: [], + }); + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(1); + + // A genuinely new tokenization — the source changed again — is a fresh loss of the same + // boundaries, and worth saying a second time. + mockBookData({ book: { ...TWO_VERSE_BOOK } }); + await act(async () => { + view.rerenderNow(); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(2); + }); + + it('leaves the dead anchors in the draft so they revive if the source comes back', async () => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + const segmentation = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: ['GEN 1:1:99'] }; + await renderWithSegmentation(segmentation); + + // The warning path is read-only: nothing persists a pruned delta in response to it. + const saves = mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft'); + expect(saves).toHaveLength(0); + }); + + it('logs a warning when the notification cannot be delivered', async () => { + jest.mocked(papi.notifications.send).mockRejectedValue(new Error('ui offline')); + await renderWithSegmentation({ removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }); + + await waitFor(() => { + expect(jest.mocked(logger.warn)).toHaveBeenCalledWith( + expect.stringContaining('failed to warn about lost segment boundaries'), + ); + }); + }); + }); + describe('save command', () => { it('saves the draft analysis to the active project when Save is clicked with an active project', async () => { const draftAnalysis = emptyAnalysis(); diff --git a/src/__tests__/utils/segmentation.test.ts b/src/__tests__/utils/segmentation.test.ts index df6fe933..a018c410 100644 --- a/src/__tests__/utils/segmentation.test.ts +++ b/src/__tests__/utils/segmentation.test.ts @@ -6,6 +6,7 @@ import { defaultVerseStarts, effectiveStarts, isDefaultSegmentation, + lostAnchors, mergeSegments, moveBoundary, removeBoundaryAt, @@ -75,6 +76,35 @@ describe('isDefaultSegmentation', () => { }); }); +describe('lostAnchors', () => { + it('is empty for undefined', () => { + expect(lostAnchors(THREE_VERSES, undefined)).toEqual([]); + }); + + it('is empty when every anchor still names a token', () => { + const delta: SegmentationDelta = { removedVerseStarts: [V2_START], addedStarts: [V1_BETA] }; + expect(lostAnchors(THREE_VERSES, delta)).toEqual([]); + }); + + it('reports a removed verse start whose token is gone', () => { + const delta: SegmentationDelta = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }; + expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:9:0']); + }); + + it('reports an added start whose char offset no longer exists', () => { + const delta: SegmentationDelta = { removedVerseStarts: [], addedStarts: ['GEN 1:1:99'] }; + expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:1:99']); + }); + + it('reports losses from both arrays, keeping the surviving anchors out', () => { + const delta: SegmentationDelta = { + removedVerseStarts: [V2_START, 'GEN 1:9:0'], + addedStarts: [V1_BETA, 'GEN 1:1:99'], + }; + expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:9:0', 'GEN 1:1:99']); + }); +}); + describe('effectiveStarts', () => { it('returns all default verse starts for the default segmentation', () => { expect(effectiveStarts(THREE_VERSES, undefined)).toEqual( diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 8042b1c0..e8554d96 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -16,13 +16,14 @@ import type { SelectMenuItemHandler } from 'platform-bible-react'; import { formatReplacementString, isPlatformError } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentProps, ReactNode, RefObject } from 'react'; -import type { TextAnalysis } from 'interlinearizer'; +import type { Book, TextAnalysis } from 'interlinearizer'; import { resegmentBook } from 'parsers/papi/resegmentBook'; import useDraftProject from '../hooks/useDraftProject'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; import { isDefaultSegmentation, + lostAnchors, mergeSegments, moveBoundary, splitSegmentBefore, @@ -161,6 +162,7 @@ const STRING_KEYS = [ '%interlinearizer_banner_pt9Import%', '%interlinearizer_banner_sync%', '%interlinearizer_banner_copy%', + '%interlinearizer_segmentation_lostBoundaries%', ] as const satisfies `%${string}%`[]; /** @@ -478,6 +480,47 @@ function InterlinearizerLoaderInner({ [verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView], ); + /** + * The last book the lost-boundary warning was evaluated against, so a re-fetch that tokenizes to + * the same book does not re-warn about a loss already reported. + */ + const lastLostAnchorsBookRef = useRef(undefined); + + /** + * Warns when the source text has changed out from under the draft's segment boundaries, whose + * anchors are then dropped and the region reverted to one segment per verse — silently, without + * this. + * + * Every new tokenization that still loses anchors warns again: a reversify, revert, reversify + * cycle loses the same boundaries three separate times. + */ + useEffect(() => { + if (!verseBook || isImportView || isDraftLoading) return; + if (lastLostAnchorsBookRef.current === verseBook) return; + lastLostAnchorsBookRef.current = verseBook; + const lost = lostAnchors(verseBook, draft?.segmentation); + if (lost.length === 0) return; + papi.notifications + .send({ + message: formatReplacementString( + localizedStrings['%interlinearizer_segmentation_lostBoundaries%'], + { count: lost.length }, + ), + severity: 'warning', + }) + .catch((error: unknown) => { + logger.warn(`Interlinearizer: failed to warn about lost segment boundaries: ${error}`); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value + }, [ + verseBook, + segmentationVersion, + draftVersion, + isDraftLoading, + isImportView, + localizedStrings, + ]); + /** * Maps each merged-away default verse boundary's word-token split anchor — the verse's first word * token, the ref the boundary slots are keyed by — to the removed default start ref (the verse's diff --git a/src/utils/segmentation.ts b/src/utils/segmentation.ts index 37f6c2c8..d2741f1d 100644 --- a/src/utils/segmentation.ts +++ b/src/utils/segmentation.ts @@ -207,3 +207,19 @@ export function splitSegmentBefore( export function isDefaultSegmentation(delta: SegmentationDelta | undefined): boolean { return !delta || (delta.removedVerseStarts.length === 0 && delta.addedStarts.length === 0); } + +/** + * The delta's anchors that no longer name a token in the book, in delta order — the boundaries + * {@link effectiveStarts} silently drops, which a reversified or upstream-edited source produces + * because both re-key the token refs anchors are written against. + * + * The delta itself is left intact, so a source that reverts brings its boundaries back. + */ +export function lostAnchors( + verseBook: Book, + delta: SegmentationDelta | undefined, +): readonly string[] { + if (!delta) return []; + const { all } = bookLookups(verseBook); + return [...delta.removedVerseStarts, ...delta.addedStarts].filter((ref) => !all.has(ref)); +} From 17b4a9f8eab59a88b7b298df0e3ff377c6861e06 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Thu, 3 Sep 2026 11:20:16 -0600 Subject: [PATCH 2/3] Scope segment-boundary anchors to the loaded book --- .../components/InterlinearizerLoader.test.tsx | 61 ++++++++++++++++++- src/__tests__/utils/segmentation.test.ts | 55 +++++++++++++++++ src/components/InterlinearizerLoader.tsx | 20 +++--- src/utils/segmentation.ts | 33 +++++++--- 4 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index ab342462..015cf1c7 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -14,6 +14,7 @@ import InterlinearizerLoader from '../../components/InterlinearizerLoader'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; +import type { OpenableProject } from '../../hooks/useDraftProject'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { PT9_MANIFEST_TIMEOUT_MS } from '../../utils/pt9-manifest'; import type { PhraseMode } from '../../types/phrase-mode'; @@ -277,6 +278,15 @@ const STUB_IMPORT_PROJECT: MockProject = { pt9Import: { fileHashes: { 'Lexicon.xml': 'aaaa1111' }, importedAt: '2026-08-01T00:00:00Z' }, }; +/** + * The project the stub picker's "Open project" button loads into the draft. Mutable so a test can + * choose the boundaries the opened project carries. + */ +let openableProjectForStub: OpenableProject = { + analysis: emptyAnalysis(), + analysisLanguages: ['en'], +}; + jest.mock('../../components/modals/ProjectModals', () => ({ __esModule: true, /** @@ -293,6 +303,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({ activeProject, defaultAnalysisLanguage, hasUnsavedWork, + loadFromProject, onImportPt9, onOpenImport, openRequest, @@ -304,7 +315,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({ defaultAnalysisLanguage?: string; hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; - loadFromProject: (project: unknown) => void; + loadFromProject: (project: OpenableProject) => void; markSynced: () => void; onImportPt9: () => void; onOpenImport: (project: MockProject) => void; @@ -366,6 +377,16 @@ jest.mock('../../components/modals/ProjectModals', () => ({ > View info + )} {modal === 'create' && ( @@ -540,6 +561,7 @@ describe('InterlinearizerLoader', () => { capturedInterlinearizerProps = undefined; capturedStoreProps = undefined; interlinearizerMountCount = 0; + openableProjectForStub = { analysis: emptyAnalysis(), analysisLanguages: ['en'] }; mockBookData(); mockOptimisticSetting(); // The loader's draft hook calls `interlinearizer.getDraft` on mount; default to a valid empty @@ -2641,6 +2663,43 @@ describe('InterlinearizerLoader', () => { expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(2); }); + it('warns for a project opened into the same book the guard already checked', async () => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + // The loaded draft's boundaries all resolve, so the initial check latches the book silently. + const view = await renderWithSegmentation({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); + + // Open replaces the draft wholesale without touching the loaded book, so the newly opened + // boundaries are unchecked even though the book is the one already latched. + openableProjectForStub = { + analysis: emptyAnalysis(), + analysisLanguages: ['en'], + segmentation: { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }, + }; + await userEvent.click(screen.getByTestId('tab-toolbar-project-menu')); + await act(async () => { + await userEvent.click(screen.getByTestId('select-modal-open-project')); + }); + view.rerenderNow(); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ + message: '%interlinearizer_segmentation_lostBoundaries%', + severity: 'warning', + }); + }); + + it('does not warn about anchors in a book other than the loaded one', async () => { + await renderWithSegmentation({ + removedVerseStarts: ['EXO 1:5:0'], + addedStarts: ['EXO 1:1:6'], + }); + + expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); + }); + it('leaves the dead anchors in the draft so they revive if the source comes back', async () => { jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); const segmentation = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: ['GEN 1:1:99'] }; diff --git a/src/__tests__/utils/segmentation.test.ts b/src/__tests__/utils/segmentation.test.ts index a018c410..a193040e 100644 --- a/src/__tests__/utils/segmentation.test.ts +++ b/src/__tests__/utils/segmentation.test.ts @@ -103,6 +103,24 @@ describe('lostAnchors', () => { }; expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:9:0', 'GEN 1:1:99']); }); + + it('ignores anchors naming a book other than the one loaded', () => { + // One delta spans the whole draft, so a boundary set in Exodus is simply not this book's + // business — it is intact, and reporting it would warn about a loss that has not happened. + const delta: SegmentationDelta = { + removedVerseStarts: ['EXO 1:5:0'], + addedStarts: ['EXO 1:1:6'], + }; + expect(lostAnchors(THREE_VERSES, delta)).toEqual([]); + }); + + it('still reports this book’s losses when another book’s anchors are present', () => { + const delta: SegmentationDelta = { + removedVerseStarts: ['EXO 1:5:0', 'GEN 1:9:0'], + addedStarts: ['EXO 1:1:6'], + }; + expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:9:0']); + }); }); describe('effectiveStarts', () => { @@ -273,4 +291,41 @@ describe('normalization', () => { addedStarts: [V1_BETA], }); }); + + it('keeps another book’s added start when splitting in this one', () => { + // One delta spans the draft, so an Exodus split must survive an edit made while Genesis is + // loaded — its ref cannot resolve here, but that is absence of evidence, not a dead anchor. + const withExodus: SegmentationDelta = { removedVerseStarts: [], addedStarts: ['EXO 1:1:6'] }; + expect(addBoundaryBefore(THREE_VERSES, withExodus, V1_BETA)).toEqual({ + removedVerseStarts: [], + addedStarts: [V1_BETA, 'EXO 1:1:6'], + }); + }); + + it('keeps another book’s removed verse start when merging in this one', () => { + const withExodus: SegmentationDelta = { removedVerseStarts: ['EXO 1:5:0'], addedStarts: [] }; + expect(removeBoundaryAt(THREE_VERSES, withExodus, V2_START)).toEqual({ + removedVerseStarts: [V2_START, 'EXO 1:5:0'], + addedStarts: [], + }); + }); + + it('keeps another book’s anchors when merging the book-first token is a no-op', () => { + const withExodus: SegmentationDelta = { + removedVerseStarts: ['EXO 1:5:0'], + addedStarts: ['EXO 1:1:6'], + }; + expect(removeBoundaryAt(THREE_VERSES, withExodus, V1_START)).toEqual(withExodus); + }); + + it('still dedupes and sorts this book’s anchors alongside another book’s', () => { + const messy: SegmentationDelta = { + removedVerseStarts: ['EXO 1:5:0', V3_START, V2_START, V2_START], + addedStarts: [], + }; + expect(removeBoundaryAt(THREE_VERSES, messy, V2_START)).toEqual({ + removedVerseStarts: [V2_START, V3_START, 'EXO 1:5:0'], + addedStarts: [], + }); + }); }); diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index e8554d96..90f390ce 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -16,7 +16,7 @@ import type { SelectMenuItemHandler } from 'platform-bible-react'; import { formatReplacementString, isPlatformError } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentProps, ReactNode, RefObject } from 'react'; -import type { Book, TextAnalysis } from 'interlinearizer'; +import type { Book, SegmentationDelta, TextAnalysis } from 'interlinearizer'; import { resegmentBook } from 'parsers/papi/resegmentBook'; import useDraftProject from '../hooks/useDraftProject'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; @@ -481,10 +481,14 @@ function InterlinearizerLoaderInner({ ); /** - * The last book the lost-boundary warning was evaluated against, so a re-fetch that tokenizes to - * the same book does not re-warn about a loss already reported. + * The book and boundary delta the lost-boundary warning was last evaluated against, so a re-fetch + * that tokenizes to the same book does not re-warn about a loss already reported. A wholesale + * draft replacement (New / Open / Wipe) swaps in unchecked boundaries while leaving the loaded + * book untouched, so both are needed to tell a fresh check from a repeat one. */ - const lastLostAnchorsBookRef = useRef(undefined); + const lastLostAnchorsCheckRef = useRef< + { book: Book; segmentation: SegmentationDelta | undefined } | undefined + >(undefined); /** * Warns when the source text has changed out from under the draft's segment boundaries, whose @@ -496,9 +500,11 @@ function InterlinearizerLoaderInner({ */ useEffect(() => { if (!verseBook || isImportView || isDraftLoading) return; - if (lastLostAnchorsBookRef.current === verseBook) return; - lastLostAnchorsBookRef.current = verseBook; - const lost = lostAnchors(verseBook, draft?.segmentation); + const segmentation = draft?.segmentation; + const last = lastLostAnchorsCheckRef.current; + if (last && last.book === verseBook && last.segmentation === segmentation) return; + lastLostAnchorsCheckRef.current = { book: verseBook, segmentation }; + const lost = lostAnchors(verseBook, segmentation); if (lost.length === 0) return; papi.notifications .send({ diff --git a/src/utils/segmentation.ts b/src/utils/segmentation.ts index d2741f1d..af4a987e 100644 --- a/src/utils/segmentation.ts +++ b/src/utils/segmentation.ts @@ -6,6 +6,7 @@ * that is what the default verse starts are derived from, and returns a normalized delta. */ import type { Book, SegmentationDelta } from 'interlinearizer'; +import { bookOfRef } from './analysis-book'; /** An empty delta — equivalent to the default verse segmentation. */ const EMPTY_DELTA: SegmentationDelta = { removedVerseStarts: [], addedStarts: [] }; @@ -97,6 +98,10 @@ export function effectiveStarts( /** * Canonicalizes a delta so that equal segmentations serialize identically: each array is deduped, * stripped of no-op entries, and sorted by document order. + * + * One delta spans every book of its draft, so anchors naming a book other than `verseBook` are + * carried through untouched, keeping their relative order after this book's — dropping them would + * delete boundaries the user set in a book they merely navigated away from. */ function normalize(verseBook: Book, delta: SegmentationDelta): SegmentationDelta { const { defaults, all, order, first } = bookLookups(verseBook); @@ -104,14 +109,21 @@ function normalize(verseBook: Book, delta: SegmentationDelta): SegmentationDelta /* v8 ignore next -- ?? 0 fallback for refs absent from order; filtered arrays only hold real refs */ (order.get(a) ?? 0) - (order.get(b) ?? 0); - const removedVerseStarts = [...new Set(delta.removedVerseStarts)] - .filter((ref) => defaults.has(ref) && ref !== first) - .sort(byOrder); - const addedStarts = [...new Set(delta.addedStarts)] - .filter((ref) => all.has(ref) && !defaults.has(ref)) - .sort(byOrder); + /** Splits deduped refs into this book's, canonicalized by `keep` and sorted, then the rest. */ + const canonicalize = (refs: string[], keep: (ref: string) => boolean) => { + const deduped = [...new Set(refs)]; + const mine = deduped.filter((ref) => bookOfRef(ref) === verseBook.bookRef); + const foreign = deduped.filter((ref) => bookOfRef(ref) !== verseBook.bookRef); + return [...mine.filter(keep).sort(byOrder), ...foreign]; + }; - return { removedVerseStarts, addedStarts }; + return { + removedVerseStarts: canonicalize( + delta.removedVerseStarts, + (ref) => defaults.has(ref) && ref !== first, + ), + addedStarts: canonicalize(delta.addedStarts, (ref) => all.has(ref) && !defaults.has(ref)), + }; } /** @@ -213,6 +225,9 @@ export function isDefaultSegmentation(delta: SegmentationDelta | undefined): boo * {@link effectiveStarts} silently drops, which a reversified or upstream-edited source produces * because both re-key the token refs anchors are written against. * + * One delta spans every book of its draft, so only anchors naming `verseBook` are considered — an + * unloaded book's are unresolvable here but intact. + * * The delta itself is left intact, so a source that reverts brings its boundaries back. */ export function lostAnchors( @@ -221,5 +236,7 @@ export function lostAnchors( ): readonly string[] { if (!delta) return []; const { all } = bookLookups(verseBook); - return [...delta.removedVerseStarts, ...delta.addedStarts].filter((ref) => !all.has(ref)); + return [...delta.removedVerseStarts, ...delta.addedStarts].filter( + (ref) => bookOfRef(ref) === verseBook.bookRef && !all.has(ref), + ); } From df626b169ad71ad2236b07533109fb2b73d304c5 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Thu, 3 Sep 2026 11:31:48 -0600 Subject: [PATCH 3/3] Hold the lost-boundary warning until its text is localized --- .../components/InterlinearizerLoader.test.tsx | 29 +++++++++++++++++++ src/components/InterlinearizerLoader.tsx | 8 +++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 015cf1c7..fd8d515a 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -2710,6 +2710,35 @@ describe('InterlinearizerLoader', () => { expect(saves).toHaveLength(0); }); + it('holds the warning until the localized strings resolve, then sends the resolved text', async () => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + // The real hook echoes each key back as its own value until its subscription resolves, and + // nothing orders that against the book load, so the warning can be reached while unresolved. + let isLoading = true; + jest.mocked(useLocalizedStrings).mockImplementation((keys: readonly string[]) => { + const record = Object.fromEntries(keys.map((k) => [k, k])); + if (!isLoading) record['%interlinearizer_segmentation_lostBoundaries%'] = '2 lost'; + return [record, isLoading]; + }); + + const view = await renderWithSegmentation({ + removedVerseStarts: ['GEN 1:9:0'], + addedStarts: ['GEN 1:1:99'], + }); + + expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); + + isLoading = false; + await act(async () => { + view.rerenderNow(); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ + message: '2 lost', + severity: 'warning', + }); + }); + it('logs a warning when the notification cannot be delivered', async () => { jest.mocked(papi.notifications.send).mockRejectedValue(new Error('ui offline')); await renderWithSegmentation({ removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }); diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 90f390ce..2b1e03e9 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -234,7 +234,7 @@ function InterlinearizerLoaderInner({ }>) { const { scrRef, navigate, scrollGroupId, setScrollGroupId, fadePhase, cancelFade } = useInterlinearNav(); - const [localizedStrings] = useLocalizedStrings(STRING_KEYS); + const [localizedStrings, isLocalizedStringsLoading] = useLocalizedStrings(STRING_KEYS); const [interfaceMode] = useSetting('platform.interfaceMode', 'simple'); const [interfaceLanguages] = useSetting('platform.interfaceLanguage', ['und']); @@ -497,9 +497,12 @@ function InterlinearizerLoaderInner({ * * Every new tokenization that still loses anchors warns again: a reversify, revert, reversify * cycle loses the same boundaries three separate times. + * + * Held until the localized strings resolve: each loss is warned about once, so a warning sent + * while they are still bare `%…%` keys is the only one the user gets. */ useEffect(() => { - if (!verseBook || isImportView || isDraftLoading) return; + if (!verseBook || isImportView || isDraftLoading || isLocalizedStringsLoading) return; const segmentation = draft?.segmentation; const last = lastLostAnchorsCheckRef.current; if (last && last.book === verseBook && last.segmentation === segmentation) return; @@ -525,6 +528,7 @@ function InterlinearizerLoaderInner({ isDraftLoading, isImportView, localizedStrings, + isLocalizedStringsLoading, ]); /**