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..fd8d515a 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
@@ -2534,6 +2556,201 @@ 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('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'] };
+ 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('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: [] });
+
+ 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..a193040e 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,53 @@ 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']);
+ });
+
+ 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', () => {
it('returns all default verse starts for the default segmentation', () => {
expect(effectiveStarts(THREE_VERSES, undefined)).toEqual(
@@ -243,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 8042b1c0..2b1e03e9 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, SegmentationDelta, 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}%`[];
/**
@@ -232,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']);
@@ -478,6 +480,57 @@ function InterlinearizerLoaderInner({
[verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView],
);
+ /**
+ * 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 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
+ * 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.
+ *
+ * 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 || isLocalizedStringsLoading) return;
+ 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({
+ 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,
+ isLocalizedStringsLoading,
+ ]);
+
/**
* 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..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)),
+ };
}
/**
@@ -207,3 +219,24 @@ 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.
+ *
+ * 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(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+): readonly string[] {
+ if (!delta) return [];
+ const { all } = bookLookups(verseBook);
+ return [...delta.removedVerseStarts, ...delta.addedStarts].filter(
+ (ref) => bookOfRef(ref) === verseBook.bookRef && !all.has(ref),
+ );
+}