diff --git a/AGENTS.md b/AGENTS.md index bf3a5bb0..c0bc20e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,16 +29,20 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla ### Extension entry point -`src/main.ts` — called by Platform.Bible on activation. Exports two lifecycle functions: +[src/main.ts](src/main.ts) — called by Platform.Bible on activation. Exports two lifecycle functions: -- `activate(context)` — registers the `interlinearizer.mainWebView` WebView provider, the `interlinearizer.openForWebView` command, and `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation. +- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, and `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation. - `deactivate()` — clears `openWebViewsByProject` and returns `true`. `openWebViewsByProject` (`Map`) tracks one open WebView ID per project to prevent duplicates; reopening an already-open project brings that tab to front via the `existingId` option. ### WebView UI -`src/interlinearizer.web-view.tsx` — React component rendered inside Platform.Bible's WebView iframe. `useWebViewScrollGroupScrRef` is a **prop injected by the PAPI host** (not a hook import). Uses PAPI frontend hooks (`useProjectData`, `useProjectSetting`, `useLocalizedStrings`, `useRecentScriptureRefs`) to fetch live data. Renders verse segments as token chips with Tailwind utility classes (all prefixed `tw:`). +[src/interlinearizer.web-view.tsx](src/interlinearizer.web-view.tsx) — entry point rendered inside Platform.Bible's WebView iframe; delegates to [InterlinearizerLoader](src/components/InterlinearizerLoader.tsx) when a `projectId` is present. `useWebViewScrollGroupScrRef` and `useWebViewState` are **props injected by the PAPI host** (not hook imports). + +[InterlinearizerLoader](src/components/InterlinearizerLoader.tsx) — real top of the React tree: owns modal state, persists the active interlinear project, fetches and tokenizes book data, and routes top-menu commands to the appropriate modal. + +[Interlinearizer](src/components/Interlinearizer.tsx) — renders the interlinear view from the loaded book data. The WebView is injected into the main bundle via Webpack's `?inline` query: @@ -47,12 +51,20 @@ import interlinearizerReact from './interlinearizer.web-view?inline'; import interlinearizerStyles from './interlinearizer.web-view.scss?inline'; ``` -`src/webpack-env.d.ts` declares the `*?inline`, `*?raw`, and `*.scss` module types that make these imports type-safe. +[src/webpack-env.d.ts](src/webpack-env.d.ts) declares the `*?inline`, `*?raw`, and `*.scss` module types that make these imports type-safe. Two separate Webpack configs handle this: `webpack.config.web-view.ts` builds the React component into `temp-build/`, then `webpack.config.main.ts` copies it into `dist/` alongside contributions, public assets, and type declarations. The WebView root component is assigned to `globalThis.webViewComponent` (not exported) — this is the PAPI WebView contract. Tests must `require()` the module and read `globalThis.webViewComponent` to get the component. +### Project modals + +[src/components/ProjectModals.tsx](src/components/ProjectModals.tsx) — single mount point for all project-related dialogs, switching between `'select' | 'create' | 'metadata' | 'none'` states. The three modal components ([SelectInterlinearProjectModal](src/components/SelectInterlinearProjectModal.tsx), [CreateProjectModal](src/components/CreateProjectModal.tsx), [ProjectMetadataModal](src/components/ProjectMetadataModal.tsx)) call backend commands to list, create, update, and delete projects. + +### Project storage + +[src/services/projectStorage.ts](src/services/projectStorage.ts) — owns all `papi.storage` reads and writes for interlinearizer projects. Two serialization queues prevent interleaved read-modify-write races. Tests must call `resetQueuesForTesting()` between tests because module state is not cleared by `resetMocks`. + ### Styling All UI uses Tailwind CSS (via `src/tailwind.css`). Every Tailwind class is prefixed `tw:` to avoid collisions with Platform.Bible's own styles (configured in `tailwind.config.ts`). For modifier variants the prefix comes first: `tw:hover:px-3`, not `hover:tw-px-3`. @@ -61,23 +73,24 @@ All UI uses Tailwind CSS (via `src/tailwind.css`). Every Tailwind class is prefi Data flows from Platform.Bible's USJ (Unified Scripture JSON) format through two stages: -1. `src/parsers/papi/usjBookExtractor.ts` — converts USJ to the internal `Book` type -2. `src/parsers/papi/bookTokenizer.ts` — segments and tokenizes the book into `Segment`/`Token` structures with character offsets +1. [src/parsers/papi/usjBookExtractor.ts](src/parsers/papi/usjBookExtractor.ts) — converts USJ to the internal `RawBook` type +2. [src/parsers/papi/bookTokenizer.ts](src/parsers/papi/bookTokenizer.ts) — segments and tokenizes the book into `Segment`/`Token` structures with character offsets -`src/parsers/pt9/interlinearXmlParser.ts` — separately parses Paratext 9 interlinear XML into the alignment model. The XML schema is documented in `src/parsers/pt9/pt9-xml.md`. +[src/parsers/pt9/interlinearXmlParser.ts](src/parsers/pt9/interlinearXmlParser.ts) — separately parses Paratext 9 interlinear XML into the alignment model. The XML schema is documented in [pt9-xml.md](src/parsers/pt9/pt9-xml.md). -### Data model (`src/types/interlinearizer.d.ts`) +### Data model ([src/types/interlinearizer.d.ts](src/types/interlinearizer.d.ts)) The core types are: -- `InterlinearAlignment` — top-level bilingual container (source + target `InterlinearText`) +- `InterlinearProject` — persisted envelope: id, createdAt, optional name/description, `sourceProjectId`, optional `targetProjectId`, `analysisLanguages`, `analysis: TextAnalysis`, and optional `links`. Only this is serialized to storage; the `Book` hierarchy is rebuilt from USJ on each load. +- `ActiveProject` — runtime pairing of `project: InterlinearProject` with reconstructed `source` and optional `target` books. - `Book → Segment → Token` — the text hierarchy - `TextAnalysis` — flat analysis layer keyed by id (does **not** mirror text hierarchy) -- `TokenAnalysis / Morpheme` — parse and 1:1 glosses; multiple analyses per token are allowed, distinguished by `status` -- `AlignmentLink` — links between source and target tokens/morphemes -- `AlignmentEndpoint` — has either token-level or morpheme-level specificity, never both +- `TokenAnalysis / MorphemeAnalysis` — parse and 1:1 glosses; multiple analyses per token are allowed, distinguished by `status` +- `AlignmentLink` — directional links between source and target endpoints +- `AlignmentEndpoint` — has either token-level or morpheme-level specificity -Key invariants: `Segment.baselineText.slice(charStart, charEnd) === Token.surfaceText`; at most one `TokenAnalysis` per token may have `status: 'approved'`. Multi-string content is tagged by BCP47 writing-system codes. `tokenSnapshot` fields detect drift when baseline text changes. +Key invariants: `Segment.baselineText.slice(charStart, charEnd) === Token.surfaceText`; at most one linked analysis per token/segment may have `status: 'approved'`. `MultiString` values are keyed by BCP 47 tags. `TokenSnapshot.surfaceText` detects drift when baseline text changes. ### TypeScript path aliases @@ -103,9 +116,10 @@ Key semantic properties of the mock setup: Mock files: - **`__mocks__/fileMock.ts`** — Stub static asset imports. +- **`__mocks__/lucide-react.tsx`** — Stubs icon components used in modals. - **`__mocks__/papi-backend.ts`** — Mocks with jest fns. Re-exports internal jest fns on the default export as `__mock*` properties (e.g., `papi.__mockRegisterCommand`) so tests can assert on them without re-importing. See file for full list. - **`__mocks__/papi-core.ts`** — Empty module; exists only for module resolution since `@papi/core` is types-only at runtime. -- **`__mocks__/papi-frontend.ts`** — Stubs `logger` (debug/error/info/warn as jest fns). +- **`__mocks__/papi-frontend.ts`** — Stubs `logger` (debug/error/info/warn as jest fns) and `papi.commands.sendCommand` / `papi.notifications.send`. - **`__mocks__/papi-frontend-react.ts`** — Stubs PAPI React hooks. - **`__mocks__/platform-bible-react.tsx`** — Stubs components with appropriate `data-testid` attributes. See file for test IDs. - **`__mocks__/platform-bible-utils.ts`** — Stubs util functions. diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx new file mode 100644 index 00000000..dc182796 --- /dev/null +++ b/__mocks__/lucide-react.tsx @@ -0,0 +1,25 @@ +/** + * @file Jest mock for lucide-react. Provides stub icon components used by extension components. + */ + +import type { ReactElement } from 'react'; + +/** + * Stub for the Trash2 icon; renders a bare SVG element so tests can locate the icon by test ID. + * + * @param props - SVG props forwarded from the component, including optional className and size. + * @returns A ReactElement SVG element used as a trash icon stub in tests. + */ +export function Trash2(props: Readonly<{ className?: string; size?: number }>): ReactElement { + return ; +} + +/** + * Stub for the Info icon; renders a bare SVG element so tests can locate the icon by test ID. + * + * @param props - SVG props forwarded from the component, including optional className and size. + * @returns A ReactElement SVG element used as an info icon stub in tests. + */ +export function Info(props: Readonly<{ className?: string; size?: number }>): ReactElement { + return ; +} diff --git a/__mocks__/papi-backend.ts b/__mocks__/papi-backend.ts index c8d3a9f4..819078b6 100644 --- a/__mocks__/papi-backend.ts +++ b/__mocks__/papi-backend.ts @@ -10,6 +10,10 @@ const mockSelectProject = jest.fn(); const mockGetOpenWebViewDefinition = jest.fn(); const mockOnDidOpenWebView = jest.fn(); const mockOnDidCloseWebView = jest.fn(); +const mockReadUserData = jest.fn(); +const mockWriteUserData = jest.fn(); +const mockDeleteUserData = jest.fn(); +const mockNotificationsSend = jest.fn(); const mockLogger = { debug: jest.fn(), error: jest.fn(), @@ -24,6 +28,14 @@ const papi = { dialogs: { selectProject: mockSelectProject, }, + notifications: { + send: mockNotificationsSend, + }, + storage: { + readUserData: mockReadUserData, + writeUserData: mockWriteUserData, + deleteUserData: mockDeleteUserData, + }, webViewProviders: { registerWebViewProvider: mockRegisterWebViewProvider, }, @@ -44,6 +56,10 @@ const defaultExport = { __mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition, __mockOnDidOpenWebView: mockOnDidOpenWebView, __mockOnDidCloseWebView: mockOnDidCloseWebView, + __mockReadUserData: mockReadUserData, + __mockWriteUserData: mockWriteUserData, + __mockDeleteUserData: mockDeleteUserData, + __mockNotificationsSend: mockNotificationsSend, __mockLogger: mockLogger, }; diff --git a/__mocks__/papi-frontend-react.ts b/__mocks__/papi-frontend-react.ts index e7f99bb6..714dac09 100644 --- a/__mocks__/papi-frontend-react.ts +++ b/__mocks__/papi-frontend-react.ts @@ -72,12 +72,27 @@ const useRecentScriptureRefs = jest .fn() .mockImplementation(() => ({ recentScriptureRefs: [], addRecentScriptureRef: jest.fn() })); +/** + * Mock for `useData`. Returns a `Proxy` whose property accesses each yield a function returning + * `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]` tuple + * without requiring a live data provider. + */ +const useData = jest.fn(() => + new Proxy( + {}, + { + get: () => jest.fn().mockReturnValue([undefined, jest.fn(), false]), + }, + ), +); + module.exports = { __esModule: true, useProjectData, useProjectSetting, useLocalizedStrings, useRecentScriptureRefs, + useData, }; /** Marks this file as a module so top-level const/let are module-scoped. */ diff --git a/__mocks__/papi-frontend.ts b/__mocks__/papi-frontend.ts index 009859da..7c5c7461 100644 --- a/__mocks__/papi-frontend.ts +++ b/__mocks__/papi-frontend.ts @@ -1,6 +1,6 @@ /** - * @file Jest mock for @papi/frontend. Provides a logger stub so WebView/frontend code can be - * unit-tested without loading the real Platform API. + * @file Jest mock for @papi/frontend. Provides a logger stub and a minimal papi object so + * WebView/frontend components can be unit-tested without loading the real Platform API. */ const mockLogger = { @@ -10,8 +10,24 @@ const mockLogger = { warn: jest.fn(), }; +const mockSendCommand = jest.fn(); +const mockNotificationsSend = jest.fn(); + +const papi = { + commands: { + sendCommand: mockSendCommand, + }, + notifications: { + send: mockNotificationsSend, + }, + menuData: { + dataProviderName: 'platform.menuDataServiceDataProvider', + }, +}; + module.exports = { __esModule: true, + default: papi, logger: mockLogger, }; diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index ba354760..8da098d5 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -7,6 +7,20 @@ import type { ReactElement, ReactNode } from 'react'; +export interface MenuItemContainingCommand { + label: `%${string}%`; + command: `${string}.${string}`; + group: `${string}.${string}`; + order: number; + localizeNotes: string; + tooltip?: `%${string}%`; + searchTerms?: `%${string}%`; + iconPathBefore?: string; + iconPathAfter?: string; +} + +export type SelectMenuItemHandler = (selectedMenuItem: MenuItemContainingCommand) => void; + interface SerializedVerseRef { book: string; chapterNum: number; @@ -15,40 +29,154 @@ interface SerializedVerseRef { versificationStr?: string; } -export const BOOK_CHAPTER_CONTROL_STRING_KEYS: string[] = []; +/** Localization keys required by {@link BookChapterControl}. */ +export const BOOK_CHAPTER_CONTROL_STRING_KEYS = [ + '%scripture_section_ot_long%', + '%scripture_section_nt_long%', + '%scripture_section_dc_long%', + '%scripture_section_extra_long%', + '%history_recent%', + '%history_recentSearches_ariaLabel%', +] as const; + +/** Sentinel menu item passed by the mock toolbar when the select-project menu button is clicked. */ +export const MOCK_SELECT_PROJECT_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_menu_select_project%', + command: 'interlinearizer.openSelectProjectModal', + group: 'interlinearizer.project.actions', + order: 1, + localizeNotes: '', +}; + +/** Sentinel menu item passed by the mock toolbar when the new-project button is clicked. */ +export const MOCK_NEW_PROJECT_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_menu_new_project%', + command: 'interlinearizer.openNewProjectModal', + group: 'interlinearizer.project.actions', + order: 2, + localizeNotes: '', +}; +/** Sentinel menu item passed by the mock toolbar when the view-project-info button is clicked. */ +export const MOCK_VIEW_PROJECT_INFO_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_menu_view_project_info%', + command: 'interlinearizer.openProjectInfoModal', + group: 'interlinearizer.project.actions', + order: 3, + localizeNotes: '', +}; + + +/** + * Stub toolbar that renders project-menu and view-info buttons using sentinel menu items so tests + * can trigger menu commands without a real toolbar implementation. + * + * @param props - Component props. + * @param props.startAreaChildren - Content rendered in the start slot. + * @param props.endAreaChildren - Content rendered in the end slot. + * @param props.onSelectProjectMenuItem - Called with a sentinel item when a project-menu button is + * clicked (select-project, new-project, or view-project-info buttons). + * @param props.onSelectViewInfoMenuItem - Called with a generic sentinel item when the view-info + * button is clicked. + * @returns A div with `data-testid="tab-toolbar"` containing the rendered buttons. + */ export function TabToolbar({ startAreaChildren, endAreaChildren, + onSelectProjectMenuItem, + onSelectViewInfoMenuItem, }: Readonly<{ className?: string; startAreaChildren?: ReactNode; + centerAreaChildren?: ReactNode; endAreaChildren?: ReactNode; - onSelectProjectMenuItem?: () => void; - onSelectViewInfoMenuItem?: () => void; + onSelectProjectMenuItem: SelectMenuItemHandler; + onSelectViewInfoMenuItem: SelectMenuItemHandler; + projectMenuData?: unknown; + tabViewMenuData?: unknown; + id?: string; + menuButtonIcon?: ReactNode; }>): ReactElement { return (
{startAreaChildren}
{endAreaChildren}
+ {onSelectProjectMenuItem && ( + + )} + {onSelectProjectMenuItem && ( + + )} + {onSelectProjectMenuItem && ( + + )} + {onSelectViewInfoMenuItem && ( + + )}
); } +/** + * Stub scroll-group selector rendered as a native `` element. + */ export function ScrollGroupSelector({ availableScrollGroupIds, scrollGroupId, onChangeScrollGroupId, }: Readonly<{ - availableScrollGroupIds?: (number | undefined)[]; - scrollGroupId?: number; - onChangeScrollGroupId?: (id: number | undefined) => void; + availableScrollGroupIds: (number | undefined)[]; + scrollGroupId: number | undefined; + onChangeScrollGroupId: (id: number | undefined) => void; + localizedStrings?: Record; + size?: 'default' | 'sm' | 'lg' | 'icon'; + className?: string; + id?: string; }>): ReactElement { return ( ` element. + */ export function Switch({ checked, disabled, @@ -105,6 +303,15 @@ export function Switch({ ); } +/** + * Stub label rendered as a native `