From fb471443862f7f911e825f4e7b6fe5ebbb15ac8e Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Mon, 31 Aug 2026 10:08:31 -0700 Subject: [PATCH 1/7] Rebuild the import replacement from buildNew and drop the summary tests --- src/__tests__/types/type-guards.test.ts | 13 ------------- src/services/projectStorage.ts | 9 +-------- 2 files changed, 1 insertion(+), 21 deletions(-) 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/services/projectStorage.ts b/src/services/projectStorage.ts index 73765749..3a3f4b47 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -494,16 +494,9 @@ export async function savePt9Import( const current = await getProject(token, existing.id); if (!current) return undefined; const updated: InterlinearProject = { + ...buildNew(), id: current.id, - modelVersion: CURRENT_MODEL_VERSION, createdAt: current.createdAt, - updatedAt: new Date().toISOString(), - name, - description, - sourceProjectId, - analysisLanguages, - analysis, - pt9Import, }; await papi.storage.writeUserData(token, projectKey(current.id), JSON.stringify(updated)); return updated; From 7bd13f0a33a12038a140ac2489d1bcb9c0d48086 Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Thu, 27 Aug 2026 09:55:59 -0700 Subject: [PATCH 2/7] Add the Paratext 9 import WebView experience with a first-open offer The UI half of the PT9 import. The select modal gains the import button (shown only when the source serves convertible data, via usePt9ImportAvailability); Pt9ImportModal carries the run, its report, and its failures, including the too-large refusal recognized by the RESOURCE_EXHAUSTED platform error code with the documented message marker as fallback. An import opens read-only: every editing affordance stays away, a banner carries sync and copy-to-editable, and CopyToEditableModal clones an import into an editable project. On the first open of a source with convertible PT9 data and no stored state, Pt9ConvertPromptModal offers the conversion up front: Yes runs the import as the only project created, No (or dismissing) persists the empty draft so the offer never repeats, per the user-questions entry. Co-Authored-By: Claude Fable 5 --- __mocks__/papi-frontend.ts | 4 + contributions/localizedStrings.json | 41 +- .../components/ContinuousView.test.tsx | 1 + .../components/Interlinearizer.test.tsx | 1 + .../components/InterlinearizerLoader.test.tsx | 650 ++++++++++++++++++ src/__tests__/components/MorphemeBox.test.tsx | 53 ++ src/__tests__/components/PhraseBox.test.tsx | 34 + .../components/PhraseStripParts.test.tsx | 18 + .../SegmentFreeTranslationInput.test.tsx | 65 ++ src/__tests__/components/SegmentView.test.tsx | 1 + src/__tests__/components/TokenChip.test.tsx | 66 ++ .../components/TokenLinkIcon.test.tsx | 23 + .../modals/CopyToEditableModal.test.tsx | 68 ++ .../modals/ProjectMetadataModal.test.tsx | 40 ++ .../components/modals/ProjectModals.test.tsx | 60 ++ .../modals/Pt9ConvertPromptModal.test.tsx | 52 ++ .../components/modals/Pt9ImportModal.test.tsx | 220 ++++++ .../SelectInterlinearProjectModal.test.tsx | 55 +- .../hooks/usePt9ImportAvailability.test.ts | 77 +++ src/__tests__/main.test.ts | 56 ++ src/__tests__/services/projectStorage.test.ts | 24 + .../services/pt9ImportService.test.ts | 67 +- src/__tests__/test-helpers.ts | 22 + src/__tests__/utils/pt9-import-error.test.ts | 31 + src/components/AnalysisStore.tsx | 26 +- src/components/InterlinearizerLoader.tsx | 526 ++++++++++++-- src/components/MorphemeBox.tsx | 61 +- src/components/PhraseBox.tsx | 23 +- src/components/PhraseStripParts.tsx | 4 + .../SegmentFreeTranslationInput.tsx | 21 +- src/components/TokenChip.tsx | 238 ++++--- src/components/TokenLinkIcon.tsx | 30 +- src/components/__mocks__/AnalysisStore.tsx | 16 + src/components/modals/CopyToEditableModal.tsx | 92 +++ .../modals/ProjectMetadataModal.tsx | 128 ++-- src/components/modals/ProjectModals.tsx | 42 +- .../modals/Pt9ConvertPromptModal.tsx | 44 ++ src/components/modals/Pt9ImportModal.tsx | 251 +++++++ .../modals/SelectInterlinearProjectModal.tsx | 26 + src/hooks/usePt9ImportAvailability.ts | 48 ++ src/main.ts | 22 +- src/services/projectStorage.ts | 16 + src/services/pt9ImportService.ts | 33 + src/utils/pt9-import-error.ts | 18 + user-questions.md | 40 ++ 45 files changed, 3179 insertions(+), 255 deletions(-) create mode 100644 src/__tests__/components/SegmentFreeTranslationInput.test.tsx create mode 100644 src/__tests__/components/modals/CopyToEditableModal.test.tsx create mode 100644 src/__tests__/components/modals/Pt9ConvertPromptModal.test.tsx create mode 100644 src/__tests__/components/modals/Pt9ImportModal.test.tsx create mode 100644 src/__tests__/hooks/usePt9ImportAvailability.test.ts create mode 100644 src/__tests__/utils/pt9-import-error.test.ts create mode 100644 src/components/modals/CopyToEditableModal.tsx create mode 100644 src/components/modals/Pt9ConvertPromptModal.tsx create mode 100644 src/components/modals/Pt9ImportModal.tsx create mode 100644 src/hooks/usePt9ImportAvailability.ts create mode 100644 src/utils/pt9-import-error.ts 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/contributions/localizedStrings.json b/contributions/localizedStrings.json index a657cb9e..d2e74894 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -161,13 +161,50 @@ "%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_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_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__/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..279c022f 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -103,6 +103,7 @@ let phraseLinkByIdMapReads = 0; jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, + useAnalysisReadOnly: () => false, /** * Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of * scope. diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 8b58c1a2..3b0b4d92 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -24,6 +24,7 @@ import { makePunctToken, makeScrollGroupHook, makeSegment, + getMockedPdpGet, makeWebViewState, makeWordToken, type ScrollGroupTuple, @@ -249,6 +250,7 @@ type MockProject = { analysisLanguages: string[]; name?: string; description?: string; + pt9Import?: { fileHashes: Record; importedAt: string }; }; const mockSendCommand = jest.mocked(papi.commands.sendCommand); @@ -263,6 +265,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 +292,9 @@ jest.mock('../../components/modals/ProjectModals', () => ({ activeProject, defaultAnalysisLanguage, hasUnsavedWork, + onImportPt9, + onOpenImport, + openRequest, useWebViewState, }: { modal: string; @@ -289,6 +305,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 +323,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 +348,16 @@ jest.mock('../../components/modals/ProjectModals', () => ({ + +
+ {localizedStrings['%interlinearizer_banner_sync%']} + + + + + )} + +
{viewArea}
runPt9Import('import')} + onOpenImport={openImportedProject} + openRequest={openRequest} projectId={projectId} setModal={setModal} useWebViewState={useWebViewState} /> + {offerPt9Import && !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 ); 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)} + /> + +