Skip to content

Commit 5aa5e4b

Browse files
Add interlinearizer project storage and createProject command (#33)
* Add interlinearizer project storage and createProject command - Add `src/projectStorage.ts` with `createProject`, `getProject`, `listProjects`, and `deleteProject` backed by `papi.storage` - Register `interlinearizer.createProject` command in `main.ts` that prompts for source/target projects, writes the record, and surfaces storage errors as notifications - Add `InterlinearProject` type and `interlinearizer.createProject` signature to shared type declarations - Extend PAPI backend mock with `papi.storage` and `papi.notifications` - Broaden Jest coverage to all `src/**` files - Add full test suites for the storage module and the new command * Documentation improvement * Documentation improvements, improve error handling when deleting project and add associated test, fix schema inconsistency * Set `restoreMocks` to `true` in jest config * Make sure notification failure doesn't cause unnecessary throw, reorder index/data write to `papi` storage to ensure data doesn't become inaccessible * Prevent same-project selection when creating an interlinear project, update mocks to match real API signatures - If the user picks the same project for both source and target roles, a warning notification is shown and the target picker re-opens until distinct projects are chosen or cancelled. - Includes new tests for this flow, updated platform-bible-react/utils mocks to match current API shapes, a new localized error string, and minor test/parser cleanups. * Add missing `localizedStrings`, add clarifying comment, improve test coverage * Improve docstring coverage * Add project creation, metadata editing, and project selection UI Introduces three modal components (CreateProjectModal, ProjectMetadataModal, SelectInterlinearProjectModal) with supporting menu contributions, localized strings, command registrations in main.ts, storage helpers in projectStorage.ts, and updated type definitions. Adds full test coverage for all new components and expands existing test suites to cover the new flows. * Fix modal close/callback ordering and trim whitespace from language input - `CreateProjectModal`: guard `onClose()` behind a `!newId` early-return so the modal doesn't close when project creation returns no ID - `ProjectMetadataModal`: trim whitespace from the language field before saving and passing to callbacks - Replace `makeHandleProjectCreated` factory with a plain `handleProjectCreated` callback; removes the closure-over-srcId pattern and reads `projectId` from the enclosing scope directly * Return full project JSON from createProject and add error handling for delete/update - `interlinearizer.createProject` now returns the full persisted project as a JSON string instead of just the UUID, so the WebView can populate `activeProject` with authoritative server data rather than reconstructing it locally - `CreateProjectModal.onProjectCreated` callback now receives the parsed `InterlinearProjectSummary` object instead of `(id, writingSystem)` pair - Add `isInterlinearProjectSummary` type guard to `SelectInterlinearProjectModal` and reuse it in the project list filter and the new create flow - Wrap `deleteProject` and `updateProjectMetadata` backend handlers in try/catch with logging and error notifications (previously unhandled rejections) - Register a no-op `interlinearizer.viewProjectInfo` backend command so the platform menu system can surface it; all logic runs in the WebView - Update tests and type declarations to match * Use `<dialog>` for modals, guard on falsy update return, and relax gloss/senseRef constraint - Replace `<div>` containers with `<dialog open aria-labelledby="…">` in `CreateProjectModal`, `ProjectMetadataModal`, and `SelectInterlinearProjectModal` for proper accessibility semantics. - In `ProjectMetadataModal.handleSave`, return early when `updateProjectMetadata` resolves with a falsy value (mirrors the existing guard in `CreateProjectModal.handleCreate`). - In `interlinearizer.d.ts`, replace the discriminated union on `Token` and `Phrase` that forbade setting both `gloss`/`glossSenseRef` (or `gloss`/`senseRef`) with optional fields, reflecting the updated rule that `gloss` serves as a local override when both are present. - Add a test for the falsy-return early-exit path in `ProjectMetadataModal`, and add the missing `waitFor` before negative assertions in the `CreateProjectModal` falsy-return test. * Fix analysis language default, button state, and storage error propagation - Default analysis language to "und" (undetermined) instead of "en" - Normalize whitespace-only language input to "und" on submit - Disable the create button while submission is in progress - Rethrow storage errors in getProjectsForSource so callers can distinguish an outage from an empty list * Throw on malformed createProject response instead of silently skipping onProjectCreated Previously, if the backend returned a JSON object that failed isInterlinearProjectSummary, the modal would call onClose() without invoking onProjectCreated, silently dropping the contract. Now it throws, routing through the existing error handler. Also fixes an incomplete fixture in the success test that was missing required summary fields. * Cleanup after incredibly messy rebase * More post-rebase cleanup * Improve command registration usage and sanity * Disable cancel during submission to avoid "I cancelled but the project was still created", update docs * Move `projectStorage` to `services` directory * Improve project creation/update error handling, improve project load validation * Improve `projectStorage` error-handling and documentation * Add missing `\@throws` docstring * Rename modal control commands for clarity, fix handlers in InterlinearizerLoader * Improve docs/schema description * Split modals into separate component (#62) Co-authored-by: alex-rawlings-yyc <alex.rawlings@wycliffe.ca> * Remove TOCTOU race from PAPI storage writes, remove duplicate notifications on error, disable metadata modal buttons when submitting * Add eslint ignore, add submitting ref * Add missing docs * Send notification when created project has unexpected shape, add submitting ref * Validate name and description in type guard * Prevent project write race, add aria prop * Disable buttons while submitting/loading, update docs, make implicit zero-check in ContinuousView explicit * Skip and log singleton corrupted project records * Update remaining `tw-` tags * Extract modal logic into ProjectModals, tighten type guards and coverage - Add ProjectModals component to own modal state (select/create/metadata) and their transitions, removing this responsibility from InterlinearizerLoader - Add ProjectModals.test.tsx with full coverage of modal visibility and transition flows - Strengthen isPlatformError mock to require platformErrorVersion to be a valid number, not just present - Handle SyntaxError in CreateProjectModal.handleSubmit with a user-visible notification instead of silently failing - Add test for invalid JSON response in CreateProjectModal - Add test for non-string description field in SelectInterlinearProjectModal - Mark isSubmittingRef guard branches with v8 ignore in modals * Update docs, fix misleading test * Align `atStart` and `atEnd` length checks, get rid of `createSourceIsSelect`, add function to reset storage queues for testing * Fix and consolidate modal styles (#74) * Simplify interlinear model (#63) * Simplify interlinear model: remove InterlinearAlignment/InterlinearText, add ActiveProject * Fix model gaps for lossless LCM / PT9 / BT Extension import * Make `analysisLanguages` required * Add comments about mapping of BT Extension's `sideNum` * Update docs/schema * Further refinement; please see updated description * Suggested model tweak * Model idea: Split linking out from analyses --------- Co-authored-by: D. Ror. <imnasnainaec@gmail.com> * Align code with simplified interlinear model Update components, storage, main, and types to match the model introduced in #63: rename commands, fix return types, update JSDoc, and adjust tests throughout. * Memoize Modal handler callbacks, add `targetProjectId` to modals where relevant to prevent silent deletion, docstring audit * Add missing JSDocs * Align stub with actual implementation, send notification when save/submission fails, docs adjustments * Trimmed down `AGENTS.md` to where it was before post-model-change update * Prevent stale data race condition, update old TW classes, TW class consolidation * Prevent double notifications * Improve docs, minor fixes * Add aria modal tag where missing * Post-rebase cleanup * Make `webViewNonce` optional, revert to earlier TW classes, introduce missing TW classes * Fix lint error * Fix Tailwind classes, clean up test files, ignore branches that don't need testing, reintroduce createSourceIsSelect to reopen SelectProjectModal if user cancels project creation * Fix lint issue * Fix test failure --------- Co-authored-by: Danny Rorabaugh <imnasnainaec@gmail.com>
1 parent 3ec4352 commit 5aa5e4b

45 files changed

Lines changed: 5798 additions & 647 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,20 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla
2929

3030
### Extension entry point
3131

32-
`src/main.ts` — called by Platform.Bible on activation. Exports two lifecycle functions:
32+
[src/main.ts](src/main.ts) — called by Platform.Bible on activation. Exports two lifecycle functions:
3333

34-
- `activate(context)` — registers the `interlinearizer.mainWebView` WebView provider, the `interlinearizer.openForWebView` command, the `interlinearizer.continuousScroll` project settings validator, and `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
34+
- `activate(context)`stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
3535
- `deactivate()` — clears `openWebViewsByProject` and returns `true`.
3636

3737
`openWebViewsByProject` (`Map<string, string>`) tracks one open WebView ID per project to prevent duplicates; reopening an already-open project brings that tab to front via the `existingId` option.
3838

3939
### WebView UI
4040

41-
`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:`).
41+
[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).
42+
43+
[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.
44+
45+
[Interlinearizer](src/components/Interlinearizer.tsx) — renders the interlinear view from the loaded book data.
4246

4347
The WebView is injected into the main bundle via Webpack's `?inline` query:
4448

@@ -47,12 +51,20 @@ import interlinearizerReact from './interlinearizer.web-view?inline';
4751
import interlinearizerStyles from './interlinearizer.web-view.scss?inline';
4852
```
4953

50-
`src/webpack-env.d.ts` declares the `*?inline`, `*?raw`, and `*.scss` module types that make these imports type-safe.
54+
[src/webpack-env.d.ts](src/webpack-env.d.ts) declares the `*?inline`, `*?raw`, and `*.scss` module types that make these imports type-safe.
5155

5256
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.
5357

5458
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.
5559

60+
### Project modals
61+
62+
[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.
63+
64+
### Project storage
65+
66+
[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`.
67+
5668
### Styling
5769

5870
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
6173

6274
Data flows from Platform.Bible's USJ (Unified Scripture JSON) format through two stages:
6375

64-
1. `src/parsers/papi/usjBookExtractor.ts` — converts USJ to the internal `Book` type
65-
2. `src/parsers/papi/bookTokenizer.ts` — segments and tokenizes the book into `Segment`/`Token` structures with character offsets
76+
1. [src/parsers/papi/usjBookExtractor.ts](src/parsers/papi/usjBookExtractor.ts) — converts USJ to the internal `RawBook` type
77+
2. [src/parsers/papi/bookTokenizer.ts](src/parsers/papi/bookTokenizer.ts) — segments and tokenizes the book into `Segment`/`Token` structures with character offsets
6678

67-
`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`.
79+
[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).
6880

69-
### Data model (`src/types/interlinearizer.d.ts`)
81+
### Data model ([src/types/interlinearizer.d.ts](src/types/interlinearizer.d.ts))
7082

7183
The core types are:
7284

73-
- `InterlinearAlignment` — top-level bilingual container (source + target `InterlinearText`)
85+
- `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.
86+
- `ActiveProject` — runtime pairing of `project: InterlinearProject` with reconstructed `source` and optional `target` books.
7487
- `Book → Segment → Token` — the text hierarchy
7588
- `TextAnalysis` — flat analysis layer keyed by id (does **not** mirror text hierarchy)
76-
- `TokenAnalysis / Morpheme` — parse and 1:1 glosses; multiple analyses per token are allowed, distinguished by `status`
77-
- `AlignmentLink` — links between source and target tokens/morphemes
78-
- `AlignmentEndpoint` — has either token-level or morpheme-level specificity, never both
89+
- `TokenAnalysis / MorphemeAnalysis` — parse and 1:1 glosses; multiple analyses per token are allowed, distinguished by `status`
90+
- `AlignmentLink`directional links between source and target endpoints
91+
- `AlignmentEndpoint` — has either token-level or morpheme-level specificity
7992

80-
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.
93+
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.
8194

8295
### TypeScript path aliases
8396

@@ -103,9 +116,10 @@ Key semantic properties of the mock setup:
103116
Mock files:
104117

105118
- **`__mocks__/fileMock.ts`** — Stub static asset imports.
119+
- **`__mocks__/lucide-react.tsx`** — Stubs icon components used in modals.
106120
- **`__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.
107121
- **`__mocks__/papi-core.ts`** — Empty module; exists only for module resolution since `@papi/core` is types-only at runtime.
108-
- **`__mocks__/papi-frontend.ts`** — Stubs `logger` (debug/error/info/warn as jest fns).
122+
- **`__mocks__/papi-frontend.ts`** — Stubs `logger` (debug/error/info/warn as jest fns) and `papi.commands.sendCommand` / `papi.notifications.send`.
109123
- **`__mocks__/papi-frontend-react.ts`** — Stubs PAPI React hooks.
110124
- **`__mocks__/platform-bible-react.tsx`** — Stubs components with appropriate `data-testid` attributes. See file for test IDs.
111125
- **`__mocks__/platform-bible-utils.ts`** — Stubs util functions.

__mocks__/lucide-react.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @file Jest mock for lucide-react. Provides stub icon components used by extension components.
3+
*/
4+
5+
import type { ReactElement } from 'react';
6+
7+
/**
8+
* Stub for the Trash2 icon; renders a bare SVG element so tests can locate the icon by test ID.
9+
*
10+
* @param props - SVG props forwarded from the component, including optional className and size.
11+
* @returns A ReactElement SVG element used as a trash icon stub in tests.
12+
*/
13+
export function Trash2(props: Readonly<{ className?: string; size?: number }>): ReactElement {
14+
return <svg data-testid="trash-icon" {...props} />;
15+
}
16+
17+
/**
18+
* Stub for the Info icon; renders a bare SVG element so tests can locate the icon by test ID.
19+
*
20+
* @param props - SVG props forwarded from the component, including optional className and size.
21+
* @returns A ReactElement SVG element used as an info icon stub in tests.
22+
*/
23+
export function Info(props: Readonly<{ className?: string; size?: number }>): ReactElement {
24+
return <svg data-testid="info-icon" {...props} />;
25+
}

__mocks__/papi-backend.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ const mockGetOpenWebViewDefinition = jest.fn();
1111
const mockOnDidOpenWebView = jest.fn();
1212
const mockOnDidCloseWebView = jest.fn();
1313
const mockRegisterValidator = jest.fn();
14+
const mockReadUserData = jest.fn();
15+
const mockWriteUserData = jest.fn();
16+
const mockDeleteUserData = jest.fn();
17+
const mockNotificationsSend = jest.fn();
1418
const mockLogger = {
1519
debug: jest.fn(),
1620
error: jest.fn(),
@@ -28,6 +32,14 @@ const papi = {
2832
projectSettings: {
2933
registerValidator: mockRegisterValidator,
3034
},
35+
notifications: {
36+
send: mockNotificationsSend,
37+
},
38+
storage: {
39+
readUserData: mockReadUserData,
40+
writeUserData: mockWriteUserData,
41+
deleteUserData: mockDeleteUserData,
42+
},
3143
webViewProviders: {
3244
registerWebViewProvider: mockRegisterWebViewProvider,
3345
},
@@ -49,6 +61,10 @@ const defaultExport = {
4961
__mockOnDidOpenWebView: mockOnDidOpenWebView,
5062
__mockOnDidCloseWebView: mockOnDidCloseWebView,
5163
__mockRegisterValidator: mockRegisterValidator,
64+
__mockReadUserData: mockReadUserData,
65+
__mockWriteUserData: mockWriteUserData,
66+
__mockDeleteUserData: mockDeleteUserData,
67+
__mockNotificationsSend: mockNotificationsSend,
5268
__mockLogger: mockLogger,
5369
};
5470

__mocks__/papi-frontend-react.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,28 @@ const useRecentScriptureRefs = jest
8989
.fn()
9090
.mockImplementation(() => ({ recentScriptureRefs: [], addRecentScriptureRef: jest.fn() }));
9191

92+
/**
93+
* Mock for `useData`. Returns a `Proxy` whose property accesses each yield a function returning
94+
* `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]` tuple
95+
* without requiring a live data provider.
96+
*/
97+
const useData = jest.fn(() =>
98+
new Proxy(
99+
{},
100+
{
101+
get: () => jest.fn().mockReturnValue([undefined, jest.fn(), false]),
102+
},
103+
),
104+
);
105+
92106
module.exports = {
93107
__esModule: true,
94108
useProjectData,
95109
useProjectSetting,
96110
useSetting,
97111
useLocalizedStrings,
98112
useRecentScriptureRefs,
113+
useData,
99114
};
100115

101116
/** Marks this file as a module so top-level const/let are module-scoped. */

__mocks__/papi-frontend.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* @file Jest mock for @papi/frontend. Provides a logger stub so WebView/frontend code can be
3-
* unit-tested without loading the real Platform API.
2+
* @file Jest mock for @papi/frontend. Provides a logger stub and a minimal papi object so
3+
* WebView/frontend components can be unit-tested without loading the real Platform API.
44
*/
55

66
const mockLogger = {
@@ -10,8 +10,24 @@ const mockLogger = {
1010
warn: jest.fn(),
1111
};
1212

13+
const mockSendCommand = jest.fn();
14+
const mockNotificationsSend = jest.fn();
15+
16+
const papi = {
17+
commands: {
18+
sendCommand: mockSendCommand,
19+
},
20+
notifications: {
21+
send: mockNotificationsSend,
22+
},
23+
menuData: {
24+
dataProviderName: 'platform.menuDataServiceDataProvider',
25+
},
26+
};
27+
1328
module.exports = {
1429
__esModule: true,
30+
default: papi,
1531
logger: mockLogger,
1632
};
1733

0 commit comments

Comments
 (0)