+ {/* The strip waits on localization whole: its button labels are localized too, so an
+ unresolved render would leave Sync and Copy with no label at all. */}
+ {isImportView && activeProject?.pt9Import && !stringsLoading && (
+
{formatReplacementString(localizedStrings['%interlinearizer_banner_pt9Import%'], {
date: new Date(activeProject.pt9Import.importedAt).toLocaleString(),
@@ -1289,6 +1313,29 @@ function InterlinearizerLoaderInner({
{
// Treat the default segmentation (undefined or a delta with both arrays empty) the same as
// `undefined`: clear the field rather than persisting a redundant custom object.
- const hasCustomBoundaries = !isDefaultSegmentation(segmentation);
+ const hasCustomBoundaries = !isEmptyDelta(segmentation);
const applied = autosaveDraft((current) => {
const next: DraftProject = { ...current, dirty: true };
// Store custom boundaries when present; clear the field for the default segmentation so the
diff --git a/src/hooks/useLostBoundaryDismissal.ts b/src/hooks/useLostBoundaryDismissal.ts
new file mode 100644
index 000000000..8615cb8f7
--- /dev/null
+++ b/src/hooks/useLostBoundaryDismissal.ts
@@ -0,0 +1,132 @@
+import type { UseWebViewStateHook } from '@papi/core';
+import type { Book, SegmentationDelta } from 'interlinearizer';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { lostBoundaries as findLostBoundaries } from '../utils/segmentation';
+
+type LostBoundaryDismissalOptions = {
+ /** The verse-tokenized loaded book, `undefined` while none is loaded. */
+ verseBook: Book | undefined;
+ /** The draft's boundary edits, whose anchors are the ones checked against the source. */
+ segmentation: SegmentationDelta | undefined;
+ /** Bumped by every boundary edit. */
+ segmentationVersion: number;
+ /** Bumped by every wholesale draft replacement (New / Open / Wipe). */
+ draftVersion: number;
+ /** Whether the initial draft load is still outstanding, which bumps neither counter. */
+ isDraftLoading: boolean;
+ /** Whether a read-only Paratext 9 import is showing, which the draft's boundaries never reach. */
+ isImportView: boolean;
+ /** Scopes the dismissal to one tab. */
+ useWebViewState: UseWebViewStateHook;
+};
+
+type LostBoundaryDismissal = {
+ /** The boundaries the loaded source text no longer carries and the user has not dismissed. */
+ undismissedLostBoundaries: readonly string[];
+ /** Dismisses the banner for exactly the boundaries it is currently reporting. */
+ onDismiss: () => void;
+};
+
+/**
+ * Finds the draft's segment boundaries the loaded source text no longer carries — a loss that
+ * reverts those regions to one segment per verse, silently without this — and tracks which of them
+ * the user has dismissed the banner for.
+ *
+ * A dismissal names the boundaries it covered rather than setting a flag, so a later edit that
+ * strands further boundaries raises the banner again while a shrinking loss leaves it down. It is
+ * dropped when the draft is replaced wholesale, and singly when one recovers, on the grounds that
+ * either makes the next loss one the user has not seen.
+ */
+export default function useLostBoundaryDismissal({
+ verseBook,
+ segmentation,
+ segmentationVersion,
+ draftVersion,
+ isDraftLoading,
+ isImportView,
+ useWebViewState,
+}: LostBoundaryDismissalOptions): LostBoundaryDismissal {
+ // Keying on the draft itself would re-run the search after every gloss auto-save, which replaces
+ // its identity without touching the boundaries.
+ const lostBoundaries = useMemo(
+ () => (verseBook && !isImportView ? findLostBoundaries(verseBook, segmentation) : []),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track segmentation, a ref value
+ [verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView],
+ );
+
+ /**
+ * The lost boundaries the user has dismissed the banner for — an acknowledgement of a message,
+ * tab-scoped rather than persisted into the draft alongside the analysis.
+ */
+ const [dismissedLostBoundaries, setDismissedLostBoundaries] = useWebViewState(
+ 'dismissedLostBoundaries',
+ [],
+ );
+
+ const undismissedLostBoundaries = useMemo(() => {
+ const dismissed = new Set(dismissedLostBoundaries);
+ return lostBoundaries.filter((ref) => !dismissed.has(ref));
+ }, [lostBoundaries, dismissedLostBoundaries]);
+
+ /**
+ * The stored list spans the whole draft while a loaded book reports only its own boundaries, so a
+ * dismissal has to leave every other book's acknowledgement standing.
+ */
+ const onDismiss = useCallback(() => {
+ setDismissedLostBoundaries([...new Set([...dismissedLostBoundaries, ...lostBoundaries])]);
+ }, [dismissedLostBoundaries, lostBoundaries, setDismissedLostBoundaries]);
+
+ /**
+ * The draft the dismissal acknowledged, so a wholesale replacement drops it: the acknowledgement
+ * was of one draft's message, and a replacement stranding the same boundary is a loss the user
+ * has not seen.
+ */
+ const dismissedDraftVersionRef = useRef(draftVersion);
+ /** Whether the dismissal has just been dropped wholesale, leaving nothing to drop singly. */
+ const draftReplacedRef = useRef(false);
+ useEffect(() => {
+ // Skipping the mount pass leaves a dismissal restored with the tab in place.
+ if (dismissedDraftVersionRef.current === draftVersion) return;
+ dismissedDraftVersionRef.current = draftVersion;
+ draftReplacedRef.current = true;
+ setDismissedLostBoundaries([]);
+ }, [draftVersion, setDismissedLostBoundaries]);
+
+ /**
+ * Drops a dismissed boundary once it recovers, so stranding it again raises the banner rather
+ * than carrying the earlier acknowledgement across the recovery.
+ *
+ * Only a recovery seen in this tab counts: a dismissal restored alongside the tab names
+ * boundaries from whatever the source looked like when it was made, so absence alone implies no
+ * recovery.
+ *
+ * A recovery is only observable in the book that owns the boundary: a book that never reports
+ * one, like an unloaded or import view, says nothing about whether it came back.
+ */
+ const observedLostBoundariesRef = useRef(new Map());
+ useEffect(() => {
+ const observableBookRef = isImportView ? undefined : verseBook?.bookRef;
+ if (!observableBookRef) return;
+ const observed = observedLostBoundariesRef.current;
+ const previous = observed.get(observableBookRef);
+ observed.set(observableBookRef, lostBoundaries);
+ if (draftReplacedRef.current) {
+ draftReplacedRef.current = false;
+ return;
+ }
+ if (previous === undefined) return;
+ const stillLost = new Set(lostBoundaries);
+ const recovered = previous.filter((ref) => !stillLost.has(ref));
+ if (recovered.length === 0) return;
+ const recoveredSet = new Set(recovered);
+ setDismissedLostBoundaries(dismissedLostBoundaries.filter((ref) => !recoveredSet.has(ref)));
+ }, [
+ lostBoundaries,
+ dismissedLostBoundaries,
+ setDismissedLostBoundaries,
+ verseBook,
+ isImportView,
+ ]);
+
+ return { undismissedLostBoundaries, onDismiss };
+}
diff --git a/src/parsers/papi/resegmentBook.ts b/src/parsers/papi/resegmentBook.ts
index 5ae20fad6..c29206eac 100644
--- a/src/parsers/papi/resegmentBook.ts
+++ b/src/parsers/papi/resegmentBook.ts
@@ -7,7 +7,7 @@ import type {
VerseStart,
} from 'interlinearizer';
-import { effectiveStarts, isDefaultSegmentation } from '../../utils/segmentation';
+import { effectiveStarts, isDefaultSegmentationForBook } from '../../utils/segmentation';
/** Separator inserted between two verses' baseline text when they are merged into one segment. */
const MERGE_SEPARATOR = ' ';
@@ -97,9 +97,9 @@ function buildSegment(run: SourcedToken[]): Segment {
* Re-groups a verse-tokenized {@link Book} into the user's custom segments, without touching the
* text-layer tokenizer.
*
- * The book is returned unchanged, by reference, for the default segmentation, so the common
- * no-custom-boundaries case incurs no work and no identity churn. Otherwise the flat document-order
- * token stream is cut at the delta's effective boundaries. A run that is exactly one original verse
+ * The book is returned unchanged, by reference, when the delta sets no boundary in it, so a book no
+ * boundary names incurs no work and no identity churn. Otherwise the flat document-order token
+ * stream is cut at the delta's effective boundaries. A run that is exactly one original verse
* reuses that verse's segment verbatim, so analyses keep resolving and React memoization is
* undisturbed; only merged or split runs are rebuilt, with baseline text and char offsets
* recomputed so the `baselineText.slice(charStart, charEnd) === surfaceText` invariant still
@@ -109,7 +109,7 @@ function buildSegment(run: SourcedToken[]): Segment {
* they survive a custom segmentation exactly as they do the default one.
*/
export function resegmentBook(book: Book, delta: SegmentationDelta | undefined): Book {
- if (isDefaultSegmentation(delta)) return book;
+ if (isDefaultSegmentationForBook(book, delta)) return book;
const starts = effectiveStarts(book, delta);
diff --git a/src/utils/segmentation.ts b/src/utils/segmentation.ts
index 37f6c2c85..1514a4e29 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: [] };
@@ -24,8 +25,12 @@ type BookLookups = Readonly<{
all: ReadonlySet;
/** Document-order index for every token ref, used to keep delta arrays canonically sorted. */
order: ReadonlyMap;
- /** The book's very first token ref — the start of the first segment, never merged leftward. */
- first: string | undefined;
+ /**
+ * The default starts a removal can actually merge leftward — those whose verse directly follows a
+ * token-bearing one. A verse opening the book or following a token-less verse marker has no
+ * preceding run to be absorbed into.
+ */
+ mergeable: ReadonlySet;
}>;
/**
@@ -40,22 +45,23 @@ function bookLookups(verseBook: Book): BookLookups {
const defaults = new Set();
const all = new Set();
const order = new Map();
+ const mergeable = new Set();
let i = 0;
+ let precededByTokens = false;
verseBook.segments.forEach((seg) => {
const firstToken = seg.tokens[0];
- if (firstToken) defaults.add(firstToken.ref);
+ if (firstToken) {
+ defaults.add(firstToken.ref);
+ if (precededByTokens) mergeable.add(firstToken.ref);
+ }
seg.tokens.forEach((t) => {
all.add(t.ref);
order.set(t.ref, i);
i += 1;
});
+ precededByTokens = seg.tokens.length > 0;
});
- const lookups: BookLookups = {
- defaults,
- all,
- order,
- first: verseBook.segments[0]?.tokens[0]?.ref,
- };
+ const lookups: BookLookups = { defaults, all, order, mergeable };
bookLookupsCache.set(verseBook, lookups);
return lookups;
}
@@ -71,53 +77,90 @@ export function defaultVerseStarts(verseBook: Book): ReadonlySet {
/**
* The token refs that begin a segment once the delta is applied to the default verse starts:
* `(defaults \ removedVerseStarts) ∪ addedStarts`. Added anchors whose token no longer exists are
- * dropped, and the book's first token is always forced to be a start. This is the single definition
- * of where a segment begins, so no two boundary operations can disagree.
+ * dropped, and only a removal with a preceding run to merge into takes effect. This is the single
+ * definition of where a segment begins, so no two boundary operations can disagree.
*/
export function effectiveStarts(
verseBook: Book,
delta: SegmentationDelta | undefined,
): Set {
- const { defaults, all, first } = bookLookups(verseBook);
+ const { defaults, all, mergeable } = bookLookups(verseBook);
const removed = new Set(delta?.removedVerseStarts ?? []);
const starts = new Set();
defaults.forEach((ref) => {
- if (!removed.has(ref)) starts.add(ref);
+ // A start with nothing to merge leftward into survives its own removal.
+ if (!removed.has(ref) || !mergeable.has(ref)) starts.add(ref);
});
if (delta) {
delta.addedStarts.forEach((ref) => {
if (all.has(ref)) starts.add(ref);
});
}
- // The first segment can never be merged away, so its start is always present.
- if (first !== undefined) starts.add(first);
return starts;
}
+/**
+ * A predicate per kind of delta entry, each answering whether this book's loaded source still lets
+ * that entry change where a segment begins — the single definition of that question.
+ *
+ * Drift unhonors an entry either by dropping its token or by moving the token into a role the entry
+ * no longer fits, which includes leaving a removal with no preceding run to merge into.
+ */
+function honorsAnchor({ defaults, all, mergeable }: BookLookups) {
+ return {
+ removal: (ref: string) => all.has(ref) && defaults.has(ref) && mergeable.has(ref),
+ addition: (ref: string) => all.has(ref) && !defaults.has(ref),
+ };
+}
+
/**
* Canonicalizes a delta so that equal segmentations serialize identically: each array is deduped,
- * stripped of no-op entries, and sorted by document order.
+ * stripped of no-op entries, and sorted.
+ *
+ * One delta spans every book of its draft, so anchors naming a book other than `verseBook` are
+ * carried through untouched — dropping them would delete boundaries the user set in a book they
+ * merely navigated away from. They sort after this book's.
+ *
+ * Anchors that this book's loaded source does not honor survive for the same reason — a drifted
+ * source may yet revert, and no edit elsewhere in the book should be what makes that loss
+ * permanent.
+ *
+ * Only this book's honored anchors have a document order to sort by; the two tails sort by ref.
*/
function normalize(verseBook: Book, delta: SegmentationDelta): SegmentationDelta {
- const { defaults, all, order, first } = bookLookups(verseBook);
+ const lookups = bookLookups(verseBook);
+ const { order } = lookups;
+ const honors = honorsAnchor(lookups);
const byOrder = (a: string, b: string) =>
/* 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 byRef = (a: string, b: string) => a.localeCompare(b);
- 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);
+ /** Orders this book's honored refs canonically, keeping the unhonored ones after them. */
+ const canonicalize = (refs: string[], isHonored: (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(isHonored).sort(byOrder),
+ ...mine.filter((ref) => !isHonored(ref)).sort(byRef),
+ ...foreign.sort(byRef),
+ ];
+ };
- return { removedVerseStarts, addedStarts };
+ return {
+ removedVerseStarts: canonicalize(delta.removedVerseStarts, honors.removal),
+ addedStarts: canonicalize(delta.addedStarts, honors.addition),
+ };
}
/**
* Makes a token begin a segment — that is, splits before it. A default verse start that had been
* merged away is un-merged; any other token is recorded as an added start. Already being a segment
* start is a no-op.
+ *
+ * An edit at a ref is authoritative over any anchor drift has left there, so the token begins a
+ * segment whichever kind of anchor already named it.
*/
export function addBoundaryBefore(
verseBook: Book,
@@ -126,22 +169,20 @@ export function addBoundaryBefore(
): SegmentationDelta {
const current = delta ?? EMPTY_DELTA;
const { defaults } = bookLookups(verseBook);
- if (defaults.has(ref)) {
- return normalize(verseBook, {
- removedVerseStarts: current.removedVerseStarts.filter((r) => r !== ref),
- addedStarts: current.addedStarts,
- });
- }
- return normalize(verseBook, {
- removedVerseStarts: current.removedVerseStarts,
- addedStarts: [...current.addedStarts, ref],
- });
+ const removedVerseStarts = current.removedVerseStarts.filter((r) => r !== ref);
+ const addedStarts = current.addedStarts.filter((r) => r !== ref);
+ if (defaults.has(ref)) return normalize(verseBook, { removedVerseStarts, addedStarts });
+ return normalize(verseBook, { removedVerseStarts, addedStarts: [...addedStarts, ref] });
}
/**
* Stops a token from beginning a segment, merging it into the preceding one. A default verse start
- * is recorded as removed; a previously added split is dropped. Merging the book's first token is a
- * no-op, since the first segment cannot merge leftward.
+ * is recorded as removed; a previously added split is dropped. Removing a default start with
+ * nothing to merge into is a no-op, which covers the book's first verse and any verse following a
+ * token-less verse marker.
+ *
+ * An edit at a ref is authoritative over any anchor drift has left there, so the token stops
+ * beginning a segment whichever kind of anchor already named it.
*/
export function removeBoundaryAt(
verseBook: Book,
@@ -149,18 +190,14 @@ export function removeBoundaryAt(
ref: string,
): SegmentationDelta {
const current = delta ?? EMPTY_DELTA;
- const { defaults, first } = bookLookups(verseBook);
- if (ref === first) return normalize(verseBook, current);
- if (defaults.has(ref)) {
- return normalize(verseBook, {
- removedVerseStarts: [...current.removedVerseStarts, ref],
- addedStarts: current.addedStarts,
- });
- }
- return normalize(verseBook, {
- removedVerseStarts: current.removedVerseStarts,
- addedStarts: current.addedStarts.filter((r) => r !== ref),
- });
+ const lookups = bookLookups(verseBook);
+ const { defaults, mergeable } = lookups;
+ if (defaults.has(ref) && !mergeable.has(ref)) return normalize(verseBook, current);
+ const removedVerseStarts = current.removedVerseStarts.filter((r) => r !== ref);
+ const addedStarts = current.addedStarts.filter((r) => r !== ref);
+ if (defaults.has(ref))
+ return normalize(verseBook, { removedVerseStarts: [...removedVerseStarts, ref], addedStarts });
+ return normalize(verseBook, { removedVerseStarts, addedStarts });
}
/**
@@ -179,8 +216,8 @@ export function moveBoundary(
/**
* Merges a segment into the one before it, identified by the first-token ref of the _second_
* segment — the one absorbed into its predecessor. Clearing that token's segment start is the whole
- * operation, so merging the book's first token is a no-op; the separate name states the merge
- * intent.
+ * operation, so a segment with no predecessor to merge into is a no-op; the separate name states
+ * the merge intent.
*/
export function mergeSegments(
verseBook: Book,
@@ -203,7 +240,53 @@ export function splitSegmentBefore(
return addBoundaryBefore(verseBook, delta, ref);
}
-/** Whether the delta represents the default verse segmentation: absent, or both arrays empty. */
-export function isDefaultSegmentation(delta: SegmentationDelta | undefined): boolean {
+/**
+ * Whether the delta records no boundary edit at all, in any book: absent, or both arrays empty.
+ * Such a delta leaves every book on the default verse segmentation.
+ */
+export function isEmptyDelta(delta: SegmentationDelta | undefined): boolean {
return !delta || (delta.removedVerseStarts.length === 0 && delta.addedStarts.length === 0);
}
+
+/**
+ * Whether the delta leaves `verseBook` on the default verse segmentation. One delta spans every
+ * book of its draft, so a delta that is custom overall may still say nothing about this book.
+ */
+export function isDefaultSegmentationForBook(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+): boolean {
+ if (!delta) return true;
+ return ![...delta.removedVerseStarts, ...delta.addedStarts].some(
+ (ref) => bookOfRef(ref) === verseBook.bookRef,
+ );
+}
+
+/**
+ * The user's boundaries the loaded book no longer carries, named by the delta ref that recorded
+ * each, in delta order — the boundaries {@link effectiveStarts} silently drops, which a reversified
+ * or upstream-edited source produces because both re-key the token refs the delta is written
+ * against.
+ *
+ * What counts is whether the boundary is absent, not whether its delta entry still changes
+ * anything: a merge stranded mid-verse is lost, while a split whose token has become a verse start
+ * is merely redundant.
+ *
+ * Only refs naming `verseBook` are considered, one delta spanning every book of its draft. The
+ * delta is left intact either way, so a source that reverts brings its boundaries back.
+ */
+export function lostBoundaries(
+ verseBook: Book,
+ delta: SegmentationDelta | undefined,
+): readonly string[] {
+ if (!delta) return [];
+ const lookups = bookLookups(verseBook);
+ const honors = honorsAnchor(lookups);
+ const { all } = lookups;
+ const isMine = (ref: string) => bookOfRef(ref) === verseBook.bookRef;
+ return [
+ ...delta.removedVerseStarts.filter((ref) => isMine(ref) && !honors.removal(ref)),
+ // A token that has become a default start carries the boundary itself.
+ ...delta.addedStarts.filter((ref) => isMine(ref) && !all.has(ref)),
+ ];
+}