diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx
index 06b34b74a..3daeb3290 100644
--- a/__mocks__/lucide-react.tsx
+++ b/__mocks__/lucide-react.tsx
@@ -26,6 +26,13 @@ export function GripVertical(props: Readonly<{ className?: string }>): ReactElem
return ;
}
+/**
+ * Stub for the SearchX icon.
+ */
+export function SearchX(props: Readonly<{ className?: string }>): ReactElement {
+ return ;
+}
+
/**
* Stub for the Info icon.
*/
diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json
index e5e8b2183..7af76d51c 100644
--- a/contributions/localizedStrings.json
+++ b/contributions/localizedStrings.json
@@ -45,9 +45,10 @@
"%interlinearizer_concordance_title%": "Concordance",
"%interlinearizer_concordance_close%": "Close concordance",
"%interlinearizer_concordance_refresh%": "Read the source text again",
- "%interlinearizer_concordance_loading%": "Reading books… {read} of {total}",
- "%interlinearizer_concordance_error%": "The text of this project could not be read.",
+ "%interlinearizer_textReading_loading%": "Reading books… {read} of {total}",
+ "%interlinearizer_textReading_error%": "The text of this project could not be read.",
"%interlinearizer_concordance_empty%": "The source text has no words to list.",
+ "%interlinearizer_concordance_partial%": "Some books could not be read, so their words are missing from this list.",
"%interlinearizer_concordance_occurrenceCount%": "Occurrences in the whole text",
"%interlinearizer_concordance_occurrenceCountInBook%": "Occurrences in {book}",
"%interlinearizer_concordance_status_analyzed%": "Analyzed",
@@ -82,6 +83,8 @@
"%interlinearizer_analysisCatalog_filter_morphemes_lacks%": "Not broken down",
"%interlinearizer_analysisCatalog_filter_zeroUsages%": "Used nowhere",
"%interlinearizer_analysisCatalog_filter_stale%": "Stale (found when a book is opened)",
+ "%interlinearizer_analysisCatalog_filter_staleEverywhere%": "Stale",
+ "%interlinearizer_analysisCatalog_filter_notInText%": "Word not in the text",
"%interlinearizer_analysisCatalog_confidence_high%": "High",
"%interlinearizer_analysisCatalog_confidence_medium%": "Medium",
"%interlinearizer_analysisCatalog_confidence_low%": "Low",
@@ -94,6 +97,7 @@
"%interlinearizer_analysisCatalog_noUsages%": "Not used anywhere",
"%interlinearizer_analysisCatalog_showAllUsages%": "Show {count} more",
"%interlinearizer_analysisCatalog_staleCount%": "Stale places",
+ "%interlinearizer_analysisCatalog_notInText%": "Not in the text",
"%interlinearizer_analysisCatalog_staleHeading%": "Stale: the text changed here after approval",
"%interlinearizer_analysisCatalog_staleApply%": "Apply to “{word}”",
"%interlinearizer_analysisCatalog_staleApplyNoTarget%": "Select a word to apply to",
@@ -183,6 +187,7 @@
"%interlinearizer_freeTranslationInput_staleDiscard%": "Discard",
"%interlinearizer_freeTranslationInput_staleNoText%": "(no translation in this language)",
"%interlinearizer_freeTranslationInput_staleKeepBlocked%": "Clear your translation to keep one of these instead",
+ "%interlinearizer_freeTranslationInput_orphaned%": "Stale: verse {verse} is no longer in the text",
"%interlinearizer_morphemeEditor_splitLabel%": "Split into morphemes",
"%interlinearizer_morphemeEditor_reset%": "Reset",
"%interlinearizer_morphemeEditor_cancel%": "Cancel",
diff --git a/docs/reanchoring.md b/docs/reanchoring.md
index c06efd804..b2e3255b1 100644
--- a/docs/reanchoring.md
+++ b/docs/reanchoring.md
@@ -21,7 +21,12 @@ Do not grow this into a general diff, fuzzy matching, or cross-verse tracking. F
## Algorithm
-When it runs: on every book load, in `useReanchorToBook` (`src/components/AnalysisStore.tsx`), for an editable project only. An imported, read-only project is a record of what was imported and is never healed. A pass that moves nothing leaves the analysis identical, so opening a book neither dirties the draft nor writes storage.
+When it runs, for an editable project only:
+
+- for the loaded book, whenever its text, its boundaries, or the draft changes;
+- for every other book the draft has records in, each time the whole source text is read (for the concordance, or a catalog filter that needs the text).
+
+A book the project no longer has is treated as holding no text, so every approval in it goes stale. An imported, read-only project is a record of what was imported and is never healed. A pass that moves nothing leaves the analysis identical, so opening a book neither dirties the draft nor writes storage.
Token and phrase links carry a `TokenSnapshot` (`tokenRef` plus the `surfaceText` it was written against). Approved, candidate, and stale links are the evidence the alignment weighs; rejected and suggested links align afterward, among themselves, against the tokens those leave unclaimed, so a verdict never decides which occurrence an approval names. For each verse independently:
diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx
index 421171515..7ad4da514 100644
--- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx
+++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx
@@ -11,13 +11,18 @@ import AnalysisCatalogPanel, {
type AnalysisCatalogPanelHandle,
} from '../../components/AnalysisCatalogPanel';
import { AnalysisStoreProvider, useGlossDispatch } from '../../components/AnalysisStore';
+import { SourceTextContext } from '../../components/SourceTextContext';
import { InterlinearNavProvider, useInterlinearNav } from '../../components/InterlinearNavContext';
+import type { SourceText } from '../../hooks/useSourceTextReader';
import type { StepSummary } from '../../hooks/useDraftProject';
import { emptyAnalysis } from '../../types/empty-factories';
import type { HeadingPlacement } from '../../utils/analysis-query';
import { defaultScrRef, FIXTURE_STAMPS, makeScrollGroupHook } from '../test-helpers';
import { mockKeyAsValueLocalizedStrings } from './test-helpers';
+// What the reading status shows is its own concern; these cover only when the panel shows it.
+jest.mock('../../components/TextReadingStatus');
+
const NO_HEADINGS: ReadonlyMap = new Map();
/**
@@ -83,6 +88,22 @@ function FocusPublishProbe({ tokenRef }: Readonly<{ tokenRef: string | undefined
return undefined;
}
+/** Builds the source text as its hook hands it over, unread by default. */
+function makeText(overrides: Partial = {}): SourceText {
+ return {
+ status: 'idle',
+ booksRead: 0,
+ bookCount: 0,
+ readings: undefined,
+ liveVersions: new Map(),
+ isPartial: false,
+ textForms: undefined,
+ refresh: () => {},
+ request: () => {},
+ ...overrides,
+ };
+}
+
/** Options every `renderPanel` call may override. */
type PanelOptions = Partial<{
ref: Ref;
@@ -111,6 +132,10 @@ type PanelOptions = Partial<{
liveSurfaceText: (tokenRef: string) => string | undefined;
/** The word the view beside the panel has focused, or none. */
focusedTokenRef: string;
+ /** The source text. Defaults to one not yet read. */
+ text: SourceText;
+ /** Whether every book has been checked for stale places. Defaults to only the opened ones. */
+ staleCoversDraft: boolean;
}>;
/** Reads every token as still carrying the form its analysis was recorded under. */
@@ -143,7 +168,9 @@ function PanelProviders({
>
- {children}
+
+ {children}
+
);
@@ -164,6 +191,7 @@ function renderPanel(overrides: PanelOptions = {}) {
onShowConcordance={overrides.onShowConcordance ?? (() => {})}
showMorphology={overrides.showMorphology ?? true}
sourceLanguageTag="el"
+ staleCoversDraft={overrides.staleCoversDraft ?? false}
/>
,
);
@@ -189,6 +217,7 @@ function ReopenableCatalog() {
onShowConcordance={() => {}}
showMorphology
sourceLanguageTag="el"
+ staleCoversDraft={false}
/>
)}
>
@@ -229,6 +258,7 @@ function renderPanelWithGlossEditing(overrides: PanelOptions = {}) {
onShowConcordance={overrides.onShowConcordance ?? (() => {})}
showMorphology={overrides.showMorphology ?? true}
sourceLanguageTag="el"
+ staleCoversDraft={false}
/>
,
);
@@ -1222,6 +1252,7 @@ describe('AnalysisCatalogPanel', () => {
onShowConcordance={() => {}}
showMorphology={false}
sourceLanguageTag="el"
+ staleCoversDraft={false}
/>
,
);
@@ -1379,6 +1410,7 @@ describe('AnalysisCatalogPanel', () => {
onShowConcordance={() => {}}
showMorphology
sourceLanguageTag="el"
+ staleCoversDraft={false}
/>
,
);
@@ -1403,6 +1435,7 @@ describe('AnalysisCatalogPanel', () => {
onShowConcordance={() => {}}
showMorphology
sourceLanguageTag="el"
+ staleCoversDraft={false}
/>
,
);
@@ -1422,6 +1455,7 @@ describe('AnalysisCatalogPanel', () => {
onShowConcordance={() => {}}
showMorphology
sourceLanguageTag="el"
+ staleCoversDraft={false}
/>
);
@@ -1998,6 +2032,38 @@ describe('AnalysisCatalogPanel', () => {
});
});
+ it('asks for the text to be read when filtering for them', async () => {
+ const request = jest.fn();
+ renderPanel({ analysis: ONLY_STALE, text: makeText({ request }) });
+ await openFilters();
+
+ await userEvent.click(
+ screen.getByRole('checkbox', { name: '%interlinearizer_analysisCatalog_filter_stale%' }),
+ );
+
+ expect(request).toHaveBeenCalled();
+ });
+
+ it('says the stale filter finds them only in opened books until every book is checked', async () => {
+ renderPanel({ analysis: ONLY_STALE });
+ await openFilters();
+
+ expect(
+ screen.getByRole('checkbox', { name: '%interlinearizer_analysisCatalog_filter_stale%' }),
+ ).toBeInTheDocument();
+ });
+
+ it('names the stale filter plainly once every book is checked', async () => {
+ renderPanel({ analysis: ONLY_STALE, staleCoversDraft: true });
+ await openFilters();
+
+ expect(
+ screen.getByRole('checkbox', {
+ name: '%interlinearizer_analysisCatalog_filter_staleEverywhere%',
+ }),
+ ).toBeInTheDocument();
+ });
+
describe('over an unsaved breakdown', () => {
/** Types a re-segmentation into an expanded row's breakdown without saving it. */
async function typeUnsavedBreakdown(analysisId = 'ta-1'): Promise {
@@ -2124,6 +2190,104 @@ describe('AnalysisCatalogPanel', () => {
expect(onClose).toHaveBeenCalled();
});
+ describe('forms not in the text', () => {
+ /** One analysis whose form the text still holds, and one whose form it no longer does. */
+ const analysis: TextAnalysis = {
+ ...emptyAnalysis(),
+ tokenAnalyses: [
+ { ...FIXTURE_STAMPS, id: 'kept', surfaceText: 'λόγος' },
+ { ...FIXTURE_STAMPS, id: 'gone', surfaceText: 'ἦν' },
+ ],
+ tokenAnalysisLinks: [
+ link('kept', 'GEN 1:1:0', 'approved', 'λόγος'),
+ link('gone', 'GEN 1:2:0', 'stale', 'ἦν'),
+ ],
+ };
+
+ /** The filter keeping only analyses whose form the text no longer holds. */
+ function notInTextFilter(): HTMLElement {
+ return screen.getByRole('checkbox', {
+ name: '%interlinearizer_analysisCatalog_filter_notInText%',
+ });
+ }
+
+ it('asks for the text to be read when filtering for them', async () => {
+ const request = jest.fn();
+ renderPanel({ analysis, text: makeText({ request }) });
+ await openFilters();
+
+ await userEvent.click(notInTextFilter());
+
+ expect(request).toHaveBeenCalled();
+ });
+
+ it('asks for the text to be read when narrowed to them from outside', () => {
+ const request = jest.fn();
+ const ref = createRef();
+ renderPanel({ ref, analysis, text: makeText({ request }) });
+
+ act(() => ref.current?.filterTo({ notInText: true }));
+
+ expect(request).toHaveBeenCalled();
+ });
+
+ it('keeps only the analyses whose form the text lacks once it is read', async () => {
+ const text = makeText({ status: 'ready', textForms: new Set(['λόγος']) });
+ renderPanel({ analysis, text });
+ await openFilters();
+
+ await userEvent.click(notInTextFilter());
+
+ expect(listedAnalysisIds()).toEqual(['gone']);
+ });
+
+ it('shows how far reading the text has got while filtering for them', async () => {
+ renderPanel({ analysis, text: makeText({ status: 'loading' }) });
+ await openFilters();
+
+ await userEvent.click(notInTextFilter());
+
+ expect(screen.getByTestId('text-reading-status')).toBeInTheDocument();
+ expect(screen.queryByTestId('catalog-row')).not.toBeInTheDocument();
+ });
+
+ it('says so when a book could not be read', async () => {
+ renderPanel({ analysis, text: makeText({ status: 'ready', isPartial: true }) });
+ await openFilters();
+
+ await userEvent.click(notInTextFilter());
+
+ expect(screen.getByTestId('text-reading-status')).toHaveAttribute('data-partial', 'true');
+ expect(screen.queryByTestId('catalog-row')).not.toBeInTheDocument();
+ });
+
+ it('marks an analysis whose form the text lacks once it is read', () => {
+ renderPanel({
+ analysis,
+ text: makeText({ status: 'ready', textForms: new Set(['λόγος']) }),
+ });
+
+ expect(within(rowFor('gone')).getByTestId('catalog-row-not-in-text')).toBeInTheDocument();
+ });
+
+ it('does not mark an analysis whose form the text holds', () => {
+ renderPanel({
+ analysis,
+ text: makeText({ status: 'ready', textForms: new Set(['λόγος']) }),
+ });
+
+ expect(
+ within(rowFor('kept')).queryByTestId('catalog-row-not-in-text'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('marks no analysis while the text is unread', () => {
+ renderPanel({ analysis });
+
+ expect(screen.queryByTestId('catalog-row-not-in-text')).not.toBeInTheDocument();
+ });
+ });
+
describe('editing a row', () => {
/** One analysis, shared by two tokens, so an edit here is visibly an edit to both. */
const SHARED: TextAnalysis = {
diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx
index b5e9dfc09..ebe459200 100644
--- a/src/__tests__/components/AnalysisStore.test.tsx
+++ b/src/__tests__/components/AnalysisStore.test.tsx
@@ -29,7 +29,7 @@ import {
useSegmentHasApprovedTranslation,
useSegmentsWithApprovedTranslation,
useStaleFreeTranslationDispatch,
- useStaleFreeTranslationsBySegment,
+ usePlacedStaleFreeTranslations,
useStaleTokenLinks,
useGloss,
useGlossDispatch,
@@ -1074,14 +1074,14 @@ describe('useSegmentsWithApprovedTranslation', () => {
});
});
-describe('useStaleFreeTranslationsBySegment', () => {
+describe('usePlacedStaleFreeTranslations', () => {
it("files a stale translation under the book's segment showing it", () => {
const book = makeVerseBook([{ sid: 'GEN 1:1', text: 'In the beginning' }]);
- const { result } = renderStoreHook(() => useStaleFreeTranslationsBySegment(book), {
+ const { result } = renderStoreHook(() => usePlacedStaleFreeTranslations(book), {
initialAnalysis: withSegmentTranslation('GEN 1:1', 'stale'),
});
- expect(result.current).toEqual(
+ expect(result.current.bySegment).toEqual(
new Map([['GEN 1:1', [{ analysisId: 'sa-1', segmentId: 'GEN 1:1', text: 'old' }]]]),
);
});
diff --git a/src/__tests__/components/CatalogMergeModal.test.tsx b/src/__tests__/components/CatalogMergeModal.test.tsx
index fc6e9276a..34ada537a 100644
--- a/src/__tests__/components/CatalogMergeModal.test.tsx
+++ b/src/__tests__/components/CatalogMergeModal.test.tsx
@@ -32,6 +32,7 @@ function row(analysisId: string, overrides: Partial = {}): CatalogRo
return {
analysisId,
surfaceText: 'λόγος',
+ form: 'λόγος',
gloss: '',
glosses: gloss ? { [analysisLanguage]: gloss } : undefined,
morphemes: [],
diff --git a/src/__tests__/components/CatalogRowHeader.test.tsx b/src/__tests__/components/CatalogRowHeader.test.tsx
index 6eb6f528c..dfc02468c 100644
--- a/src/__tests__/components/CatalogRowHeader.test.tsx
+++ b/src/__tests__/components/CatalogRowHeader.test.tsx
@@ -19,6 +19,7 @@ function row(overrides: Partial = {}): CatalogRow {
return {
analysisId: 'ta-1',
surfaceText: 'λόγος',
+ form: 'λόγος',
gloss: 'word',
morphemes: [],
usageCount: 0,
@@ -38,6 +39,7 @@ function renderHeader(overrides: Partial
usageCountInBookLabel: 'uses in Genesis',
isExpanded: false,
onToggle: jest.fn(),
+ isNotInText: false,
isChecked: false,
onCheckedChange: jest.fn(),
localizedStrings: STRINGS,
@@ -124,6 +126,20 @@ describe('CatalogRowHeader', () => {
expect(screen.queryByTestId('catalog-row-stale-count')).not.toBeInTheDocument();
});
+ it('marks an analysis whose form the text lacks', () => {
+ renderHeader({ isNotInText: true });
+
+ expect(screen.getByTestId('catalog-row-not-in-text')).toHaveTextContent(
+ '%interlinearizer_analysisCatalog_notInText%',
+ );
+ });
+
+ it('leaves unmarked an analysis whose form the text holds', () => {
+ renderHeader();
+
+ expect(screen.queryByTestId('catalog-row-not-in-text')).not.toBeInTheDocument();
+ });
+
it('leaves the toggle unlabeled so that its own content names it', () => {
renderHeader();
diff --git a/src/__tests__/components/ConcordanceIndexContext.test.tsx b/src/__tests__/components/ConcordanceIndexContext.test.tsx
deleted file mode 100644
index 111c61d16..000000000
--- a/src/__tests__/components/ConcordanceIndexContext.test.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-///
-
-import { renderHook } from '@testing-library/react';
-import type { ReactNode } from 'react';
-import {
- ConcordanceIndexProvider,
- useConcordanceIndexContext,
-} from '../../components/ConcordanceIndexContext';
-import useConcordanceIndex, { type ConcordanceIndex } from '../../hooks/useConcordanceIndex';
-import { GEN_1_1_BOOK } from '../test-helpers';
-
-// Reading books is the hook's own concern; the provider only hands on whatever it builds.
-jest.mock('../../hooks/useConcordanceIndex');
-
-const INDEX: ConcordanceIndex = {
- status: 'ready',
- booksRead: 1,
- bookCount: 1,
- entries: [],
- refresh: () => {},
-};
-
-describe('ConcordanceIndexProvider', () => {
- it('builds the index from its props and hands it to what it wraps', () => {
- jest.mocked(useConcordanceIndex).mockReturnValue(INDEX);
- const wrapper = ({ children }: Readonly<{ children: ReactNode }>) => (
-
- {children}
-
- );
-
- const { result } = renderHook(() => useConcordanceIndexContext(), { wrapper });
-
- expect(result.current).toBe(INDEX);
- expect(useConcordanceIndex).toHaveBeenCalledWith({
- enabled: true,
- liveBook: GEN_1_1_BOOK,
- projectId: 'src',
- shown: true,
- writingSystem: 'en',
- });
- });
-});
-
-describe('useConcordanceIndexContext', () => {
- it('throws outside a provider', () => {
- // React logs the render error before rethrowing it; silenced so the expected throw is quiet.
- jest.spyOn(console, 'error').mockImplementation(() => {});
-
- expect(() => renderHook(() => useConcordanceIndexContext())).toThrow(
- 'useConcordanceIndexContext must be used within its provider',
- );
- });
-});
diff --git a/src/__tests__/components/ConcordancePanel.test.tsx b/src/__tests__/components/ConcordancePanel.test.tsx
index 12efea216..f116273a9 100644
--- a/src/__tests__/components/ConcordancePanel.test.tsx
+++ b/src/__tests__/components/ConcordancePanel.test.tsx
@@ -8,15 +8,18 @@ import type { TextAnalysis, TokenAnalysisLink } from 'interlinearizer';
import { Collator } from 'platform-bible-utils';
import { useEffect, type ReactNode } from 'react';
import { AnalysisStoreProvider, useGlossDispatch } from '../../components/AnalysisStore';
-import { ConcordanceIndexContext } from '../../components/ConcordanceIndexContext';
import ConcordancePanel from '../../components/ConcordancePanel';
import { InterlinearNavProvider, useInterlinearNav } from '../../components/InterlinearNavContext';
-import type { ConcordanceIndex } from '../../hooks/useConcordanceIndex';
+import { ConcordanceEntriesContext, SourceTextContext } from '../../components/SourceTextContext';
+import type { SourceText } from '../../hooks/useSourceTextReader';
import { emptyAnalysis } from '../../types/empty-factories';
-import { buildConcordanceEntries, indexBook } from '../../utils/concordance';
+import { buildConcordanceEntries, indexBook, type ConcordanceEntry } from '../../utils/concordance';
import { defaultScrRef, FIXTURE_STAMPS, makeScrollGroupHook, makeVerseBook } from '../test-helpers';
import { mockKeyAsValueLocalizedStrings } from './test-helpers';
+// What the reading status shows is its own concern; these cover only when the panel shows it.
+jest.mock('../../components/TextReadingStatus');
+
/**
* The intersection-observer Jest stub exposes a helper for firing intersections on the global
* object. Declared here so the windowing tests reach it without a type assertion.
@@ -56,14 +59,18 @@ const ENTRIES = buildConcordanceEntries(
new Collator('en'),
);
-/** Builds an index as the loading hook hands it over, ready with {@link ENTRIES} by default. */
-function makeIndex(overrides: Partial = {}): ConcordanceIndex {
+/** Builds the source text as the reading hook hands it over, ready by default. */
+function makeText(overrides: Partial = {}): SourceText {
return {
status: 'ready',
booksRead: 2,
bookCount: 2,
- entries: ENTRIES,
+ readings: new Map(),
+ liveVersions: new Map(),
+ isPartial: false,
+ textForms: undefined,
refresh: () => {},
+ request: () => {},
...overrides,
};
}
@@ -79,7 +86,9 @@ function link(analysisId: string, tokenRef: string): TokenAnalysisLink {
}
type PanelOptions = Partial<{
- index: ConcordanceIndex;
+ text: SourceText;
+ /** The entries listed, {@link ENTRIES} by default. */
+ entries: readonly ConcordanceEntry[];
analysis: TextAnalysis;
currentBook: string;
onClose: () => void;
@@ -101,14 +110,16 @@ function panelTree(options: PanelOptions = {}) {
>
{options.beside}
-
- {})}
- onShowCatalog={options.onShowCatalog ?? (() => {})}
- sourceLanguageTag="en"
- />
-
+
+
+ {})}
+ onShowCatalog={options.onShowCatalog ?? (() => {})}
+ sourceLanguageTag="en"
+ />
+
+
);
@@ -127,7 +138,6 @@ function rowFor(form: string): HTMLElement {
beforeEach(() => {
mockKeyAsValueLocalizedStrings({
- '%interlinearizer_concordance_loading%': 'Reading {read} of {total}',
'%interlinearizer_concordance_occurrenceCountInBook%': 'in {book}',
});
claimedFocusRequest = undefined;
@@ -135,28 +145,43 @@ beforeEach(() => {
describe('ConcordancePanel', () => {
it('shows how far reading the books has got while the index is built', () => {
- renderPanel({ index: makeIndex({ status: 'loading', booksRead: 12, bookCount: 66 }) });
+ renderPanel({ text: makeText({ status: 'loading' }) });
- expect(screen.getByTestId('concordance-loading')).toHaveTextContent('Reading 12 of 66');
+ expect(screen.getByTestId('text-reading-status')).toBeInTheDocument();
expect(screen.queryByTestId('concordance-row')).not.toBeInTheDocument();
});
it('says so when the books could not be listed', () => {
- renderPanel({ index: makeIndex({ status: 'error' }) });
+ renderPanel({ text: makeText({ status: 'error' }) });
- expect(screen.getByTestId('concordance-error')).toHaveTextContent(
- '%interlinearizer_concordance_error%',
- );
+ expect(screen.getByTestId('text-reading-status')).toBeInTheDocument();
+ expect(screen.queryByTestId('concordance-row')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('concordance-empty')).not.toBeInTheDocument();
});
it('says so when the text has no words', () => {
- renderPanel({ index: makeIndex({ entries: [] }) });
+ renderPanel({ entries: [] });
expect(screen.getByTestId('concordance-empty')).toHaveTextContent(
'%interlinearizer_concordance_empty%',
);
});
+ it('lists what was read beside a notice when a book failed to read', () => {
+ renderPanel({ text: makeText({ isPartial: true }) });
+
+ expect(screen.getByTestId('concordance-partial')).toHaveTextContent(
+ '%interlinearizer_concordance_partial%',
+ );
+ expect(screen.getAllByTestId('concordance-row')).not.toHaveLength(0);
+ });
+
+ it('shows no partial-reading notice when every book was read', () => {
+ renderPanel();
+
+ expect(screen.queryByTestId('concordance-partial')).not.toBeInTheDocument();
+ });
+
it('lists every form of the text, most frequent first', () => {
renderPanel();
@@ -255,7 +280,7 @@ describe('ConcordancePanel', () => {
it('reads the text again on refresh', async () => {
const refresh = jest.fn();
- renderPanel({ index: makeIndex({ refresh }) });
+ renderPanel({ text: makeText({ refresh }) });
await userEvent.click(screen.getByTestId('concordance-refresh'));
@@ -275,7 +300,7 @@ describe('ConcordancePanel', () => {
});
it('offers no refresh while the text is being read', () => {
- renderPanel({ index: makeIndex({ status: 'loading' }) });
+ renderPanel({ text: makeText({ status: 'loading' }) });
expect(screen.getByTestId('concordance-refresh')).toBeDisabled();
});
@@ -314,7 +339,7 @@ describe('ConcordancePanel', () => {
);
it('mounts only part of it', () => {
- renderPanel({ index: makeIndex({ entries: MANY }) });
+ renderPanel({ entries: MANY });
const mounted = screen.getAllByTestId('concordance-row').length;
expect(mounted).toBeGreaterThan(0);
@@ -322,7 +347,7 @@ describe('ConcordancePanel', () => {
});
it('mounts more as the end of what is mounted comes into view', () => {
- renderPanel({ index: makeIndex({ entries: MANY }) });
+ renderPanel({ entries: MANY });
const before = screen.getAllByTestId('concordance-row').length;
act(() => {
@@ -333,14 +358,14 @@ describe('ConcordancePanel', () => {
});
it('starts over at its first rows once a refresh has read the text again', () => {
- const { rerender } = renderPanel({ index: makeIndex({ entries: MANY }) });
+ const { rerender } = renderPanel({ entries: MANY });
const initial = screen.getAllByTestId('concordance-row').length;
act(() => {
global.triggerIntersection(screen.getByTestId('concordance-rows-sentinel'), true);
});
- rerender(panelTree({ index: makeIndex({ status: 'loading', entries: [] }) }));
- rerender(panelTree({ index: makeIndex({ entries: MANY }) }));
+ rerender(panelTree({ text: makeText({ status: 'loading' }), entries: [] }));
+ rerender(panelTree({ entries: MANY }));
expect(screen.getAllByTestId('concordance-row')).toHaveLength(initial);
});
diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx
index 6063477ce..fed871e45 100644
--- a/src/__tests__/components/Interlinearizer.test.tsx
+++ b/src/__tests__/components/Interlinearizer.test.tsx
@@ -74,6 +74,8 @@ type CapturedSegmentViewProps = {
gutterLabel?: string;
/** Controls whether tokens are rendered as chips or as raw baseline text. */
displayMode: SegmentDisplayMode;
+ /** Height a stand-in reserves for the chips it is standing in for. */
+ placeholderHeightPx?: number;
/** The `Token.ref` string of the currently focused token, if any. */
focusedTokenRef: string | undefined;
/** Whether this segment corresponds to the currently active verse. */
@@ -106,9 +108,18 @@ let phraseLinkByIdMapReads = 0;
/** What the mocked `useAnalysisReadOnly` reports; reset in `beforeEach`. */
let mockReadOnly = false;
-/** Stale free translations served by the mocked `useStaleFreeTranslationsBySegment`. */
+/**
+ * Stale free translations each segment reviews, served by the mocked
+ * `usePlacedStaleFreeTranslations`.
+ */
const mockStaleBySegment = new Map();
+/** Orphaned translations by the segment they follow, served by `usePlacedStaleFreeTranslations`. */
+const mockOrphanedAfter = new Map();
+
+/** Orphaned translations ahead of the book, served by `usePlacedStaleFreeTranslations`. */
+const mockOrphanedAhead: StaleFreeTranslation[] = [];
+
/** Segments the mocked `useSegmentsWithApprovedTranslation` reports holding an approved translation. */
const mockApprovedSegments = new Set();
@@ -117,7 +128,11 @@ jest.mock('../../components/AnalysisStore', () => ({
useAnalysisReadOnly: () => mockReadOnly,
/** No segment carries a free translation, so a read-only height table charges none for one. */
useFreeTranslationsBySegment: () => new Map(),
- useStaleFreeTranslationsBySegment: () => mockStaleBySegment,
+ usePlacedStaleFreeTranslations: () => ({
+ bySegment: mockStaleBySegment,
+ orphanedAfter: mockOrphanedAfter,
+ orphanedAhead: mockOrphanedAhead,
+ }),
useSegmentsWithApprovedTranslation: () => mockApprovedSegments,
/**
* Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of
@@ -174,6 +189,16 @@ jest.mock('../../components/ContinuousView', () => ({
},
}));
+jest.mock('../../components/OrphanedFreeTranslationCard', () => ({
+ __esModule: true,
+ // The list spreads the real key list into its own, so the stub must still export it.
+ ORPHANED_CARD_STRING_KEYS: jest.requireActual('../../components/OrphanedFreeTranslationCard')
+ .ORPHANED_CARD_STRING_KEYS,
+ default: ({ translation }: Readonly<{ translation: StaleFreeTranslation }>) => (
+
+ ),
+}));
+
jest.mock('../../components/SegmentView', () => ({
__esModule: true,
// The list spreads the real key list into its own, so the stub must still export it.
@@ -459,6 +484,8 @@ beforeEach(() => {
// The phrase-link map is a plain Map (not a jest mock), so resetMocks does not clear it.
mockPhraseLinkById.clear();
mockStaleBySegment.clear();
+ mockOrphanedAfter.clear();
+ mockOrphanedAhead.length = 0;
mockApprovedSegments.clear();
capturedSegmentation = undefined;
mockReadOnly = false;
@@ -1585,6 +1612,120 @@ describe('Interlinearizer', () => {
expect(trailingSpacerPx(STALE_BELOW_WINDOW)).toBe(without);
});
+ it('reserves the cards orphaned after a segment below the window', () => {
+ const without = trailingSpacerPx(STALE_BELOW_WINDOW);
+ mockOrphanedAfter.set('GEN 1:190', [
+ { analysisId: 'sa-1', segmentId: 'GEN 1:190:9', text: 'A' },
+ { analysisId: 'sa-2', segmentId: 'GEN 1:190:9', text: 'B' },
+ ]);
+
+ // Each card's gap and height.
+ expect(trailingSpacerPx(STALE_BELOW_WINDOW) - without).toBe(2 * (8 + 66));
+ });
+
+ it('reserves a long orphaned translation no more than a short one', () => {
+ const without = trailingSpacerPx(STALE_BELOW_WINDOW);
+ mockOrphanedAfter.set('GEN 1:190', [
+ { analysisId: 'sa-1', segmentId: 'GEN 1:190:9', text: 'a'.repeat(200) },
+ ]);
+
+ expect(trailingSpacerPx(STALE_BELOW_WINDOW) - without).toBe(8 + 66);
+ });
+
+ it('reserves the cards orphaned ahead of the book above the window', () => {
+ const leadingSpacerPx = () => {
+ const { container, unmount } = renderInterlinearizer(STALE_BELOW_WINDOW);
+ const spacer = container.querySelector('[data-leading-spacer]');
+ if (!(spacer instanceof HTMLElement)) throw new Error('leading spacer not found');
+ const height = Number.parseFloat(spacer.style.height);
+ unmount();
+ return height;
+ };
+ const without = leadingSpacerPx();
+ mockOrphanedAhead.push({ analysisId: 'sa-1', segmentId: 'GEN 1:0', text: 'A' });
+
+ expect(leadingSpacerPx() - without).toBe(8 + 66);
+ });
+
+ it('reserves no orphaned cards for a read-only analysis', () => {
+ mockReadOnly = true;
+ const without = trailingSpacerPx(STALE_BELOW_WINDOW);
+ mockOrphanedAfter.set('GEN 1:190', [
+ { analysisId: 'sa-1', segmentId: 'GEN 1:190:9', text: 'A' },
+ ]);
+
+ expect(trailingSpacerPx(STALE_BELOW_WINDOW)).toBe(without);
+ });
+
+ it('reserves a stand-in its own box, leaving out the cards orphaned after it', () => {
+ const standInPx = () => {
+ capturedSegmentViewPropsList = [];
+ const { unmount } = renderInterlinearizer(STALE_BELOW_WINDOW);
+ const props = capturedSegmentViewPropsList.findLast((p) => p.segment.id === 'GEN 1:110');
+ unmount();
+ return props?.placeholderHeightPx;
+ };
+ const without = standInPx();
+ mockOrphanedAfter.set('GEN 1:110', [
+ { analysisId: 'sa-1', segmentId: 'GEN 1:110:9', text: 'A' },
+ ]);
+
+ expect(without).toEqual(expect.any(Number));
+ expect(standInPx()).toBe(without);
+ });
+
+ it('shows a translation orphaned after a segment as a card right after it', () => {
+ mockOrphanedAfter.set('GEN 1:1', [{ analysisId: 'sa-1', segmentId: 'GEN 1:9', text: 'A' }]);
+
+ renderInterlinearizer({ book: makeManySegmentBook(3), showFreeTranslation: true });
+
+ expect(screen.getByTestId('orphaned-free-translation').previousElementSibling).toHaveAttribute(
+ 'data-segment-id',
+ 'GEN 1:1',
+ );
+ });
+
+ it('shows the translations orphaned ahead of the book in a block before its first segment', () => {
+ mockOrphanedAhead.push({ analysisId: 'sa-1', segmentId: 'GEN 1:0', text: 'A' });
+
+ const { container } = renderInterlinearizer({
+ book: makeManySegmentBook(3),
+ showFreeTranslation: true,
+ });
+
+ const ahead = container.querySelector('[data-orphaned-ahead]');
+ expect(ahead).toContainElement(screen.getByTestId('orphaned-free-translation'));
+ expect(ahead?.nextElementSibling).toHaveAttribute('data-segment-id', 'GEN 1:1');
+ });
+
+ it('shows no block ahead of the book when nothing is orphaned there', () => {
+ const { container } = renderInterlinearizer({
+ book: makeManySegmentBook(3),
+ showFreeTranslation: true,
+ });
+
+ expect(container.querySelector('[data-orphaned-ahead]')).toBeNull();
+ });
+
+ it('shows no orphaned translations while free translations are hidden', () => {
+ mockOrphanedAfter.set('GEN 1:1', [{ analysisId: 'sa-1', segmentId: 'GEN 1:9', text: 'A' }]);
+ mockOrphanedAhead.push({ analysisId: 'sa-2', segmentId: 'GEN 1:0', text: 'A' });
+
+ renderInterlinearizer({ book: makeManySegmentBook(3), showFreeTranslation: false });
+
+ expect(screen.queryByTestId('orphaned-free-translation')).toBeNull();
+ });
+
+ it('shows no orphaned translations in a read-only analysis', () => {
+ mockReadOnly = true;
+ mockOrphanedAfter.set('GEN 1:1', [{ analysisId: 'sa-1', segmentId: 'GEN 1:9', text: 'A' }]);
+ mockOrphanedAhead.push({ analysisId: 'sa-2', segmentId: 'GEN 1:0', text: 'A' });
+
+ renderInterlinearizer({ book: makeManySegmentBook(3), showFreeTranslation: true });
+
+ expect(screen.queryByTestId('orphaned-free-translation')).toBeNull();
+ });
+
it('reserves nothing above a window that starts at the first segment', () => {
const book = makeManySegmentBook(40);
const { container } = renderInterlinearizer({
diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx
index d42b7e741..52297e1b0 100644
--- a/src/__tests__/components/InterlinearizerLoader.test.tsx
+++ b/src/__tests__/components/InterlinearizerLoader.test.tsx
@@ -24,7 +24,11 @@ import InterlinearizerLoader, {
UNDO_NOTIFICATION_DURATION_MS,
} from '../../components/InterlinearizerLoader';
import { RECENTER_FADE_MS } from '../../components/recenter-fade';
-import useConcordanceIndex, { type ConcordanceIndex } from '../../hooks/useConcordanceIndex';
+import useConcordanceEntries from '../../hooks/useConcordanceEntries';
+import useSourceTextReader, {
+ type SourceText,
+ type UseSourceTextReaderArgs,
+} from '../../hooks/useSourceTextReader';
import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData';
import useLexiconRegistry from '../../hooks/useLexiconRegistry';
import useLostBoundaryDismissal from '../../hooks/useLostBoundaryDismissal';
@@ -52,7 +56,8 @@ import {
} from '../test-helpers';
import { mockKeyAsValueLocalizedStrings } from './test-helpers';
-jest.mock('../../hooks/useConcordanceIndex');
+jest.mock('../../hooks/useConcordanceEntries');
+jest.mock('../../hooks/useSourceTextReader');
jest.mock('../../hooks/useInterlinearizerBookData');
jest.mock('../../hooks/useLexiconRegistry');
jest.mock('../../hooks/useLostBoundaryDismissal');
@@ -594,6 +599,28 @@ function renderLoader(
return { ...result, updateWebViewDefinition };
}
+/** Builds the source text as its hook hands it over, unread by default. */
+function makeSourceText(overrides: Partial = {}): SourceText {
+ return {
+ status: 'idle',
+ booksRead: 0,
+ bookCount: 0,
+ readings: undefined,
+ liveVersions: new Map(),
+ isPartial: false,
+ textForms: undefined,
+ refresh: jest.fn(),
+ request: jest.fn(),
+ ...overrides,
+ };
+}
+
+/** Has the source text report itself unread, as it is until something asks for it. */
+function mockUnreadSourceText(): void {
+ jest.mocked(useSourceTextReader).mockReturnValue(makeSourceText());
+ jest.mocked(useConcordanceEntries).mockReturnValue([]);
+}
+
/** Configures useInterlinearizerBookData to return the given state. */
function mockBookData(
overrides: Partial<{
@@ -774,6 +801,7 @@ describe('InterlinearizerLoader', () => {
});
mockSettings();
mockSourceShortName('WEB');
+ mockUnreadSourceText();
});
it('shows nav controls when interface mode is power', async () => {
@@ -4005,13 +4033,7 @@ describe('InterlinearizerLoader', () => {
describe('concordance command', () => {
beforeEach(() => {
- jest.mocked(useConcordanceIndex).mockReturnValue({
- status: 'ready',
- booksRead: 0,
- bookCount: 0,
- entries: [],
- refresh: jest.fn(),
- });
+ jest.mocked(useSourceTextReader).mockReturnValue(makeSourceText({ status: 'ready' }));
});
it('opens the concordance beside the interlinear view', async () => {
@@ -4110,12 +4132,12 @@ describe('InterlinearizerLoader', () => {
});
await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog'));
- expect(useConcordanceIndex).toHaveBeenLastCalledWith(
+ expect(useSourceTextReader).toHaveBeenLastCalledWith(
expect.objectContaining({ enabled: false }),
);
});
- it('indexes the loaded book of this project once the concordance is shown', async () => {
+ it('reads the text of this project, the loaded book live, once the concordance is shown', async () => {
mockBookData({ writingSystem: 'hbo' });
await act(async () => {
renderLoader();
@@ -4123,31 +4145,39 @@ describe('InterlinearizerLoader', () => {
await userEvent.click(screen.getByTestId('tab-toolbar-concordance'));
- expect(useConcordanceIndex).toHaveBeenLastCalledWith({
- projectId: testProjectId,
- writingSystem: 'hbo',
- liveBook: GEN_1_1_BOOK,
- enabled: true,
- shown: true,
+ expect(useSourceTextReader).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ projectId: testProjectId,
+ writingSystem: 'hbo',
+ liveBook: GEN_1_1_BOOK,
+ enabled: true,
+ }),
+ );
+ });
+
+ it('builds the concordance as shown once it is shown', async () => {
+ mockBookData({ writingSystem: 'hbo' });
+ await act(async () => {
+ renderLoader();
});
+
+ await userEvent.click(screen.getByTestId('tab-toolbar-concordance'));
+
+ expect(useConcordanceEntries).toHaveBeenLastCalledWith(
+ expect.objectContaining({ shown: true, writingSystem: 'hbo' }),
+ );
});
- it('keeps the concordance index across a wipe that replaces the draft', async () => {
- let indexMounts = 0;
- const index: ConcordanceIndex = {
- status: 'ready',
- booksRead: 0,
- bookCount: 0,
- entries: [],
- refresh: jest.fn(),
- };
- jest.mocked(useConcordanceIndex).mockImplementation(() => {
+ it('keeps the source text across a wipe that replaces the draft', async () => {
+ let readerMounts = 0;
+ const text = makeSourceText({ status: 'ready' });
+ jest.mocked(useSourceTextReader).mockImplementation(() => {
// Held in state so a remount of whatever calls the hook shows up as a fresh count.
useReactState(() => {
- indexMounts += 1;
- return indexMounts;
+ readerMounts += 1;
+ return readerMounts;
});
- return index;
+ return text;
});
await act(async () => {
renderLoader();
@@ -4159,10 +4189,10 @@ describe('InterlinearizerLoader', () => {
await userEvent.click(screen.getByTestId('wipe-confirm-all'));
expect(interlinearizerMountCount).toBeGreaterThan(viewMounts);
- expect(indexMounts).toBe(1);
+ expect(readerMounts).toBe(1);
});
- it('keeps the concordance index once the side panel switches away from it', async () => {
+ it('keeps reading the text once the side panel switches away from the concordance', async () => {
await act(async () => {
renderLoader();
});
@@ -4171,7 +4201,7 @@ describe('InterlinearizerLoader', () => {
await userEvent.click(screen.getByTestId('side-panel-tab-catalog'));
await userEvent.click(screen.getByTestId('analysis-catalog-close'));
- expect(useConcordanceIndex).toHaveBeenLastCalledWith(
+ expect(useSourceTextReader).toHaveBeenLastCalledWith(
expect.objectContaining({ enabled: true }),
);
});
@@ -4184,7 +4214,7 @@ describe('InterlinearizerLoader', () => {
await userEvent.click(screen.getByTestId('side-panel-tab-catalog'));
- expect(useConcordanceIndex).toHaveBeenLastCalledWith(
+ expect(useConcordanceEntries).toHaveBeenLastCalledWith(
expect.objectContaining({ shown: false }),
);
});
@@ -4878,6 +4908,7 @@ function prepareStoreProbeTest(): void {
mockLostBoundaries([]);
mockStaleAnalyses();
mockProjectBookIds(undefined);
+ mockUnreadSourceText();
mockSendCommand.mockResolvedValue(JSON.stringify(emptyDraft(testProjectId)));
jest
.mocked(useData)
@@ -4978,6 +5009,94 @@ describe('analysis store lifetime', () => {
});
});
+describe('whole-text re-anchoring', () => {
+ beforeEach(prepareStoreProbeTest);
+
+ afterEach(() => {
+ mountStoreProbe = false;
+ });
+
+ /** The arguments the loader last handed the reader of the source text. */
+ function readerArgs(): UseSourceTextReaderArgs {
+ const { calls } = jest.mocked(useSourceTextReader).mock;
+ return calls[calls.length - 1][0];
+ }
+
+ /** Loads a draft approving "beta" at `tokenRef`, as written against "Al beta.". */
+ async function renderWithApprovalAt(tokenRef: string): Promise {
+ const analysis = analysisApprovingAt(tokenRef, 'beta');
+ mockSendCommand.mockResolvedValue(JSON.stringify({ ...emptyDraft(testProjectId), analysis }));
+ await act(async () => renderLoader());
+ }
+
+ it('re-anchors the draft to a book the text read hands on', async () => {
+ await renderWithApprovalAt('EXO 1:1:3');
+ const exodus: Book = {
+ id: 'EXO',
+ bookRef: 'EXO',
+ textVersion: 'v1',
+ duplicateVerseIds: [],
+ segments: [
+ makeSegment('EXO 1:1', 'Alpha beta.', [
+ makeWordToken('EXO 1:1:0', 'Alpha'),
+ makeWordToken('EXO 1:1:6', 'beta', 6),
+ ]),
+ ],
+ };
+
+ act(() => readerArgs().onBookRead?.(exodus));
+
+ expect(probeAnalysis?.tokenAnalysisLinks[0].token.tokenRef).toBe('EXO 1:1:6');
+ });
+
+ it('stales the approvals in a book the read text lacks', async () => {
+ await renderWithApprovalAt('EXO 1:1:3');
+
+ act(() => readerArgs().onTextRead?.(['GEN']));
+
+ expect(probeAnalysis?.tokenAnalysisLinks[0].status).toBe('stale');
+ });
+
+ /** Opens the catalog and its filter controls. */
+ async function openCatalogFilters(): Promise {
+ await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog'));
+ await userEvent.click(screen.getByTestId('catalog-filters-button'));
+ }
+
+ it('says stale places are found only in opened books until the text is read', async () => {
+ await renderWithApprovalAt('EXO 1:1:3');
+
+ await openCatalogFilters();
+
+ expect(
+ screen.getByRole('checkbox', { name: '%interlinearizer_analysisCatalog_filter_stale%' }),
+ ).toBeInTheDocument();
+ });
+
+ it('says stale places are found in every book once the text is read', async () => {
+ await renderWithApprovalAt('EXO 1:1:3');
+ act(() => readerArgs().onTextRead?.(['GEN', 'EXO']));
+
+ await openCatalogFilters();
+
+ expect(
+ screen.getByRole('checkbox', {
+ name: '%interlinearizer_analysisCatalog_filter_staleEverywhere%',
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('reads the text again once the draft is replaced', async () => {
+ await renderWithApprovalAt('EXO 1:1:3');
+ const { readKey } = readerArgs();
+
+ await userEvent.click(screen.getByTestId('tab-toolbar-wipe'));
+ await userEvent.click(screen.getByTestId('wipe-confirm-book'));
+
+ expect(readerArgs().readKey).not.toBe(readKey);
+ });
+});
+
describe('undo and redo', () => {
beforeEach(() => {
prepareStoreProbeTest();
diff --git a/src/__tests__/components/OrphanedFreeTranslationCard.test.tsx b/src/__tests__/components/OrphanedFreeTranslationCard.test.tsx
new file mode 100644
index 000000000..2fbf6bfc3
--- /dev/null
+++ b/src/__tests__/components/OrphanedFreeTranslationCard.test.tsx
@@ -0,0 +1,73 @@
+///
+///
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import OrphanedFreeTranslationCard from '../../components/OrphanedFreeTranslationCard';
+import type { StaleFreeTranslation } from '../../utils/stale-free-translations';
+import { withTooltipProvider } from './test-helpers';
+
+const mockDiscard = jest.fn();
+
+jest.mock('../../components/AnalysisStore', () => ({
+ __esModule: true,
+ useStaleFreeTranslationDispatch: () => ({ keep: jest.fn(), discard: mockDiscard }),
+}));
+
+const STRINGS = {
+ '%interlinearizer_freeTranslationInput_orphaned%': 'Gone: {verse}',
+ '%interlinearizer_freeTranslationInput_staleDiscard%': 'Discard',
+ '%interlinearizer_freeTranslationInput_staleNoText%': '(none)',
+};
+
+/** Renders the card for a translation of a piece of GEN 1:5 split off mid-verse, reading `text`. */
+function renderCard(text = 'Au début') {
+ const translation: StaleFreeTranslation = { analysisId: 'sa-1', segmentId: 'GEN 1:5:12', text };
+ return render(
+ withTooltipProvider(
+ ,
+ ),
+ );
+}
+
+describe('OrphanedFreeTranslationCard', () => {
+ it('names the verse the translation was written for', () => {
+ renderCard();
+
+ expect(screen.getByText('Gone: 1:5')).toBeInTheDocument();
+ });
+
+ it("shows the translation's text", () => {
+ renderCard();
+
+ expect(screen.getByTestId('orphaned-free-translation')).toHaveTextContent('Au début');
+ });
+
+ it("offers the translation's full text in a tooltip", () => {
+ const longText =
+ 'In the beginning God created the heavens and the earth, and the earth was void';
+ renderCard(longText);
+
+ expect(screen.getByText(longText)).toHaveAttribute('title', longText);
+ });
+
+ it('shows the placeholder for a translation with no text in the language', () => {
+ renderCard('');
+
+ expect(screen.getByTestId('orphaned-free-translation')).toHaveTextContent('(none)');
+ });
+
+ it('discards the translation', async () => {
+ renderCard();
+
+ await userEvent.click(screen.getByTestId('orphaned-free-translation-discard'));
+
+ expect(mockDiscard).toHaveBeenCalledWith('sa-1');
+ });
+
+ it('offers nothing to do with the translation but discard it', () => {
+ renderCard();
+
+ expect(screen.getAllByRole('button')).toHaveLength(1);
+ });
+});
diff --git a/src/__tests__/components/SourceTextContext.test.tsx b/src/__tests__/components/SourceTextContext.test.tsx
new file mode 100644
index 000000000..a5eed7055
--- /dev/null
+++ b/src/__tests__/components/SourceTextContext.test.tsx
@@ -0,0 +1,28 @@
+///
+
+import { renderHook } from '@testing-library/react';
+import {
+ useConcordanceEntriesContext,
+ useSourceTextContext,
+} from '../../components/SourceTextContext';
+
+describe('useSourceTextContext', () => {
+ it('throws outside a provider', () => {
+ // React logs the render error before rethrowing it; silenced so the expected throw is quiet.
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => renderHook(() => useSourceTextContext())).toThrow(
+ 'useSourceTextContext must be used within its provider',
+ );
+ });
+});
+
+describe('useConcordanceEntriesContext', () => {
+ it('throws outside a provider', () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => renderHook(() => useConcordanceEntriesContext())).toThrow(
+ 'useConcordanceEntriesContext must be used within its provider',
+ );
+ });
+});
diff --git a/src/__tests__/components/StaleAnalysesReporter.test.tsx b/src/__tests__/components/StaleAnalysesReporter.test.tsx
index 63a1d66b9..8fe3d5616 100644
--- a/src/__tests__/components/StaleAnalysesReporter.test.tsx
+++ b/src/__tests__/components/StaleAnalysesReporter.test.tsx
@@ -2,18 +2,18 @@
import { render } from '@testing-library/react';
import type { TokenAnalysisLink } from 'interlinearizer';
-import {
- useStaleFreeTranslationsBySegment,
- useStaleTokenLinks,
-} from '../../components/AnalysisStore';
+import { usePlacedStaleFreeTranslations, useStaleTokenLinks } from '../../components/AnalysisStore';
import StaleAnalysesReporter from '../../components/StaleAnalysesReporter';
-import type { StaleFreeTranslation } from '../../utils/stale-free-translations';
-import { FIXTURE_STAMPS, GEN_1_1_BOOK } from '../test-helpers';
+import type {
+ PlacedStaleFreeTranslations,
+ StaleFreeTranslation,
+} from '../../utils/stale-free-translations';
+import { FIXTURE_STAMPS, GEN_1_1_BOOK, makeVerseBook } from '../test-helpers';
jest.mock('../../components/AnalysisStore', () => ({
__esModule: true,
useStaleTokenLinks: jest.fn(),
- useStaleFreeTranslationsBySegment: jest.fn(),
+ usePlacedStaleFreeTranslations: jest.fn(),
}));
function staleLink(analysisId: string, tokenRef: string): TokenAnalysisLink {
@@ -24,15 +24,28 @@ function staleTranslation(analysisId: string, segmentId: string): StaleFreeTrans
return { analysisId, segmentId, text: 'Au début' };
}
-/** Serves the store's stale analyses: these stale links, and these translations by segment. */
+/** Serves the store's stale analyses: these stale links, and these translations as placed. */
function mockStale(
links: readonly TokenAnalysisLink[],
translationsBySegment: ReadonlyMap = new Map(),
+ orphaned: Partial = {},
): void {
jest.mocked(useStaleTokenLinks).mockReturnValue(links);
- jest.mocked(useStaleFreeTranslationsBySegment).mockReturnValue(translationsBySegment);
+ jest.mocked(usePlacedStaleFreeTranslations).mockReturnValue({
+ bySegment: translationsBySegment,
+ orphanedAfter: new Map(),
+ orphanedAhead: [],
+ ...orphaned,
+ });
}
+/** Verses 1 through 3 of Genesis 1, each its own segment. */
+const THREE_VERSE_BOOK = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 1:2', text: 'and the earth' },
+ { sid: 'GEN 1:3', text: 'and God said' },
+]);
+
describe('StaleAnalysesReporter', () => {
it('reports a key per place in the book an analysis went stale at', () => {
mockStale([staleLink('a', 'GEN 1:1:0'), staleLink('b', 'GEN 1:1:0')]);
@@ -72,13 +85,13 @@ describe('StaleAnalysesReporter', () => {
mockStale(
[],
new Map([
- ['GEN 1:1', [staleTranslation('s1', 'GEN 1:1'), staleTranslation('s2', 'GEN 1:1:9')]],
['GEN 1:2', [staleTranslation('s3', 'GEN 1:2')]],
+ ['GEN 1:1', [staleTranslation('s1', 'GEN 1:1'), staleTranslation('s2', 'GEN 1:1:9')]],
]),
);
const onReport = jest.fn();
- render();
+ render();
expect(onReport).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -91,6 +104,42 @@ describe('StaleAnalysesReporter', () => {
);
});
+ it('reports a translation orphaned after a segment with that segment, after its own', () => {
+ mockStale([], new Map([['GEN 1:1', [staleTranslation('s1', 'GEN 1:1')]]]), {
+ orphanedAfter: new Map([['GEN 1:1', [staleTranslation('s2', 'GEN 1:9')]]]),
+ });
+ const onReport = jest.fn();
+
+ render();
+
+ expect(onReport).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ freeTranslations: [
+ { key: 'freeTranslation s1', segmentId: 'GEN 1:1' },
+ { key: 'freeTranslation s2', segmentId: 'GEN 1:1' },
+ ],
+ }),
+ );
+ });
+
+ it("reports a translation orphaned ahead of the book with its first segment, before that segment's own", () => {
+ mockStale([], new Map([['GEN 1:1', [staleTranslation('s1', 'GEN 1:1')]]]), {
+ orphanedAhead: [staleTranslation('s0', 'GEN 1:0')],
+ });
+ const onReport = jest.fn();
+
+ render();
+
+ expect(onReport).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ freeTranslations: [
+ { key: 'freeTranslation s0', segmentId: 'GEN 1:1' },
+ { key: 'freeTranslation s1', segmentId: 'GEN 1:1' },
+ ],
+ }),
+ );
+ });
+
it('reports again when the stale analyses change', () => {
mockStale([staleLink('a', 'GEN 1:1:0')]);
const onReport = jest.fn();
diff --git a/src/__tests__/components/TextReadingStatus.test.tsx b/src/__tests__/components/TextReadingStatus.test.tsx
new file mode 100644
index 000000000..87e99df3a
--- /dev/null
+++ b/src/__tests__/components/TextReadingStatus.test.tsx
@@ -0,0 +1,51 @@
+///
+///
+
+import { render, screen } from '@testing-library/react';
+import TextReadingStatus from '../../components/TextReadingStatus';
+
+const localizedStrings = {
+ '%interlinearizer_textReading_loading%': 'Reading {read} of {total}',
+ '%interlinearizer_textReading_error%': 'The text could not be read.',
+};
+
+describe('TextReadingStatus', () => {
+ it('shows how many books have been read while the text is read', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('panel-loading')).toHaveTextContent('Reading 12 of 66');
+ expect(screen.queryByTestId('panel-error')).not.toBeInTheDocument();
+ });
+
+ it('says so when the text could not be read', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('panel-error')).toHaveTextContent('The text could not be read.');
+ expect(screen.queryByTestId('panel-loading')).not.toBeInTheDocument();
+ });
+
+ it('says the text could not be read when a book of it failed to read', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('panel-error')).toHaveTextContent('The text could not be read.');
+ expect(screen.queryByTestId('panel-loading')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/hooks/useConcordanceEntries.test.ts b/src/__tests__/hooks/useConcordanceEntries.test.ts
new file mode 100644
index 000000000..33da78a50
--- /dev/null
+++ b/src/__tests__/hooks/useConcordanceEntries.test.ts
@@ -0,0 +1,105 @@
+///
+
+import { renderHook } from '@testing-library/react';
+import useConcordanceEntries, {
+ type UseConcordanceEntriesArgs,
+} from '../../hooks/useConcordanceEntries';
+import { buildConcordanceEntries, type BookConcordance } from '../../utils/concordance';
+
+// The entries' own contents are the concordance core's concern; this stand-in names each entry by
+// the book and text version it was built from, so a test can read off which reading went in.
+jest.mock('../../utils/concordance');
+
+/** A stand-in index of one book, which the stand-in entries read only the code and version of. */
+function index(book: string, textVersion: string): BookConcordance {
+ return { book, textVersion, occurrencesByForm: new Map(), spellingCountsByForm: new Map() };
+}
+
+/** Indexes keyed by book, as the source text holds them. */
+function byBook(...indexes: BookConcordance[]): ReadonlyMap {
+ return new Map(indexes.map((i) => [i.book, i]));
+}
+
+const READINGS = byBook(index('GEN', 'v1'), index('EXO', 'v1'));
+
+const baseArgs: UseConcordanceEntriesArgs = {
+ text: { status: 'ready', readings: READINGS, liveVersions: new Map() },
+ shown: true,
+ writingSystem: 'en',
+};
+
+beforeEach(() => {
+ jest.mocked(buildConcordanceEntries).mockImplementation((books) =>
+ [...books].map((b) => ({
+ form: `${b.book}@${b.textVersion}`,
+ displayText: b.book,
+ occurrences: [],
+ countByBook: new Map(),
+ })),
+ );
+});
+
+/** The book and version each entry was built from. */
+function builtFrom(entries: readonly { form: string }[]): string[] {
+ return entries.map((e) => e.form).sort();
+}
+
+describe('useConcordanceEntries', () => {
+ it('builds nothing until every book has been read', () => {
+ const { result } = renderHook(() =>
+ useConcordanceEntries({
+ ...baseArgs,
+ text: { status: 'loading', readings: undefined, liveVersions: new Map() },
+ }),
+ );
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('builds an entry from every reading', () => {
+ const { result } = renderHook(() => useConcordanceEntries(baseArgs));
+
+ expect(builtFrom(result.current)).toEqual(['EXO@v1', 'GEN@v1']);
+ });
+
+ it('takes a live version in place of its reading', () => {
+ const { result } = renderHook(() =>
+ useConcordanceEntries({
+ ...baseArgs,
+ text: { ...baseArgs.text, liveVersions: byBook(index('GEN', 'v2')) },
+ }),
+ );
+
+ expect(builtFrom(result.current)).toEqual(['EXO@v1', 'GEN@v2']);
+ });
+
+ it('holds back live versions while the concordance is hidden', () => {
+ const { result, rerender } = renderHook(
+ (args: UseConcordanceEntriesArgs) => useConcordanceEntries(args),
+ { initialProps: { ...baseArgs, shown: false } },
+ );
+ const builds = jest.mocked(buildConcordanceEntries).mock.calls.length;
+
+ rerender({
+ ...baseArgs,
+ shown: false,
+ text: { ...baseArgs.text, liveVersions: byBook(index('GEN', 'v2')) },
+ });
+
+ expect(jest.mocked(buildConcordanceEntries).mock.calls.length).toBe(builds);
+ expect(builtFrom(result.current)).toEqual(['EXO@v1', 'GEN@v1']);
+ });
+
+ it('takes in live versions held back while hidden once the concordance is shown', () => {
+ const liveVersions = byBook(index('GEN', 'v2'));
+ const { result, rerender } = renderHook(
+ (args: UseConcordanceEntriesArgs) => useConcordanceEntries(args),
+ { initialProps: { ...baseArgs, shown: false } },
+ );
+ rerender({ ...baseArgs, shown: false, text: { ...baseArgs.text, liveVersions } });
+
+ rerender({ ...baseArgs, text: { ...baseArgs.text, liveVersions } });
+
+ expect(builtFrom(result.current)).toEqual(['EXO@v1', 'GEN@v2']);
+ });
+});
diff --git a/src/__tests__/hooks/useConcordanceIndex.test.ts b/src/__tests__/hooks/useConcordanceIndex.test.ts
deleted file mode 100644
index 4fb3d2d87..000000000
--- a/src/__tests__/hooks/useConcordanceIndex.test.ts
+++ /dev/null
@@ -1,335 +0,0 @@
-///
-
-import papi, { logger } from '@papi/frontend';
-import { act, renderHook, waitFor } from '@testing-library/react';
-import type { Book } from 'interlinearizer';
-import { tokenizeBook } from 'parsers/papi/bookTokenizer';
-import { extractBookFromUsj, type UsjDocument } from 'parsers/papi/usjBookExtractor';
-import useConcordanceIndex, { type UseConcordanceIndexArgs } from '../../hooks/useConcordanceIndex';
-import { buildConcordanceEntries, indexBook } from '../../utils/concordance';
-import { getMockedPdpGet } from '../test-helpers';
-
-// The index's own contents are the concordance core's concern; these stand-ins name each entry by
-// the book and text version it was built from, so a test can read off which reading went in.
-jest.mock('../../utils/concordance');
-jest.mock('parsers/papi/bookTokenizer');
-jest.mock('parsers/papi/usjBookExtractor');
-
-const mockPdpGet = getMockedPdpGet(papi);
-
-/** A stand-in USJ document naming the book and text version it holds. */
-function usj(bookId: string, version: string): UsjDocument {
- return { content: [bookId, version] };
-}
-
-/** A stand-in tokenized book, which the stand-in index reads only the code and version of. */
-function book(bookRef: string, textVersion: string): Book {
- return { id: bookRef, bookRef, textVersion, segments: [], duplicateVerseIds: [] };
-}
-
-/** Wires the providers to serve `booksPresent` and each book's text from `readBook`. */
-function serveProject(
- booksPresent: string,
- readBook: (bookId: string) => Promise,
-) {
- const getBookUSJ = jest.fn(({ book: bookId }: { book: string }) => readBook(bookId));
- mockPdpGet.mockImplementation(async (type: string) =>
- type === 'platform.base' ? { getSetting: async () => booksPresent } : { getBookUSJ },
- );
- return getBookUSJ;
-}
-
-/** A promise with its resolver exposed, for holding a read open mid-test. */
-function deferred() {
- let settle: (value: T) => void = () => {};
- const promise = new Promise((resolve) => {
- settle = resolve;
- });
- return { promise, resolve: settle };
-}
-
-const baseArgs: UseConcordanceIndexArgs = {
- projectId: 'src',
- writingSystem: 'en',
- liveBook: undefined,
- enabled: true,
- shown: true,
-};
-
-beforeEach(() => {
- jest.mocked(extractBookFromUsj).mockImplementation((doc) => ({
- bookCode: String(doc.content[0]),
- contentHash: String(doc.content[1]),
- writingSystem: 'en',
- duplicateVerseIds: [],
- segments: [],
- frontMatter: [],
- }));
- jest.mocked(tokenizeBook).mockImplementation((raw) => book(raw.bookCode, raw.contentHash));
- jest.mocked(indexBook).mockImplementation((b) => ({
- book: b.bookRef,
- textVersion: b.textVersion,
- occurrencesByForm: new Map(),
- spellingCountsByForm: new Map(),
- }));
- jest.mocked(buildConcordanceEntries).mockImplementation((books) =>
- [...books].map((b) => ({
- form: `${b.book}@${b.textVersion}`,
- displayText: b.book,
- occurrences: [],
- countByBook: new Map(),
- })),
- );
-});
-
-/** The book and version each entry was built from. */
-function builtFrom(entries: readonly { form: string }[]): string[] {
- return entries.map((e) => e.form).sort();
-}
-
-describe('useConcordanceIndex', () => {
- it('reads nothing until the index is wanted', () => {
- serveProject('1', async () => usj('GEN', 'v1'));
-
- const { result } = renderHook(() => useConcordanceIndex({ ...baseArgs, enabled: false }));
-
- expect(result.current.status).toBe('idle');
- expect(mockPdpGet).not.toHaveBeenCalled();
- });
-
- it('reads every book the project marks present', async () => {
- const getBookUSJ = serveProject('1010', async (id) => usj(id, 'v1'));
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.status).toBe('ready'));
- expect(builtFrom(result.current.entries)).toEqual(['GEN@v1', 'LEV@v1']);
- expect(getBookUSJ.mock.calls.map(([ref]) => ref.book)).toEqual(['GEN', 'LEV']);
- });
-
- it('ignores present-book flags past the end of the canon', async () => {
- const getBookUSJ = serveProject(`${'0'.repeat(199)}1`, async (id) => usj(id, 'v1'));
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.status).toBe('ready'));
- expect(getBookUSJ).not.toHaveBeenCalled();
- });
-
- it('reports progress and withholds the entries until every book is read', async () => {
- const exodus = deferred();
- serveProject('11', (id) => (id === 'EXO' ? exodus.promise : Promise.resolve(usj(id, 'v1'))));
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.booksRead).toBe(1));
- expect(result.current).toMatchObject({ status: 'loading', bookCount: 2, entries: [] });
-
- await act(async () => exodus.resolve(usj('EXO', 'v1')));
-
- expect(result.current).toMatchObject({ status: 'ready', booksRead: 2, bookCount: 2 });
- });
-
- it('skips a book the project serves no text for', async () => {
- serveProject('11', async (id) => (id === 'GEN' ? undefined : usj(id, 'v1')));
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.status).toBe('ready'));
- expect(builtFrom(result.current.entries)).toEqual(['EXO@v1']);
- expect(logger.warn).toHaveBeenCalled();
- });
-
- it('skips a book whose text fails to read', async () => {
- serveProject('11', async (id) => {
- if (id === 'GEN') throw new Error('unreadable');
- return usj(id, 'v1');
- });
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.status).toBe('ready'));
- expect(builtFrom(result.current.entries)).toEqual(['EXO@v1']);
- });
-
- it('reports an error when no book could be read', async () => {
- serveProject('11', async (id) => {
- if (id === 'GEN') throw new Error('unreadable');
- return undefined;
- });
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.status).toBe('error'));
- expect(result.current.entries).toEqual([]);
- expect(logger.error).toHaveBeenCalled();
- });
-
- it('reports an empty index when no book has text and none failed', async () => {
- serveProject('11', async () => undefined);
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.status).toBe('ready'));
- expect(result.current.entries).toEqual([]);
- });
-
- it('reports an error when the list of books cannot be read', async () => {
- mockPdpGet.mockRejectedValue(new Error('no provider'));
-
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
-
- await waitFor(() => expect(result.current.status).toBe('error'));
- expect(logger.error).toHaveBeenCalled();
- });
-
- it('takes the live book in place of its reading', async () => {
- serveProject('11', async (id) => usj(id, 'v1'));
-
- const { result } = renderHook(() =>
- useConcordanceIndex({ ...baseArgs, liveBook: book('GEN', 'v2') }),
- );
-
- await waitFor(() => expect(result.current.status).toBe('ready'));
- expect(builtFrom(result.current.entries)).toEqual(['EXO@v1', 'GEN@v2']);
- });
-
- it('keeps the last live version of a book the editor moves off', async () => {
- serveProject('11', async (id) => usj(id, 'v1'));
- const { result, rerender } = renderHook(
- (args: UseConcordanceIndexArgs) => useConcordanceIndex(args),
- { initialProps: { ...baseArgs, liveBook: book('GEN', 'v2') } },
- );
- await waitFor(() => expect(result.current.status).toBe('ready'));
-
- rerender({ ...baseArgs, liveBook: book('EXO', 'v2') });
-
- expect(builtFrom(result.current.entries)).toEqual(['EXO@v2', 'GEN@v2']);
- });
-
- it('holds back live-book edits while the concordance is hidden', async () => {
- serveProject('1', async (id) => usj(id, 'v1'));
- const { result, rerender } = renderHook(
- (args: UseConcordanceIndexArgs) => useConcordanceIndex(args),
- { initialProps: { ...baseArgs, shown: false } },
- );
- await waitFor(() => expect(result.current.status).toBe('ready'));
- const builds = jest.mocked(buildConcordanceEntries).mock.calls.length;
-
- rerender({ ...baseArgs, shown: false, liveBook: book('GEN', 'v2') });
-
- expect(jest.mocked(buildConcordanceEntries).mock.calls.length).toBe(builds);
- expect(builtFrom(result.current.entries)).toEqual(['GEN@v1']);
- });
-
- it('takes in edits held back while hidden once the concordance is shown', async () => {
- serveProject('1', async (id) => usj(id, 'v1'));
- const { result, rerender } = renderHook(
- (args: UseConcordanceIndexArgs) => useConcordanceIndex(args),
- { initialProps: { ...baseArgs, shown: false } },
- );
- await waitFor(() => expect(result.current.status).toBe('ready'));
- rerender({ ...baseArgs, shown: false, liveBook: book('GEN', 'v2') });
-
- rerender({ ...baseArgs, liveBook: book('GEN', 'v2') });
-
- expect(builtFrom(result.current.entries)).toEqual(['GEN@v2']);
- });
-
- it('does not rebuild for a live book whose text matches its reading', async () => {
- serveProject('1', async (id) => usj(id, 'v1'));
- const { result, rerender } = renderHook(
- (args: UseConcordanceIndexArgs) => useConcordanceIndex(args),
- { initialProps: baseArgs },
- );
- await waitFor(() => expect(result.current.status).toBe('ready'));
- const builds = jest.mocked(buildConcordanceEntries).mock.calls.length;
-
- rerender({ ...baseArgs, liveBook: book('GEN', 'v1') });
-
- expect(jest.mocked(buildConcordanceEntries).mock.calls.length).toBe(builds);
- });
-
- it('reads every book again on refresh, keeping the live one', async () => {
- let version = 'v1';
- serveProject('111', async (id) => usj(id, version));
- const { result, rerender } = renderHook(
- (args: UseConcordanceIndexArgs) => useConcordanceIndex(args),
- { initialProps: { ...baseArgs, liveBook: book('GEN', 'v2') } },
- );
- await waitFor(() => expect(result.current.status).toBe('ready'));
- rerender({ ...baseArgs, liveBook: book('EXO', 'v2') });
-
- version = 'v3';
- act(() => result.current.refresh());
-
- await waitFor(() => expect(result.current.status).toBe('ready'));
- expect(builtFrom(result.current.entries)).toEqual(['EXO@v2', 'GEN@v3', 'LEV@v3']);
- });
-
- it('abandons a read in progress when refreshed', async () => {
- const firstGenesis = deferred();
- let genesisReads = 0;
- serveProject('11', (id) => {
- if (id !== 'GEN') return Promise.resolve(usj(id, 'v1'));
- genesisReads += 1;
- return genesisReads === 1 ? firstGenesis.promise : Promise.resolve(usj(id, 'v2'));
- });
- const { result } = renderHook(() => useConcordanceIndex(baseArgs));
- await waitFor(() => expect(genesisReads).toBe(1));
-
- act(() => result.current.refresh());
- await waitFor(() => expect(result.current.status).toBe('ready'));
- await act(async () => firstGenesis.resolve(usj('GEN', 'stale')));
-
- expect(result.current.status).toBe('ready');
- expect(builtFrom(result.current.entries)).toEqual(['EXO@v1', 'GEN@v2']);
- });
-
- it('forgets live versions of other books on refresh when no book is live', async () => {
- let version = 'v1';
- serveProject('1', async (id) => usj(id, version));
- const genesisLive: UseConcordanceIndexArgs = { ...baseArgs, liveBook: book('GEN', 'v2') };
- const { result, rerender } = renderHook(
- (args: UseConcordanceIndexArgs) => useConcordanceIndex(args),
- { initialProps: genesisLive },
- );
- await waitFor(() => expect(result.current.status).toBe('ready'));
- rerender(baseArgs);
-
- version = 'v3';
- act(() => result.current.refresh());
-
- await waitFor(() => expect(builtFrom(result.current.entries)).toEqual(['GEN@v3']));
- });
-
- it('abandons listing the books when unmounted', async () => {
- const booksPresent = deferred();
- const getBookUSJ = jest.fn();
- mockPdpGet.mockResolvedValue({ getSetting: () => booksPresent.promise, getBookUSJ });
- const { unmount } = renderHook(() => useConcordanceIndex(baseArgs));
- await waitFor(() => expect(mockPdpGet).toHaveBeenCalled());
-
- unmount();
- await act(async () => booksPresent.resolve('1'));
-
- expect(getBookUSJ).not.toHaveBeenCalled();
- });
-
- it('logs nothing when a listing fails after it was abandoned', async () => {
- let fail: (e: Error) => void = () => {};
- mockPdpGet.mockResolvedValue({
- getSetting: () =>
- new Promise((_resolve, reject) => {
- fail = reject;
- }),
- });
- const { unmount } = renderHook(() => useConcordanceIndex(baseArgs));
- await waitFor(() => expect(mockPdpGet).toHaveBeenCalled());
-
- unmount();
- await act(async () => fail(new Error('gone')));
-
- expect(logger.error).not.toHaveBeenCalled();
- });
-});
diff --git a/src/__tests__/hooks/useHydrationRange.test.ts b/src/__tests__/hooks/useHydrationRange.test.ts
index 99ea794fb..e3a7d9459 100644
--- a/src/__tests__/hooks/useHydrationRange.test.ts
+++ b/src/__tests__/hooks/useHydrationRange.test.ts
@@ -16,7 +16,7 @@ function uniformTable(count: number): HeightTable {
const heights = Array.from({ length: count }, () => SEGMENT_PX);
const offsets = heights.map((_h, i) => i * SEGMENT_PX);
offsets.push(count * SEGMENT_PX);
- return { heights, offsets, total: count * SEGMENT_PX };
+ return { heights, boxHeights: heights, offsets, total: count * SEGMENT_PX };
}
/**
diff --git a/src/__tests__/hooks/useSegmentWindow.test.ts b/src/__tests__/hooks/useSegmentWindow.test.ts
index 8f3213120..91f846336 100644
--- a/src/__tests__/hooks/useSegmentWindow.test.ts
+++ b/src/__tests__/hooks/useSegmentWindow.test.ts
@@ -32,7 +32,7 @@ function uniformHeightTable(book: Book): HeightTable {
const heights = book.segments.map(() => UNIFORM_SEGMENT_PX);
const offsets = heights.map((_h, i) => i * UNIFORM_SEGMENT_PX);
offsets.push(heights.length * UNIFORM_SEGMENT_PX);
- return { heights, offsets, total: heights.length * UNIFORM_SEGMENT_PX };
+ return { heights, boxHeights: heights, offsets, total: heights.length * UNIFORM_SEGMENT_PX };
}
/** One animation frame under fake timers, which schedules `requestAnimationFrame` on a 16ms clock. */
@@ -1962,6 +1962,27 @@ describe('useSegmentWindow', () => {
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' });
});
+ it('snaps the cards ahead of the first segment to the top with it', () => {
+ const book = makeBook(60, 0);
+ const { container, rerender } = renderSegmentWindow(book, {
+ book: 'GEN',
+ chapterNum: 1,
+ verseNum: 1,
+ });
+ const ahead = document.createElement('div');
+ ahead.setAttribute('data-orphaned-ahead', '');
+ ahead.scrollIntoView = jest.fn();
+ container.appendChild(ahead);
+ const active = mountActiveSegment(container);
+ active.scrollIntoView = jest.fn();
+
+ act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } }));
+ act(() => jest.advanceTimersByTime(RECENTER_FADE_MS));
+
+ expect(ahead.scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' });
+ expect(active.scrollIntoView).not.toHaveBeenCalled();
+ });
+
it('grows the snap spacer when scrollIntoView cannot reach the top', () => {
const book = makeBook(60, 0);
const scrollIntoView = jest.fn();
diff --git a/src/__tests__/hooks/useSourceTextReader.test.ts b/src/__tests__/hooks/useSourceTextReader.test.ts
new file mode 100644
index 000000000..5140d3325
--- /dev/null
+++ b/src/__tests__/hooks/useSourceTextReader.test.ts
@@ -0,0 +1,476 @@
+///
+
+import papi, { logger } from '@papi/frontend';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import type { Book } from 'interlinearizer';
+import { tokenizeBook } from 'parsers/papi/bookTokenizer';
+import { extractBookFromUsj, type UsjDocument } from 'parsers/papi/usjBookExtractor';
+import useSourceTextReader, {
+ type SourceText,
+ type UseSourceTextReaderArgs,
+} from '../../hooks/useSourceTextReader';
+import { indexBook } from '../../utils/concordance';
+import { getMockedPdpGet } from '../test-helpers';
+
+// A book's index is the concordance core's concern; this stand-in names the one form it holds by
+// the book and text version it was built from, so a test can read off which reading went in.
+jest.mock('../../utils/concordance');
+jest.mock('parsers/papi/bookTokenizer');
+jest.mock('parsers/papi/usjBookExtractor');
+
+const mockPdpGet = getMockedPdpGet(papi);
+
+/** A stand-in USJ document naming the book and text version it holds. */
+function usj(bookId: string, version: string): UsjDocument {
+ return { content: [bookId, version] };
+}
+
+/** A stand-in tokenized book, which the stand-in index reads only the code and version of. */
+function book(bookRef: string, textVersion: string): Book {
+ return { id: bookRef, bookRef, textVersion, segments: [], duplicateVerseIds: [] };
+}
+
+/** Wires the providers to serve `booksPresent` and each book's text from `readBook`. */
+function serveProject(
+ booksPresent: string,
+ readBook: (bookId: string) => Promise,
+) {
+ const getBookUSJ = jest.fn(({ book: bookId }: { book: string }) => readBook(bookId));
+ mockPdpGet.mockImplementation(async (type: string) =>
+ type === 'platform.base' ? { getSetting: async () => booksPresent } : { getBookUSJ },
+ );
+ return getBookUSJ;
+}
+
+/** A promise with its resolver exposed, for holding a read open mid-test. */
+function deferred() {
+ let settle: (value: T) => void = () => {};
+ const promise = new Promise((resolve) => {
+ settle = resolve;
+ });
+ return { promise, resolve: settle };
+}
+
+const baseArgs: UseSourceTextReaderArgs = {
+ projectId: 'src',
+ writingSystem: 'en',
+ liveBook: undefined,
+ enabled: true,
+};
+
+beforeEach(() => {
+ jest.mocked(extractBookFromUsj).mockImplementation((doc) => ({
+ bookCode: String(doc.content[0]),
+ contentHash: String(doc.content[1]),
+ writingSystem: 'en',
+ duplicateVerseIds: [],
+ segments: [],
+ frontMatter: [],
+ }));
+ jest.mocked(tokenizeBook).mockImplementation((raw) => book(raw.bookCode, raw.contentHash));
+ jest.mocked(indexBook).mockImplementation((b) => ({
+ book: b.bookRef,
+ textVersion: b.textVersion,
+ occurrencesByForm: new Map([[`${b.bookRef}@${b.textVersion}`, []]]),
+ spellingCountsByForm: new Map(),
+ }));
+});
+
+/** The book and version each of the indexes was built from. */
+function builtFrom(indexes: SourceText['readings']): string[] {
+ return [...(indexes?.values() ?? [])].map((i) => `${i.book}@${i.textVersion}`).sort();
+}
+
+/** The forms the text holds, in a stable order. */
+function formsOf(text: SourceText): string[] | undefined {
+ return text.textForms && [...text.textForms].sort();
+}
+
+describe('useSourceTextReader', () => {
+ it('reads nothing until the text is wanted', () => {
+ serveProject('1', async () => usj('GEN', 'v1'));
+
+ const { result } = renderHook(() => useSourceTextReader({ ...baseArgs, enabled: false }));
+
+ expect(result.current.status).toBe('idle');
+ expect(mockPdpGet).not.toHaveBeenCalled();
+ });
+
+ it('reads the books once they are requested', async () => {
+ serveProject('1', async (id) => usj(id, 'v1'));
+ const { result } = renderHook(() => useSourceTextReader({ ...baseArgs, enabled: false }));
+
+ act(() => result.current.request());
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(formsOf(result.current)).toEqual(['GEN@v1']);
+ });
+
+ it('reads every book the project marks present', async () => {
+ const getBookUSJ = serveProject('1010', async (id) => usj(id, 'v1'));
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(builtFrom(result.current.readings)).toEqual(['GEN@v1', 'LEV@v1']);
+ expect(getBookUSJ.mock.calls.map(([ref]) => ref.book)).toEqual(['GEN', 'LEV']);
+ });
+
+ it('ignores present-book flags past the end of the canon', async () => {
+ const getBookUSJ = serveProject(`${'0'.repeat(199)}1`, async (id) => usj(id, 'v1'));
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(getBookUSJ).not.toHaveBeenCalled();
+ });
+
+ it('reports progress and withholds the readings until every book is read', async () => {
+ const exodus = deferred();
+ serveProject('11', (id) => (id === 'EXO' ? exodus.promise : Promise.resolve(usj(id, 'v1'))));
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.booksRead).toBe(1));
+ expect(result.current).toMatchObject({ status: 'loading', bookCount: 2, readings: undefined });
+
+ await act(async () => exodus.resolve(usj('EXO', 'v1')));
+
+ expect(result.current).toMatchObject({ status: 'ready', booksRead: 2, bookCount: 2 });
+ });
+
+ it('skips a book the project serves no text for', async () => {
+ serveProject('11', async (id) => (id === 'GEN' ? undefined : usj(id, 'v1')));
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(builtFrom(result.current.readings)).toEqual(['EXO@v1']);
+ expect(logger.warn).toHaveBeenCalled();
+ });
+
+ it('skips a book whose text fails to read', async () => {
+ serveProject('11', async (id) => {
+ if (id === 'GEN') throw new Error('unreadable');
+ return usj(id, 'v1');
+ });
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(builtFrom(result.current.readings)).toEqual(['EXO@v1']);
+ });
+
+ it('hands on each book it reads', async () => {
+ serveProject('11', async (id) => usj(id, 'v1'));
+ const onBookRead = jest.fn();
+
+ renderHook(() => useSourceTextReader({ ...baseArgs, onBookRead }));
+
+ await waitFor(() =>
+ expect(onBookRead.mock.calls.map(([read]) => read)).toEqual([
+ book('GEN', 'v1'),
+ book('EXO', 'v1'),
+ ]),
+ );
+ });
+
+ it('reports the books it read text for once every book is in', async () => {
+ serveProject('111', async (id) => (id === 'EXO' ? undefined : usj(id, 'v1')));
+ const onTextRead = jest.fn();
+
+ renderHook(() => useSourceTextReader({ ...baseArgs, onTextRead }));
+
+ await waitFor(() => expect(onTextRead).toHaveBeenCalledWith(['GEN', 'LEV']));
+ });
+
+ it('reports no reading of the text while a book of it failed to read', async () => {
+ serveProject('11', async (id) => {
+ if (id === 'GEN') throw new Error('unreadable');
+ return usj(id, 'v1');
+ });
+ const onTextRead = jest.fn();
+
+ const { result } = renderHook(() => useSourceTextReader({ ...baseArgs, onTextRead }));
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(onTextRead).not.toHaveBeenCalled();
+ });
+
+ it('withholds the text forms while a book of it failed to read', async () => {
+ serveProject('11', async (id) => {
+ if (id === 'GEN') throw new Error('unreadable');
+ return usj(id, 'v1');
+ });
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(result.current.isPartial).toBe(true);
+ expect(result.current.textForms).toBeUndefined();
+ });
+
+ it('drops a partial reading while the text is read again', async () => {
+ const secondGenesis = deferred();
+ let genesisReads = 0;
+ serveProject('11', (id) => {
+ if (id !== 'GEN') return Promise.resolve(usj(id, 'v1'));
+ genesisReads += 1;
+ return genesisReads === 1 ? Promise.reject(new Error('unreadable')) : secondGenesis.promise;
+ });
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+ await waitFor(() => expect(result.current.isPartial).toBe(true));
+
+ act(() => result.current.refresh());
+
+ expect(result.current.status).toBe('loading');
+ expect(result.current.isPartial).toBe(false);
+ });
+
+ it('reports the text forms once a re-read takes in the book that failed', async () => {
+ let genesisReads = 0;
+ serveProject('11', async (id) => {
+ if (id !== 'GEN') return usj(id, 'v1');
+ genesisReads += 1;
+ if (genesisReads === 1) throw new Error('unreadable');
+ return usj(id, 'v1');
+ });
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+ await waitFor(() => expect(result.current.isPartial).toBe(true));
+
+ act(() => result.current.refresh());
+
+ await waitFor(() => expect(result.current.textForms).toBeDefined());
+ expect(formsOf(result.current)).toEqual(['EXO@v1', 'GEN@v1']);
+ expect(result.current.isPartial).toBe(false);
+ });
+
+ it('reports an error when no book could be read', async () => {
+ serveProject('11', async (id) => {
+ if (id === 'GEN') throw new Error('unreadable');
+ return undefined;
+ });
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('error'));
+ expect(result.current.readings).toBeUndefined();
+ expect(logger.error).toHaveBeenCalled();
+ });
+
+ it('reports an empty text when no book has text and none failed', async () => {
+ serveProject('11', async () => undefined);
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(formsOf(result.current)).toEqual([]);
+ });
+
+ it('reports an error when the list of books cannot be read', async () => {
+ mockPdpGet.mockRejectedValue(new Error('no provider'));
+
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+
+ await waitFor(() => expect(result.current.status).toBe('error'));
+ expect(logger.error).toHaveBeenCalled();
+ });
+
+ it('keeps a live book newer than its reading as a live version', async () => {
+ serveProject('11', async (id) => usj(id, 'v1'));
+
+ const { result } = renderHook(() =>
+ useSourceTextReader({ ...baseArgs, liveBook: book('GEN', 'v2') }),
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(builtFrom(result.current.liveVersions)).toEqual(['GEN@v2']);
+ });
+
+ it('serves the forms of every book, the live one in place of its reading', async () => {
+ serveProject('11', async (id) => usj(id, 'v1'));
+
+ const { result } = renderHook(() =>
+ useSourceTextReader({ ...baseArgs, liveBook: book('GEN', 'v2') }),
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ expect(formsOf(result.current)).toEqual(['EXO@v1', 'GEN@v2']);
+ });
+
+ it('keeps the last live version of a book the editor moves off', async () => {
+ serveProject('11', async (id) => usj(id, 'v1'));
+ const { result, rerender } = renderHook(
+ (args: UseSourceTextReaderArgs) => useSourceTextReader(args),
+ { initialProps: { ...baseArgs, liveBook: book('GEN', 'v2') } },
+ );
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+
+ rerender({ ...baseArgs, liveBook: book('EXO', 'v2') });
+
+ expect(builtFrom(result.current.liveVersions)).toEqual(['EXO@v2', 'GEN@v2']);
+ });
+
+ it('takes live-book edits into the forms', async () => {
+ serveProject('1', async (id) => usj(id, 'v1'));
+ const { result, rerender } = renderHook(
+ (args: UseSourceTextReaderArgs) => useSourceTextReader(args),
+ { initialProps: baseArgs },
+ );
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+
+ rerender({ ...baseArgs, liveBook: book('GEN', 'v2') });
+
+ expect(formsOf(result.current)).toEqual(['GEN@v2']);
+ });
+
+ it('keeps no live version for a live book whose text matches its reading', async () => {
+ serveProject('1', async (id) => usj(id, 'v1'));
+ const { result, rerender } = renderHook(
+ (args: UseSourceTextReaderArgs) => useSourceTextReader(args),
+ { initialProps: baseArgs },
+ );
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ const { liveVersions } = result.current;
+
+ rerender({ ...baseArgs, liveBook: book('GEN', 'v1') });
+
+ expect(result.current.liveVersions).toBe(liveVersions);
+ });
+
+ it('reads every book again on refresh', async () => {
+ let version = 'v1';
+ serveProject('111', async (id) => usj(id, version));
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+
+ version = 'v3';
+ act(() => result.current.refresh());
+
+ await waitFor(() =>
+ expect(builtFrom(result.current.readings)).toEqual(['EXO@v3', 'GEN@v3', 'LEV@v3']),
+ );
+ });
+
+ it("keeps only the live book's live version on refresh", async () => {
+ serveProject('111', async (id) => usj(id, 'v1'));
+ const { result, rerender } = renderHook(
+ (args: UseSourceTextReaderArgs) => useSourceTextReader(args),
+ { initialProps: { ...baseArgs, liveBook: book('GEN', 'v2') } },
+ );
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ rerender({ ...baseArgs, liveBook: book('EXO', 'v2') });
+
+ act(() => result.current.refresh());
+
+ expect(builtFrom(result.current.liveVersions)).toEqual(['EXO@v2']);
+ });
+
+ it('forgets live versions of other books on refresh when no book is live', async () => {
+ serveProject('1', async (id) => usj(id, 'v1'));
+ const genesisLive: UseSourceTextReaderArgs = { ...baseArgs, liveBook: book('GEN', 'v2') };
+ const { result, rerender } = renderHook(
+ (args: UseSourceTextReaderArgs) => useSourceTextReader(args),
+ { initialProps: genesisLive },
+ );
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ rerender(baseArgs);
+
+ act(() => result.current.refresh());
+
+ expect(builtFrom(result.current.liveVersions)).toEqual([]);
+ });
+
+ it('abandons a read in progress when refreshed', async () => {
+ const firstGenesis = deferred();
+ let genesisReads = 0;
+ serveProject('11', (id) => {
+ if (id !== 'GEN') return Promise.resolve(usj(id, 'v1'));
+ genesisReads += 1;
+ return genesisReads === 1 ? firstGenesis.promise : Promise.resolve(usj(id, 'v2'));
+ });
+ const { result } = renderHook(() => useSourceTextReader(baseArgs));
+ await waitFor(() => expect(genesisReads).toBe(1));
+
+ act(() => result.current.refresh());
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ await act(async () => firstGenesis.resolve(usj('GEN', 'stale')));
+
+ expect(result.current.status).toBe('ready');
+ expect(builtFrom(result.current.readings)).toEqual(['EXO@v1', 'GEN@v2']);
+ });
+
+ it('hands on no book from a read abandoned by a refresh', async () => {
+ const firstGenesis = deferred();
+ let genesisReads = 0;
+ serveProject('1', () => {
+ genesisReads += 1;
+ return genesisReads === 1 ? firstGenesis.promise : Promise.resolve(usj('GEN', 'v2'));
+ });
+ const onBookRead = jest.fn();
+ const { result } = renderHook(() => useSourceTextReader({ ...baseArgs, onBookRead }));
+ await waitFor(() => expect(genesisReads).toBe(1));
+ act(() => result.current.refresh());
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+
+ await act(async () => firstGenesis.resolve(usj('GEN', 'stale')));
+
+ expect(onBookRead.mock.calls.map(([read]) => read)).toEqual([book('GEN', 'v2')]);
+ });
+
+ it('reads every book again when its read key changes', async () => {
+ let version = 'v1';
+ serveProject('1', async (id) => usj(id, version));
+ const { result, rerender } = renderHook(
+ (args: UseSourceTextReaderArgs) => useSourceTextReader(args),
+ { initialProps: { ...baseArgs, readKey: 1 } },
+ );
+ await waitFor(() => expect(result.current.status).toBe('ready'));
+ version = 'v2';
+
+ rerender({ ...baseArgs, readKey: 2 });
+
+ await waitFor(() => expect(formsOf(result.current)).toEqual(['GEN@v2']));
+ });
+
+ it('reads nothing when its read key changes before the text is wanted', () => {
+ const getBookUSJ = serveProject('1', async (id) => usj(id, 'v1'));
+ const { rerender } = renderHook((args: UseSourceTextReaderArgs) => useSourceTextReader(args), {
+ initialProps: { ...baseArgs, enabled: false, readKey: 1 },
+ });
+
+ rerender({ ...baseArgs, enabled: false, readKey: 2 });
+
+ expect(getBookUSJ).not.toHaveBeenCalled();
+ });
+
+ it('abandons listing the books when unmounted', async () => {
+ const booksPresent = deferred();
+ const getBookUSJ = jest.fn();
+ mockPdpGet.mockResolvedValue({ getSetting: () => booksPresent.promise, getBookUSJ });
+ const { unmount } = renderHook(() => useSourceTextReader(baseArgs));
+ await waitFor(() => expect(mockPdpGet).toHaveBeenCalled());
+
+ unmount();
+ await act(async () => booksPresent.resolve('1'));
+
+ expect(getBookUSJ).not.toHaveBeenCalled();
+ });
+
+ it('logs nothing when a listing fails after it was abandoned', async () => {
+ let fail: (e: Error) => void = () => {};
+ mockPdpGet.mockResolvedValue({
+ getSetting: () =>
+ new Promise((_resolve, reject) => {
+ fail = reject;
+ }),
+ });
+ const { unmount } = renderHook(() => useSourceTextReader(baseArgs));
+ await waitFor(() => expect(mockPdpGet).toHaveBeenCalled());
+
+ unmount();
+ await act(async () => fail(new Error('gone')));
+
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/hooks/useWholeTextReanchor.test.ts b/src/__tests__/hooks/useWholeTextReanchor.test.ts
new file mode 100644
index 000000000..046f940f6
--- /dev/null
+++ b/src/__tests__/hooks/useWholeTextReanchor.test.ts
@@ -0,0 +1,183 @@
+///
+
+import { act, renderHook } from '@testing-library/react';
+import type { Book, DraftProject } from 'interlinearizer';
+import useWholeTextReanchor, {
+ type UseWholeTextReanchorArgs,
+} from '../../hooks/useWholeTextReanchor';
+import { emptyAnalysis, emptyDraft } from '../../types/empty-factories';
+import { reanchorDraftToBook, reanchorDraftToMissingBook } from '../../utils/reanchor-draft';
+import { FIXTURE_STAMPS } from '../test-helpers';
+
+// What a pass does to the draft is the re-anchoring core's concern; these tests check only which
+// pass each book is handed.
+jest.mock('../../utils/reanchor-draft');
+
+const toBookPass = jest.fn();
+const toMissingBookPass = jest.fn();
+
+/** A draft approving a word at each of `tokenRefs`. */
+function draftLinkingInto(...tokenRefs: string[]): DraftProject {
+ return {
+ ...emptyDraft('src'),
+ analysis: {
+ ...emptyAnalysis(),
+ tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'beta' }],
+ tokenAnalysisLinks: tokenRefs.map((tokenRef) => ({
+ ...FIXTURE_STAMPS,
+ analysisId: 'ta-1',
+ status: 'approved',
+ token: { tokenRef, surfaceText: 'beta' },
+ })),
+ },
+ };
+}
+
+/** A stand-in reading of a book, which the hook reads only the code of. */
+function book(bookRef: string): Book {
+ return { id: bookRef, bookRef, textVersion: 'v1', segments: [], duplicateVerseIds: [] };
+}
+
+let reanchorBook: jest.Mock;
+
+/** Arguments for a loaded draft approving a word in Exodus, with Genesis loaded. */
+function makeArgs(overrides: Partial = {}): UseWholeTextReanchorArgs {
+ return {
+ loadedBookCode: 'GEN',
+ isImportView: false,
+ isDraftLoading: false,
+ draftVersion: 1,
+ getDraftSnapshot: () => draftLinkingInto('EXO 1:1:3'),
+ reanchorBook,
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ reanchorBook = jest.fn();
+ jest.mocked(reanchorDraftToBook).mockReturnValue(toBookPass);
+ jest.mocked(reanchorDraftToMissingBook).mockReturnValue(toMissingBookPass);
+});
+
+describe('useWholeTextReanchor', () => {
+ describe('readKey', () => {
+ it('is the draft version once the draft has loaded', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs({ draftVersion: 3 })));
+
+ expect(result.current.readKey).toBe(3);
+ });
+
+ it('is undefined while the draft loads', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs({ isDraftLoading: true })));
+
+ expect(result.current.readKey).toBeUndefined();
+ });
+
+ it('is undefined while an import is shown', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs({ isImportView: true })));
+
+ expect(result.current.readKey).toBeUndefined();
+ });
+ });
+
+ describe('onBookRead', () => {
+ it('re-anchors the draft to a book it links into', () => {
+ const exodus = book('EXO');
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs()));
+
+ act(() => result.current.onBookRead(exodus));
+
+ expect(reanchorDraftToBook).toHaveBeenCalledWith(exodus);
+ expect(reanchorBook).toHaveBeenCalledWith('EXO', toBookPass);
+ });
+
+ it('leaves the loaded book to its live text', () => {
+ const { result } = renderHook(() =>
+ useWholeTextReanchor(makeArgs({ loadedBookCode: 'EXO' })),
+ );
+
+ act(() => result.current.onBookRead(book('EXO')));
+
+ expect(reanchorBook).not.toHaveBeenCalled();
+ });
+
+ it('leaves a book the draft has no links in', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs()));
+
+ act(() => result.current.onBookRead(book('LEV')));
+
+ expect(reanchorBook).not.toHaveBeenCalled();
+ });
+
+ it('leaves the draft alone while an import is shown', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs({ isImportView: true })));
+
+ act(() => result.current.onBookRead(book('EXO')));
+
+ expect(reanchorBook).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('onTextRead', () => {
+ it('stales the approvals in a book the text lacks', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs()));
+
+ act(() => result.current.onTextRead(['GEN']));
+
+ expect(reanchorDraftToMissingBook).toHaveBeenCalledWith('EXO');
+ expect(reanchorBook).toHaveBeenCalledWith('EXO', toMissingBookPass);
+ });
+
+ it('leaves a book the text holds', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs()));
+
+ act(() => result.current.onTextRead(['GEN', 'EXO']));
+
+ expect(reanchorBook).not.toHaveBeenCalled();
+ });
+
+ it('leaves the draft alone while an import is shown', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs({ isImportView: true })));
+
+ act(() => result.current.onTextRead(['GEN']));
+
+ expect(reanchorBook).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('staleCoversDraft', () => {
+ it('is false until the text is read', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs()));
+
+ expect(result.current.staleCoversDraft).toBe(false);
+ });
+
+ it('is true once the text is read', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs()));
+
+ act(() => result.current.onTextRead(['GEN', 'EXO']));
+
+ expect(result.current.staleCoversDraft).toBe(true);
+ });
+
+ it('is false again once the draft is replaced', () => {
+ const { result, rerender } = renderHook(
+ (args: UseWholeTextReanchorArgs) => useWholeTextReanchor(args),
+ { initialProps: makeArgs() },
+ );
+ act(() => result.current.onTextRead(['GEN', 'EXO']));
+
+ rerender(makeArgs({ draftVersion: 2 }));
+
+ expect(result.current.staleCoversDraft).toBe(false);
+ });
+
+ it('stays false for a text read while an import is shown', () => {
+ const { result } = renderHook(() => useWholeTextReanchor(makeArgs({ isImportView: true })));
+
+ act(() => result.current.onTextRead(['GEN', 'EXO']));
+
+ expect(result.current.staleCoversDraft).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/store/analysisSlice.test.ts b/src/__tests__/store/analysisSlice.test.ts
index 47a859bb2..b5f6b4b14 100644
--- a/src/__tests__/store/analysisSlice.test.ts
+++ b/src/__tests__/store/analysisSlice.test.ts
@@ -5885,6 +5885,7 @@ describe('analysis-keyed reducers', () => {
return {
analysisId,
surfaceText: 'word',
+ form: 'word',
gloss: '',
morphemes: payload?.morphemes ?? [],
usageCount: 1,
diff --git a/src/__tests__/utils/analysis-query.test.ts b/src/__tests__/utils/analysis-query.test.ts
index dc6ff844b..9a9a445e8 100644
--- a/src/__tests__/utils/analysis-query.test.ts
+++ b/src/__tests__/utils/analysis-query.test.ts
@@ -891,6 +891,34 @@ describe('applyCatalogQuery filters', () => {
expect(applyCatalogQuery(rows, query)).toEqual([]);
});
+ it('keeps only rows whose form the text lacks when filtering for not in the text', () => {
+ const analysis: TextAnalysis = {
+ ...emptyAnalysis(),
+ tokenAnalyses: [
+ { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'Abraham' },
+ { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'Sarai' },
+ ],
+ };
+ const rows = buildCatalogRows(analysis, scope);
+ const query = makeQuery({
+ filters: { notInText: true },
+ textForms: new Set(['abraham', 'sarah']),
+ });
+
+ expect(applyCatalogQuery(rows, query).map((r) => r.analysisId)).toEqual(['ta-2']);
+ });
+
+ it('keeps no row as not in the text while the text is unread', () => {
+ const analysis: TextAnalysis = {
+ ...emptyAnalysis(),
+ tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'Sarai' }],
+ };
+ const rows = buildCatalogRows(analysis, scope);
+ const query = makeQuery({ filters: { notInText: true } });
+
+ expect(applyCatalogQuery(rows, query)).toEqual([]);
+ });
+
// ta-2 is glossed, just not in the scope's language, so an any-language check would drop it.
it('keeps a row glossed only in another language when filtering for a missing gloss', () => {
const analysis: TextAnalysis = {
@@ -1189,6 +1217,12 @@ describe('reconcileFilters', () => {
expect(filters.morphemes).toBe('has');
});
+ it('keeps the not-in-the-text filter when another selection is withdrawn', () => {
+ const filters = reconcileFilters({ books: ['EXO'], notInText: true }, { books: undefined });
+
+ expect(filters.notInText).toBe(true);
+ });
+
it('drops a feature selection whose name lost its facet', () => {
const filters = reconcileFilters(
{ features: { case: ['nom'], number: ['sg'] } },
diff --git a/src/__tests__/utils/hydration-range.test.ts b/src/__tests__/utils/hydration-range.test.ts
index b3ed9d405..02f7f6ad2 100644
--- a/src/__tests__/utils/hydration-range.test.ts
+++ b/src/__tests__/utils/hydration-range.test.ts
@@ -22,7 +22,7 @@ function uniformTable(count: number): HeightTable {
const heights = Array.from({ length: count }, () => SEGMENT_PX);
const offsets = heights.map((_h, i) => i * SEGMENT_PX);
offsets.push(count * SEGMENT_PX);
- return { heights, offsets, total: count * SEGMENT_PX };
+ return { heights, boxHeights: heights, offsets, total: count * SEGMENT_PX };
}
describe('hydrationTarget', () => {
diff --git a/src/__tests__/utils/merge-content.test.ts b/src/__tests__/utils/merge-content.test.ts
index 77ea0ad52..a168b49f1 100644
--- a/src/__tests__/utils/merge-content.test.ts
+++ b/src/__tests__/utils/merge-content.test.ts
@@ -28,6 +28,7 @@ function row(analysisId: string, overrides: Partial = {}): CatalogRo
return {
analysisId,
surfaceText: 'λόγος',
+ form: 'λόγος',
gloss: '',
glosses: gloss ? { [analysisLanguage]: gloss } : undefined,
morphemes: [],
diff --git a/src/__tests__/utils/reanchor-draft.test.ts b/src/__tests__/utils/reanchor-draft.test.ts
index be2d3259e..195e72970 100644
--- a/src/__tests__/utils/reanchor-draft.test.ts
+++ b/src/__tests__/utils/reanchor-draft.test.ts
@@ -2,7 +2,7 @@
import type { TextAnalysis } from 'interlinearizer';
import { emptyAnalysis } from '../../types/empty-factories';
-import { reanchorDraftToBook } from '../../utils/reanchor-draft';
+import { reanchorDraftToBook, reanchorDraftToMissingBook } from '../../utils/reanchor-draft';
import { FIXTURE_STAMPS, makeVerseBook } from '../test-helpers';
/** An analysis approving `gloss` on the sole occurrence of `surfaceText` in a one-verse `text`. */
@@ -96,3 +96,43 @@ describe('reanchorDraftToBook', () => {
expect(reanchored.segmentation).toBe(segmentation);
});
});
+
+describe('reanchorDraftToMissingBook', () => {
+ it('stales an approval in the book', () => {
+ const content = { analysis: glossedOn('alpha beta', 'beta', 'bêta'), segmentation: undefined };
+
+ const reanchored = reanchorDraftToMissingBook('GEN')(content);
+
+ expect(reanchored.analysis.tokenAnalysisLinks[0].status).toBe('stale');
+ });
+
+ it('stales a free translation in the book', () => {
+ const content = {
+ analysis: {
+ ...emptyAnalysis(),
+ segmentAnalyses: [{ ...FIXTURE_STAMPS, id: 'sa-1', surfaceText: 'alpha beta' }],
+ segmentAnalysisLinks: [
+ {
+ ...FIXTURE_STAMPS,
+ analysisId: 'sa-1',
+ status: 'approved' as const,
+ segmentId: 'GEN 1:1',
+ },
+ ],
+ },
+ segmentation: undefined,
+ };
+
+ const reanchored = reanchorDraftToMissingBook('GEN')(content);
+
+ expect(reanchored.analysis.segmentAnalysisLinks[0].status).toBe('stale');
+ });
+
+ it("leaves another book's approvals approved", () => {
+ const content = { analysis: glossedOn('alpha beta', 'beta', 'bêta'), segmentation: undefined };
+
+ const reanchored = reanchorDraftToMissingBook('EXO')(content);
+
+ expect(reanchored.analysis.tokenAnalysisLinks[0].status).toBe('approved');
+ });
+});
diff --git a/src/__tests__/utils/segment-heights.test.ts b/src/__tests__/utils/segment-heights.test.ts
index 111cdfe3a..11d978689 100644
--- a/src/__tests__/utils/segment-heights.test.ts
+++ b/src/__tests__/utils/segment-heights.test.ts
@@ -6,6 +6,7 @@ import {
heightForRows,
offsetOfSegment,
predictLineCount,
+ predictOrphanedCardHeights,
predictRowCount,
predictSegmentHeights,
segmentIndexAtOffset,
@@ -286,7 +287,12 @@ describe('buildHeightTable', () => {
});
it('builds an empty table for a book with no segments', () => {
- expect(buildHeightTable(CONFIG, [])).toEqual({ heights: [], offsets: [0], total: 0 });
+ expect(buildHeightTable(CONFIG, [])).toEqual({
+ heights: [],
+ boxHeights: [],
+ offsets: [0],
+ total: 0,
+ });
});
it('adds the gap between segments to each height after the first', () => {
@@ -307,12 +313,54 @@ describe('buildHeightTable', () => {
expect(table.heights[0]).toBe(100);
});
+ it('adds the cards beside a segment to its height', () => {
+ const table = buildHeightTable({ ...CONFIG, segmentGapPx: 8 }, [100, 100], [74, 0]);
+ expect(table.heights).toEqual([174, 108]);
+ });
+
+ it("reports each segment's own box apart from its gap and the cards beside it", () => {
+ const table = buildHeightTable({ ...CONFIG, segmentGapPx: 8 }, [100, 100], [74, 74]);
+ expect(table.boxHeights).toEqual([100, 100]);
+ });
+
it('reports a total that spans every gap between segments', () => {
const table = buildHeightTable({ ...CONFIG, segmentGapPx: 8 }, [100, 100, 100]);
expect(table.total).toBe(316);
});
});
+describe('predictOrphanedCardHeights', () => {
+ const withCards = { ...CONFIG, showFreeTranslation: true, segmentGapPx: 8 };
+
+ it('charges nothing beside a segment showing no cards', () => {
+ expect(predictOrphanedCardHeights(2, withCards)).toEqual([0, 0]);
+ });
+
+ it('charges each card its height and the gap beside it', () => {
+ const config = { ...withCards, orphanedCardCount: () => 2 };
+
+ expect(predictOrphanedCardHeights(1, config)).toEqual([2 * (8 + 66)]);
+ });
+
+ it('charges no gap beside a card where the config sets none', () => {
+ const config = { ...CONFIG, showFreeTranslation: true, orphanedCardCount: () => 1 };
+
+ expect(predictOrphanedCardHeights(1, config)).toEqual([66]);
+ });
+
+ it('charges only the segments showing cards', () => {
+ const config = { ...withCards, orphanedCardCount: (index: number) => (index === 1 ? 1 : 0) };
+
+ expect(predictOrphanedCardHeights(2, config)).toEqual([0, 8 + 66]);
+ });
+
+ it('charges nothing while free translations are hidden', () => {
+ const config = { ...withCards, showFreeTranslation: false, orphanedCardCount: () => 1 };
+
+ expect(predictOrphanedCardHeights(1, config)).toEqual([0]);
+ });
+});
+
describe('segmentIndexAtOffset', () => {
function table() {
return buildHeightTable(CONFIG, [132, 132, 132]);
diff --git a/src/__tests__/utils/stale-free-translations.test.ts b/src/__tests__/utils/stale-free-translations.test.ts
index 36ee07db3..b05393bfd 100644
--- a/src/__tests__/utils/stale-free-translations.test.ts
+++ b/src/__tests__/utils/stale-free-translations.test.ts
@@ -6,6 +6,7 @@ import {
nextSegmentAmong,
placeStaleFreeTranslations,
type StaleFreeTranslation,
+ verseLabelOf,
} from '../../utils/stale-free-translations';
import { makeVerseBook } from '../test-helpers';
@@ -21,17 +22,34 @@ const verseBook = makeVerseBook([
{ sid: 'GEN 1:3', text: 'and God said' },
]);
-/** The analysis ids filed under each segment, keyed by segment id. */
+/** The analysis ids in each list, keyed by segment id. */
+function idsBySegment(
+ bySegment: ReadonlyMap,
+): Record {
+ return Object.fromEntries(
+ [...bySegment].map(([segmentId, list]) => [segmentId, list.map((t) => t.analysisId)]),
+ );
+}
+
+/** The analysis ids filed under each segment for its own review, keyed by segment id. */
function placed(
translations: readonly StaleFreeTranslation[],
book = verseBook,
): Record {
- return Object.fromEntries(
- [...placeStaleFreeTranslations(translations, book)].map(([segmentId, list]) => [
- segmentId,
- list.map((t) => t.analysisId),
- ]),
- );
+ return idsBySegment(placeStaleFreeTranslations(translations, book).bySegment);
+}
+
+/** The analysis ids orphaned after each segment, keyed by segment id. */
+function orphanedAfter(
+ translations: readonly StaleFreeTranslation[],
+ book = verseBook,
+): Record {
+ return idsBySegment(placeStaleFreeTranslations(translations, book).orphanedAfter);
+}
+
+/** The analysis ids orphaned ahead of the book's first segment. */
+function orphanedAhead(translations: readonly StaleFreeTranslation[], book = verseBook): string[] {
+ return placeStaleFreeTranslations(translations, book).orphanedAhead.map((t) => t.analysisId);
}
describe('placeStaleFreeTranslations', () => {
@@ -109,12 +127,142 @@ describe('placeStaleFreeTranslations', () => {
expect(placed([stale('GEN 1:1/s1')], book)).toEqual({ 'GEN 1:1': ['sa GEN 1:1/s1'] });
});
- it('shows a translation of a heading whose verse the book no longer holds nowhere', () => {
- expect(placed([stale('GEN 1:9/s1')])).toEqual({});
+ it('files a translation of a verse since bridged under the bridge', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 1:2-3', text: 'and the earth was void and God said' },
+ ]);
+
+ expect(placed([stale('GEN 1:3')], book)).toEqual({ 'GEN 1:2-3': ['sa GEN 1:3'] });
+ });
+
+ it('files a translation begun mid-verse in a verse since bridged under the start of the bridge', () => {
+ const book = resegmentBook(
+ makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 1:2-3', text: 'and the earth was void and God said' },
+ ]),
+ { removedVerseStarts: [], addedStarts: [{ tokenRef: 'GEN 1:2-3:4', surfaceText: 'the' }] },
+ );
+
+ expect(placed([stale('GEN 1:3:4')], book)).toEqual({ 'GEN 1:2-3': ['sa GEN 1:3:4'] });
+ });
+
+ it('files a translation of a bridge since split under its first verse', () => {
+ expect(placed([stale('GEN 1:2-3')])).toEqual({ 'GEN 1:2': ['sa GEN 1:2-3'] });
+ });
+
+ it('files a translation of a verse since bridged under the bridge rather than a heading in it', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { heading: 's1', verseId: 'GEN 1:2-3', text: 'Title', charIndex: 0 },
+ { sid: 'GEN 1:2-3', text: 'and the earth was void and God said' },
+ ]);
+
+ expect(placed([stale('GEN 1:3')], book)).toEqual({ 'GEN 1:2-3': ['sa GEN 1:3'] });
+ });
+
+ it("orphans a translation of a deleted verse that another chapter's bridge numbers", () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 2:2-3', text: 'And on the seventh day' },
+ ]);
+
+ expect(orphanedAfter([stale('GEN 1:3')], book)).toEqual({ 'GEN 1:1': ['sa GEN 1:3'] });
+ });
+
+ it('orphans a translation of a deleted verse after the segment ending the verse before it', () => {
+ const book = resegmentBook(
+ makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning God created' },
+ { sid: 'GEN 1:3', text: 'and God said' },
+ ]),
+ { removedVerseStarts: [], addedStarts: [{ tokenRef: 'GEN 1:1:17', surfaceText: 'God' }] },
+ );
+
+ expect(orphanedAfter([stale('GEN 1:2')], book)).toEqual({ 'GEN 1:1:17': ['sa GEN 1:2'] });
+ });
+
+ it('files a translation of a deleted verse under no segment for review', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 1:3', text: 'and God said' },
+ ]);
+
+ expect(placed([stale('GEN 1:2')], book)).toEqual({});
+ });
+
+ it('orphans a translation of a deleted verse after the last segment of an earlier chapter', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 1:3', text: 'and God said' },
+ { sid: 'GEN 2:1', text: 'Thus the heavens' },
+ ]);
+
+ expect(orphanedAfter([stale('GEN 1:9')], book)).toEqual({ 'GEN 1:3': ['sa GEN 1:9'] });
+ });
+
+ it('orphans a translation of a deleted verse ahead of every other ahead of the book', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:2', text: 'and the earth was void' },
+ { sid: 'GEN 1:3', text: 'and God said' },
+ ]);
+
+ expect(orphanedAhead([stale('GEN 1:1')], book)).toEqual(['sa GEN 1:1']);
+ });
+
+ it('orphans nothing ahead of the book when an earlier verse remains', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 1:3', text: 'and God said' },
+ ]);
+
+ expect(orphanedAhead([stale('GEN 1:2')], book)).toEqual([]);
+ });
+
+ it('orphans a translation of a heading whose verse was deleted after the segment before it', () => {
+ expect(orphanedAfter([stale('GEN 1:9/s1')])).toEqual({ 'GEN 1:3': ['sa GEN 1:9/s1'] });
});
- it('shows a translation of a verse the book no longer holds nowhere', () => {
- expect(placed([stale('GEN 1:9')])).toEqual({});
+ it('lists the translations orphaned after a segment in the order their verses ran', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 2:1', text: 'Thus the heavens' },
+ ]);
+
+ expect(orphanedAfter([stale('GEN 1:3'), stale('GEN 1:2')], book)).toEqual({
+ 'GEN 1:1': ['sa GEN 1:2', 'sa GEN 1:3'],
+ });
+ });
+
+ it('lists the translations orphaned in one chapter ahead of those in a later one', () => {
+ const book = makeVerseBook([
+ { sid: 'GEN 1:1', text: 'In the beginning' },
+ { sid: 'GEN 3:1', text: 'Now the serpent' },
+ ]);
+
+ expect(orphanedAfter([stale('GEN 2:1'), stale('GEN 1:5')], book)).toEqual({
+ 'GEN 1:1': ['sa GEN 1:5', 'sa GEN 2:1'],
+ });
+ });
+
+ it('lists the translations orphaned ahead of the book in the order their verses ran', () => {
+ const book = makeVerseBook([{ sid: 'GEN 1:3', text: 'and God said' }]);
+
+ expect(orphanedAhead([stale('GEN 1:2'), stale('GEN 1:1')], book)).toEqual([
+ 'sa GEN 1:1',
+ 'sa GEN 1:2',
+ ]);
+ });
+
+ it('shows a translation nowhere in a book holding no text', () => {
+ const placedNowhere = placeStaleFreeTranslations([stale('GEN 1:1')], makeVerseBook([]));
+
+ expect(placedNowhere).toEqual({
+ bySegment: new Map(),
+ orphanedAfter: new Map(),
+ orphanedAhead: [],
+ });
});
it('shows a translation of another book nowhere', () => {
@@ -122,6 +270,24 @@ describe('placeStaleFreeTranslations', () => {
});
});
+describe('verseLabelOf', () => {
+ it('names the verse a segment begun at a verse falls in', () => {
+ expect(verseLabelOf('GEN 1:5')).toBe('1:5');
+ });
+
+ it('names a verse range verbatim', () => {
+ expect(verseLabelOf('GEN 1:5-6')).toBe('1:5-6');
+ });
+
+ it('names the verse a segment begun mid-verse falls in', () => {
+ expect(verseLabelOf('GEN 1:5:12')).toBe('1:5');
+ });
+
+ it('names the verse a heading is filed under', () => {
+ expect(verseLabelOf('GEN 1:5/s1#2')).toBe('1:5');
+ });
+});
+
describe('adoptedStaleTranslation', () => {
it('adopts the only stale translation of a segment holding no approved one', () => {
const only = stale('GEN 1:1');
diff --git a/src/__tests__/utils/verse-ref.test.ts b/src/__tests__/utils/verse-ref.test.ts
index dd62fca3c..2fff1bac6 100644
--- a/src/__tests__/utils/verse-ref.test.ts
+++ b/src/__tests__/utils/verse-ref.test.ts
@@ -2,7 +2,7 @@
import type { SerializedVerseRef } from '@sillsdev/scripture';
import type { Segment } from 'interlinearizer';
-import { editVerse, firstVerseNumber, segmentContainsVerse } from '../../utils/verse-ref';
+import { firstVerseNumber, segmentContainsVerse, verseOfId } from '../../utils/verse-ref';
import { makeVerseBook } from '../test-helpers';
/**
@@ -207,20 +207,20 @@ describe('firstVerseNumber', () => {
});
});
-describe('editVerse', () => {
+describe('verseOfId', () => {
it("names a token's verse", () => {
- expect(editVerse('LUK 2:4:7')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
+ expect(verseOfId('LUK 2:4:7')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
});
it("names a verse segment's verse", () => {
- expect(editVerse('LUK 2:4')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
+ expect(verseOfId('LUK 2:4')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
});
it('names the verse a heading is filed under', () => {
- expect(editVerse('LUK 2:4/s1')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
+ expect(verseOfId('LUK 2:4/s1')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
});
it('names the verse a heading is filed under for a token in it', () => {
- expect(editVerse('LUK 2:4/s1:0')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
+ expect(verseOfId('LUK 2:4/s1:0')).toEqual({ book: 'LUK', chapterNum: 2, verseNum: 4 });
});
});
diff --git a/src/components/AnalysisCatalogPanel.tsx b/src/components/AnalysisCatalogPanel.tsx
index a860cbfa5..dbc48fafb 100644
--- a/src/components/AnalysisCatalogPanel.tsx
+++ b/src/components/AnalysisCatalogPanel.tsx
@@ -32,6 +32,8 @@ import CatalogList from './CatalogList';
import CatalogRowView, { ROW_STRING_KEYS } from './CatalogRowView';
import CatalogSelectionBar, { SELECTION_BAR_STRING_KEYS } from './CatalogSelectionBar';
import SidePanelTabs, { SIDE_PANEL_TAB_STRING_KEYS } from './SidePanelTabs';
+import { useSourceTextContext } from './SourceTextContext';
+import TextReadingStatus, { TEXT_READING_STRING_KEYS } from './TextReadingStatus';
import { useInterlinearNav } from './InterlinearNavContext';
import useDraftDiscardGate from '../hooks/useDraftDiscardGate';
import useRowSelection from '../hooks/useRowSelection';
@@ -108,6 +110,7 @@ const STRING_KEYS = [
'%interlinearizer_analysisCatalog_mergedEmpty%',
'%interlinearizer_analysisCatalog_usageCountInBook%',
'%interlinearizer_analysisCatalog_noMatches%',
+ ...TEXT_READING_STRING_KEYS,
...QUERY_CONTROL_STRING_KEYS,
...ROW_STRING_KEYS,
...SELECTION_BAR_STRING_KEYS,
@@ -169,6 +172,8 @@ type AnalysisCatalogPanelProps = Readonly<{
showMorphology: boolean;
/** BCP 47 tag of the source text, so surface forms collate by their own language. */
sourceLanguageTag: string;
+ /** Whether every book has been checked for stale places, rather than only the books opened. */
+ staleCoversDraft: boolean;
}>;
/**
@@ -194,6 +199,7 @@ export default function AnalysisCatalogPanel({
liveSurfaceText,
showMorphology,
sourceLanguageTag,
+ staleCoversDraft,
}: AnalysisCatalogPanelProps) {
const [localizedStrings] = useLocalizedStrings(STRING_KEYS);
const analysisLanguage = useAnalysisLanguage();
@@ -216,6 +222,18 @@ export default function AnalysisCatalogPanel({
*/
const [chosenFilters, setFilters] = useState({});
+ const sourceText = useSourceTextContext();
+ const { request: requestText, textForms } = sourceText;
+
+ /** Records the reader's filters, asking for the text to be read once one needs it. */
+ const handleFiltersChange = useCallback(
+ (next: CatalogFilters) => {
+ if (next.stale) requestText();
+ setFilters(next);
+ },
+ [requestText],
+ );
+
// Each rebuilt only when its own tag changes: the query around them turns over on every keystroke
// in the search box, and a collator is expensive enough to be worth not rebuilding that often.
const surfaceCollator = useMemo(() => collatorForTag(sourceLanguageTag), [sourceLanguageTag]);
@@ -243,12 +261,20 @@ export default function AnalysisCatalogPanel({
/** How the listing is narrowed and ordered, from the controls above the list. */
const query = useMemo(
- () => ({ search, sort, filters, surfaceCollator, glossCollator }),
- [search, sort, filters, surfaceCollator, glossCollator],
+ () => ({ search, sort, filters, surfaceCollator, glossCollator, textForms }),
+ [search, sort, filters, surfaceCollator, glossCollator, textForms],
);
const rows = useMemo(() => applyCatalogQuery(catalogRows, query), [catalogRows, query]);
+ /** Whether the not-in-text filter is on before the whole text has been read. */
+ const isAwaitingText = filters.notInText && !textForms;
+
+ // Covers every way filters get set, not just the controls.
+ useEffect(() => {
+ if (filters.notInText) requestText();
+ }, [filters.notInText, requestText]);
+
/**
* The current book's name key, asked for separately from {@link STRING_KEYS} so that changing book
* re-resolves this alone rather than every string the panel shows.
@@ -1011,12 +1037,13 @@ export default function AnalysisCatalogPanel({
facets={facets}
filters={filters}
localizedStrings={localizedStrings}
- onFiltersChange={setFilters}
+ onFiltersChange={handleFiltersChange}
onSearchChange={setSearch}
onSortChange={setSort}
search={search}
showMorphology={showMorphology}
sort={sort}
+ staleCoversDraft={staleCoversDraft}
/>
)}
@@ -1049,52 +1076,64 @@ export default function AnalysisCatalogPanel({
/>
)}
- {rows.length === 0 ? (
- // Two ways to have nothing to list, and they call for different answers: a draft that has
- // recorded nothing yet, and a query that kept none of what it did. Telling a reader the
- // draft is empty when they have merely mistyped would send them looking for lost work.
-
- ) : (
-
- {windowRows.map((row) => (
-
- ))}
-
)}
+ {!isAwaitingText &&
+ (rows.length === 0 ? (
+ // Two ways to have nothing to list, and they call for different answers: a draft that has
+ // recorded nothing yet, and a query that kept none of what it did. Telling a reader the
+ // draft is empty when they have merely mistyped would send them looking for lost work.
+
+ ) : (
+
+ {windowRows.map((row) => (
+
+ ))}
+
+ ))}
+
{/*
Both modals are mounted against what they still have to act on rather than against the id
alone, so a listing that turns over beneath one — an edit made in the view beside the
diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx
index 2ddc9de34..3797b0265 100644
--- a/src/components/AnalysisStore.tsx
+++ b/src/components/AnalysisStore.tsx
@@ -71,7 +71,7 @@ import { emptyAnalysis } from '../types/empty-factories';
import type { CatalogRow, HeadingPlacement } from '../utils/analysis-query';
import {
placeStaleFreeTranslations,
- type StaleFreeTranslation,
+ type PlacedStaleFreeTranslations,
} from '../utils/stale-free-translations';
import { resolvedTokenAnalysisEqual, type ResolvedTokenAnalysis } from '../utils/suggestion-engine';
@@ -1222,16 +1222,12 @@ export function useSegmentsWithApprovedTranslation(): ReadonlySet {
}
/**
- * Returns the stale free translations each of `book`'s segments shows, keyed by segment id, for the
- * segments showing any. A translation whose own segment has vanished is shown by the segment now
- * covering where it began.
+ * Returns `book`'s stale free translations, filed by where the view shows each.
*
* @throws When called outside an {@link AnalysisStoreProvider}.
*/
-export function useStaleFreeTranslationsBySegment(
- book: Book,
-): ReadonlyMap {
- useRequiredCallbacks('useStaleFreeTranslationsBySegment');
+export function usePlacedStaleFreeTranslations(book: Book): PlacedStaleFreeTranslations {
+ useRequiredCallbacks('usePlacedStaleFreeTranslations');
const stale = useSelector((state: AnalysisRootState) =>
selectStaleFreeTranslations(state.analysis),
diff --git a/src/components/CatalogFilterPopover.tsx b/src/components/CatalogFilterPopover.tsx
index c50c38872..3b4d78c2a 100644
--- a/src/components/CatalogFilterPopover.tsx
+++ b/src/components/CatalogFilterPopover.tsx
@@ -16,7 +16,11 @@ import {
import { formatReplacementString, type LanguageStrings } from 'platform-bible-utils';
import { useId, useState } from 'react';
import type { Confidence } from 'interlinearizer';
-import type { CatalogFacets, CatalogFilters } from '../utils/analysis-query';
+import {
+ TOGGLE_FILTER_KEYS,
+ type CatalogFacets,
+ type CatalogFilters,
+} from '../utils/analysis-query';
/**
* The name each confidence level is offered under. Confidence is a closed vocabulary, unlike a part
@@ -46,6 +50,8 @@ export const FILTER_STRING_KEYS = [
'%interlinearizer_analysisCatalog_filter_morphemes_lacks%',
'%interlinearizer_analysisCatalog_filter_zeroUsages%',
'%interlinearizer_analysisCatalog_filter_stale%',
+ '%interlinearizer_analysisCatalog_filter_staleEverywhere%',
+ '%interlinearizer_analysisCatalog_filter_notInText%',
...Object.values(CONFIDENCE_LABEL_KEYS),
] as const satisfies `%${string}%`[];
@@ -244,6 +250,8 @@ type CatalogFilterPopoverProps = Readonly<{
onFiltersChange: (filters: CatalogFilters) => void;
/** Whether this project breaks words into morphemes, which the breakdown filter is offered for. */
showMorphology: boolean;
+ /** Whether every book has been checked for stale places, rather than only the books opened. */
+ staleCoversDraft: boolean;
/** What the language the missing-gloss filter asks about is called, as its label names it. */
analysisLanguageName: string;
/** Resolved localizations covering at least {@link FILTER_STRING_KEYS}. */
@@ -266,6 +274,7 @@ export default function CatalogFilterPopover({
filters,
onFiltersChange,
showMorphology,
+ staleCoversDraft,
analysisLanguageName,
localizedStrings,
}: CatalogFilterPopoverProps) {
@@ -288,8 +297,7 @@ export default function CatalogFilterPopover({
filters.confidence,
...Object.values(filters.features ?? {}),
].filter((selected) => selected?.length).length +
- [filters.missingGloss, filters.morphemes, filters.zeroUsages, filters.stale].filter(Boolean)
- .length;
+ TOGGLE_FILTER_KEYS.filter((key) => filters[key]).length;
return (
@@ -428,9 +436,19 @@ export default function CatalogFilterPopover({
onFiltersChange({ ...filters, stale })}
/>
+
+ onFiltersChange({ ...filters, notInText })}
+ />
)}
diff --git a/src/components/CatalogList.tsx b/src/components/CatalogList.tsx
index da478e07f..349778d04 100644
--- a/src/components/CatalogList.tsx
+++ b/src/components/CatalogList.tsx
@@ -17,7 +17,7 @@ type CatalogListProps = Readonly<{
export default function CatalogList({ scrollRef, sentinelRef, children }: CatalogListProps) {
return (
{children}
diff --git a/src/components/CatalogQueryControls.tsx b/src/components/CatalogQueryControls.tsx
index 30a1d3b27..82e541852 100644
--- a/src/components/CatalogQueryControls.tsx
+++ b/src/components/CatalogQueryControls.tsx
@@ -50,6 +50,8 @@ type CatalogQueryControlsProps = Readonly<{
onFiltersChange: (filters: CatalogFilters) => void;
/** Whether this project breaks words into morphemes, which the breakdown filter is offered for. */
showMorphology: boolean;
+ /** Whether every book has been checked for stale places, rather than only the books opened. */
+ staleCoversDraft: boolean;
/** What the language the missing-gloss filter asks about is called. */
analysisLanguageName: string;
/** What the current book is called, as prose, for the per-book sort to be named after. */
@@ -75,6 +77,7 @@ export default function CatalogQueryControls({
filters,
onFiltersChange,
showMorphology,
+ staleCoversDraft,
analysisLanguageName,
currentBookName,
localizedStrings,
@@ -125,6 +128,7 @@ export default function CatalogQueryControls({
localizedStrings={localizedStrings}
onFiltersChange={onFiltersChange}
showMorphology={showMorphology}
+ staleCoversDraft={staleCoversDraft}
/>
diff --git a/src/components/CatalogRowHeader.tsx b/src/components/CatalogRowHeader.tsx
index 483b598c0..fb2233306 100644
--- a/src/components/CatalogRowHeader.tsx
+++ b/src/components/CatalogRowHeader.tsx
@@ -1,4 +1,4 @@
-import { ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react';
+import { ChevronDown, ChevronRight, SearchX, TriangleAlert } from 'lucide-react';
import {
Button,
Checkbox,
@@ -18,6 +18,7 @@ export const ROW_HEADER_STRING_KEYS = [
'%interlinearizer_analysisCatalog_noGloss%',
'%interlinearizer_analysisCatalog_usageCount%',
'%interlinearizer_analysisCatalog_staleCount%',
+ '%interlinearizer_analysisCatalog_notInText%',
] as const satisfies `%${string}%`[];
/** Props for {@link CatalogRowHeader}. */
@@ -30,6 +31,8 @@ type CatalogRowHeaderProps = Readonly<{
isExpanded: boolean;
/** Shows the row's detail, or hides it. */
onToggle: () => void;
+ /** Whether the source text, as last read, holds this analysis's form nowhere. */
+ isNotInText: boolean;
/** Whether the row is among those a bulk action applies to. */
isChecked: boolean;
/**
@@ -50,12 +53,14 @@ export default function CatalogRowHeader({
usageCountInBookLabel,
isExpanded,
onToggle,
+ isNotInText,
isChecked,
onCheckedChange,
localizedStrings,
}: CatalogRowHeaderProps) {
const usageCountLabel = localizedStrings['%interlinearizer_analysisCatalog_usageCount%'];
const staleCountLabel = localizedStrings['%interlinearizer_analysisCatalog_staleCount%'];
+ const notInTextLabel = localizedStrings['%interlinearizer_analysisCatalog_notInText%'];
// This is visible cell text, so blanking an unresolved key would empty the gloss column. The em
// dash reads as "no gloss" in any language and stands in until the lookup lands.
@@ -165,6 +170,18 @@ export default function CatalogRowHeader({
)}
+ {isNotInText && (
+
+ {/* Placed in the last column, since the stale count before it may be absent. */}
+
+
+ {` ${notInTextLabel}`}
+
+
+ )}
);
diff --git a/src/components/CatalogRowView.tsx b/src/components/CatalogRowView.tsx
index bd35a8f01..c0b5c8c20 100644
--- a/src/components/CatalogRowView.tsx
+++ b/src/components/CatalogRowView.tsx
@@ -40,6 +40,8 @@ type CatalogRowViewProps = Readonly<{
* analysis, which no bulk action applies to.
*/
onCheckedChange?: (analysisId: string, checked: boolean) => void;
+ /** Whether the source text, as last read, holds this analysis's form nowhere. */
+ isNotInText: boolean;
/** Jumps the interlinear view to one of this analysis's usages. */
onUsageSelect: (analysisId: string, usage: CatalogUsage) => void;
/** Moves the interlinear view to the verse of a place this analysis went stale at. */
@@ -111,6 +113,7 @@ function CatalogRowView({
isSelected,
isChecked,
onCheckedChange,
+ isNotInText,
onUsageSelect,
onStaleSelect,
onStaleDiscard,
@@ -210,6 +213,7 @@ function CatalogRowView({
(undefined);
-
-/** Builds the concordance's index, re-rendering only the components below that read it. */
-export function ConcordanceIndexProvider({
- children,
- ...args
-}: Readonly) {
- const index = useConcordanceIndex(args);
- return (
- {children}
- );
-}
-
-/**
- * Reads the concordance's index.
- *
- * @throws When called outside a {@link ConcordanceIndexProvider}.
- */
-export function useConcordanceIndexContext(): ConcordanceIndex {
- const index = useContext(ConcordanceIndexContext);
- if (!index) throw new Error('useConcordanceIndexContext must be used within its provider');
- return index;
-}
diff --git a/src/components/ConcordancePanel.tsx b/src/components/ConcordancePanel.tsx
index cef445f08..6fa012bff 100644
--- a/src/components/ConcordancePanel.tsx
+++ b/src/components/ConcordancePanel.tsx
@@ -4,7 +4,6 @@ import { RefreshCw, X } from 'lucide-react';
import {
Button,
EmptyState,
- Spinner,
Tooltip,
TooltipContent,
TooltipProvider,
@@ -13,10 +12,11 @@ import {
import { formatReplacementString } from 'platform-bible-utils';
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { useAnalysis, useAnalysisLanguage } from './AnalysisStore';
-import { useConcordanceIndexContext } from './ConcordanceIndexContext';
+import { useConcordanceEntriesContext, useSourceTextContext } from './SourceTextContext';
import ConcordanceRowView, { CONCORDANCE_ROW_STRING_KEYS } from './ConcordanceRowView';
import { useInterlinearNav } from './InterlinearNavContext';
import SidePanelTabs, { SIDE_PANEL_TAB_STRING_KEYS } from './SidePanelTabs';
+import TextReadingStatus, { TEXT_READING_STRING_KEYS } from './TextReadingStatus';
import useRowWindow from '../hooks/useRowWindow';
import { resolvedOrEmpty, tooltipContentOrUndefined } from '../utils/localized-strings';
import {
@@ -32,12 +32,12 @@ import {
const STRING_KEYS = [
'%interlinearizer_concordance_close%',
'%interlinearizer_concordance_refresh%',
- '%interlinearizer_concordance_loading%',
- '%interlinearizer_concordance_error%',
'%interlinearizer_concordance_empty%',
+ '%interlinearizer_concordance_partial%',
'%interlinearizer_concordance_occurrenceCountInBook%',
...SIDE_PANEL_TAB_STRING_KEYS,
...CONCORDANCE_ROW_STRING_KEYS,
+ ...TEXT_READING_STRING_KEYS,
] as const satisfies `%${string}%`[];
/** Props for {@link ConcordancePanel}. */
@@ -69,7 +69,8 @@ export default function ConcordancePanel({
const refreshTooltip = tooltipContentOrUndefined(
resolvedOrEmpty(localizedStrings['%interlinearizer_concordance_refresh%']),
);
- const index = useConcordanceIndexContext();
+ const text = useSourceTextContext();
+ const entries = useConcordanceEntriesContext();
const analysis = useAnalysis();
const analysisLanguage = useAnalysisLanguage();
@@ -83,11 +84,7 @@ export default function ConcordancePanel({
);
// Keyed on the status so a refresh, which passes through loading, starts the window over.
- const {
- windowRows: windowEntries,
- scrollRef,
- sentinelRef,
- } = useRowWindow(index.entries, index.status);
+ const { windowRows: windowEntries, scrollRef, sentinelRef } = useRowWindow(entries, text.status);
// Joined only for the mounted rows: a join over every form walks every occurrence in the text,
// too much to repeat on each gloss written beside the panel.
@@ -147,29 +144,11 @@ export default function ConcordancePanel({
);
let body: ReactNode;
- if (index.status === 'error') {
+ if (text.status !== 'ready') {
body = (
-
- );
- } else if (index.status !== 'ready') {
- body = (
-
+ )}
{body}
diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx
index 4dc49851d..933587241 100644
--- a/src/components/InterlinearizerLoader.tsx
+++ b/src/components/InterlinearizerLoader.tsx
@@ -32,7 +32,9 @@ import type { ComponentProps, ReactNode, RefObject } from 'react';
import type { Segment, TextAnalysis } from 'interlinearizer';
import type { Pt9InterlinearProjectManifest } from 'platform-scripture';
import { resegmentBook } from 'parsers/papi/resegmentBook';
+import useLatestRef from '../hooks/useLatestRef';
import useUndoRedoKeys from '../hooks/useUndoRedoKeys';
+import useWholeTextReanchor from '../hooks/useWholeTextReanchor';
import useDraftProject, { type EditStep } from '../hooks/useDraftProject';
import { formatTemplate } from '../utils/format-template';
import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData';
@@ -61,7 +63,7 @@ import { AnalysisStoreProvider } from './AnalysisStore';
import StaleAnalysesReporter, { type StaleAnalysesReport } from './StaleAnalysesReporter';
import AnalysisCatalogPanel, { type AnalysisCatalogPanelHandle } from './AnalysisCatalogPanel';
import BookNotInProjectView from './BookNotInProjectView';
-import { ConcordanceIndexProvider } from './ConcordanceIndexContext';
+import { SourceTextProvider } from './SourceTextContext';
import ConcordancePanel from './ConcordancePanel';
import type { SidePanelView } from './SidePanelTabs';
import ViewOptionsDropdown from './controls/ViewOptionsDropdown';
@@ -77,11 +79,11 @@ import ScriptureNavControls from './controls/ScriptureNavControls';
import { InterlinearNavProvider, useInterlinearNav, type FadePhase } from './InterlinearNavContext';
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
import {
- editVerse,
firstVerseNumber,
isSameVerse,
segmentContainsVerse,
toSerializedVerseRef,
+ verseOfId,
} from '../utils/verse-ref';
import { placeHeadings, type CatalogFilters } from '../utils/analysis-query';
import { nextSegmentAmong } from '../utils/stale-free-translations';
@@ -695,6 +697,15 @@ function InterlinearizerLoaderInner({
reanchorBook(reanchor.bookCode, reanchor.pass);
}, [reanchorBook, reanchor, isImportView, isDraftLoading, segmentationVersion, draftVersion]);
+ const wholeTextReanchor = useWholeTextReanchor({
+ loadedBookCode: verseBook?.bookRef,
+ isImportView,
+ isDraftLoading,
+ draftVersion,
+ getDraftSnapshot,
+ reanchorBook,
+ });
+
/**
* The book the views render: the verse-tokenized book re-grouped into the user's custom segments.
* Identical (by reference) to `verseBook` when no custom boundaries are set in it, so the common
@@ -880,7 +891,7 @@ function InterlinearizerLoaderInner({
DEFAULT_SIDE_PANEL_LAYOUT,
);
- /** Whether the concordance has been shown in this tab, which is what starts the text being read. */
+ /** Whether the concordance has been shown in this tab. */
const [concordanceWanted, setConcordanceWanted] = useState(sidePanel === 'concordance');
if (sidePanel === 'concordance' && !concordanceWanted) setConcordanceWanted(true);
@@ -891,8 +902,7 @@ function InterlinearizerLoaderInner({
* Assigned during render rather than from an effect so a promise resolving in the same tick as
* the dismissal still sees the move.
*/
- const modalRef = useRef(modal);
- modalRef.current = modal;
+ const modalRef = useLatestRef(modal);
/** Whether the destructive wipe dialog (book / whole-draft scope picker) is open. */
const [wipeModalOpen, setWipeModalOpen] = useState(false);
@@ -954,7 +964,7 @@ function InterlinearizerLoaderInner({
);
if (step?.location) {
requestFocusToken(step.location);
- navigate(editVerse(step.location));
+ navigate(verseOfId(step.location));
} else if (step?.summary) {
const { kind, ...replacers } = step.summary;
if ('analysisId' in replacers) {
@@ -1221,7 +1231,7 @@ function InterlinearizerLoaderInner({
.send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' })
.catch(() => {});
}
- }, [pt9ImportedId, fetchSummary, setActiveProject]);
+ }, [pt9ImportedId, fetchSummary, setActiveProject, modalRef]);
/**
* Dismisses the import modal: a first import returns to the select modal it came from; every
@@ -1492,12 +1502,11 @@ function InterlinearizerLoaderInner({
* runs. Restoring as the flag turns therefore reaches the group while it still knows only of the
* view, taking the WebView down.
*/
- const sidePanelLayoutRef = useRef(sidePanelLayout);
- sidePanelLayoutRef.current = sidePanelLayout;
+ const sidePanelLayoutRef = useLatestRef(sidePanelLayout);
useEffect(() => {
if (sidePanel !== 'closed' && sidePanelRegistered)
sidePanelGroupRef.current?.setLayout(sidePanelLayoutRef.current);
- }, [sidePanel, sidePanelRegistered]);
+ }, [sidePanel, sidePanelRegistered, sidePanelLayoutRef]);
const sidePanelResizeRef = usePanelResizeKeys(
/* v8 ignore next -- every stored layout names the side panel, the default included */
@@ -1728,6 +1737,7 @@ function InterlinearizerLoaderInner({
onShowConcordance={handleShowConcordance}
showMorphology={showMorphology}
sourceLanguageTag={writingSystem}
+ staleCoversDraft={wholeTextReanchor.staleCoversDraft}
/>
) : (
{/* Above the keyed stores in the view area, so the source text is read once per tab. */}
-
{viewArea}
-
+
;
+
+/**
+ * A stale free translation of a verse the book no longer holds, shown as an item of its own in the
+ * segment list. It offers only Discard, since no segment remains to keep it for.
+ */
+export default function OrphanedFreeTranslationCard({
+ translation,
+ localizedStrings,
+}: OrphanedFreeTranslationCardProps) {
+ const { discard } = useStaleFreeTranslationDispatch();
+ const text =
+ translation.text || localizedStrings['%interlinearizer_freeTranslationInput_staleNoText%'];
+ return (
+
+ {/* Each line truncates: the list predicts every card at the same height. */}
+
+ {formatReplacementString(
+ localizedStrings['%interlinearizer_freeTranslationInput_orphaned%'],
+ { verse: verseLabelOf(translation.segmentId) },
+ )}
+
+
+ {/* The tooltip is the only place a truncated translation can be read in full. */}
+
+
+ {text}
+
+ {text}
+
+
+
+
+ );
+}
diff --git a/src/components/SegmentListView.tsx b/src/components/SegmentListView.tsx
index 4dd72ef66..141a6bd01 100644
--- a/src/components/SegmentListView.tsx
+++ b/src/components/SegmentListView.tsx
@@ -17,17 +17,23 @@ import type { ViewOptions } from '../types/view-options';
import { resolvedOrEmpty, tooltipContentOrUndefined } from '../utils/localized-strings';
import { altHeldSwap } from './alt-key-hint';
import { buildSegmentLabels } from '../utils/segment-labels';
-import { adoptedStaleTranslation } from '../utils/stale-free-translations';
+import {
+ adoptedStaleTranslation,
+ type StaleFreeTranslation,
+} from '../utils/stale-free-translations';
import { segmentContainsVerse } from '../utils/verse-ref';
import { buildVerseStartLabels } from '../utils/verse-superscripts';
import {
useAnalysisReadOnly,
useFreeTranslationsBySegment,
useSegmentsWithApprovedTranslation,
- useStaleFreeTranslationsBySegment,
+ usePlacedStaleFreeTranslations,
} from './AnalysisStore';
import { useFocus, useFocusActions } from './FocusStore';
import type { RecenterRequest } from './InterlinearNavContext';
+import OrphanedFreeTranslationCard, {
+ ORPHANED_CARD_STRING_KEYS,
+} from './OrphanedFreeTranslationCard';
import { useSegmentation } from './SegmentationStore';
import MemoizedSegmentView, { SEGMENT_STRING_KEYS, type SegmentDisplayMode } from './SegmentView';
import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade';
@@ -97,7 +103,7 @@ const MERGE_CONTROL_GAP_PX = 24;
const LIST_STRING_KEYS = [
'%interlinearizer_segmentList_scrollToActiveVerse%',
'%interlinearizer_segmentList_noVerseData%',
- '%interlinearizer_freeTranslationInput_staleNoText%',
+ ...ORPHANED_CARD_STRING_KEYS,
...SEGMENT_STRING_KEYS,
] as const satisfies `%${string}%`[];
@@ -325,6 +331,9 @@ export default function SegmentListView({
scrollContainerRef.current = el ?? undefined;
}, []);
+ /** Whether translations of verses the book no longer holds show, as cards of their own. */
+ const showsOrphanedCards = viewOptions.showFreeTranslation && !readOnly;
+
/** Whether the current state offers merge controls at all, before per-segment eligibility. */
const showsMergeControls = phraseMode.kind === 'view' && !readOnly;
@@ -336,7 +345,11 @@ export default function SegmentListView({
);
const freeTranslationsBySegment = useFreeTranslationsBySegment();
- const staleFreeTranslationsBySegment = useStaleFreeTranslationsBySegment(book);
+ const {
+ bySegment: staleFreeTranslationsBySegment,
+ orphanedAfter,
+ orphanedAhead,
+ } = usePlacedStaleFreeTranslations(book);
const segmentsWithApprovedTranslation = useSegmentsWithApprovedTranslation();
/**
@@ -367,6 +380,14 @@ export default function SegmentListView({
[staleFreeTranslationsBySegment, segmentsWithApprovedTranslation, book.segments, staleNoText],
);
+ /** How many orphaned-translation cards render beside a segment, ahead of it or after. */
+ const orphanedCardCount = useCallback(
+ (index: number) =>
+ (index === 0 ? orphanedAhead.length : 0) +
+ (orphanedAfter.get(book.segments[index].id)?.length ?? 0),
+ [orphanedAhead, orphanedAfter, book.segments],
+ );
+
/** Whether a segment's review rows offer Keep, which they do only while it has no approval. */
const staleReviewOffersKeep = useCallback(
(index: number) => !segmentsWithApprovedTranslation.has(book.segments[index].id),
@@ -400,6 +421,7 @@ export default function SegmentListView({
freeTranslationText: readOnly ? freeTranslationText : undefined,
staleReviewTexts: readOnly ? undefined : staleReviewTexts,
staleReviewOffersKeep,
+ orphanedCardCount: readOnly ? undefined : orphanedCardCount,
segmentGapPx: SEGMENT_ROW_GAP_PX,
extraGapPx,
}),
@@ -411,6 +433,7 @@ export default function SegmentListView({
freeTranslationText,
staleReviewTexts,
staleReviewOffersKeep,
+ orphanedCardCount,
extraGapPx,
],
);
@@ -556,6 +579,15 @@ export default function SegmentListView({
};
}, [scrollContainerRef, chapterByIndexRef, heightTableRef, rangeRef]);
+ const orphanedCards = (translations: readonly StaleFreeTranslation[] | undefined) =>
+ translations?.map((translation) => (
+
+ ));
+
return (
{/* Chapter header band — a real row above the scroll area, not an overlay inside it, so
@@ -647,13 +679,18 @@ export default function SegmentListView({
const displayMode = segmentDisplayMode(bookIndex);
// A stand-in holds the height its chips will occupy so hydrating shifts nothing below
// it; a segment rendering as baseline text in its own right takes its natural height.
- // The reserved height is the segment's own box, so its gap comes back off.
const placeholderHeightPx =
displayMode === 'baseline-text' && !displayContinuousScroll
- ? heightTable.heights[bookIndex] - gapAbovePx(bookIndex)
+ ? heightTable.boxHeights[bookIndex]
: undefined;
return (
+ {/* Tagged so a recenter onto the first segment frames these with it. */}
+ {showsOrphanedCards && bookIndex === 0 && orphanedAhead.length > 0 && (
+
+ {orphanedCards(orphanedAhead)}
+
+ )}
{showMergeControl && (
)}
@@ -681,6 +718,7 @@ export default function SegmentListView({
viewOptions={viewOptions}
staleFreeTranslations={staleFreeTranslationsBySegment.get(seg.id)}
/>
+ {showsOrphanedCards && orphanedCards(orphanedAfter.get(seg.id))}
);
})}
diff --git a/src/components/SourceTextContext.tsx b/src/components/SourceTextContext.tsx
new file mode 100644
index 000000000..7080e3357
--- /dev/null
+++ b/src/components/SourceTextContext.tsx
@@ -0,0 +1,63 @@
+import { createContext, useContext, type ReactNode } from 'react';
+import useConcordanceEntries from '../hooks/useConcordanceEntries';
+import useSourceTextReader, {
+ type SourceText,
+ type UseSourceTextReaderArgs,
+} from '../hooks/useSourceTextReader';
+import type { ConcordanceEntry } from '../utils/concordance';
+
+/** The source project's whole text, as read so far. */
+export const SourceTextContext = createContext(undefined);
+
+/** The concordance's entries across the source text. */
+export const ConcordanceEntriesContext = createContext(
+ undefined,
+);
+
+/**
+ * Reads the source text and builds the concordance's entries from it, re-rendering only the
+ * components below that read either.
+ */
+export function SourceTextProvider({
+ children,
+ shown,
+ ...args
+}: Readonly<
+ UseSourceTextReaderArgs & {
+ /** Whether the concordance is on screen; its entries take in live-book edits only while it is. */
+ shown: boolean;
+ children: ReactNode;
+ }
+>) {
+ const text = useSourceTextReader(args);
+ const entries = useConcordanceEntries({ text, shown, writingSystem: args.writingSystem });
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Reads the source text.
+ *
+ * @throws When called outside a {@link SourceTextProvider}.
+ */
+export function useSourceTextContext(): SourceText {
+ const text = useContext(SourceTextContext);
+ if (!text) throw new Error('useSourceTextContext must be used within its provider');
+ return text;
+}
+
+/**
+ * Reads the concordance's entries.
+ *
+ * @throws When called outside a {@link SourceTextProvider}.
+ */
+export function useConcordanceEntriesContext(): readonly ConcordanceEntry[] {
+ const entries = useContext(ConcordanceEntriesContext);
+ if (!entries) throw new Error('useConcordanceEntriesContext must be used within its provider');
+ return entries;
+}
diff --git a/src/components/StaleAnalysesReporter.tsx b/src/components/StaleAnalysesReporter.tsx
index b22adae2c..0a8f0a1ba 100644
--- a/src/components/StaleAnalysesReporter.tsx
+++ b/src/components/StaleAnalysesReporter.tsx
@@ -1,7 +1,7 @@
import type { Book } from 'interlinearizer';
import { useEffect, useMemo, useRef } from 'react';
import { bookOfRef } from '../utils/analysis-book';
-import { useStaleFreeTranslationsBySegment, useStaleTokenLinks } from './AnalysisStore';
+import { usePlacedStaleFreeTranslations, useStaleTokenLinks } from './AnalysisStore';
/** The stale analyses a loaded book holds, each named by a key that holds while it stays stale. */
export type StaleAnalysesReport = Readonly<{
@@ -9,8 +9,8 @@ export type StaleAnalysesReport = Readonly<{
/** A key per place in the book an analysis went stale at. */
glosses: readonly string[];
/**
- * A key per stale free translation the book shows, with the segment showing it, in document
- * order.
+ * A key per stale free translation the book shows, with the segment showing it or, for one whose
+ * verse is gone, the segment it is shown beside, in document order.
*/
freeTranslations: readonly Readonly<{ key: string; segmentId: string }>[];
}>;
@@ -41,7 +41,7 @@ export default function StaleAnalysesReporter({
onReport: (report: StaleAnalysesReport | undefined) => void;
}>) {
const staleTokenLinks = useStaleTokenLinks();
- const freeTranslationsBySegment = useStaleFreeTranslationsBySegment(book);
+ const placedFreeTranslations = usePlacedStaleFreeTranslations(book);
const report = useMemo(
() => ({
@@ -53,11 +53,16 @@ export default function StaleAnalysesReporter({
.map((link) => `gloss ${link.analysisId} ${link.token.tokenRef}`),
),
],
- freeTranslations: [...freeTranslationsBySegment].flatMap(([segmentId, translations]) =>
- translations.map((t) => ({ key: `freeTranslation ${t.analysisId}`, segmentId })),
- ),
+ freeTranslations: book.segments.flatMap(({ id: segmentId }, index) => {
+ const { bySegment, orphanedAfter, orphanedAhead } = placedFreeTranslations;
+ return [
+ ...(index === 0 ? orphanedAhead : []),
+ ...(bySegment.get(segmentId) ?? []),
+ ...(orphanedAfter.get(segmentId) ?? []),
+ ].map((t) => ({ key: `freeTranslation ${t.analysisId}`, segmentId }));
+ }),
}),
- [book.bookRef, staleTokenLinks, freeTranslationsBySegment],
+ [book.bookRef, book.segments, staleTokenLinks, placedFreeTranslations],
);
// Every analysis edit rebuilds the stale links, and reporting each rebuild would re-render the
diff --git a/src/components/TextReadingStatus.tsx b/src/components/TextReadingStatus.tsx
new file mode 100644
index 000000000..83ea2a5e2
--- /dev/null
+++ b/src/components/TextReadingStatus.tsx
@@ -0,0 +1,48 @@
+import { EmptyState, Spinner } from 'platform-bible-react';
+import { formatReplacementString, type LanguageStrings } from 'platform-bible-utils';
+import type { SourceText } from '../hooks/useSourceTextReader';
+
+/** Localized string keys {@link TextReadingStatus} renders. */
+export const TEXT_READING_STRING_KEYS = [
+ '%interlinearizer_textReading_loading%',
+ '%interlinearizer_textReading_error%',
+] as const satisfies `%${string}%`[];
+
+/** Props for {@link TextReadingStatus}. */
+type TextReadingStatusProps = Readonly<{
+ text: Pick;
+ /** Prefixes the `-loading` and `-error` test ids the status is found by. */
+ idPrefix: string;
+ /** Resolved localizations covering at least {@link TEXT_READING_STRING_KEYS}. */
+ localizedStrings: LanguageStrings;
+}>;
+
+/** How far reading the source text has got, or that it could not be read. */
+export default function TextReadingStatus({
+ text,
+ idPrefix,
+ localizedStrings,
+}: TextReadingStatusProps) {
+ if (text.status === 'error' || text.isPartial) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/src/components/__mocks__/TextReadingStatus.tsx b/src/components/__mocks__/TextReadingStatus.tsx
new file mode 100644
index 000000000..eee993f9c
--- /dev/null
+++ b/src/components/__mocks__/TextReadingStatus.tsx
@@ -0,0 +1,16 @@
+import type { SourceText } from '../../hooks/useSourceTextReader';
+
+export const { TEXT_READING_STRING_KEYS } = jest.requireActual('../TextReadingStatus');
+
+/** Stands in for the reading status, exposing the reading state it was handed. */
+export default function TextReadingStatus({
+ text,
+}: Readonly<{ text: Pick }>) {
+ return (
+
+ );
+}
diff --git a/src/hooks/useConcordanceEntries.ts b/src/hooks/useConcordanceEntries.ts
new file mode 100644
index 000000000..81d4e7a64
--- /dev/null
+++ b/src/hooks/useConcordanceEntries.ts
@@ -0,0 +1,37 @@
+import { useMemo, useState } from 'react';
+import { buildConcordanceEntries, type ConcordanceEntry } from '../utils/concordance';
+import { collatorForTag } from '../utils/language-tags';
+import type { SourceText } from './useSourceTextReader';
+
+/** Arguments for {@link useConcordanceEntries}. */
+export interface UseConcordanceEntriesArgs {
+ /** The source text the entries are built from. */
+ text: Pick;
+ /** Whether the concordance is on screen; the entries take in live-book edits only while it is. */
+ shown: boolean;
+ /** BCP 47 tag of the source text, which the entries are collated by. */
+ writingSystem: string;
+}
+
+/**
+ * Builds the concordance's entries, one per form across every book, from the source text; empty
+ * until every book has been read.
+ */
+export default function useConcordanceEntries({
+ text: { status, readings, liveVersions },
+ shown,
+ writingSystem,
+}: UseConcordanceEntriesArgs): readonly ConcordanceEntry[] {
+ const collator = useMemo(() => collatorForTag(writingSystem), [writingSystem]);
+
+ // Held back while hidden: merging every book again is too costly to repeat for edits nobody sees.
+ const [mergedLiveVersions, setMergedLiveVersions] = useState(liveVersions);
+ if (shown && mergedLiveVersions !== liveVersions) setMergedLiveVersions(liveVersions);
+
+ return useMemo(() => {
+ if (status !== 'ready' || !readings) return [];
+ const books = new Map(readings);
+ mergedLiveVersions.forEach((index, book) => books.set(book, index));
+ return buildConcordanceEntries(books.values(), collator);
+ }, [status, readings, mergedLiveVersions, collator]);
+}
diff --git a/src/hooks/useConcordanceIndex.ts b/src/hooks/useConcordanceIndex.ts
deleted file mode 100644
index e6fff1376..000000000
--- a/src/hooks/useConcordanceIndex.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import papi, { logger } from '@papi/frontend';
-import { Canon } from '@sillsdev/scripture';
-import type { Book } from 'interlinearizer';
-import type { IUSJBookProjectDataProvider } from 'platform-scripture';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { tokenizeBook } from '../parsers/papi/bookTokenizer';
-import { extractBookFromUsj } from '../parsers/papi/usjBookExtractor';
-import {
- buildConcordanceEntries,
- indexBook,
- type BookConcordance,
- type ConcordanceEntry,
-} from '../utils/concordance';
-import { collatorForTag } from '../utils/language-tags';
-
-/** Where reading the project's books has got to. */
-export type ConcordanceIndexStatus = 'idle' | 'loading' | 'ready' | 'error';
-
-/** Arguments for {@link useConcordanceIndex}. */
-export interface UseConcordanceIndexArgs {
- /** Source project whose books are indexed. */
- projectId: string;
- /** BCP 47 tag of the source text, which the entries are collated by. */
- writingSystem: string;
- /** The book the editor has loaded, which stands in for that book's reading while it is current. */
- liveBook: Book | undefined;
- /** Whether the index is wanted; nothing is read until it first is. */
- enabled: boolean;
- /** Whether the concordance is on screen; the entries take in live-book edits only while it is. */
- shown: boolean;
-}
-
-/** What {@link useConcordanceIndex} hands back. */
-export interface ConcordanceIndex {
- status: ConcordanceIndexStatus;
- /** How many of the project's books have been read so far. */
- booksRead: number;
- /** How many books the project has; `0` until the list of them has been read. */
- bookCount: number;
- /** One entry per form across every book; empty until every book has been read. */
- entries: readonly ConcordanceEntry[];
- /** Reads every book again, the live one aside. */
- refresh: () => void;
-}
-
-/**
- * Reads the books a `platformScripture.booksPresent` flag string marks present — one `'1'` per
- * present book, indexed by canonical book number — in canonical order. Flags past the end of the
- * canon mark nothing.
- */
-function presentBookIds(booksPresent: string): string[] {
- const bookCount = Math.min(booksPresent.length, Canon.allBookIds.length);
- const ids: string[] = [];
- for (let index = 0; index < bookCount; index += 1) {
- if (booksPresent[index] === '1') ids.push(Canon.bookNumberToId(index + 1));
- }
- return ids;
-}
-
-/**
- * How many books are read at once. Enough to keep the provider busy while earlier books are
- * indexed, few enough that the whole canon's USJ is never in memory at the same time.
- */
-const READ_CONCURRENCY = 4;
-
-/** Reads a book and indexes it, `undefined` when the project serves no text for it. */
-async function readBook(
- usjPdp: Pick,
- bookId: string,
- writingSystem: string,
-): Promise {
- const usj = await usjPdp.getBookUSJ({ book: bookId, chapterNum: 1, verseNum: 1 });
- if (!usj) return undefined;
- return indexBook(tokenizeBook(extractBookFromUsj(usj, writingSystem)));
-}
-
-/**
- * Indexes every book of a source project for the concordance, reading them the first time the index
- * is wanted and keeping them for as long as the caller stays mounted.
- *
- * Every book but the live one is a reading taken when the index was built. The live book replaces
- * its reading whenever its text changes, and a book the editor moves off keeps the last live
- * version it had rather than going back to the older reading.
- */
-export default function useConcordanceIndex({
- projectId,
- writingSystem,
- liveBook,
- enabled,
- shown,
-}: UseConcordanceIndexArgs): ConcordanceIndex {
- const [status, setStatus] = useState('idle');
- const [progress, setProgress] = useState({ booksRead: 0, bookCount: 0 });
- const [readings, setReadings] = useState>();
-
- /** Live versions of books the editor has had loaded, each newer than the book's reading. */
- const [liveVersions, setLiveVersions] = useState>(
- () => new Map(),
- );
-
- /** Bumped to read every book again. */
- const [generation, setGeneration] = useState(0);
-
- // Read through a ref so the tag settling after the first render does not restart the reads: the
- // tag only labels the tokens, which the index never looks at.
- const writingSystemRef = useRef(writingSystem);
- writingSystemRef.current = writingSystem;
-
- const readingsRef = useRef(readings);
- readingsRef.current = readings;
-
- useEffect(() => {
- if (!enabled) return undefined;
- let isCurrent = true;
- setStatus('loading');
- setProgress({ booksRead: 0, bookCount: 0 });
- (async () => {
- try {
- const basePdp = await papi.projectDataProviders.get('platform.base', projectId);
- const bookIds = presentBookIds(await basePdp.getSetting('platformScripture.booksPresent'));
- const usjPdp = await papi.projectDataProviders.get('platformScripture.USJ_Book', projectId);
- if (!isCurrent) return;
- setProgress({ booksRead: 0, bookCount: bookIds.length });
-
- const read = new Map();
- const unread = [...bookIds];
- let booksRead = 0;
- let booksFailed = 0;
- const readNext = async (): Promise => {
- const bookId = unread.shift();
- if (bookId === undefined) return;
- try {
- const reading = await readBook(usjPdp, bookId, writingSystemRef.current);
- if (reading) read.set(bookId, reading);
- else logger.warn(`Concordance: project ${projectId} has no text for ${bookId}`);
- } catch (e) {
- booksFailed += 1;
- logger.warn(`Concordance: skipping ${bookId} in project ${projectId}`, e);
- }
- if (!isCurrent) return;
- booksRead += 1;
- setProgress({ booksRead, bookCount: bookIds.length });
- await readNext();
- };
- await Promise.all(Array.from({ length: READ_CONCURRENCY }, readNext));
- if (!isCurrent) return;
- // With nothing read, a failure could be hiding text, so an empty list would misreport it.
- if (read.size === 0 && booksFailed > 0) {
- logger.error(`Concordance: no book of project ${projectId} could be read`);
- setStatus('error');
- return;
- }
- setReadings(read);
- setStatus('ready');
- } catch (e) {
- if (!isCurrent) return;
- logger.error(`Concordance: could not list the books of project ${projectId}`, e);
- setStatus('error');
- }
- })();
- return () => {
- isCurrent = false;
- };
- }, [enabled, projectId, generation]);
-
- /** The live book, indexed only once the index is wanted. */
- const liveIndex = useMemo(
- () => (enabled && liveBook ? indexBook(liveBook) : undefined),
- [enabled, liveBook],
- );
-
- useEffect(() => {
- if (!liveIndex) return;
- setLiveVersions((prev) => {
- const known = prev.get(liveIndex.book) ?? readingsRef.current?.get(liveIndex.book);
- // Unchanged text would only rebuild every entry to the same result.
- if (known?.textVersion === liveIndex.textVersion) return prev;
- return new Map(prev).set(liveIndex.book, liveIndex);
- });
- }, [liveIndex]);
-
- const liveIndexRef = useRef(liveIndex);
- liveIndexRef.current = liveIndex;
-
- const refresh = useCallback(() => {
- const live = liveIndexRef.current;
- setLiveVersions(live ? new Map([[live.book, live]]) : new Map());
- setGeneration((g) => g + 1);
- }, []);
-
- const collator = useMemo(() => collatorForTag(writingSystem), [writingSystem]);
-
- // Held back while hidden: merging every book again is too costly to repeat for edits nobody sees.
- const [mergedLiveVersions, setMergedLiveVersions] = useState(liveVersions);
- if (shown && mergedLiveVersions !== liveVersions) setMergedLiveVersions(liveVersions);
-
- const entries = useMemo(() => {
- if (status !== 'ready' || !readings) return [];
- const books = new Map(readings);
- mergedLiveVersions.forEach((index, book) => books.set(book, index));
- return buildConcordanceEntries(books.values(), collator);
- }, [status, readings, mergedLiveVersions, collator]);
-
- return useMemo(
- () => ({ status, ...progress, entries, refresh }),
- [status, progress, entries, refresh],
- );
-}
diff --git a/src/hooks/useSegmentHeights.ts b/src/hooks/useSegmentHeights.ts
index 769dee604..4d8a63da6 100644
--- a/src/hooks/useSegmentHeights.ts
+++ b/src/hooks/useSegmentHeights.ts
@@ -2,7 +2,11 @@ import type { Book } from 'interlinearizer';
import type { RefObject } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { HeightConfig, HeightTable } from '../utils/segment-heights';
-import { buildHeightTable, predictSegmentHeights } from '../utils/segment-heights';
+import {
+ buildHeightTable,
+ predictOrphanedCardHeights,
+ predictSegmentHeights,
+} from '../utils/segment-heights';
/** Width used for the wrap box before the container has been laid out. */
const FALLBACK_WRAP_WIDTH_PX = 1000;
@@ -60,5 +64,13 @@ export default function useSegmentHeights({
[book.segments, config, wrapWidth],
);
- return useMemo(() => buildHeightTable(config, predicted), [config, predicted]);
+ const cardHeights = useMemo(
+ () => predictOrphanedCardHeights(book.segments.length, config),
+ [book.segments.length, config],
+ );
+
+ return useMemo(
+ () => buildHeightTable(config, predicted, cardHeights),
+ [config, predicted, cardHeights],
+ );
}
diff --git a/src/hooks/useSegmentWindow.ts b/src/hooks/useSegmentWindow.ts
index e18143647..790f46ff0 100644
--- a/src/hooks/useSegmentWindow.ts
+++ b/src/hooks/useSegmentWindow.ts
@@ -584,14 +584,17 @@ export default function useSegmentWindow({
);
/* v8 ignore next -- the recentered target is always mounted, so its element exists */
if (!target) return;
+ // Cards the list renders ahead of the book's first segment frame with it.
+ const ahead = target.previousElementSibling;
+ const frame = ahead?.hasAttribute('data-orphaned-ahead') ? ahead : target;
const spacer = container.querySelector('[data-snap-spacer]');
if (spacer) spacer.style.height = '0px';
- target.scrollIntoView({ behavior: 'auto', block: 'start' });
+ frame.scrollIntoView({ behavior: 'auto', block: 'start' });
const remainingOffset =
- target.getBoundingClientRect().top - container.getBoundingClientRect().top;
+ frame.getBoundingClientRect().top - container.getBoundingClientRect().top;
if (remainingOffset > 1 && spacer) {
spacer.style.height = `${Math.ceil(remainingOffset)}px`;
- target.scrollIntoView({ behavior: 'auto', block: 'start' });
+ frame.scrollIntoView({ behavior: 'auto', block: 'start' });
}
}, [scrollContainerRef]);
diff --git a/src/hooks/useSourceTextReader.ts b/src/hooks/useSourceTextReader.ts
new file mode 100644
index 000000000..9364163b9
--- /dev/null
+++ b/src/hooks/useSourceTextReader.ts
@@ -0,0 +1,254 @@
+import papi, { logger } from '@papi/frontend';
+import { Canon } from '@sillsdev/scripture';
+import type { Book } from 'interlinearizer';
+import type { IUSJBookProjectDataProvider } from 'platform-scripture';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { tokenizeBook } from '../parsers/papi/bookTokenizer';
+import { extractBookFromUsj } from '../parsers/papi/usjBookExtractor';
+import { indexBook, type BookConcordance } from '../utils/concordance';
+import useLatestRef from './useLatestRef';
+
+/** Where reading the project's books has got to. */
+export type SourceTextStatus = 'idle' | 'loading' | 'ready' | 'error';
+
+/** Arguments for {@link useSourceTextReader}. */
+export interface UseSourceTextReaderArgs {
+ /** Source project whose books are read. */
+ projectId: string;
+ /** BCP 47 tag of the source text, which its tokens are labeled with. */
+ writingSystem: string;
+ /** The book the editor has loaded, which stands in for that book's reading while it is current. */
+ liveBook: Book | undefined;
+ /** Whether the text is wanted; nothing is read until it first is, or is requested. */
+ enabled: boolean;
+ /**
+ * Reads every book again, as a refresh does, whenever this changes; results from a read started
+ * under an earlier key are dropped.
+ */
+ readKey?: unknown;
+ /** Receives each book as it is read, before only its index is kept. */
+ onBookRead?: (book: Book) => void;
+ /**
+ * Receives, in canonical order, the books found to have text once every book is read; not called
+ * for a reading that any book failed.
+ */
+ onTextRead?: (bookIds: readonly string[]) => void;
+}
+
+/** What {@link useSourceTextReader} hands back. */
+export interface SourceText {
+ status: SourceTextStatus;
+ /** How many of the project's books have been read so far. */
+ booksRead: number;
+ /** How many books the project has; `0` until the list of them has been read. */
+ bookCount: number;
+ /** Each book's index as last read, by book code; `undefined` until a reading completes. */
+ readings: ReadonlyMap | undefined;
+ /** Live versions of books the editor has had loaded, each newer than the book's reading. */
+ liveVersions: ReadonlyMap;
+ /** Whether a book failed to read, leaving the readings short of the whole text. */
+ isPartial: boolean;
+ /**
+ * Every form the text holds, the live book's as it now reads; `undefined` until every book has
+ * been read, and for a partial reading.
+ */
+ textForms: ReadonlySet | undefined;
+ /** Reads every book again, the live one aside. */
+ refresh: () => void;
+ /** Wants the text from now on, whatever `enabled` says. */
+ request: () => void;
+}
+
+/**
+ * Reads the books a `platformScripture.booksPresent` flag string marks present — one `'1'` per
+ * present book, indexed by canonical book number — in canonical order. Flags past the end of the
+ * canon mark nothing.
+ */
+function presentBookIds(booksPresent: string): string[] {
+ const bookCount = Math.min(booksPresent.length, Canon.allBookIds.length);
+ const ids: string[] = [];
+ for (let index = 0; index < bookCount; index += 1) {
+ if (booksPresent[index] === '1') ids.push(Canon.bookNumberToId(index + 1));
+ }
+ return ids;
+}
+
+/**
+ * How many books are read at once. Enough to keep the provider busy while earlier books are
+ * indexed, few enough that the whole canon's USJ is never in memory at the same time.
+ */
+const READ_CONCURRENCY = 4;
+
+/** Reads and tokenizes a book, `undefined` when the project serves no text for it. */
+async function readBook(
+ usjPdp: Pick,
+ bookId: string,
+ writingSystem: string,
+): Promise {
+ const usj = await usjPdp.getBookUSJ({ book: bookId, chapterNum: 1, verseNum: 1 });
+ if (!usj) return undefined;
+ return tokenizeBook(extractBookFromUsj(usj, writingSystem));
+}
+
+/**
+ * Reads and indexes every book of a source project, the first time the text is wanted or requested,
+ * and keeps them for as long as the caller stays mounted.
+ *
+ * Every book but the live one is a reading taken when the text was last read. The live book
+ * replaces its reading whenever its text changes, and a book the editor moves off keeps the last
+ * live version it had rather than going back to the older reading.
+ */
+export default function useSourceTextReader({
+ projectId,
+ writingSystem,
+ liveBook,
+ enabled,
+ onBookRead,
+ onTextRead,
+ readKey,
+}: UseSourceTextReaderArgs): SourceText {
+ const [status, setStatus] = useState('idle');
+ const [progress, setProgress] = useState({ booksRead: 0, bookCount: 0 });
+ const [readings, setReadings] = useState>();
+ const [isPartial, setIsPartial] = useState(false);
+
+ const [liveVersions, setLiveVersions] = useState>(
+ () => new Map(),
+ );
+
+ const [requested, setRequested] = useState(false);
+ const wanted = enabled || requested;
+
+ /** Bumped to read every book again. */
+ const [generation, setGeneration] = useState(0);
+
+ // Read through a ref so the tag settling after the first render does not restart the reads: the
+ // tag only labels the tokens, which the index never looks at.
+ const writingSystemRef = useLatestRef(writingSystem);
+
+ // Read through refs so a read in flight calls whichever callbacks are current when it lands.
+ const onBookReadRef = useLatestRef(onBookRead);
+ const onTextReadRef = useLatestRef(onTextRead);
+ const readKeyRef = useLatestRef(readKey);
+
+ const readingsRef = useLatestRef(readings);
+
+ useEffect(() => {
+ if (!wanted) return undefined;
+ let isAbandoned = false;
+ const key = readKeyRef.current;
+ // `readKeyRef` updates during render, before this cleanup runs, so check the key too.
+ const isCurrent = () => !isAbandoned && readKeyRef.current === key;
+ setStatus('loading');
+ setIsPartial(false);
+ setProgress({ booksRead: 0, bookCount: 0 });
+ (async () => {
+ try {
+ const basePdp = await papi.projectDataProviders.get('platform.base', projectId);
+ const bookIds = presentBookIds(await basePdp.getSetting('platformScripture.booksPresent'));
+ const usjPdp = await papi.projectDataProviders.get('platformScripture.USJ_Book', projectId);
+ if (!isCurrent()) return;
+ setProgress({ booksRead: 0, bookCount: bookIds.length });
+
+ const read = new Map();
+ const unread = [...bookIds];
+ let booksRead = 0;
+ let booksFailed = 0;
+ const readNext = async (): Promise => {
+ const bookId = unread.shift();
+ if (bookId === undefined) return;
+ let book: Book | undefined;
+ try {
+ book = await readBook(usjPdp, bookId, writingSystemRef.current);
+ if (book) read.set(bookId, indexBook(book));
+ else logger.warn(`Source text: project ${projectId} has no text for ${bookId}`);
+ } catch (e) {
+ booksFailed += 1;
+ logger.warn(`Source text: skipping ${bookId} in project ${projectId}`, e);
+ }
+ if (!isCurrent()) return;
+ if (book) onBookReadRef.current?.(book);
+ booksRead += 1;
+ setProgress({ booksRead, bookCount: bookIds.length });
+ await readNext();
+ };
+ await Promise.all(Array.from({ length: READ_CONCURRENCY }, readNext));
+ if (!isCurrent()) return;
+ // With nothing read, a failure could be hiding text, so an empty reading would misreport it.
+ if (read.size === 0 && booksFailed > 0) {
+ logger.error(`Source text: no book of project ${projectId} could be read`);
+ setStatus('error');
+ return;
+ }
+ setReadings(read);
+ setIsPartial(booksFailed > 0);
+ setStatus('ready');
+ if (booksFailed === 0) onTextReadRef.current?.(bookIds.filter((id) => read.has(id)));
+ } catch (e) {
+ if (!isCurrent()) return;
+ logger.error(`Source text: could not list the books of project ${projectId}`, e);
+ setStatus('error');
+ }
+ })();
+ return () => {
+ isAbandoned = true;
+ };
+ }, [wanted, projectId, generation, readKeyRef, writingSystemRef, onBookReadRef, onTextReadRef]);
+
+ /** The live book, indexed only once the text is wanted. */
+ const liveIndex = useMemo(
+ () => (wanted && liveBook ? indexBook(liveBook) : undefined),
+ [wanted, liveBook],
+ );
+
+ useEffect(() => {
+ if (!liveIndex) return;
+ setLiveVersions((prev) => {
+ const known = prev.get(liveIndex.book) ?? readingsRef.current?.get(liveIndex.book);
+ // Unchanged text would only rebuild everything built on the readings to the same result.
+ if (known?.textVersion === liveIndex.textVersion) return prev;
+ return new Map(prev).set(liveIndex.book, liveIndex);
+ });
+ }, [liveIndex, readingsRef]);
+
+ const liveIndexRef = useLatestRef(liveIndex);
+
+ const refresh = useCallback(() => {
+ const live = liveIndexRef.current;
+ setLiveVersions(live ? new Map([[live.book, live]]) : new Map());
+ setGeneration((g) => g + 1);
+ }, [liveIndexRef]);
+
+ const [readKeyRead, setReadKeyRead] = useState(readKey);
+ if (readKey !== readKeyRead) {
+ setReadKeyRead(readKey);
+ refresh();
+ }
+
+ const request = useCallback(() => setRequested(true), []);
+
+ const textForms = useMemo(() => {
+ if (status !== 'ready' || !readings || isPartial) return undefined;
+ const books = new Map(readings);
+ liveVersions.forEach((index, book) => books.set(book, index));
+ const forms = new Set();
+ books.forEach(({ occurrencesByForm }) =>
+ occurrencesByForm.forEach((_, form) => forms.add(form)),
+ );
+ return forms;
+ }, [status, readings, isPartial, liveVersions]);
+
+ return useMemo(
+ () => ({
+ status,
+ ...progress,
+ readings,
+ liveVersions,
+ isPartial,
+ textForms,
+ refresh,
+ request,
+ }),
+ [status, progress, readings, liveVersions, isPartial, textForms, refresh, request],
+ );
+}
diff --git a/src/hooks/useWholeTextReanchor.ts b/src/hooks/useWholeTextReanchor.ts
new file mode 100644
index 000000000..44a9c7ee5
--- /dev/null
+++ b/src/hooks/useWholeTextReanchor.ts
@@ -0,0 +1,86 @@
+import type { Book, DraftProject } from 'interlinearizer';
+import { useCallback, useState } from 'react';
+import { booksLinkedIn } from '../utils/analysis-book';
+import { reanchorDraftToBook, reanchorDraftToMissingBook } from '../utils/reanchor-draft';
+import type { BookPass } from '../utils/undo-history';
+import type { DraftContent } from './useDraftProject';
+import useLatestRef from './useLatestRef';
+
+/** Arguments for {@link useWholeTextReanchor}. */
+export interface UseWholeTextReanchorArgs {
+ /** The book the view has loaded, which re-anchors to its own live text rather than to a reading. */
+ loadedBookCode: string | undefined;
+ /** Whether an import is shown in place of the draft, which is then left as it is. */
+ isImportView: boolean;
+ /** Whether the draft is still loading, before which there is nothing to re-anchor. */
+ isDraftLoading: boolean;
+ /** Bumped whenever the draft is replaced wholesale. */
+ draftVersion: number;
+ getDraftSnapshot: () => DraftProject | undefined;
+ reanchorBook: (bookCode: string, pass: BookPass) => void;
+}
+
+/** What {@link useWholeTextReanchor} hands back, for a whole-text read to drive. */
+export interface WholeTextReanchor {
+ /**
+ * Draft version the whole-text read re-anchors, `undefined` while loading or showing an import;
+ * each change reads the text again.
+ */
+ readKey: number | undefined;
+ /**
+ * As the whole text is read, re-anchors the draft to each book it links into, except the loaded
+ * book, which re-anchors to its live text.
+ */
+ onBookRead: (book: Book) => void;
+ /**
+ * Once the whole text is read, stales the draft's approvals in books the text lacks and records
+ * the draft as fully re-anchored.
+ */
+ onTextRead: (bookIds: readonly string[]) => void;
+ /** Whether every book has been re-anchored for the current draft, rather than only those opened. */
+ staleCoversDraft: boolean;
+}
+
+/** Re-anchors the draft to every book of the source text each time the whole text is read. */
+export default function useWholeTextReanchor({
+ loadedBookCode,
+ isImportView,
+ isDraftLoading,
+ draftVersion,
+ getDraftSnapshot,
+ reanchorBook,
+}: UseWholeTextReanchorArgs): WholeTextReanchor {
+ const loadedBookCodeRef = useLatestRef(loadedBookCode);
+ const readKey = isImportView || isDraftLoading ? undefined : draftVersion;
+ const readKeyRef = useLatestRef(readKey);
+
+ /** The draft version every book was last re-anchored for. */
+ const [textReanchoredFor, setTextReanchoredFor] = useState();
+ const staleCoversDraft = readKey !== undefined && textReanchoredFor === readKey;
+
+ const onBookRead = useCallback(
+ (read: Book) => {
+ if (read.bookRef === loadedBookCodeRef.current) return;
+ const analysis = readKeyRef.current === undefined ? undefined : getDraftSnapshot()?.analysis;
+ if (!analysis || !booksLinkedIn(analysis).has(read.bookRef)) return;
+ reanchorBook(read.bookRef, reanchorDraftToBook(read));
+ },
+ [getDraftSnapshot, reanchorBook, loadedBookCodeRef, readKeyRef],
+ );
+
+ const onTextRead = useCallback(
+ (bookIds: readonly string[]) => {
+ const target = readKeyRef.current;
+ const analysis = target === undefined ? undefined : getDraftSnapshot()?.analysis;
+ if (!analysis) return;
+ const present = new Set(bookIds);
+ booksLinkedIn(analysis).forEach((bookCode) => {
+ if (!present.has(bookCode)) reanchorBook(bookCode, reanchorDraftToMissingBook(bookCode));
+ });
+ setTextReanchoredFor(target);
+ },
+ [getDraftSnapshot, reanchorBook, readKeyRef],
+ );
+
+ return { readKey, onBookRead, onTextRead, staleCoversDraft };
+}
diff --git a/src/utils/analysis-book.ts b/src/utils/analysis-book.ts
index 15c6dc384..a43b759bd 100644
--- a/src/utils/analysis-book.ts
+++ b/src/utils/analysis-book.ts
@@ -11,6 +11,15 @@ export function bookOfRef(ref: string): string {
return spaceIndex === -1 ? ref : ref.slice(0, spaceIndex);
}
+/** Lists the books an analysis has a link of any kind into. */
+export function booksLinkedIn(analysis: TextAnalysis): ReadonlySet {
+ return new Set([
+ ...analysis.tokenAnalysisLinks.map((link) => bookOfRef(link.token.tokenRef)),
+ ...analysis.segmentAnalysisLinks.map((link) => bookOfRef(link.segmentId)),
+ ...analysis.phraseAnalysisLinks.map((link) => bookOfRef(link.tokens[0].tokenRef)),
+ ]);
+}
+
/**
* Partition key for payloads no link references, which describe a spelling rather than any one
* occurrence and so belong to no book. Its space cannot occur in a book code, so it never collides
diff --git a/src/utils/analysis-query.ts b/src/utils/analysis-query.ts
index d1dbf9bfe..d0fd80feb 100644
--- a/src/utils/analysis-query.ts
+++ b/src/utils/analysis-query.ts
@@ -11,6 +11,7 @@ import type {
} from 'interlinearizer';
import type { Collator } from 'platform-bible-utils';
import { bookOfRef } from './analysis-book';
+import { normalizeSurfaceForm } from './analysis-identity';
import { multiStringText } from './multi-string';
import { foldForSearch } from './search-fold';
import { firstVerseNumber } from './verse-ref';
@@ -40,6 +41,8 @@ export interface HeadingPlacement {
export interface CatalogRow {
analysisId: string;
surfaceText: string;
+ /** `surfaceText` normalized as the text's word forms are, for finding it in the text. */
+ form: string;
/**
* Gloss in the scope's analysis language; `''` when the analysis has none, blank counting as
* none.
@@ -101,12 +104,26 @@ export interface CatalogFilters {
zeroUsages?: boolean;
/** Keeps only rows with a stale location, in one of the selected books where `books` is active. */
stale?: boolean;
+ /**
+ * Keeps only rows whose form isn't in {@link CatalogQuery.textForms}, and none while that is
+ * unset.
+ */
+ notInText?: boolean;
/** Keeps only rows with no gloss in the scope's analysis language. */
missingGloss?: boolean;
/** Keeps rows according to whether they carry a morpheme breakdown. */
morphemes?: 'has' | 'lacks';
}
+/** The filters set by a single toggle rather than by a selection of values. */
+export const TOGGLE_FILTER_KEYS = [
+ 'zeroUsages',
+ 'stale',
+ 'notInText',
+ 'missingGloss',
+ 'morphemes',
+] as const satisfies readonly (keyof CatalogFilters)[];
+
/** How the caller narrows and orders the rows. */
export interface CatalogQuery {
/**
@@ -121,6 +138,8 @@ export interface CatalogQuery {
surfaceCollator: Collator;
/** Collates glosses, so ordering follows the analysis language rather than code points. */
glossCollator: Collator;
+ /** Every normalized word form the source text holds, `undefined` until every book has been read. */
+ textForms?: ReadonlySet;
}
/**
@@ -310,6 +329,7 @@ export function buildCatalogRows(
return {
analysisId: ta.id,
surfaceText: ta.surfaceText,
+ form: normalizeSurfaceForm(ta.surfaceText),
gloss: multiStringText(ta.gloss, scope.analysisLanguage),
glosses: ta.gloss,
glossSenseRef: ta.glossSenseRef,
@@ -499,6 +519,15 @@ function retainOffered(
return kept.length === 0 ? undefined : kept;
}
+/** Copies one filter across where `from` sets it, leaving it absent rather than `undefined`. */
+function copyDefinedFilter(
+ from: CatalogFilters,
+ to: CatalogFilters,
+ key: K,
+): void {
+ if (from[key] !== undefined) to[key] = from[key];
+}
+
/** Whether two feature selections name the same values for the same features. */
function sameFeatureSelections(
a: CatalogFilters['features'],
@@ -539,15 +568,12 @@ export function reconcileFilters(filters: CatalogFilters, facets: CatalogFacets)
// Built field by field rather than by overriding a spread of `filters`: a withdrawn selection is
// absent rather than `undefined`, and spreading the original first would keep the stale key.
const reconciled: CatalogFilters = {
- ...(filters.zeroUsages !== undefined && { zeroUsages: filters.zeroUsages }),
- ...(filters.stale !== undefined && { stale: filters.stale }),
- ...(filters.missingGloss !== undefined && { missingGloss: filters.missingGloss }),
- ...(filters.morphemes !== undefined && { morphemes: filters.morphemes }),
...(books && { books }),
...(pos && { pos }),
...(confidence && { confidence }),
...(keptFeatures && { features: keptFeatures }),
};
+ TOGGLE_FILTER_KEYS.forEach((key) => copyDefinedFilter(filters, reconciled, key));
// The value filters are the only ones a facet can withdraw: the rest are offered unconditionally,
// so nothing can strand them.
@@ -579,7 +605,11 @@ function passesFeatures(row: CatalogRow, selected: CatalogFilters['features']):
}
/** Whether the row survives every active filter. */
-function passesFilters(row: CatalogRow, filters: CatalogFilters): boolean {
+function passesFilters(
+ row: CatalogRow,
+ filters: CatalogFilters,
+ textForms: ReadonlySet | undefined,
+): boolean {
const { books } = filters;
if (isActive(books) && !books.some((book) => row.books.has(book))) return false;
if (!passesValue(filters.pos, row.pos)) return false;
@@ -591,6 +621,7 @@ function passesFilters(row: CatalogRow, filters: CatalogFilters): boolean {
!row.staleLocations.some((place) => !isActive(books) || books.includes(place.book))
)
return false;
+ if (filters.notInText && (!textForms || textForms.has(row.form))) return false;
if (filters.missingGloss && row.gloss !== '') return false;
if (filters.morphemes) {
const hasMorphemes = row.morphemes.length > 0;
@@ -654,6 +685,9 @@ export function applyCatalogQuery(
// it is whitespace only once folded.
const search = foldForSearch(collapseLineEndings(query.search)).trim();
return rows
- .filter((row) => row.searchText.includes(search) && passesFilters(row, query.filters))
+ .filter(
+ (row) =>
+ row.searchText.includes(search) && passesFilters(row, query.filters, query.textForms),
+ )
.toSorted((a, b) => compareBySort(a, b, query));
}
diff --git a/src/utils/reanchor-draft.ts b/src/utils/reanchor-draft.ts
index b1cbab251..1d01979bc 100644
--- a/src/utils/reanchor-draft.ts
+++ b/src/utils/reanchor-draft.ts
@@ -20,3 +20,14 @@ export function reanchorDraftToBook(verseBook: Book): BookPass {
};
};
}
+
+/** Builds the pass that re-anchors a draft's content to a book the project no longer has. */
+export function reanchorDraftToMissingBook(bookCode: string): BookPass {
+ return reanchorDraftToBook({
+ id: bookCode,
+ bookRef: bookCode,
+ textVersion: '',
+ segments: [],
+ duplicateVerseIds: [],
+ });
+}
diff --git a/src/utils/segment-heights.ts b/src/utils/segment-heights.ts
index 861eaefaf..1a1667a0d 100644
--- a/src/utils/segment-heights.ts
+++ b/src/utils/segment-heights.ts
@@ -51,6 +51,9 @@ const STALE_REVIEW_GAP_PX = 4;
/** Height of one line of stale translation text listed for review. */
const STALE_REVIEW_TEXT_LINE_PX = 20;
+/** Height of an orphaned-translation card, which holds its heading and its text to a line apiece. */
+const ORPHANED_CARD_PX = 66;
+
/** Width assumed for one character of plain baseline text, in pixels. */
const BASELINE_CHAR_PX = 8.4;
@@ -82,6 +85,11 @@ export type HeightConfig = Readonly<{
* charging both buttons.
*/
staleReviewOffersKeep?: (index: number) => boolean;
+ /**
+ * How many cards the list renders beside the segment at `index` for stale translations of verses
+ * the book no longer holds. Defaults to none.
+ */
+ orphanedCardCount?: (index: number) => number;
/** Which renderer the segment uses; `baseline-text` has no chips and so no rows. */
displayMode: 'token-chip' | 'baseline-text';
/**
@@ -211,13 +219,34 @@ export function predictSegmentHeights(
});
}
+/**
+ * Predicts the height of the orphaned-translation cards the list renders beside each segment, a gap
+ * apiece included. The result is index-aligned with a book of `segmentCount` segments.
+ */
+export function predictOrphanedCardHeights(
+ segmentCount: number,
+ config: HeightConfig,
+): readonly number[] {
+ return Array.from({ length: segmentCount }, (_unused, index) => {
+ if (!config.showFreeTranslation) return 0;
+ return (
+ (config.orphanedCardCount?.(index) ?? 0) * ((config.segmentGapPx ?? 0) + ORPHANED_CARD_PX)
+ );
+ });
+}
+
/**
* Per-segment heights for a whole book, with the prefix sums that turn a scroll offset into a
* segment index and back.
*/
export type HeightTable = Readonly<{
- /** Height of each segment, index-aligned with the book's segment list, including the gap above it. */
+ /**
+ * Height of each segment, index-aligned with the book's segment list, including the gap above it
+ * and the orphaned-translation cards beside it.
+ */
heights: readonly number[];
+ /** Height of each segment's own box, without the gap above it or the cards beside it. */
+ boxHeights: readonly number[];
/**
* Running top edge of each segment, with a trailing entry for the bottom of the last one, so
* `offsets` is always one longer than {@link HeightTable.heights}.
@@ -233,21 +262,24 @@ export type HeightTable = Readonly<{
* @param config - Supplies the gaps charged between segments.
* @param predictedHeights - Predicted height of each segment, index-aligned with the book's
* segments, as {@link predictSegmentHeights} builds it.
+ * @param cardHeights - Predicted height of the cards beside each segment, as
+ * {@link predictOrphanedCardHeights} builds it.
*/
export function buildHeightTable(
config: HeightConfig,
predictedHeights: readonly number[],
+ cardHeights: readonly number[] = [],
): HeightTable {
const heights: number[] = [];
const offsets: number[] = [0];
predictedHeights.forEach((predicted, index) => {
// The gap above a segment belongs to it, leaving nothing above the first.
const gap = index === 0 ? 0 : (config.segmentGapPx ?? 0) + (config.extraGapPx?.(index) ?? 0);
- const height = gap + predicted;
+ const height = gap + predicted + (cardHeights[index] ?? 0);
heights.push(height);
offsets.push(offsets[offsets.length - 1] + height);
});
- return { heights, offsets, total: offsets[offsets.length - 1] };
+ return { heights, boxHeights: predictedHeights, offsets, total: offsets[offsets.length - 1] };
}
/**
diff --git a/src/utils/stale-free-translations.ts b/src/utils/stale-free-translations.ts
index 6d33c05f1..d09b4aab7 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 } from './verse-ref';
+import { segmentContainsVerse, verseLabelCovers, verseOfId } from './verse-ref';
/** A free translation whose segment's text has changed since it was written. */
export type StaleFreeTranslation = Readonly<{
@@ -87,36 +87,137 @@ function vanishedHeadingPlaces(
}
/**
- * Files each of the book's stale free translations under the segment that shows it: the segment it
- * was written for, or, where that segment has since vanished, the one now covering the position it
- * began at. A vanished heading's translation goes to its verse's heading of the same marker, else
- * the start of its verse. Each segment's list runs in document order of those positions.
- *
- * A translation whose verse the book no longer holds at all is shown nowhere.
+ * Finds the start of the verse now holding a vanished verse's first verse, as `GEN 1:3-4` holds
+ * `GEN 1:4`, else `undefined` where no verse of the book holds it.
+ */
+function coveringVerseStart(
+ verse: string,
+ placesByVerse: ReadonlyMap,
+): Place | undefined {
+ const { chapterNum, verseNum } = verseOfId(verse);
+ const covering = [...placesByVerse].find(
+ ([key]) =>
+ !key.includes('/') &&
+ verseOfId(key).chapterNum === chapterNum &&
+ verseLabelCovers(key.slice(key.indexOf(':') + 1), verseNum),
+ );
+ return covering?.[1][0];
+}
+
+/** A place, with the chapter and verse it falls in. */
+type VersePlace = Readonly<{
+ place: Place;
+ chapterNum: number;
+ /** Its verse number, a bridged verse's first. */
+ verseNum: number;
+}>;
+
+/** Lists every place in the book in document order, each with the verse it falls in. */
+function placesInDocumentOrder(placesByVerse: ReadonlyMap): VersePlace[] {
+ return [...placesByVerse]
+ .flatMap(([verse, places]) => {
+ const { chapterNum, verseNum } = verseOfId(verse);
+ return places.map((place) => ({ place, chapterNum, verseNum }));
+ })
+ .sort((a, b) => a.place.order - b.place.order);
+}
+
+/** Finds the last place of the nearest earlier verse, `undefined` where none precedes it. */
+function placeBefore(
+ { chapterNum, verseNum }: SerializedVerseRef,
+ inOrder: readonly VersePlace[],
+): Place | undefined {
+ const isEarlier = (entry: VersePlace) =>
+ entry.chapterNum < chapterNum || (entry.chapterNum === chapterNum && entry.verseNum < verseNum);
+ return inOrder.findLast(isEarlier)?.place;
+}
+
+/** Reads the chapter and verse a segment id falls in, e.g. `1:5` or `1:5-6`. */
+export function verseLabelOf(segmentId: string): string {
+ const { verse } = positionOf(segmentId);
+ return verse.slice(verse.indexOf(' ') + 1).replace(/\/.*$/, '');
+}
+
+/** A book's stale free translations, filed by where the view shows each. */
+export type PlacedStaleFreeTranslations = Readonly<{
+ /** Those each segment reviews beside its own translation, by segment id, in document order. */
+ bySegment: ReadonlyMap;
+ /**
+ * Those of verses the book no longer holds, by the id of the segment ending the nearest earlier
+ * verse, in verse order.
+ */
+ orphanedAfter: ReadonlyMap;
+ /** Those of verses the book no longer holds that ran ahead of every verse it does. */
+ orphanedAhead: readonly StaleFreeTranslation[];
+}>;
+
+function fileUnder(
+ map: Map,
+ key: string,
+ translation: StaleFreeTranslation,
+) {
+ const list = map.get(key) ?? [];
+ list.push(translation);
+ map.set(key, list);
+}
+
+/**
+ * Files each of the book's stale free translations where the view shows it. One whose segment still
+ * stands goes to that segment, and one whose segment has since vanished to the segment now covering
+ * the position it began at. A vanished heading's translation goes to its verse's heading of the
+ * same marker, else the start of its verse. One whose verse the book holds only under another verse
+ * marker, joined into a bridge or split out of one, goes to the start of that marker's verse. A
+ * translation whose verse the book no longer holds is orphaned, following the segment that ends the
+ * nearest earlier verse.
*/
export function placeStaleFreeTranslations(
stale: readonly StaleFreeTranslation[],
book: Book,
-): ReadonlyMap {
+): PlacedStaleFreeTranslations {
const placesByVerse = indexPlacesByVerse(book);
- const placed = stale.flatMap((translation) => {
- if (bookOfRef(translation.segmentId) !== book.bookRef) return [];
+ let inOrder: readonly VersePlace[] | undefined;
+ const placed: { translation: StaleFreeTranslation; covering: Place }[] = [];
+ const orphaned: {
+ translation: StaleFreeTranslation;
+ after: Place | undefined;
+ verse: SerializedVerseRef;
+ }[] = [];
+ stale.forEach((translation) => {
+ if (bookOfRef(translation.segmentId) !== book.bookRef) return;
const { verse, offset } = positionOf(translation.segmentId);
const places = placesByVerse.get(verse) ?? vanishedHeadingPlaces(verse, placesByVerse);
- if (!places) return [];
- const covering = places.findLast((place) => place.offset <= offset) ?? places[0];
- return [{ translation, covering }];
+ // An offset names no position in a verse its own has since been bridged into or split from.
+ const covering = places
+ ? (places.findLast((place) => place.offset <= offset) ?? places[0])
+ : coveringVerseStart(verse, placesByVerse);
+ if (covering) {
+ placed.push({ translation, covering });
+ return;
+ }
+ inOrder ??= placesInDocumentOrder(placesByVerse);
+ // A book holding no text has nothing to show an orphan beside.
+ if (inOrder.length === 0) return;
+ const verseRef = verseOfId(verse);
+ orphaned.push({ translation, after: placeBefore(verseRef, inOrder), verse: verseRef });
});
const bySegment = new Map();
placed
.toSorted((a, b) => a.covering.order - b.covering.order)
- .forEach(({ translation, covering }) => {
- const list = bySegment.get(covering.segmentId) ?? [];
- list.push(translation);
- bySegment.set(covering.segmentId, list);
+ .forEach(({ translation, covering }) => fileUnder(bySegment, covering.segmentId, translation));
+
+ const orphanedAfter = new Map();
+ const orphanedAhead: StaleFreeTranslation[] = [];
+ orphaned
+ .toSorted(
+ (a, b) => a.verse.chapterNum - b.verse.chapterNum || a.verse.verseNum - b.verse.verseNum,
+ )
+ .forEach(({ translation, after }) => {
+ if (after) fileUnder(orphanedAfter, after.segmentId, translation);
+ else orphanedAhead.push(translation);
});
- return bySegment;
+
+ return { bySegment, orphanedAfter, orphanedAhead };
}
/**
diff --git a/src/utils/verse-ref.ts b/src/utils/verse-ref.ts
index 045c56bd3..a6baf2ffd 100644
--- a/src/utils/verse-ref.ts
+++ b/src/utils/verse-ref.ts
@@ -40,7 +40,7 @@ export function firstVerseNumber(verseStartNumber: string): number | undefined {
* Whether a verbatim USJ verse label names the given verse. A range matches any verse between its
* endpoints inclusive; a label that parses to no digits matches nothing.
*/
-function verseLabelCovers(verseStartNumber: string, verseNum: number): boolean {
+export function verseLabelCovers(verseStartNumber: string, verseNum: number): boolean {
const range = parseVerseLabel(verseStartNumber);
if (!range) return false;
return verseNum >= range.first && verseNum <= range.last;
@@ -77,14 +77,14 @@ export function toSerializedVerseRef(ref: ScriptureRef): SerializedVerseRef {
return { book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse };
}
-/** The verse that shows the reader an edit made at `location`, a token ref or segment id. */
-export function editVerse(location: string): SerializedVerseRef {
- const space = location.indexOf(' ');
- const [chapter, verse] = location.slice(space + 1).split(':');
+/** 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(' ');
+ const [chapter, verse] = id.slice(space + 1).split(':');
return {
- book: location.slice(0, space),
+ book: id.slice(0, space),
chapterNum: Number(chapter),
- /* v8 ignore next -- an edit is made in a verse or a heading filed under one, never before */
+ /* v8 ignore next -- every id the text yields names a verse number */
verseNum: firstVerseNumber(verse) ?? 0,
};
}