Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c1b1ee5
Add interlinearizer project storage and createProject command
alex-rawlings-yyc Apr 29, 2026
3e7d82b
Documentation improvement
alex-rawlings-yyc May 5, 2026
ad76fd6
Documentation improvements, improve error handling when deleting proj…
alex-rawlings-yyc May 5, 2026
c95221c
Set `restoreMocks` to `true` in jest config
alex-rawlings-yyc May 5, 2026
8de9731
Make sure notification failure doesn't cause unnecessary throw, reord…
alex-rawlings-yyc May 5, 2026
e94f32f
Prevent same-project selection when creating an interlinear project, …
alex-rawlings-yyc May 5, 2026
4975d39
Add missing `localizedStrings`, add clarifying comment, improve test …
alex-rawlings-yyc May 6, 2026
c6310bb
Improve docstring coverage
alex-rawlings-yyc May 6, 2026
685653f
Add project creation, metadata editing, and project selection UI
alex-rawlings-yyc May 7, 2026
0d22ce5
Fix modal close/callback ordering and trim whitespace from language i…
alex-rawlings-yyc May 7, 2026
c0be1a6
Return full project JSON from createProject and add error handling fo…
alex-rawlings-yyc May 7, 2026
f15b5fd
Use `<dialog>` for modals, guard on falsy update return, and relax gl…
alex-rawlings-yyc May 7, 2026
3190e89
Fix analysis language default, button state, and storage error propag…
alex-rawlings-yyc May 7, 2026
8088367
Throw on malformed createProject response instead of silently skippin…
alex-rawlings-yyc May 7, 2026
cf6a062
Cleanup after incredibly messy rebase
alex-rawlings-yyc May 11, 2026
3208f37
More post-rebase cleanup
alex-rawlings-yyc May 11, 2026
a36ef0d
Improve command registration usage and sanity
alex-rawlings-yyc May 11, 2026
baa8a46
Disable cancel during submission to avoid "I cancelled but the projec…
alex-rawlings-yyc May 12, 2026
dce9879
Move `projectStorage` to `services` directory
alex-rawlings-yyc May 12, 2026
3eeb0ff
Improve project creation/update error handling, improve project load …
alex-rawlings-yyc May 12, 2026
197c8b5
Improve `projectStorage` error-handling and documentation
alex-rawlings-yyc May 12, 2026
fbe6733
Add missing `\@throws` docstring
alex-rawlings-yyc May 12, 2026
8146abe
Rename modal control commands for clarity, fix handlers in Interlinea…
alex-rawlings-yyc May 12, 2026
7621a99
Improve docs/schema description
alex-rawlings-yyc May 12, 2026
f666689
Split modals into separate component (#62)
imnasnainaec May 13, 2026
4e85d63
Remove TOCTOU race from PAPI storage writes, remove duplicate notific…
alex-rawlings-yyc May 13, 2026
16b4c84
Add eslint ignore, add submitting ref
alex-rawlings-yyc May 13, 2026
c38a314
Add missing docs
alex-rawlings-yyc May 13, 2026
4b76f35
Send notification when created project has unexpected shape, add subm…
alex-rawlings-yyc May 13, 2026
efdc4e7
Validate name and description in type guard
alex-rawlings-yyc May 13, 2026
a589de7
Prevent project write race, add aria prop
alex-rawlings-yyc May 13, 2026
53e138e
Disable buttons while submitting/loading, update docs, make implicit …
alex-rawlings-yyc May 13, 2026
2648c53
Skip and log singleton corrupted project records
alex-rawlings-yyc May 13, 2026
786c6f0
Update remaining `tw-` tags
alex-rawlings-yyc May 14, 2026
e06edd5
Extract modal logic into ProjectModals, tighten type guards and coverage
alex-rawlings-yyc May 14, 2026
41af5bc
Update docs, fix misleading test
alex-rawlings-yyc May 14, 2026
379012e
Align `atStart` and `atEnd` length checks, get rid of `createSourceIs…
alex-rawlings-yyc May 15, 2026
7053a8e
Fix and consolidate modal styles (#74)
imnasnainaec May 15, 2026
fb6fc29
Simplify interlinear model (#63)
alex-rawlings-yyc May 15, 2026
2454f0a
Align code with simplified interlinear model
alex-rawlings-yyc May 15, 2026
7a4bb9c
Memoize Modal handler callbacks, add `targetProjectId` to modals wher…
alex-rawlings-yyc May 15, 2026
d9cd466
Add missing JSDocs
alex-rawlings-yyc May 15, 2026
5f5a2cb
Align stub with actual implementation, send notification when save/su…
alex-rawlings-yyc May 15, 2026
83172cc
Trimmed down `AGENTS.md` to where it was before post-model-change update
alex-rawlings-yyc May 15, 2026
ea7d395
Prevent stale data race condition, update old TW classes, TW class co…
alex-rawlings-yyc May 15, 2026
7eca1f1
Prevent double notifications
alex-rawlings-yyc May 15, 2026
b47af98
Improve docs, minor fixes
alex-rawlings-yyc May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.

### 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:

Expand All @@ -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`.
Expand All @@ -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

Expand All @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions __mocks__/lucide-react.tsx
Original file line number Diff line number Diff line change
@@ -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 <svg data-testid="trash-icon" {...props} />;
}

/**
* 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 <svg data-testid="info-icon" {...props} />;
}
16 changes: 16 additions & 0 deletions __mocks__/papi-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -24,6 +28,14 @@ const papi = {
dialogs: {
selectProject: mockSelectProject,
},
notifications: {
send: mockNotificationsSend,
},
storage: {
readUserData: mockReadUserData,
writeUserData: mockWriteUserData,
deleteUserData: mockDeleteUserData,
},
webViewProviders: {
registerWebViewProvider: mockRegisterWebViewProvider,
},
Expand All @@ -44,6 +56,10 @@ const defaultExport = {
__mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition,
__mockOnDidOpenWebView: mockOnDidOpenWebView,
__mockOnDidCloseWebView: mockOnDidCloseWebView,
__mockReadUserData: mockReadUserData,
__mockWriteUserData: mockWriteUserData,
__mockDeleteUserData: mockDeleteUserData,
__mockNotificationsSend: mockNotificationsSend,
__mockLogger: mockLogger,
};

Expand Down
15 changes: 15 additions & 0 deletions __mocks__/papi-frontend-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
20 changes: 18 additions & 2 deletions __mocks__/papi-frontend.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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,
};

Expand Down
Loading
Loading