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 = ( -

- - {formatReplacementString(localizedStrings['%interlinearizer_concordance_loading%'], { - read: index.booksRead, - total: index.bookCount, - })} -

+ ); - } else if (index.entries.length === 0) { + } else if (entries.length === 0) { body = ( @@ -241,6 +220,15 @@ export default function ConcordancePanel({ + {text.isPartial && ( +

+ {localizedStrings['%interlinearizer_concordance_partial%']} +

+ )} {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 ( +

+ + {formatReplacementString(localizedStrings['%interlinearizer_textReading_loading%'], { + read: text.booksRead, + total: text.bookCount, + })} +

+ ); +} 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, }; }