diff --git a/__mocks__/papi-frontend.ts b/__mocks__/papi-frontend.ts index 7c5c7461..c72659c7 100644 --- a/__mocks__/papi-frontend.ts +++ b/__mocks__/papi-frontend.ts @@ -12,6 +12,7 @@ const mockLogger = { const mockSendCommand = jest.fn(); const mockNotificationsSend = jest.fn(); +const mockProjectDataProvidersGet = jest.fn(); const papi = { commands: { @@ -23,6 +24,9 @@ const papi = { menuData: { dataProviderName: 'platform.menuDataServiceDataProvider', }, + projectDataProviders: { + get: mockProjectDataProvidersGet, + }, }; module.exports = { diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index e59ecf58..161f0033 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -623,6 +623,11 @@ export function RadioGroupItem({ * {@link DialogContent} and {@link DialogTitle}, mirroring how the real Radix-based component * reaches its parts from the root. */ +/** Stub spinner: a marker element standing in for the platform's indeterminate spinner. */ +export function Spinner({ className }: { className?: string }) { + return ; +} + const DialogContext = createContext<{ onOpenChange?: (open: boolean) => void; titleId?: string }>( {}, ); diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index a657cb9e..d10a677e 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -161,13 +161,52 @@ "%interlinearizer_error_delete_project_failed%": "Could not delete the interlinearizer project. Please try again.", "%interlinearizer_error_update_project_failed%": "Could not update the interlinearizer project. Please try again.", "%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.", + "%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again.", + "%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. Please try again.", + "%interlinearizer_pt9Import_name%": "Paratext 9 Interlinear", "%interlinearizer_pt9Import_description%": "Imported from this project's Paratext 9 interlinear data. Read-only; synced from the Paratext 9 files.", "%interlinearizer_error_pt9Import_failed%": "Could not import the Paratext 9 interlinear data. Please try again.", "%interlinearizer_warning_pt9Import_sourceEmpty%": "The project's Paratext 9 interlinear files are missing, so the imported data was left as it was.", "%interlinearizer_error_createEditableCopy_failed%": "Could not copy the imported project. Please try again.", - "%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again.", - "%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. Please try again." + "%interlinearizer_error_pt9Import_load_failed%": "The imported interlinear data could not be loaded. Try syncing from Paratext 9.", + + "%interlinearizer_modal_select_importPt9%": "Import from Paratext 9", + "%interlinearizer_readonly_chip%": "Read-only", + "%interlinearizer_pt9ConvertPrompt_message%": "This project has Paratext 9 interlinear data. Would you like to convert it now?", + "%interlinearizer_pt9ConvertPrompt_yes%": "Yes", + "%interlinearizer_pt9ConvertPrompt_no%": "No", + "%interlinearizer_pt9ConvertPrompt_checking%": "Checking for Paratext 9 interlinear data…", + "%interlinearizer_pt9ImportModal_title%": "Import from Paratext 9", + "%interlinearizer_pt9ImportModal_syncTitle%": "Sync from Paratext 9", + "%interlinearizer_pt9ImportModal_importing%": "Importing from Paratext 9…", + "%interlinearizer_pt9ImportModal_syncing%": "Syncing from Paratext 9…", + "%interlinearizer_pt9ImportModal_failed%": "The Paratext 9 interlinear data could not be imported.", + "%interlinearizer_pt9ImportModal_tooLarge%": "The interlinear data in this Paratext 9 project is larger than can currently be handled.", + "%interlinearizer_pt9ImportModal_languages_label%": "Languages", + "%interlinearizer_pt9ImportModal_books_label%": "Books", + "%interlinearizer_pt9ImportModal_imported_label%": "Imported", + "%interlinearizer_pt9ImportModal_importedCounts%": "{converted} of {total} clusters", + "%interlinearizer_pt9ImportModal_phraseCounts%": "{phrases} phrases", + "%interlinearizer_pt9ImportModal_notImported_label%": "Not imported", + "%interlinearizer_pt9ImportModal_notImportedCount%": "{count} clusters: {reasons}", + "%interlinearizer_pt9ImportModal_reason_verseNotFound%": "verse not found in the project", + "%interlinearizer_pt9ImportModal_reason_formMismatch%": "did not match the project's text", + "%interlinearizer_pt9ImportModal_reason_lemmaOrOther%": "unused legacy data", + "%interlinearizer_pt9ImportModal_reason_duplicateCluster%": "duplicate data", + "%interlinearizer_pt9ImportModal_reason_unparseableLexemeId%": "unreadable data", + "%interlinearizer_pt9ImportModal_missingBooks%": "Books with no text in this project: {books}", + "%interlinearizer_pt9ImportModal_open%": "Open", + "%interlinearizer_pt9ImportModal_close%": "Close", + "%interlinearizer_banner_pt9Import%": "Imported from Paratext 9 · read-only · last synced {date}", + "%interlinearizer_banner_sync%": "Sync", + "%interlinearizer_banner_copy%": "Copy to editable", + "%interlinearizer_copyModal_title%": "Copy to editable project", + "%interlinearizer_copyModal_defaultName%": "Copy of Paratext 9 Interlinear", + "%interlinearizer_copyModal_create%": "Create copy", + "%interlinearizer_copyModal_cancel%": "Cancel", + "%interlinearizer_warning_pt9Sync_failed%": "The Paratext 9 data couldn't be refreshed; showing the last imported data.", + "%interlinearizer_modal_metadata_lastSynced_label%": "Last synced" } } } diff --git a/src/__tests__/analysis-store-read-only-mock.ts b/src/__tests__/analysis-store-read-only-mock.ts new file mode 100644 index 00000000..96b7a55a --- /dev/null +++ b/src/__tests__/analysis-store-read-only-mock.ts @@ -0,0 +1,31 @@ +/** + * Shared access to the manual `AnalysisStore` mock's read-only switch, for the several test files + * that render components under `useAnalysisReadOnly`. + * + * This lives apart from `test-helpers` on purpose: that module imports the real + * `AnalysisStoreProvider`, so in a file that mocks `AnalysisStore` its `withAnalysisStore` would + * silently render the mock's provider instead. + */ + +/** The manual AnalysisStore mock's test-only controls. */ +interface AnalysisStoreReadOnlyMock { + __setMockAnalysisReadOnly: (value: boolean) => void; +} + +function isAnalysisStoreReadOnlyMock(m: unknown): m is AnalysisStoreReadOnlyMock { + return !!m && typeof m === 'object' && '__setMockAnalysisReadOnly' in m; +} + +/** + * Sets what the mocked `useAnalysisReadOnly` returns. Resolves the mock on each call rather than at + * import time, so importing this module never depends on `jest.mock` having run first. + * + * @param value Whether the mocked store reports the analysis as read-only. + */ +export function setMockAnalysisReadOnly(value: boolean): void { + const analysisStoreMock: unknown = jest.requireMock('../components/AnalysisStore'); + if (!isAnalysisStoreReadOnlyMock(analysisStoreMock)) + throw new Error('Expected the AnalysisStore manual mock with read-only controls'); + const { __setMockAnalysisReadOnly: setReadOnly } = analysisStoreMock; + setReadOnly(value); +} diff --git a/src/__tests__/components/ArcOverlay.test.tsx b/src/__tests__/components/ArcOverlay.test.tsx index d3acd4bc..8e16a5bf 100644 --- a/src/__tests__/components/ArcOverlay.test.tsx +++ b/src/__tests__/components/ArcOverlay.test.tsx @@ -5,9 +5,16 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ArcOverlay } from '../../components/ArcOverlay'; import type { ArcPath } from '../../utils/phrase-arc'; +import { setMockAnalysisReadOnly } from '../analysis-store-read-only-mock'; import { makePhraseLink } from '../test-helpers'; import { withTooltipProvider } from './test-helpers'; +jest.mock('../../components/AnalysisStore'); + +beforeEach(() => { + setMockAnalysisReadOnly(false); +}); + /** Builds a minimal `ArcPath` fixture. */ function makeArcPath(phraseId: string, splitAfterTokenRef = 'tok-a'): ArcPath { // `d` is derived from splitAfterTokenRef so distinct split points yield distinct @@ -45,7 +52,13 @@ function requiredProps(): Parameters[0] { * require. */ function renderOverlay(overrides: Partial[0]> = {}) { - return render(withTooltipProvider()); + const props = { ...requiredProps(), ...overrides }; + const result = render(withTooltipProvider()); + return { + ...result, + /** Re-renders with the same props, for a test that changed what the mocked store reports. */ + rerenderOverlay: () => result.rerender(withTooltipProvider()), + }; } describe('ArcOverlay', () => { @@ -75,6 +88,17 @@ describe('ArcOverlay', () => { expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument(); }); + it('draws the arcs but no split buttons for a read-only analysis', () => { + const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']); + setMockAnalysisReadOnly(true); + renderOverlay({ + arcPaths: [makeArcPath('p1', 'tok-a')], + phraseLinkById: new Map([['p1', phraseLink]]), + }); + expect(document.querySelectorAll('path')).toHaveLength(1); + expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument(); + }); + it('renders a split button in view mode even when the arc phrase is neither hovered nor focused', () => { const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']); renderOverlay({ @@ -224,6 +248,56 @@ describe('ArcOverlay', () => { expect(onSplitHoverChange).toHaveBeenLastCalledWith(new Set()); }); + it('clears the freed-token preview when the analysis turns read-only mid-hover', async () => { + const onSplitHoverChange = jest.fn(); + const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']); + const { rerenderOverlay } = renderOverlay({ + arcPaths: [makeArcPath('p1', 'tok-a')], + hoveredPhraseId: 'p1', + phraseLinkById: new Map([['p1', phraseLink]]), + tokenDocOrder: new Map([ + ['tok-a', 0], + ['tok-b', 1], + ]), + onSplitHoverChange, + }); + await userEvent.hover(screen.getByTestId('split-arc-btn')); + expect(onSplitHoverChange).toHaveBeenLastCalledWith(new Set(['tok-a', 'tok-b'])); + + // The button vanishes with the mouse still over it, so no mouse-leave of its own ever fires. + setMockAnalysisReadOnly(true); + rerenderOverlay(); + + expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument(); + expect(onSplitHoverChange).toHaveBeenLastCalledWith(new Set()); + }); + + it('clears the phrase highlight when the analysis turns read-only mid-reshape-hover', async () => { + const onHoverPhrase = jest.fn(); + // Four-token phrase: splitting after tok-b leaves both halves ≥ 2, the reshape preview. + const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']); + const { rerenderOverlay } = renderOverlay({ + arcPaths: [makeArcPath('p1', 'tok-b')], + hoveredPhraseId: 'p1', + phraseLinkById: new Map([['p1', phraseLink]]), + tokenDocOrder: new Map([ + ['tok-a', 0], + ['tok-b', 1], + ['tok-c', 2], + ['tok-d', 3], + ]), + onHoverPhrase, + }); + await userEvent.hover(screen.getByTestId('split-arc-btn')); + expect(onHoverPhrase).toHaveBeenLastCalledWith('p1'); + + setMockAnalysisReadOnly(true); + rerenderOverlay(); + + expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument(); + expect(onHoverPhrase).toHaveBeenLastCalledWith(undefined); + }); + it('does not call onSplitHoverChange with free refs on enter when no token would become free (both halves ≥ 2)', async () => { const onSplitHoverChange = jest.fn(); // Four-token phrase: splitting after tok-b gives before=[tok-a,tok-b] and after=[tok-c,tok-d], both ≥ 2. diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 1f63a0e0..678388eb 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -71,6 +71,7 @@ const mockUsePhraseDispatch = jest.fn, []>().m jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, + useAnalysisReadOnly: () => false, AnalysisStoreProvider({ children }: Readonly<{ children: ReactNode; analysisLanguage: string }>) { return children; }, diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 4862fbe0..178157c4 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -101,8 +101,12 @@ const mockPhraseLinkById = new Map(); /** Read once per `Interlinearizer` render, so this doubles as a render counter. */ let phraseLinkByIdMapReads = 0; +/** What the mocked `useAnalysisReadOnly` reports; reset in `beforeEach`. */ +let mockReadOnly = false; + jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, + useAnalysisReadOnly: () => mockReadOnly, /** * Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of * scope. @@ -424,6 +428,7 @@ beforeEach(() => { // The phrase-link map is a plain Map (not a jest mock), so resetMocks does not clear it. mockPhraseLinkById.clear(); capturedSegmentation = undefined; + mockReadOnly = false; // The merge control's label comes from a localized string. mockKeyAsValueLocalizedStrings(); }); @@ -1641,6 +1646,14 @@ describe('between-rows merge control', () => { expect(button).toHaveAttribute('title', 'Merge'); }); + it('renders no merge control for a read-only analysis', () => { + // Omitted rather than disabled: a read-only analysis offers no boundary editing at all. + mockReadOnly = true; + renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); + expect(screen.queryByTestId('segment-merge-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('segment-merge-indicator')).not.toBeInTheDocument(); + }); + it('renders no merge control while a phrase mode is active', () => { // A merge mid-mode could re-segment the phrase the mode UI is operating on, so the between-rows // control is omitted entirely (not merely disabled) throughout a phrase edit. diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 8b58c1a2..041c013d 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -4,7 +4,7 @@ import papi, { logger } from '@papi/frontend'; import { useData, useLocalizedStrings, useSetting } from '@papi/frontend/react'; import type { SerializedVerseRef } from '@sillsdev/scripture'; -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { Book, DraftProject, PhraseAnalysisLink, TextAnalysis } from 'interlinearizer'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; @@ -15,6 +15,7 @@ import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; +import { PT9_MANIFEST_TIMEOUT_MS } from '../../utils/pt9-manifest'; import type { PhraseMode } from '../../types/phrase-mode'; import type { ViewOptions } from '../../types/view-options'; import type { SegmentationDispatch } from '../../components/SegmentationStore'; @@ -24,6 +25,7 @@ import { makePunctToken, makeScrollGroupHook, makeSegment, + getMockedPdpGet, makeWebViewState, makeWordToken, type ScrollGroupTuple, @@ -249,6 +251,7 @@ type MockProject = { analysisLanguages: string[]; name?: string; description?: string; + pt9Import?: { fileHashes: Record; importedAt: string }; }; const mockSendCommand = jest.mocked(papi.commands.sendCommand); @@ -263,6 +266,17 @@ const STUB_ACTIVE_PROJECT: MockProject = { name: 'My Project', }; +/** A stored Paratext 9 import, as the picker would hand it to the open flow. */ +const STUB_IMPORT_PROJECT: MockProject = { + id: 'import-1', + createdAt: '2026-08-01T00:00:00Z', + updatedAt: '2026-08-01T00:00:00Z', + sourceProjectId: testProjectId, + analysisLanguages: ['en'], + name: 'Paratext 9 Interlinear', + pt9Import: { fileHashes: { 'Lexicon.xml': 'aaaa1111' }, importedAt: '2026-08-01T00:00:00Z' }, +}; + jest.mock('../../components/modals/ProjectModals', () => ({ __esModule: true, /** @@ -279,6 +293,9 @@ jest.mock('../../components/modals/ProjectModals', () => ({ activeProject, defaultAnalysisLanguage, hasUnsavedWork, + onImportPt9, + onOpenImport, + openRequest, useWebViewState, }: { modal: string; @@ -289,6 +306,9 @@ jest.mock('../../components/modals/ProjectModals', () => ({ getDraftSnapshot: () => DraftProject | undefined; loadFromProject: (project: unknown) => void; markSynced: () => void; + onImportPt9: () => void; + onOpenImport: (project: MockProject) => void; + openRequest?: { project: MockProject; requestId: number }; useWebViewState: ( key: string, def: MockProject | undefined, @@ -304,6 +324,8 @@ jest.mock('../../components/modals/ProjectModals', () => ({ data-has-unsaved-work={hasUnsavedWork} data-active-project-name={activeProject?.name} data-active-project-updated={activeProject?.updatedAt} + data-open-request-id={openRequest?.requestId} + data-open-request-name={openRequest?.project.name} > {modal === 'select' && (
@@ -327,6 +349,16 @@ jest.mock('../../components/modals/ProjectModals', () => ({ + + + @@ -281,6 +295,9 @@ type ModalsOverrides = Partial<{ newDraft: jest.Mock; markSynced: jest.Mock; modal: ModalState; + onImportPt9: jest.Mock; + onOpenImport: jest.Mock; + openRequest: { project: InterlinearProjectSummary; requestId: number }; setModal: jest.Mock; useWebViewState: ReturnType; }>; @@ -299,6 +316,9 @@ function buildProps(overrides: ModalsOverrides = {}) { newDraft: overrides.newDraft ?? jest.fn(), markSynced: overrides.markSynced ?? jest.fn(), modal: overrides.modal ?? 'none', + onImportPt9: overrides.onImportPt9 ?? jest.fn(), + onOpenImport: overrides.onOpenImport ?? jest.fn(), + openRequest: overrides.openRequest, projectId: 'source-proj', setModal: overrides.setModal ?? jest.fn(), useWebViewState: overrides.useWebViewState ?? makeWebViewState(), @@ -1273,3 +1293,80 @@ describe('ProjectModals', () => { }); }); }); + +describe('ProjectModals Paratext 9 import routing', () => { + beforeEach(() => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + jest.mocked(papi.commands.sendCommand).mockResolvedValue(undefined); + }); + + it('routes an import row to onOpenImport without touching the draft-open flow', async () => { + const onOpenImport = jest.fn(); + const imported = { + ...MOCK_PROJECT, + pt9Import: { fileHashes: {}, importedAt: '2026-08-01T00:00:00.000Z' }, + }; + render( + , + ); + + await waitFor(() => expect(onOpenImport).toHaveBeenCalledWith(imported)); + // The draft-open flow was bypassed entirely: no project fetch, no discard confirmation. + expect(jest.mocked(papi.commands.sendCommand)).not.toHaveBeenCalled(); + expect(screen.queryByTestId('discard-modal')).not.toBeInTheDocument(); + }); + + it('holds the picker inert while an import is opening', async () => { + let finishOpen: (() => void) | undefined; + const onOpenImport = jest.fn( + () => + new Promise((resolve) => { + finishOpen = resolve; + }), + ); + render(); + expect(screen.getByTestId('select-modal')).toHaveAttribute('data-is-opening', 'false'); + + await userEvent.click(screen.getByTestId('select-select-import')); + + expect(onOpenImport).toHaveBeenCalledWith(MOCK_IMPORT_PROJECT); + expect(screen.getByTestId('select-modal')).toHaveAttribute('data-is-opening', 'true'); + + await act(async () => { + finishOpen?.(); + }); + + expect(screen.getByTestId('select-modal')).toHaveAttribute('data-is-opening', 'false'); + }); + + it('opens an openRequest project through the draft-open flow', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValue(JSON.stringify({ ...MOCK_PROJECT, analysis: MOCK_DRAFT.analysis })); + const loadFromProject = jest.fn(); + render( + , + ); + + await waitFor(() => expect(loadFromProject).toHaveBeenCalled()); + }); +}); + +describe('ProjectModals metadata for an import', () => { + it('renders the metadata modal for an import project', () => { + const imported = { + ...MOCK_PROJECT, + pt9Import: { fileHashes: {}, importedAt: '2026-08-01T00:00:00.000Z' }, + }; + render(); + + expect(screen.getByTestId('metadata-modal')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/modals/Pt9ConvertPromptModal.test.tsx b/src/__tests__/components/modals/Pt9ConvertPromptModal.test.tsx new file mode 100644 index 00000000..73fdfcee --- /dev/null +++ b/src/__tests__/components/modals/Pt9ConvertPromptModal.test.tsx @@ -0,0 +1,72 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { + Pt9CheckingModal, + Pt9ConvertPromptModal, +} from '../../../components/modals/Pt9ConvertPromptModal'; + +const LOCALIZED: Record = { + '%interlinearizer_pt9ImportModal_title%': 'Import from Paratext 9', + '%interlinearizer_pt9ConvertPrompt_message%': + 'This project has Paratext 9 interlinear data. Would you like to convert it now?', + '%interlinearizer_pt9ConvertPrompt_yes%': 'Yes', + '%interlinearizer_pt9ConvertPrompt_no%': 'No', + '%interlinearizer_pt9ConvertPrompt_checking%': 'Checking for Paratext 9 interlinear data…', +}; + +describe('Pt9ConvertPromptModal', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); + }); + + it('renders the import title, the offer message, and both answers', () => { + render(); + + expect(screen.getByTestId('pt9-convert-prompt-title')).toHaveTextContent( + 'Import from Paratext 9', + ); + expect(screen.getByTestId('pt9-convert-prompt-message')).toHaveTextContent( + 'This project has Paratext 9 interlinear data. Would you like to convert it now?', + ); + expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument(); + }); + + it('answers Yes', async () => { + const onYes = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Yes' })); + + expect(onYes).toHaveBeenCalledTimes(1); + }); + + it('answers No', async () => { + const onNo = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'No' })); + + expect(onNo).toHaveBeenCalledTimes(1); + }); +}); + +describe('Pt9CheckingModal', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); + }); + + it('shows the spinner and the checking status with no dismiss affordances', () => { + render(); + + expect(screen.getByTestId('pt9-checking')).toHaveTextContent( + 'Checking for Paratext 9 interlinear data…', + ); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/modals/Pt9ImportModal.test.tsx b/src/__tests__/components/modals/Pt9ImportModal.test.tsx new file mode 100644 index 00000000..ea1b1a08 --- /dev/null +++ b/src/__tests__/components/modals/Pt9ImportModal.test.tsx @@ -0,0 +1,238 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Pt9ImportModal } from '../../../components/modals/Pt9ImportModal'; +import type { Pt9ImportReport } from '../../../converters/pt9'; + +const LOCALIZED: Record = { + '%interlinearizer_pt9ImportModal_title%': 'Import from Paratext 9', + '%interlinearizer_pt9ImportModal_syncTitle%': 'Sync from Paratext 9', + '%interlinearizer_pt9ImportModal_importing%': 'Importing…', + '%interlinearizer_pt9ImportModal_syncing%': 'Syncing…', + '%interlinearizer_pt9ImportModal_failed%': 'The import failed.', + '%interlinearizer_pt9ImportModal_tooLarge%': 'The interlinear data is too large.', + '%interlinearizer_pt9ImportModal_languages_label%': 'Languages', + '%interlinearizer_pt9ImportModal_books_label%': 'Books', + '%interlinearizer_pt9ImportModal_imported_label%': 'Imported', + '%interlinearizer_pt9ImportModal_importedCounts%': '{converted} of {total} clusters', + '%interlinearizer_pt9ImportModal_phraseCounts%': '{phrases} phrases', + '%interlinearizer_pt9ImportModal_notImported_label%': 'Not imported', + '%interlinearizer_pt9ImportModal_notImportedCount%': '{count} clusters: {reasons}', + '%interlinearizer_pt9ImportModal_reason_verseNotFound%': 'verse not found', + '%interlinearizer_pt9ImportModal_reason_formMismatch%': 'did not match the text', + '%interlinearizer_pt9ImportModal_reason_lemmaOrOther%': 'unused legacy data', + '%interlinearizer_pt9ImportModal_reason_duplicateCluster%': 'duplicate data', + '%interlinearizer_pt9ImportModal_reason_unparseableLexemeId%': 'unreadable data', + '%interlinearizer_pt9ImportModal_missingBooks%': 'Books with no text: {books}', + '%interlinearizer_pt9ImportModal_open%': 'Open', + '%interlinearizer_pt9ImportModal_close%': 'Close', +}; + +/** Builds a two-language report exercising totals, drops, phrases, and a missing book. */ +function makeReport(): Pt9ImportReport { + const emptyDrops = { + verseNotFound: 0, + formMismatch: 0, + lemmaOrOther: 0, + duplicateCluster: 0, + unparseableLexemeId: 0, + }; + return { + languages: [ + { + rawLanguage: 'en', + tag: 'en', + tagIsFallback: false, + books: [ + { + bookId: 'MAT', + bookFound: true, + versesTotal: 10, + versesHashed: 5, + versesNotFound: 0, + clustersTotal: 20, + clustersConverted: 15, + phrasesConverted: 2, + clusterDrops: { ...emptyDrops, formMismatch: 4, verseNotFound: 1, duplicateCluster: 1 }, + ambiguousAnchors: 0, + punctuationEntriesIgnored: 0, + }, + ], + }, + { + rawLanguage: 'fr', + tag: 'fr', + tagIsFallback: false, + books: [ + { + bookId: 'MRK', + bookFound: false, + versesTotal: 3, + versesHashed: 0, + versesNotFound: 3, + clustersTotal: 5, + clustersConverted: 0, + phrasesConverted: 0, + clusterDrops: { ...emptyDrops, verseNotFound: 5 }, + ambiguousAnchors: 0, + punctuationEntriesIgnored: 0, + }, + ], + }, + ], + merge: { + mergedTokenRecords: 0, + parseConflicts: 0, + approvedDemotedToCandidate: 0, + sameTagCollisions: [], + }, + senses: { + specificResolved: 0, + defaultSingleResolved: 0, + unresolvedGlossText: 0, + entryRefsResolved: 0, + entryRefsUnresolved: 0, + senseRefsResolved: 0, + senseRefsUnresolved: 0, + }, + barePayloads: { added: 0, skippedExistingIdentical: 0, droppedUnparseable: 0, droppedEmpty: 0 }, + booksMissingIdentity: 0, + booksDroppedAsDuplicates: 0, + }; +} + +describe('Pt9ImportModal', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); + }); + + it('shows the import progress line while running, with no dismiss affordances', () => { + render(); + + expect(screen.getByTestId('pt9-import-running')).toHaveTextContent('Importing…'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('shows the sync progress line and title in sync mode', () => { + render(); + + expect(screen.getByTestId('pt9-import-modal-title')).toHaveTextContent('Sync from Paratext 9'); + expect(screen.getByTestId('pt9-import-running')).toHaveTextContent('Syncing…'); + }); + + it('shows the failure line with Close on error', async () => { + const onClose = jest.fn(); + render(); + + expect(screen.getByTestId('pt9-import-error')).toHaveTextContent('The import failed.'); + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalled(); + }); + + it('shows the too-large message when the failure reason is tooLarge', () => { + render( + , + ); + + expect(screen.getByTestId('pt9-import-error')).toHaveTextContent( + 'The interlinear data is too large.', + ); + }); + + it('summarizes languages, books, and counts on the report', () => { + render( + , + ); + + const report = screen.getByTestId('pt9-import-report'); + expect(report).toHaveTextContent('Languages: en, fr'); + expect(report).toHaveTextContent('Books: MAT, MRK'); + expect(report).toHaveTextContent('15 of 25 clusters, 2 phrases'); + expect(report).toHaveTextContent('11 clusters: 6 verse not found; 4 did not match the text'); + // Only the top two reasons are named; the third stays in the JSON report. + expect(report).not.toHaveTextContent('duplicate data'); + expect(report).toHaveTextContent('Books with no text: MRK'); + }); + + it('omits the not-imported and missing-book rows when the import was clean', () => { + const report = makeReport(); + report.languages.splice(1, 1); + report.languages[0].books[0].clusterDrops = { + verseNotFound: 0, + formMismatch: 0, + lemmaOrOther: 0, + duplicateCluster: 0, + unparseableLexemeId: 0, + }; + report.languages[0].books[0].phrasesConverted = 0; + render( + , + ); + + const rendered = screen.getByTestId('pt9-import-report'); + expect(rendered).not.toHaveTextContent('Not imported'); + expect(rendered).not.toHaveTextContent('Books with no text'); + expect(rendered).not.toHaveTextContent('phrases'); + }); + + it('offers Open as well as Close on an import report and fires onOpen', async () => { + const onOpen = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(onOpen).toHaveBeenCalled(); + }); + + it('offers a single Open on an offer-run report and fires it', async () => { + const onOpen = jest.fn(); + render( + , + ); + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(onOpen).toHaveBeenCalled(); + }); + + it('offers only Close on a sync report', () => { + render( + , + ); + + expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx b/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx index 9be5bd4f..4f1f1a02 100644 --- a/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx +++ b/src/__tests__/components/modals/SelectInterlinearProjectModal.test.tsx @@ -7,7 +7,7 @@ import papi, { logger } from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; import { useState } from 'react'; import { SelectInterlinearProjectModal } from '../../../components/modals/SelectInterlinearProjectModal'; -import { makeProjectSummary } from '../../test-helpers'; +import { getMockedPdpGet, makeProjectSummary } from '../../test-helpers'; const mockSendCommand = jest.mocked(papi.commands.sendCommand); @@ -20,6 +20,8 @@ const LOCALIZED: Record = { '%interlinearizer_modal_select_info_button_label%': 'Project info', '%interlinearizer_modal_select_active_badge%': 'Active', '%interlinearizer_modal_select_modified_prefix%': 'Modified', + '%interlinearizer_modal_select_importPt9%': 'Import from Paratext 9', + '%interlinearizer_readonly_chip%': 'Read-only', }; const STUB_PROJECT = makeProjectSummary(); @@ -36,6 +38,7 @@ const defaultProps = { sourceProjectId: 'src-proj', onSelect: jest.fn(), onCreateNew: jest.fn(), + onImportPt9: jest.fn(), onClose: jest.fn(), onViewInfo: jest.fn(), }; @@ -386,3 +389,53 @@ describe('SelectInterlinearProjectModal', () => { expect(screen.getByText('French glosses')).toBeInTheDocument(); }); }); + +describe('SelectInterlinearProjectModal Paratext 9 import entry', () => { + const mockPdpGet = getMockedPdpGet(papi); + + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); + jest.mocked(papi.notifications.send).mockResolvedValue('mock-notification-id'); + }); + + it('offers the import button when the probe finds files and fires onImportPt9', async () => { + mockSendCommand.mockResolvedValue('[]'); + mockPdpGet.mockResolvedValue({ + getPt9InterlinearManifest: async () => ({ 'Lexicon.xml': 'aaaa1111' }), + }); + const onImportPt9 = jest.fn(); + render(); + + const button = await screen.findByTestId('import-pt9-button'); + await userEvent.click(button); + + expect(onImportPt9).toHaveBeenCalledTimes(1); + }); + + it('never offers the import button when the probe finds nothing', async () => { + mockSendCommand.mockResolvedValue('[]'); + mockPdpGet.mockResolvedValue({ getPt9InterlinearManifest: async () => ({}) }); + render(); + + await waitFor(() => expect(mockPdpGet).toHaveBeenCalled()); + expect(screen.queryByTestId('import-pt9-button')).not.toBeInTheDocument(); + }); + + it('chips the import row read-only and skips the probe once an import exists', async () => { + const imported = { + ...makeProjectSummary(), + id: 'import-id', + name: 'Paratext 9 Interlinear', + pt9Import: { + fileHashes: { 'Lexicon.xml': 'aaaa1111' }, + importedAt: '2026-08-01T00:00:00.000Z', + }, + }; + mockSendCommand.mockResolvedValue(JSON.stringify([imported])); + render(); + + expect(await screen.findByTestId('readonly-chip')).toHaveTextContent('Read-only'); + expect(screen.queryByTestId('import-pt9-button')).not.toBeInTheDocument(); + expect(mockPdpGet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/hooks/usePt9ImportAvailability.test.ts b/src/__tests__/hooks/usePt9ImportAvailability.test.ts new file mode 100644 index 00000000..461599e0 --- /dev/null +++ b/src/__tests__/hooks/usePt9ImportAvailability.test.ts @@ -0,0 +1,127 @@ +/// + +import { renderHook, waitFor } from '@testing-library/react'; +import papi from '@papi/frontend'; +import usePt9ImportAvailability, { usePt9ImportProbe } from '../../hooks/usePt9ImportAvailability'; +import { getMockedPdpGet, makeStubProject } from '../test-helpers'; + +const mockPdpGet = getMockedPdpGet(papi); + +/** Serves a fake Pt9Interlinear provider whose manifest call resolves to `manifest`. */ +function mockManifest(manifest: Record): void { + mockPdpGet.mockResolvedValue({ getPt9InterlinearManifest: async () => manifest }); +} + +describe('usePt9ImportAvailability', () => { + it('reports true when the probe finds files and no import exists', async () => { + mockManifest({ 'Lexicon.xml': 'aaaa1111' }); + + const { result } = renderHook(() => usePt9ImportAvailability('src-project', [], false)); + + await waitFor(() => expect(result.current).toBe(true)); + expect(mockPdpGet).toHaveBeenCalledWith('platformScripture.Pt9Interlinear', 'src-project'); + }); + + it('reports false when the manifest is empty', async () => { + mockManifest({}); + + const { result } = renderHook(() => usePt9ImportAvailability('src-project', [], false)); + + await waitFor(() => expect(mockPdpGet).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + it('never probes when an import already exists', () => { + const imported = { + ...makeStubProject('import-id'), + pt9Import: { fileHashes: {}, importedAt: '2026-08-01T00:00:00.000Z' }, + }; + + const { result } = renderHook(() => usePt9ImportAvailability('src-project', [imported], false)); + + expect(result.current).toBe(false); + expect(mockPdpGet).not.toHaveBeenCalled(); + }); + + it('never probes while the project list is still loading', () => { + renderHook(() => usePt9ImportAvailability('src-project', [], true)); + + expect(mockPdpGet).not.toHaveBeenCalled(); + }); + + it('reports false when the probe fails', async () => { + mockPdpGet.mockRejectedValue(new Error('no such projectInterface')); + + const { result } = renderHook(() => usePt9ImportAvailability('src-project', [], false)); + + await waitFor(() => expect(mockPdpGet).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + it('returns to false when a re-probe fails after an earlier success', async () => { + mockManifest({ 'Lexicon.xml': 'aaaa1111' }); + const { result, rerender } = renderHook( + ({ loading }) => usePt9ImportAvailability('src-project', [], loading), + { initialProps: { loading: false } }, + ); + await waitFor(() => expect(result.current).toBe(true)); + + rerender({ loading: true }); + mockPdpGet.mockRejectedValue(new Error('probe failed')); + rerender({ loading: false }); + + await waitFor(() => expect(mockPdpGet).toHaveBeenCalledTimes(2)); + expect(result.current).toBe(false); + }); + + it('ignores a probe that lands after unmount', async () => { + let resolveManifest: (m: Record) => void = () => {}; + mockPdpGet.mockResolvedValue({ + getPt9InterlinearManifest: () => + new Promise((resolve) => { + resolveManifest = resolve; + }), + }); + + const { unmount } = renderHook(() => usePt9ImportAvailability('src-project', [], false)); + await waitFor(() => expect(mockPdpGet).toHaveBeenCalled()); + unmount(); + resolveManifest({ 'Lexicon.xml': 'aaaa1111' }); + // The ignore flag makes the late result a no-op; reaching here without React act warnings (an + // update after unmount would emit one) is the observable behavior. + }); +}); + +describe('usePt9ImportProbe', () => { + it('moves from pending to available when the manifest lists files', async () => { + mockManifest({ 'Lexicon.xml': 'aaaa1111' }); + + const { result } = renderHook(() => usePt9ImportProbe('src-project', true)); + + expect(result.current).toBe('pending'); + await waitFor(() => expect(result.current).toBe('available')); + }); + + it('reports unavailable for an empty manifest', async () => { + mockManifest({}); + + const { result } = renderHook(() => usePt9ImportProbe('src-project', true)); + + await waitFor(() => expect(result.current).toBe('unavailable')); + }); + + it('reports unavailable when the probe fails', async () => { + mockPdpGet.mockRejectedValue(new Error('no such projectInterface')); + + const { result } = renderHook(() => usePt9ImportProbe('src-project', true)); + + await waitFor(() => expect(result.current).toBe('unavailable')); + }); + + it('stays pending and never probes while disabled', () => { + const { result } = renderHook(() => usePt9ImportProbe('src-project', false)); + + expect(result.current).toBe('pending'); + expect(mockPdpGet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 70b47b67..4904c933 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -359,6 +359,22 @@ describe('main', () => { expect(result).toMatchObject({ projectId: 'my-project' }); }); + it('carries the convert offer flag into the WebView state on an explicit open', async () => { + const context = createTestActivationContext(); + await activate(context); + + const provider = getRegisteredProvider(); + const savedWebView: SavedWebViewDefinition = { + id: 'test-webview-id', + webViewType: mainWebViewType, + }; + + const options: InterlinearizerOpenOptions = { offerPt9Import: true }; + const result = await provider.getWebView(savedWebView, options); + + expect(result?.state).toEqual({ offerPt9Import: true }); + }); + it('falls back to savedWebView.projectId when options has no projectId', async () => { const context = createTestActivationContext(); await activate(context); @@ -428,6 +444,28 @@ describe('main', () => { ); }); + it('passes the convert offer computed for a fresh tab into the open options', async () => { + __mockGetOpenWebViewDefinition.mockResolvedValue({ + id: 'some-webview', + webViewType: 'someExtension.view', + projectId: 'project-from-webview', + }); + jest.mocked(pt9ImportService.hasNoInterlinearizerState).mockResolvedValue(true); + const openForWebView = await getOpenForWebViewHandler(); + + await openForWebView('some-webview'); + + expect(jest.mocked(pt9ImportService.hasNoInterlinearizerState)).toHaveBeenCalledWith( + expect.anything(), + 'project-from-webview', + ); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ offerPt9Import: true }), + ); + }); + it('shows a project picker when the WebView has no projectId', async () => { __mockGetOpenWebViewDefinition.mockResolvedValue({ id: 'some-webview', @@ -590,6 +628,24 @@ describe('main', () => { ); }); + it('does not compute the convert offer when reusing an existing tab', async () => { + __mockSelectProject.mockResolvedValue('my-project'); + const context = createTestActivationContext(); + await activate(context); + getOpenWebViewCallback()({ + webView: { id: 'tab-from-event', webViewType: mainWebViewType, projectId: 'my-project' }, + }); + + await findRegisteredHandler('interlinearizer.openForWebView')?.(); + + expect(jest.mocked(pt9ImportService.hasNoInterlinearizerState)).not.toHaveBeenCalled(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.not.objectContaining({ offerPt9Import: expect.anything() }), + ); + }); + it('ignores webViews with a non-matching webViewType', async () => { __mockSelectProject.mockResolvedValue('my-project'); const context = createTestActivationContext(); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index fb84a5d6..4af5c561 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -9,6 +9,7 @@ import { getProject, getProjectsForSource, getPt9ImportForSource, + hasDraft, listProjects, resetQueuesForTesting, saveDraft, @@ -1028,6 +1029,27 @@ describe('projectStorage', () => { }); }); + describe('hasDraft', () => { + it('answers true when a draft is stored, without parsing it', async () => { + __mockReadUserData.mockResolvedValue('not even json'); + + await expect(hasDraft(token, 'src-proj')).resolves.toBe(true); + expect(__mockReadUserData).toHaveBeenCalledWith(token, 'draft:src-proj'); + }); + + it('answers false when no draft has ever been written', async () => { + __mockReadUserData.mockRejectedValue(enoentError()); + + await expect(hasDraft(token, 'src-proj')).resolves.toBe(false); + }); + + it('rethrows a read failure that is not file-not-found', async () => { + __mockReadUserData.mockRejectedValue(new Error('storage unavailable')); + + await expect(hasDraft(token, 'src-proj')).rejects.toThrow('storage unavailable'); + }); + }); + describe('getDraft', () => { it('returns the parsed stored draft read from the draft key', async () => { const stored = { ...emptyDraft('src-proj'), analysisLanguages: ['fr'], dirty: true }; diff --git a/src/__tests__/services/pt9ImportService.test.ts b/src/__tests__/services/pt9ImportService.test.ts index f2af38f6..aa46561c 100644 --- a/src/__tests__/services/pt9ImportService.test.ts +++ b/src/__tests__/services/pt9ImportService.test.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import papiBackendMock from '@papi/backend'; import type { Pt9InterlinearProjectData } from 'platform-scripture'; -import { importPt9Project } from '../../services/pt9ImportService'; +import { hasNoInterlinearizerState, importPt9Project } from '../../services/pt9ImportService'; import { resetQueuesForTesting } from '../../services/projectStorage'; import { createTestActivationContext, enoentError, makeStubProject } from '../test-helpers'; @@ -280,3 +280,48 @@ describe('importPt9Project', () => { expect(__mockWriteUserData).not.toHaveBeenCalled(); }); }); + +describe('hasNoInterlinearizerState', () => { + beforeEach(() => { + resetQueuesForTesting(); + }); + + /** Seeds storage reads by key; unlisted keys read as never written. */ + function seedStorage(entries: Record): void { + __mockReadUserData.mockImplementation(async (_token: unknown, key: unknown) => { + if (typeof key === 'string' && Object.hasOwn(entries, key)) return entries[key]; + throw enoentError(); + }); + } + + it('answers true when the source has no draft and no projects', async () => { + seedStorage({}); + + await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(true); + }); + + it('answers false when a draft is already stored', async () => { + seedStorage({ 'draft:src-project': 'anything' }); + + await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(false); + }); + + it('answers false when a project already exists for the source', async () => { + seedStorage({ + projectIds: JSON.stringify(['p1']), + 'project:p1': JSON.stringify(makeStubProject('p1')), + }); + + await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(false); + }); + + it('answers false when the state check fails, and only warns', async () => { + __mockReadUserData.mockRejectedValue(new Error('storage unavailable')); + + await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(false); + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: Paratext 9 convert-offer state check failed; not offering', + expect.objectContaining({ message: 'storage unavailable' }), + ); + }); +}); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index 58dd6886..5dfc7c4d 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -365,3 +365,25 @@ export function pretendMacOs(): void { export function enoentError(): Error { return Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); } + +/** + * Returns the papi-frontend mock's `projectDataProviders.get` as the raw jest fn, so tests can + * resolve partial provider objects (only the methods under test) without type assertions against + * the full provider interface. + * + * @throws When the module is not the jest papi-frontend mock. + */ +export function getMockedPdpGet(papiModule: unknown): jest.Mock { + if ( + !!papiModule && + typeof papiModule === 'object' && + 'projectDataProviders' in papiModule && + !!papiModule.projectDataProviders && + typeof papiModule.projectDataProviders === 'object' && + 'get' in papiModule.projectDataProviders && + jest.isMockFunction(papiModule.projectDataProviders.get) + ) { + return papiModule.projectDataProviders.get; + } + throw new Error('Expected the mocked @papi/frontend projectDataProviders.get'); +} diff --git a/src/__tests__/types/type-guards.test.ts b/src/__tests__/types/type-guards.test.ts index f7a27d6e..7a697c67 100644 --- a/src/__tests__/types/type-guards.test.ts +++ b/src/__tests__/types/type-guards.test.ts @@ -1,7 +1,5 @@ import { emptyAnalysis } from '../../types/empty-factories'; import { isPt9ImportProvenance, isTextAnalysis } from '../../types/type-guards'; -import { toProjectSummary } from '../../types/interlinear-project-summary'; -import { makeStubProject } from '../test-helpers'; /** Stands in for any id space: the boundary never checks which authority a ref names. */ const AUTHORITY = 'x-test'; @@ -138,14 +136,3 @@ describe('isPt9ImportProvenance', () => { expect(isPt9ImportProvenance('pt9')).toBe(false); }); }); - -describe('toProjectSummary', () => { - it('carries pt9Import through and drops fields outside the summary', () => { - const summary = toProjectSummary({ ...makeStubProject('import-id'), pt9Import: PROVENANCE }); - expect(summary.pt9Import).toStrictEqual(PROVENANCE); - }); - - it('omits pt9Import when the input has none', () => { - expect(toProjectSummary(makeStubProject('plain-id'))).not.toHaveProperty('pt9Import'); - }); -}); diff --git a/src/__tests__/utils/pt9-import-error.test.ts b/src/__tests__/utils/pt9-import-error.test.ts new file mode 100644 index 00000000..deebc0c9 --- /dev/null +++ b/src/__tests__/utils/pt9-import-error.test.ts @@ -0,0 +1,31 @@ +import type { PlatformError } from 'platform-bible-utils'; +import { isPt9TooLargeError } from '../../utils/pt9-import-error'; + +const MARKER_MESSAGE = 'PT9 interlinear data is too large: the files exceed the cap'; + +function platformError(message: string, code?: PlatformError['code']): PlatformError { + return { platformErrorVersion: 1, message, ...(code !== undefined && { code }) }; +} + +describe('isPt9TooLargeError', () => { + it('recognizes the RESOURCE_EXHAUSTED platform error code without relying on the message', () => { + expect(isPt9TooLargeError(platformError('some message', 'RESOURCE_EXHAUSTED'))).toBe(true); + }); + + it('falls back to the message marker on a platform error without the code', () => { + expect(isPt9TooLargeError(platformError(MARKER_MESSAGE))).toBe(true); + }); + + it('rejects a platform error with neither the code nor the marker', () => { + expect(isPt9TooLargeError(platformError('something else', 'NOT_FOUND'))).toBe(false); + }); + + it('recognizes the marker on a plain Error', () => { + expect(isPt9TooLargeError(new Error(MARKER_MESSAGE))).toBe(true); + }); + + it('rejects a plain Error without the marker and values that are no error at all', () => { + expect(isPt9TooLargeError(new Error('boom'))).toBe(false); + expect(isPt9TooLargeError(MARKER_MESSAGE)).toBe(false); + }); +}); diff --git a/src/__tests__/utils/pt9-manifest.test.ts b/src/__tests__/utils/pt9-manifest.test.ts new file mode 100644 index 00000000..7c7851d6 --- /dev/null +++ b/src/__tests__/utils/pt9-manifest.test.ts @@ -0,0 +1,51 @@ +/// + +import papi from '@papi/frontend'; +import { PT9_MANIFEST_TIMEOUT_MS, readPt9Manifest } from '../../utils/pt9-manifest'; +import { getMockedPdpGet } from '../test-helpers'; + +const mockPdpGet = getMockedPdpGet(papi); + +describe('readPt9Manifest', () => { + it('resolves the manifest the source project serves', async () => { + mockPdpGet.mockResolvedValue({ + getPt9InterlinearManifest: async () => ({ 'Lexicon.xml': 'aaaa1111' }), + }); + + await expect(readPt9Manifest('src-project')).resolves.toEqual({ 'Lexicon.xml': 'aaaa1111' }); + expect(mockPdpGet).toHaveBeenCalledWith('platformScripture.Pt9Interlinear', 'src-project'); + }); + + it('rejects when the source serves no Pt9Interlinear projectInterface', async () => { + mockPdpGet.mockRejectedValue(new Error('no such projectInterface')); + + await expect(readPt9Manifest('src-project')).rejects.toThrow('no such projectInterface'); + }); + + it('rejects when the read goes unanswered, so a caller behind blocking UI can finish', async () => { + jest.useFakeTimers(); + // A provider that accepts the call and never responds - the hang the timeout exists for. + mockPdpGet.mockResolvedValue({ getPt9InterlinearManifest: () => new Promise(() => {}) }); + + const read = readPt9Manifest('src-project'); + const settled = jest.fn(); + read.catch(settled); + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(PT9_MANIFEST_TIMEOUT_MS); + + await expect(read).rejects.toThrow('went unanswered'); + jest.useRealTimers(); + }); + + it('leaves no timer pending once the read answers', async () => { + jest.useFakeTimers(); + mockPdpGet.mockResolvedValue({ getPt9InterlinearManifest: async () => ({}) }); + + await expect(readPt9Manifest('src-project')).resolves.toEqual({}); + + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + }); +}); diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index eda99e79..a304618d 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -70,6 +70,12 @@ type CallbackRefs = { * input without threading a prop through the segment/phrase tree. */ showSuggestions: boolean; + /** + * Whether the store holds a read-only analysis ({@link useAnalysisReadOnly}). Carried on the + * provider so every editing affordance in the tree can render its static form without threading a + * prop. + */ + readOnly: boolean; }; /** Internal context that carries callback refs alongside the Redux {@link ReduxProvider}. */ @@ -113,6 +119,12 @@ type AnalysisStoreProviderProps = Readonly<{ * opts in via a demo toggle. */ showSuggestions?: boolean; + /** + * When `true`, the subtree renders the analysis as read-only: glosses and free translations show + * as static text, and linking, splitting, phrase, suggestion, and boundary controls do not + * render. Used for a Paratext 9 import, whose analysis only sync may change. + */ + readOnly?: boolean; }>; /** @@ -128,6 +140,7 @@ export function AnalysisStoreProvider({ onGlossChange, onPendingEditsChange, showSuggestions = false, + readOnly = false, }: AnalysisStoreProviderProps) { // Lazy initialization: useRef(createStore()) would create and discard a store on every render const storeRef = useRef | undefined>(undefined); @@ -182,8 +195,9 @@ export function AnalysisStoreProvider({ reportEditing, requestGlossEdit, showSuggestions, + readOnly, }), - [reportEditing, requestGlossEdit, showSuggestions], + [reportEditing, requestGlossEdit, showSuggestions, readOnly], ); return ( @@ -336,6 +350,16 @@ export function useShowSuggestions(): boolean { return useRequiredCallbacks('useShowSuggestions').showSuggestions; } +/** + * Returns whether the analysis in the nearest {@link AnalysisStoreProvider} is read-only, as set by + * its `readOnly` prop. Editing affordances render their static form when this is `true`. + * + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useAnalysisReadOnly(): boolean { + return useRequiredCallbacks('useAnalysisReadOnly').readOnly; +} + /** * Returns the morpheme breakdown from the approved `TokenAnalysis` for `tokenRef`, re-rendering * only when the morpheme array changes. Returns a stable empty array when no approved analysis diff --git a/src/components/ArcOverlay.tsx b/src/components/ArcOverlay.tsx index bb0efc3a..6793f346 100644 --- a/src/components/ArcOverlay.tsx +++ b/src/components/ArcOverlay.tsx @@ -1,10 +1,11 @@ import type { PhraseAnalysisLink } from 'interlinearizer'; import { Link2Off } from 'lucide-react'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'platform-bible-react'; -import { memo, useState, useCallback } from 'react'; +import { memo, useState, useCallback, useEffect } from 'react'; import type { PhraseMode } from '../types/phrase-mode'; import { resolvedOrEmpty, tooltipContentOrUndefined } from '../utils/localized-strings'; import { computeSplitFreeRefs, getArcStrokeProps, type ArcPath } from '../utils/phrase-arc'; +import { useAnalysisReadOnly } from './AnalysisStore'; /** * Identifies one specific arc boundary by phrase id and the token immediately before the split, @@ -109,9 +110,9 @@ type ArcOverlayProps = Readonly<{ }>; /** - * Renders the phrase-arc SVG layer and (in view mode) the split-button overlay on top of a token - * row. Intended to sit as a sibling of the row inside the `arc-container` element that owns the - * coordinate space the arc paths were measured in. + * Renders the phrase-arc SVG layer and (in view mode, for an editable analysis) the split-button + * overlay on top of a token row. Intended to sit as a sibling of the row inside the `arc-container` + * element that owns the coordinate space the arc paths were measured in. * * @returns The SVG + split-button overlay, or `undefined` when there are no arcs to draw. */ @@ -131,6 +132,9 @@ export function ArcOverlay({ }: ArcOverlayProps) { const [splitHoveredArc, setSplitHoveredArc] = useState(); + // The arcs themselves are the read-only view's phrase rendering; only splitting them is an edit. + const readOnly = useAnalysisReadOnly(); + const splitTooltip = tooltipContentOrUndefined(resolvedOrEmpty(splitHereLabel)); /** @@ -170,6 +174,15 @@ export function ArcOverlay({ onHoverPhrase(undefined); }, [onHoverPhrase]); + // A split button that goes away because the analysis turned read-only never fires its own + // mouse-leave, so whatever preview the hover put up - freed tokens dimmed, or the whole phrase + // highlighted - would stay on a view that no longer offers the split. Take it down here. + useEffect(() => { + if (!readOnly || splitHoveredArc === undefined) return; + if (splitHoveredArc.kind === 'free') handleSplitHoverLeave(); + else handleReshapeHoverLeave(); + }, [readOnly, splitHoveredArc, handleSplitHoverLeave, handleReshapeHoverLeave]); + if (arcPaths.length === 0) return undefined; /** @@ -289,6 +302,7 @@ export function ArcOverlay({ )} {phraseMode.kind === 'view' && + !readOnly && sortedArcPaths // When simplifyPhrases is on, only the focused phrase keeps its split button; every // other phrase's button is hidden while its arc stays drawn. diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 807694bc..60a6acdf 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -6,15 +6,17 @@ import type { import papi, { logger } from '@papi/frontend'; import { useData, useLocalizedStrings, useSetting } from '@papi/frontend/react'; import { + Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, TabToolbar, } from 'platform-bible-react'; import type { SelectMenuItemHandler } from 'platform-bible-react'; -import { isPlatformError } from 'platform-bible-utils'; +import { formatReplacementString, isPlatformError } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentProps, ReactNode, RefObject } from 'react'; +import type { TextAnalysis } from 'interlinearizer'; import { resegmentBook } from 'parsers/papi/resegmentBook'; import useDraftProject from '../hooks/useDraftProject'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; @@ -25,8 +27,11 @@ import { moveBoundary, splitSegmentBefore, } from '../utils/segmentation'; -import { isWordToken } from '../types/type-guards'; -import type { SegmentationDispatch } from './SegmentationStore'; +import { isInterlinearProjectSummary, isTextAnalysis, isWordToken } from '../types/type-guards'; +import { isPt9ImportReport } from '../converters/pt9'; +import { toProjectSummary } from '../types/interlinear-project-summary'; +import useSubmitGuard from '../hooks/useSubmitGuard'; +import { NO_OP_SEGMENTATION_DISPATCH, type SegmentationDispatch } from './SegmentationStore'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import Interlinearizer from './Interlinearizer'; import { AnalysisStoreProvider } from './AnalysisStore'; @@ -34,6 +39,10 @@ import AnalysisCatalogPanel from './AnalysisCatalogPanel'; import ViewOptionsDropdown from './controls/ViewOptionsDropdown'; import type { PhraseMode } from '../types/phrase-mode'; import ProjectModals, { type ModalState } from './modals/ProjectModals'; +import { CopyToEditableModal } from './modals/CopyToEditableModal'; +import { Pt9ImportModal, type Pt9ImportModalPhase } from './modals/Pt9ImportModal'; +import { Pt9CheckingModal, Pt9ConvertPromptModal } from './modals/Pt9ConvertPromptModal'; +import { usePt9ImportProbe } from '../hooks/usePt9ImportAvailability'; import { WipeModal, type WipeScope } from './modals/WipeModal'; import ScriptureNavControls from './controls/ScriptureNavControls'; import { InterlinearNavProvider, useInterlinearNav, type FadePhase } from './InterlinearNavContext'; @@ -41,6 +50,8 @@ import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; import { firstVerseNumber, segmentContainsVerse } from '../utils/verse-ref'; import { resolvedOrEmpty } from '../utils/localized-strings'; import usePanelResizeKeys from '../hooks/usePanelResizeKeys'; +import { isPt9TooLargeError } from '../utils/pt9-import-error'; +import { readPt9Manifest } from '../utils/pt9-manifest'; /** Host-injected callback to update this WebView's definition (used to toggle the tab title). */ type UpdateWebViewDefinition = WebViewProps['updateWebViewDefinition']; @@ -144,10 +155,32 @@ const DEFAULT_CATALOG_LAYOUT: PanelLayout = { [VIEW_PANEL_ID]: 75, [CATALOG_PANE const STRING_KEYS = [ '%interlinearizer_error_load_book_heading%', '%interlinearizer_error_process_book_heading%', + '%interlinearizer_error_pt9Import_load_failed%', '%interlinearizer_loading%', '%interlinearizer_analysisCatalog_resize%', + '%interlinearizer_banner_pt9Import%', + '%interlinearizer_banner_sync%', + '%interlinearizer_banner_copy%', ] as const satisfies `%${string}%`[]; +/** + * How long the first-open data probe may stay unanswered before the checking dialog shows. A fast + * answer - which every project without Paratext 9 data gives - never shows one. + */ +const PT9_CHECKING_DELAY_MS = 400; + +/** The phrase mode a read-only view is always in, shared so its identity stays stable. */ +const VIEW_PHRASE_MODE: PhraseMode = { kind: 'view' }; + +/** The provenance an import project carries; the open-import path requires it present. */ +type Pt9ImportProvenance = NonNullable; + +/** Whether two path-to-hash maps are identical: the same keys, with the same hash under each. */ +function fileHashesEqual(a: Record, b: Record): boolean { + const aKeys = Object.keys(a); + return aKeys.length === Object.keys(b).length && aKeys.every((key) => a[key] === b[key]); +} + /** * Root component for the Interlinearizer WebView. Mounts the {@link InterlinearNavProvider} so the * loader and the whole {@link Interlinearizer} subtree read and write navigation through one source @@ -220,6 +253,32 @@ function InterlinearizerLoaderInner({ undefined, ); + /** + * First-open flag written by the open command when this source has convertible Paratext 9 + * interlinear data and no interlinearizer state stored yet. Cleared when the user answers the + * offer either way, so a tab restore never re-asks a question that was already answered. + */ + const [offerPt9Import, setOfferPt9Import] = useWebViewState('offerPt9Import', false); + + // What the convertible-data probe knows; the offer waits for `available`. The flag alone only + // says the source has no interlinearizer state yet, which is true of every brand-new project. + const offerProbe = usePt9ImportProbe(projectId, offerPt9Import); + + /** + * Whether the transient checking dialog shows: only when the probe is still unanswered + * {@link PT9_CHECKING_DELAY_MS} after it started, so the fast answer every ordinary project gets + * never flashes a dialog. + */ + const [showPt9Checking, setShowPt9Checking] = useState(false); + useEffect(() => { + if (!offerPt9Import || offerProbe !== 'pending') { + setShowPt9Checking(false); + return undefined; + } + const timer = setTimeout(() => setShowPt9Checking(true), PT9_CHECKING_DELAY_MS); + return () => clearTimeout(timer); + }, [offerPt9Import, offerProbe]); + // The always-present draft is the runtime source of truth for the analysis being edited. Edits // auto-save here (not to the active project); Save / Save As copy the draft into a project. const { @@ -244,6 +303,60 @@ function InterlinearizerLoaderInner({ */ const analysisLanguage = draft?.analysisLanguages[0] ?? platformLanguage; + /** + * Whether the active project is a Paratext 9 import, which renders read-only: the view is fed + * from the stored analysis rather than the draft, and every editing affordance stays away. + */ + const isImportView = activeProject?.pt9Import !== undefined; + + /** + * Which version of the import the view is on: its id and the modification time a sync bumps. + * `undefined` while the draft is the view. + */ + const importTag = + isImportView && activeProject ? `${activeProject.id}:${activeProject.updatedAt}` : undefined; + + /** + * The import analysis last fetched, under the version tag it was fetched for; no `analysis` when + * that fetch found none to show. An analysis the sync has already replaced is therefore never one + * the view can paint. + */ + const [importLoad, setImportLoad] = useState<{ tag: string; analysis?: TextAnalysis }>(); + useEffect(() => { + if (importTag === undefined || !activeProject) return undefined; + const { id } = activeProject; + let ignore = false; + (async () => { + try { + const json = await papi.commands.sendCommand('interlinearizer.getProject', id); + const parsed: unknown = json ? JSON.parse(json) : undefined; + const analysis = + parsed && typeof parsed === 'object' && 'analysis' in parsed + ? parsed.analysis + : undefined; + if (ignore) return; + // Either outcome is reported by the panel's own line rather than a toast: it stays on + // screen next to the empty view, and there is only one message to reconcile. + setImportLoad(isTextAnalysis(analysis) ? { tag: importTag, analysis } : { tag: importTag }); + } catch (e) { + logger.error('Interlinearizer: failed to load the imported analysis', e); + if (!ignore) setImportLoad({ tag: importTag }); + } + })(); + return () => { + ignore = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- the tag names the version to fetch; the project object also changes for edits that leave the analysis alone + }, [importTag]); + + /** The fetch's outcome for the version on screen; `undefined` until that version has one. */ + const importLoaded = importLoad?.tag === importTag ? importLoad : undefined; + + const importAnalysis = importLoaded?.analysis; + + /** Whether the version on screen is one whose analysis could not be read. */ + const importLoadFailed = importLoaded !== undefined && importLoaded.analysis === undefined; + // Whether any gloss input currently holds uncommitted text. Gloss writes are deferred to blur, so // the persisted `dirty` flag does not flip until then; tracking in-progress edits here lets the // unsaved indicator light up the moment the user starts typing. @@ -356,9 +469,13 @@ function InterlinearizerLoaderInner({ * draft load. */ const book = useMemo( - () => (verseBook ? resegmentBook(verseBook, draft?.segmentation) : undefined), + // An import has no custom boundaries, and the draft's must not bleed into its view. + () => + verseBook + ? resegmentBook(verseBook, isImportView ? undefined : draft?.segmentation) + : undefined, // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value - [verseBook, segmentationVersion, draftVersion, isDraftLoading], + [verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView], ); /** @@ -385,12 +502,17 @@ function InterlinearizerLoaderInner({ }, [verseBook, segmentationVersion, draftVersion, isDraftLoading]); /** - * Boundary-editing operations exposed through the segmentation context. Each reads the draft's - * latest boundary delta synchronously (so rapid edits compose correctly), applies the relevant - * pure transform against the original verse book, and auto-saves the normalized result — clearing - * the field back to `undefined` when the edit restores the default verse segmentation. + * Boundary-editing operations exposed through the segmentation context, inert while an import is + * the view. Each reads the draft's latest boundary delta synchronously (so rapid edits compose + * correctly), applies the relevant pure transform against the original verse book, and auto-saves + * the normalized result — clearing the field back to `undefined` when the edit restores the + * default verse segmentation. */ const segmentationDispatch = useMemo(() => { + // An import is read-only and its view is not backed by the draft, so a boundary edit reached + // from it has nowhere legitimate to land: the controls are absent there, and this keeps any + // that slips through from rewriting the draft the import is preserving. + if (isImportView) return NO_OP_SEGMENTATION_DISPATCH; /** * Auto-saves the result of a boundary transform, clearing the segmentation field back to * `undefined` when the edit restores the default verse segmentation. @@ -415,7 +537,7 @@ function InterlinearizerLoaderInner({ apply(moveBoundary(verseBook, getDraftSnapshot()?.segmentation, fromRef, toRef)); }, }; - }, [verseBook, getDraftSnapshot, autosaveSegmentation]); + }, [autosaveSegmentation, getDraftSnapshot, isImportView, verseBook]); // The active reference handed to the interlinearizer. The host emits `verseNum: 0` both for a // chapter's verse-0 superscription (which has its own segment) and for a plain whole-chapter @@ -487,16 +609,254 @@ function InterlinearizerLoaderInner({ const [modal, setModal] = useState('none'); + /** + * The modal on screen, for an async handler that must not act on a dialog the user has left. + * 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; + /** Whether the destructive wipe dialog (book / whole-draft scope picker) is open. */ const [wipeModalOpen, setWipeModalOpen] = useState(false); - const [phraseMode, setPhraseMode] = useState({ kind: 'view' }); + const [phraseMode, setPhraseMode] = useState(VIEW_PHRASE_MODE); - // Reset phraseMode whenever the draft is replaced wholesale (New / Open / Wipe) so stale - // edit/confirm-unlink state is never passed to the newly mounted Interlinearizer. + // Reset phraseMode whenever the draft is replaced wholesale (New / Open / Wipe), and whenever the + // view crosses between the draft and an import, so stale edit/confirm-unlink state is never + // passed to the newly mounted Interlinearizer. An import opens without touching the draft or its + // version, and a mode carried into that read-only view renders its edit-target affordances. + // Crossing into the import is all this covers; the render below pins the import view's mode + // outright, since a mode set from inside that view has no crossing to reset it. useEffect(() => { - setPhraseMode({ kind: 'view' }); - }, [draftVersion]); + setPhraseMode(VIEW_PHRASE_MODE); + }, [draftVersion, isImportView]); + + /** What the Paratext 9 import modal shows while `modal` is `'importPt9'`. */ + const [pt9Phase, setPt9Phase] = useState({ kind: 'running' }); + + /** + * Which run the import modal belongs to: a first import from the select modal (report offers + * Close and Open, closing returning to the select modal), the accepted first-open offer (report + * offers Open alone, dismissal included), a manual sync (report offers Close), or the automatic + * sync on open (running state only; the view opens itself when the run settles). + */ + const [pt9Mode, setPt9Mode] = useState<'import' | 'offer' | 'sync' | 'autoSync'>('import'); + + /** The import project id the report's Open button targets; set when a first import succeeds. */ + const [pt9ImportedId, setPt9ImportedId] = useState(undefined); + + /** Whether the copy-to-editable dialog is open. */ + const [copyModalOpen, setCopyModalOpen] = useState(false); + + /** Guards the copy round-trip against double-submit. */ + const copyGuard = useSubmitGuard(); + + /** + * A project handed to {@link ProjectModals} to open through the normal draft-open flow (unsaved + * -work confirmation included); bumped ids perform one open each. Used for a fresh editable + * copy. + */ + const [openRequest, setOpenRequest] = useState< + { project: InterlinearProjectSummary; requestId: number } | undefined + >(undefined); + const openRequestIdRef = useRef(0); + + /** Fetches a project by id and returns its summary, or `undefined` when missing or malformed. */ + const fetchSummary = useCallback( + async (id: string): Promise => { + const json = await papi.commands.sendCommand('interlinearizer.getProject', id); + const parsed: unknown = json ? JSON.parse(json) : undefined; + return isInterlinearProjectSummary(parsed) ? toProjectSummary(parsed) : undefined; + }, + [], + ); + + /** + * Runs the import command behind the import modal for the button-driven runs: a first import from + * the select modal, a Yes on the first-open offer, or a manual sync from the view banner. Success + * shows the report (a sync also refreshes the open view first); the keep-stale outcome closes the + * modal, since the backend already warned; failure shows the in-modal error (the backend already + * sent the error notification). + */ + const runPt9Import = useCallback( + async (mode: 'import' | 'offer' | 'sync') => { + setPt9Mode(mode); + setPt9Phase({ kind: 'running' }); + setModal('importPt9'); + try { + const json = await papi.commands.sendCommand('interlinearizer.importPt9Project', projectId); + const parsed: unknown = JSON.parse(json); + const outcome = + parsed && typeof parsed === 'object' && 'outcome' in parsed ? parsed.outcome : undefined; + const importedId = + parsed && typeof parsed === 'object' && 'projectId' in parsed + ? parsed.projectId + : undefined; + const report = + parsed && typeof parsed === 'object' && 'report' in parsed ? parsed.report : undefined; + if (outcome === 'imported' && typeof importedId === 'string' && isPt9ImportReport(report)) { + if (mode === 'sync') { + const summary = await fetchSummary(importedId); + if (summary) setActiveProject(summary); + } + setPt9ImportedId(importedId); + setPt9Phase({ kind: 'report', report }); + } else if (outcome === 'staleKept') { + setModal('none'); + } else { + setPt9Phase({ kind: 'error' }); + } + } catch (e) { + logger.error('Interlinearizer: Paratext 9 import failed', e); + if (isPt9TooLargeError(e)) setPt9Phase({ kind: 'error', reason: 'tooLarge' }); + else setPt9Phase({ kind: 'error' }); + } + }, + [projectId, fetchSummary, setActiveProject], + ); + + /** + * Opens a Paratext 9 import from the select modal: probes the manifest and, when the source files + * changed since the last import, syncs first behind the import modal's running state - closing + * straight into the view, with no report step on the open path. Every failure - a manifest read + * that never answers included - opens the stored (stale) import with one warning instead of + * blocking access to it. + */ + const openImportedProject = useCallback( + async (project: InterlinearProjectSummary & { pt9Import: Pt9ImportProvenance }) => { + try { + const manifest = await readPt9Manifest(projectId); + if (fileHashesEqual(manifest, project.pt9Import.fileHashes)) { + setActiveProject(project); + setModal('none'); + return; + } + setPt9Mode('autoSync'); + setPt9Phase({ kind: 'running' }); + setModal('importPt9'); + const json = await papi.commands.sendCommand('interlinearizer.importPt9Project', projectId); + const parsed: unknown = JSON.parse(json); + const outcome = + parsed && typeof parsed === 'object' && 'outcome' in parsed ? parsed.outcome : undefined; + const importedId = + parsed && typeof parsed === 'object' && 'projectId' in parsed + ? parsed.projectId + : undefined; + const summary = + outcome === 'imported' && typeof importedId === 'string' + ? await fetchSummary(importedId) + : undefined; + setActiveProject(summary ?? project); + setModal('none'); + } catch (e) { + logger.error('Interlinearizer: Paratext 9 sync on open failed', e); + await papi.notifications + .send({ message: '%interlinearizer_warning_pt9Sync_failed%', severity: 'warning' }) + .catch(() => {}); + setActiveProject(project); + setModal('none'); + } + }, + [projectId, fetchSummary, setActiveProject], + ); + + /** + * Opens the freshly imported project from the report into the read-only view. A fetch that fails + * leaves the report standing, so the Open can be taken again once whatever broke is fixed. The + * report stays dismissable while that fetch is in flight, and one settling after the user has + * left it neither switches the project nor reports into whatever they moved on to. + */ + const handlePt9Open = useCallback(async () => { + /* v8 ignore next -- Open only renders on a report, which always sets the imported id first */ + if (!pt9ImportedId) return; + let summary: InterlinearProjectSummary | undefined; + try { + summary = await fetchSummary(pt9ImportedId); + } catch (e) { + logger.error('Interlinearizer: failed to load the imported project for opening', e); + } + if (modalRef.current !== 'importPt9') return; + if (summary) { + setActiveProject(summary); + setModal('none'); + } else { + await papi.notifications + .send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' }) + .catch(() => {}); + } + }, [pt9ImportedId, fetchSummary, setActiveProject]); + + /** + * Dismisses the import modal: a first import returns to the select modal it came from; every + * other run (the first-open offer included) returns to the view behind it. + */ + const handlePt9Close = useCallback(() => { + setModal(pt9Mode === 'import' ? 'select' : 'none'); + }, [pt9Mode]); + + /** Accepts the first-open offer: the conversion runs and becomes the only project created. */ + const handlePt9OfferYes = useCallback(() => { + setOfferPt9Import(false); + runPt9Import('offer'); + }, [setOfferPt9Import, runPt9Import]); + + /** + * Declines the first-open offer (dismissing the dialog means No): persists the empty draft so the + * offer never repeats for this source, then continues into the draft as an open does today. + */ + const handlePt9OfferNo = useCallback(() => { + setOfferPt9Import(false); + const snapshot = getDraftSnapshot(); + /* v8 ignore next -- the offer only renders once the draft has loaded */ + if (!snapshot) return; + papi.commands + .sendCommand('interlinearizer.saveDraft', projectId, JSON.stringify(snapshot)) + .catch((e) => + logger.error('Interlinearizer: failed to persist the draft declining the offer', e), + ); + }, [setOfferPt9Import, getDraftSnapshot, projectId]); + + /** + * Creates the editable copy and asks {@link ProjectModals} to open it through the normal + * draft-open flow, so the existing unsaved-work protection applies unchanged. The command sends + * its own error notification; here we only log. + */ + const handleCopySubmit = useCallback( + async (name: string, description?: string) => { + await copyGuard.runGuarded(async () => { + /* v8 ignore next -- the copy dialog only renders in the import view, which has a project */ + if (!activeProject) return; + try { + const json = await papi.commands.sendCommand( + 'interlinearizer.createEditableCopy', + activeProject.id, + name, + description, + ); + const parsed: unknown = JSON.parse(json); + if (!isInterlinearProjectSummary(parsed)) { + await papi.notifications + .send({ + message: '%interlinearizer_error_createEditableCopy_failed%', + severity: 'error', + }) + .catch(() => {}); + return; + } + setCopyModalOpen(false); + openRequestIdRef.current += 1; + setOpenRequest({ + project: toProjectSummary(parsed), + requestId: openRequestIdRef.current, + }); + } catch (e) { + logger.error('Interlinearizer: failed to copy the imported project', e); + } + }); + }, + [activeProject, copyGuard], + ); const isSavingRef = useRef(false); @@ -646,6 +1006,15 @@ function InterlinearizerLoaderInner({ */ const menuCommandHandler = useCallback( (item) => { + // The platform's menu items cannot be disabled per state, so while a read-only import is + // open the draft-editing commands do nothing at all - deliberately without a notification; + // the banner is the on-screen signal, and the storage guard is the backstop. + const draftCommandsInert = + isImportView && + (item.command === 'interlinearizer.save' || + item.command === 'interlinearizer.openSaveAsModal' || + item.command === 'interlinearizer.wipe'); + if (draftCommandsInert) return; if (item.command === 'interlinearizer.openSelectProjectModal') { setModal('select'); } else if (item.command === 'interlinearizer.openNewProjectModal') { @@ -664,7 +1033,7 @@ function InterlinearizerLoaderInner({ setCatalogOpen(true); } }, - [activeProject, handleSave, setCatalogOpen], + [activeProject, handleSave, isImportView, setCatalogOpen], ); /** @@ -726,6 +1095,12 @@ function InterlinearizerLoaderInner({ {resolvedOrEmpty(localizedStrings['%interlinearizer_loading%'])}

)} + + {!hasError && !showLoading && importLoadFailed && ( +

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

+ )}
); @@ -738,7 +1113,7 @@ function InterlinearizerLoaderInner({ book={book} continuousScroll={continuousScroll} scrRef={activeScrRef} - phraseMode={phraseMode} + phraseMode={isImportView ? VIEW_PHRASE_MODE : phraseMode} setPhraseMode={setPhraseMode} viewOptions={viewOptions} segmentationDispatch={segmentationDispatch} @@ -747,6 +1122,95 @@ function InterlinearizerLoaderInner({ /> ); + /* + * The group stays mounted whether or not the catalog is open, only the catalog's own panel + * coming and going, so that the view keeps one place in the tree. A view that changed place + * here would remount, losing what the reader was in the middle of: where the segment list was + * scrolled to, a gloss typed but not yet committed, an open breakdown editor. + */ + const panelGroup = ( + + + {bookArea} + + {catalogOpen && ( + <> + + + + + + )} + + ); + + // What fills the view area: the import's read-only store, the draft-backed store, or the + // loading/error panel while either source is still arriving. The store sits above the + // cross-book fade curtain (which lives inside the view panel), so the catalog panel can read + // the store without being dimmed by it. + let viewArea: ReactNode; + if (isImportView && activeProject) { + viewArea = + importAnalysis === undefined ? ( + {loadingOrErrorPanel} + ) : ( + // Keyed on the version tag so a sync reseeds by remounting, the same non-reactive-seed + // contract the draft-backed store relies on. + + {panelGroup} + + ); + } else if (isDraftLoading) { + // The store below waits for the draft: it seeds on mount alone, and the draft version that + // remounts it does not bump when the load completes. Nothing is lost by waiting - while the + // draft loads there is only ever a placeholder or an error panel to show. + viewArea = {loadingOrErrorPanel}; + } else { + // The store's lifetime is the draft's, not the loaded book's - it holds every book. Keyed on + // the draft version because the seed is not reactive, so a wholesale replacement (New / Open / + // Wipe) reseeds by remounting. Wrapping the loading and error branches too keeps it alive + // across the gap while the next book's USJ is in flight. + viewArea = ( + + {panelGroup} + + ); + } + return (
-
- {isDraftLoading ? ( - // The store below waits for the draft: it seeds on mount alone, and the draft version - // that remounts it does not bump when the load completes. Nothing is lost by waiting — - // while the draft loads there is only ever a placeholder or an error panel to show. - {loadingOrErrorPanel} - ) : ( - // The store's lifetime is the draft's, not the loaded book's — it holds every book. - // Keyed on the draft version because the seed is not reactive, so a wholesale replacement - // (New / Open / Wipe) reseeds by remounting. Wrapping the loading and error branches too - // keeps it alive across the gap while the next book's USJ is in flight. - // - // Declared above the cross-book curtain, not inside it, so the catalog panel can read the - // store without being dimmed by it: a jump to a usage in another book fades the view it - // navigates, and fading the list the jump was made from along with it would blank the - // panel at precisely the moment it is being used. - - {/* - * The group stays mounted whether or not the catalog is open, only the catalog's own - * panel coming and going, so that the view keeps one place in the tree. A view that - * changed place here would remount, losing what the reader was in the middle of: where - * the segment list was scrolled to, a gloss typed but not yet committed, an open - * breakdown editor. - */} - + + {formatReplacementString(localizedStrings['%interlinearizer_banner_pt9Import%'], { + date: new Date(activeProject.pt9Import.importedAt).toLocaleString(), + })} + + +
+ {localizedStrings['%interlinearizer_banner_sync%']} + + + +
+ )} + +
{viewArea}
runPt9Import('import')} + onOpenImport={openImportedProject} + openRequest={openRequest} projectId={projectId} setModal={setModal} useWebViewState={useWebViewState} /> + {showPt9Checking && offerProbe === 'pending' && } + + {offerPt9Import && offerProbe === 'available' && !isDraftLoading && modal === 'none' && ( + + )} + + {modal === 'importPt9' && ( + + )} + + {copyModalOpen && ( + setCopyModalOpen(false)} + /> + )} + {wipeModalOpen && ( { - const formClassName = `tw:flex tw:items-center tw:justify-center tw:whitespace-nowrap tw:rounded tw:px-0.5 tw:font-mono tw:text-xs tw:text-muted-foreground tw:transition-colors${disabled ? '' : ' tw:cursor-pointer'}${isFormsHovered && !disabled ? ' tw:bg-accent' : ''}`; + const formClassName = `tw:flex tw:items-center tw:justify-center tw:whitespace-nowrap tw:rounded tw:px-0.5 tw:font-mono tw:text-xs tw:text-muted-foreground tw:transition-colors${inert ? '' : ' tw:cursor-pointer'}${isFormsHovered && !inert ? ' tw:bg-accent' : ''}`; const formStyle = { gridColumn: i + 1, gridRow: 1 }; // preventDefault stops the ancestor
- - {dropdownShown && ( - - )} - + // When the empty input shows a suggested gloss as its placeholder, color that ghost + // text via the same `gloss-suggested` utility the dropdown's accept row uses (one + // source of truth for the suggested blue) and italicize it at full opacity, so it + // reads as a suggestion rather than a faint generic hint. + className={`tw:gloss-input${showSuggestedPlaceholder ? ' tw:placeholder:gloss-suggested tw:placeholder:italic tw:placeholder:opacity-100' : ''}`} + disabled={disabled} + id={glossInputId} + placeholder={ + showSuggestedPlaceholder + ? `${suggestedGloss}${SUGGESTED_PLACEHOLDER_PAD}` + : glossPlaceholder + } + role={hasSuggestions ? 'combobox' : undefined} + // Inline padding overrides the `gloss-input` utility's default px to reserve room + // for the trailing "+" button symmetrically (keeping the gloss text centered) + // without a spacer element. The top margin is zeroed here and moved to the wrapping + // span so the span's box matches the input exactly, letting the absolutely- + // positioned button center on the input rather than on a box inflated at the top by + // the margin. + style={{ + fieldSizing: 'content', + marginTop: 0, + minWidth: '5ch', + paddingLeft: '0.75rem', + paddingRight: '0.75rem', + }} + value={draft} + onBlur={ + disabled + ? undefined + : () => { + setInputFocused(false); + closeSuggestions(); + commitDraft(); + } + } + onChange={(e) => handleDraftChange(e.target.value)} + onFocus={disabled ? undefined : handleFocus} + onKeyDown={disabled ? undefined : handleGlossKeyDown} + onMouseDown={disabled ? undefined : handleMouseDown} + type="text" + /> + {hasMultipleSuggestions && ( + + )} + + + {dropdownShown && ( + + )} + + )} ); diff --git a/src/components/TokenLinkIcon.tsx b/src/components/TokenLinkIcon.tsx index 79283856..411a24fb 100644 --- a/src/components/TokenLinkIcon.tsx +++ b/src/components/TokenLinkIcon.tsx @@ -5,7 +5,7 @@ import { memo, useCallback } from 'react'; import type { SlotFocusInfo } from '../types/token-layout'; import { resolvedOrEmpty, tooltipContentOrUndefined } from '../utils/localized-strings'; import { computeSplitFreeRefs, sortByDocOrder, splitPhraseAtBoundary } from '../utils/phrase-arc'; -import { usePhraseDispatch } from './AnalysisStore'; +import { useAnalysisReadOnly, usePhraseDispatch } from './AnalysisStore'; import { usePhraseStripContext } from './PhraseStripContext'; /** Props for {@link TokenLinkIcon}. */ @@ -59,7 +59,7 @@ type TokenLinkIconProps = Readonly<{ * - One half = 1 token → that token leaves the phrase; the other half keeps/shrinks the phrase. * - Both halves = 1 token → delete the phrase entirely. */ -export function TokenLinkIcon({ +function EditableTokenLinkIcon({ prevToken, nextToken, prevPhraseLink, @@ -358,6 +358,32 @@ export function TokenLinkIcon({ ); } +/** + * The link / unlink control for the slot between two tokens - or nothing at all for a read-only + * analysis, which offers no linking. + */ +export function TokenLinkIcon({ + prevToken, + nextToken, + prevPhraseLink, + nextPhraseLink, + slotFocus, + isPhraseRevealed, +}: TokenLinkIconProps) { + const readOnly = useAnalysisReadOnly(); + if (readOnly) return undefined; + return ( + + ); +} + /** Memoized version of {@link TokenLinkIcon}; use in render-stable token rows. */ const MemoizedTokenLinkIcon = memo(TokenLinkIcon); export default MemoizedTokenLinkIcon; diff --git a/src/components/__mocks__/AnalysisStore.tsx b/src/components/__mocks__/AnalysisStore.tsx index 495fc470..492f63db 100644 --- a/src/components/__mocks__/AnalysisStore.tsx +++ b/src/components/__mocks__/AnalysisStore.tsx @@ -167,6 +167,22 @@ export function useShowSuggestions(): boolean { return false; } +/** + * What {@link useAnalysisReadOnly} returns. Module state, so `resetMocks` does not clear it: a test + * that sets it must reset it in `afterEach` via {@link __setMockAnalysisReadOnly}. + */ +let mockReadOnly = false; + +/** Test-only setter for what {@link useAnalysisReadOnly} returns. */ +export function __setMockAnalysisReadOnly(value: boolean): void { + mockReadOnly = value; +} + +/** Returns whether the analysis renders read-only in mock context; defaults to `false`. */ +export function useAnalysisReadOnly(): boolean { + return mockReadOnly; +} + /** * Returns a no-op dispatch for approving an analysis (accept / promote) in mock context. */ diff --git a/src/components/modals/CopyToEditableModal.tsx b/src/components/modals/CopyToEditableModal.tsx new file mode 100644 index 00000000..707d5ca1 --- /dev/null +++ b/src/components/modals/CopyToEditableModal.tsx @@ -0,0 +1,92 @@ +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Button, Input, Label, Textarea } from 'platform-bible-react'; +import { useState } from 'react'; +import { ModalShell } from './ModalShell'; + +/** Localized string keys requested for this modal's rendered text. */ +const COPY_TO_EDITABLE_STRING_KEYS: `%${string}%`[] = [ + '%interlinearizer_copyModal_title%', + '%interlinearizer_copyModal_defaultName%', + '%interlinearizer_copyModal_create%', + '%interlinearizer_copyModal_cancel%', + '%interlinearizer_modal_metadata_name_label%', + '%interlinearizer_modal_metadata_description_label%', +]; + +/** + * Dialog for copying a Paratext 9 import into an editable project: a name prefilled with the + * localized default and an optional description. There is no languages field - the copy carries the + * import's languages verbatim. + * + * @param props.isSubmitting - When `true`, the copy is being created: the buttons go inert and the + * modal cannot be dismissed, so the in-flight work cannot be abandoned. + * @param props.onSubmit - Called with the trimmed name and the trimmed description (or `undefined` + * when blank) when the user confirms. + * @param props.onClose - Called when the user cancels without copying. + */ +export function CopyToEditableModal({ + isSubmitting, + onSubmit, + onClose, +}: Readonly<{ + isSubmitting: boolean; + onSubmit: (name: string, description?: string) => void; + onClose: () => void; +}>) { + const [localizedStrings, stringsLoading] = useLocalizedStrings(COPY_TO_EDITABLE_STRING_KEYS); + const [name, setName] = useState(undefined); + const [description, setDescription] = useState(''); + + /* v8 ignore next */ if (stringsLoading) return undefined; + + // The prefill resolves with the strings, after the first render, so the draft name starts + // undefined and falls back to the localized default until the user edits it. + const nameValue = name ?? localizedStrings['%interlinearizer_copyModal_defaultName%']; + + const handleSubmit = () => { + const trimmedName = nameValue.trim(); + const trimmedDescription = description.trim(); + onSubmit( + trimmedName === '' + ? localizedStrings['%interlinearizer_copyModal_defaultName%'] + : trimmedName, + trimmedDescription === '' ? undefined : trimmedDescription, + ); + }; + + return ( + + + setName(e.target.value)} + /> + +