diff --git a/.vscode/settings.json b/.vscode/settings.json index 132b08ec..fd6a177b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,9 +17,10 @@ }, "files.eol": "\n", - "javascript.validate.enable": false, - "javascript.format.enable": false, - "typescript.format.enable": false, + "[javascript][javascriptreact]": { + "js/ts.validate.enabled": false + }, + "js/ts.format.enabled": false, "search.exclude": { ".git": true, diff --git a/README.md b/README.md index c5566168..5bd167a7 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The general file structure for an extension is as follows: - `*.web-view.tsx` files will be treated as React WebViews - `*.web-view.scss` files provide styles for WebViews - `*.web-view.html` files are a conventional way to provide HTML WebViews (no special functionality) - - `src/__tests__/` contains unit tests (Jest) for the extension, including parser tests (valid and invalid XML, edge cases) and web-view tests + - `src/__tests__/` contains unit tests (Jest) for the extension, including parser tests (valid and invalid XML, edge cases) and WebView tests - `__mocks__/` contains Jest mocks for the PAPI, file modules, and test fixtures used by tests in `src/__tests__/`. The `@papi/backend` and `@papi/frontend` mocks are used mutually exclusively (backend for main.ts tests, frontend for WebView tests); each mock file ends with `export {}` so TypeScript treats it as a module. - `assets/` contains asset files the extension and its WebViews can retrieve using the `papi-extension:` protocol, as well as textual descriptions in various languages. It is copied into the build folder - `assets/displayData.json` contains (optionally) a path to the extension's icon file as well as text for the extension's display name, short summary, and path to the full description file diff --git a/__mocks__/papi-backend.ts b/__mocks__/papi-backend.ts index da57a7da..4f9cab20 100644 --- a/__mocks__/papi-backend.ts +++ b/__mocks__/papi-backend.ts @@ -3,8 +3,13 @@ * loading the real Platform API. */ -const mockRegisterWebViewProvider = jest.fn().mockResolvedValue({ dispose: jest.fn() }); -const mockOpenWebView = jest.fn().mockResolvedValue(undefined); +const mockRegisterWebViewProvider = jest.fn(); +const mockRegisterCommand = jest.fn(); +const mockOpenWebView = jest.fn(); +const mockSelectProject = jest.fn(); +const mockGetOpenWebViewDefinition = jest.fn(); +const mockOnDidOpenWebView = jest.fn(); +const mockOnDidCloseWebView = jest.fn(); const mockLogger = { debug: jest.fn(), error: jest.fn(), @@ -13,18 +18,32 @@ const mockLogger = { }; const papi = { + commands: { + registerCommand: mockRegisterCommand, + }, + dialogs: { + selectProject: mockSelectProject, + }, webViewProviders: { registerWebViewProvider: mockRegisterWebViewProvider, }, webViews: { openWebView: mockOpenWebView, + getOpenWebViewDefinition: mockGetOpenWebViewDefinition, + onDidOpenWebView: mockOnDidOpenWebView, + onDidCloseWebView: mockOnDidCloseWebView, }, }; const defaultExport = { ...papi, __mockRegisterWebViewProvider: mockRegisterWebViewProvider, + __mockRegisterCommand: mockRegisterCommand, __mockOpenWebView: mockOpenWebView, + __mockSelectProject: mockSelectProject, + __mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition, + __mockOnDidOpenWebView: mockOnDidOpenWebView, + __mockOnDidCloseWebView: mockOnDidCloseWebView, __mockLogger: mockLogger, }; @@ -33,7 +52,12 @@ module.exports = { default: defaultExport, logger: mockLogger, __mockRegisterWebViewProvider: mockRegisterWebViewProvider, + __mockRegisterCommand: mockRegisterCommand, __mockOpenWebView: mockOpenWebView, + __mockSelectProject: mockSelectProject, + __mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition, + __mockOnDidOpenWebView: mockOnDidOpenWebView, + __mockOnDidCloseWebView: mockOnDidCloseWebView, __mockLogger: mockLogger, }; diff --git a/__mocks__/platform-bible-utils.ts b/__mocks__/platform-bible-utils.ts index 9ce5fbda..35990083 100644 --- a/__mocks__/platform-bible-utils.ts +++ b/__mocks__/platform-bible-utils.ts @@ -53,4 +53,13 @@ class UnsubscriberAsyncList { } } -export { UnsubscriberAsyncList }; +/** Minimal PlatformError shape matching the real platform-bible-utils type. */ +interface PlatformError { + message: string; + isPlatformError: true; +} + +const isPlatformError = (value: unknown): value is PlatformError => + typeof value === 'object' && value !== null && (value as PlatformError).isPlatformError === true; + +export { UnsubscriberAsyncList, isPlatformError }; diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 56de66ef..2ebf8ee7 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -1,6 +1,10 @@ { "metadata": {}, "localizedStrings": { - "en": {} + "en": { + "%interlinearizer_dialog_open_title%": "Open Interlinearizer", + "%interlinearizer_dialog_open_prompt%": "Choose a project to open in the Interlinearizer:", + "%interlinearizer_openForProject%": "Open Interlinearizer for this Project" + } } } diff --git a/contributions/menus.json b/contributions/menus.json index f68441dc..d5a03455 100644 --- a/contributions/menus.json +++ b/contributions/menus.json @@ -13,5 +13,26 @@ "groups": {}, "items": [] }, - "webViewMenus": {} + "webViewMenus": { + "platformScriptureEditor.react": { + "topMenu": { + "columns": {}, + "groups": { + "interlinearizer.editor": { + "column": "platformScriptureEditor.edit", + "order": 12 + } + }, + "items": [ + { + "label": "%interlinearizer_openForProject%", + "localizeNotes": "Scripture Editor top menu > Open Interlinearizer for this project", + "group": "interlinearizer.editor", + "order": 1, + "command": "interlinearizer.openForWebView" + } + ] + } + } + } } diff --git a/jest.config.ts b/jest.config.ts index 2e43f5fa..d4dd6155 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -10,8 +10,12 @@ import type { Config } from 'jest'; const config: Config = { - /** Automatically clear mock calls, instances, contexts and results before every test. */ - clearMocks: true, + /** + * Reset mock implementations before every test (superset of clearMocks: also removes + * mockReturnValue/mockImplementation so implementations never leak between tests). Each test must + * set up the implementations it needs, typically in beforeEach. + */ + resetMocks: true, /** * Coverage only when run with --coverage (see npm run test:coverage). Omit for faster default diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index f1a0d324..bab23ce8 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -5,17 +5,7 @@ import type { WebViewProps } from '@papi/core'; import type { SerializedVerseRef } from '@sillsdev/scripture'; import { render, screen } from '@testing-library/react'; -import { InterlinearXmlParser } from 'parsers/interlinearXmlParser'; - -/** Mock parser to allow overriding constructor behavior per test. */ -jest.mock('parsers/interlinearXmlParser', () => { - const actual = jest.requireActual( - 'parsers/interlinearXmlParser', - ); - return { - InterlinearXmlParser: jest.fn().mockImplementation(() => new actual.InterlinearXmlParser()), - }; -}); +import { useProjectData } from '@papi/frontend/react'; /** * Load the WebView module; it assigns the component to globalThis.webViewComponent. This pattern is @@ -32,94 +22,92 @@ if (!InterlinearizerWebView) throw new Error('webViewComponent not loaded'); /** Minimal SerializedVerseRef for hook mock return. */ const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; -/** Full WebViewProps for tests; interlinearizer component ignores hooks/update. */ -const testWebViewProps: WebViewProps = { - id: 'test-id', - webViewType: 'interlinearizer.mainWebView', - useWebViewState: (_key: string, defaultValue: T): [T, (v: T) => void, () => void] => [ - defaultValue, - () => {}, - () => {}, - ], - useWebViewScrollGroupScrRef: (): [ - SerializedVerseRef, - (r: SerializedVerseRef) => void, - number | undefined, - (id: number | undefined) => void, - ] => [defaultScrRef, () => {}, undefined, () => {}], - updateWebViewDefinition: () => true, -}; +const testProjectId = 'test-project-id'; + +/** Builds a minimal WebViewProps for tests. */ +function makeProps(projectId?: string): WebViewProps { + return { + id: 'test-id', + webViewType: 'interlinearizer.mainWebView', + projectId, + useWebViewState: (_key: string, defaultValue: T): [T, (v: T) => void, () => void] => [ + defaultValue, + () => {}, + () => {}, + ], + useWebViewScrollGroupScrRef: (): [ + SerializedVerseRef, + (r: SerializedVerseRef) => void, + number | undefined, + (id: number | undefined) => void, + ] => [defaultScrRef, () => {}, undefined, () => {}], + updateWebViewDefinition: () => true, + }; +} + +/** Configures useProjectData to return the given BookUSJ value and loading state this render. */ +function mockBookData(value: unknown, isLoading = false): void { + jest.mocked(useProjectData).mockImplementation(() => ({ + BookUSJ: () => [value, jest.fn(), isLoading], + })); +} describe('InterlinearizerWebView', () => { + beforeEach(() => { + mockBookData(undefined); + }); + it('renders the heading "Interlinearizer"', () => { - render(); + render(); expect(screen.getByRole('heading', { name: /interlinearizer/i })).toBeInTheDocument(); }); - it('renders the description mentioning test-data XML', () => { - render(); + it('shows a prompt to open from a project when no projectId is provided', () => { + render(); - expect( - screen.getByText(/raw json of the model parsed from/i, { exact: false }), - ).toBeInTheDocument(); - expect(screen.getByText(/test-data\/Interlinear_en_MAT\.xml/i)).toBeInTheDocument(); + expect(screen.getByText(/open this webview from a paratext project/i)).toBeInTheDocument(); }); - it('parses the bundled test XML and displays parsed JSON', () => { - render(); + it('shows the book and projectId when a project is linked', () => { + mockBookData({ type: 'USJ', version: '3.1', content: [] }); + render(); - expect(screen.getByText(/parsed interlinear data \(json\)/i)).toBeInTheDocument(); - expect(screen.getByText(/"GlossLanguage"/)).toBeInTheDocument(); - expect(screen.getByText(/"BookId"/)).toBeInTheDocument(); + expect(screen.getByText(new RegExp(testProjectId))).toBeInTheDocument(); + expect(screen.getByText(/GEN · project/)).toBeInTheDocument(); }); - it('displays parsed structure with expected verse data', () => { - render(); + it('shows Loading when projectId is set but book data has not arrived', () => { + mockBookData(undefined, true); + render(); - expect(screen.getByText(/"en"/)).toBeInTheDocument(); - expect(screen.getByText(/"MAT"/)).toBeInTheDocument(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); }); - it('does not show parse error when XML is valid', () => { - render(); + it('shows an error when no USJ book is available for the project', () => { + mockBookData(undefined, false); + render(); - expect(screen.queryByText(/^parse error$/i)).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); + expect(screen.getByText(/no usj book available for gen in project/i)).toBeInTheDocument(); }); - it('displays parse error when parser throws an Error (uses err.message)', () => { - const actual = jest.requireActual( - '../parsers/interlinearXmlParser', - ); - const realInstance = new actual.InterlinearXmlParser(); - const throwingParse = (): never => { - throw new Error('Invalid XML structure'); - }; - Object.defineProperty(realInstance, 'parse', { value: throwingParse, writable: true }); - jest.mocked(InterlinearXmlParser).mockImplementationOnce(() => realInstance); - - render(); - - expect(screen.getByRole('heading', { name: /^parse error$/i })).toBeInTheDocument(); - expect(screen.getByText(/invalid xml structure/i)).toBeInTheDocument(); + it('shows the raw USFM when book data arrives', () => { + mockBookData({ + type: 'USJ', + version: '3.1', + content: [{ type: 'book', marker: 'id', code: 'EXO' }], + }); + render(); + + expect(screen.getByText(/"code": "EXO"/)).toBeInTheDocument(); }); - it('displays parse error when parser throws non-Error (uses String(err))', () => { - const actual = jest.requireActual( - '../parsers/interlinearXmlParser', - ); - const realInstance = new actual.InterlinearXmlParser(); - const throwingParse = (): never => { - // Intentionally throw a non-Error to test the String(err) branch in the catch block. - // eslint-disable-next-line no-throw-literal -- testing non-Error handling - throw 'plain string error'; - }; - Object.defineProperty(realInstance, 'parse', { value: throwingParse, writable: true }); - jest.mocked(InterlinearXmlParser).mockImplementationOnce(() => realInstance); - - render(); - - expect(screen.getByRole('heading', { name: /^parse error$/i })).toBeInTheDocument(); - expect(screen.getByText('plain string error')).toBeInTheDocument(); + it('shows an error heading and message when book data is a PlatformError', () => { + mockBookData({ isPlatformError: true, message: 'Project not found' }); + render(); + + expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); + expect(screen.getByText(/project not found/i)).toBeInTheDocument(); }); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 69bd8c7f..8cdf744d 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -1,15 +1,21 @@ /** @file Unit tests for the extension entry point (main.ts). */ /// -import type { IWebViewProvider, SavedWebViewDefinition } from '@papi/core'; +import type { SavedWebViewDefinition } from '@papi/core'; import papiBackendMock from '@papi/backend'; import { activate, deactivate } from '@main'; +import type { InterlinearizerOpenOptions } from '@main'; import { createTestActivationContext } from './test-helpers'; /** Shape of the Jest-mocked @papi/backend default export used in these tests. */ interface PapiBackendTestMock { __mockRegisterWebViewProvider: jest.Mock; + __mockRegisterCommand: jest.Mock; __mockOpenWebView: jest.Mock; + __mockSelectProject: jest.Mock; + __mockGetOpenWebViewDefinition: jest.Mock; + __mockOnDidOpenWebView: jest.Mock; + __mockOnDidCloseWebView: jest.Mock; __mockLogger: { debug: jest.Mock; error: jest.Mock; info: jest.Mock; warn: jest.Mock }; } @@ -19,34 +25,44 @@ interface PapiBackendTestMock { */ function isPapiBackendTestMock(m: unknown): m is PapiBackendTestMock { return ( + !!m && typeof m === 'object' && - m !== undefined && - m instanceof Object && '__mockRegisterWebViewProvider' in m && + '__mockRegisterCommand' in m && '__mockOpenWebView' in m && + '__mockSelectProject' in m && + '__mockGetOpenWebViewDefinition' in m && + '__mockOnDidOpenWebView' in m && + '__mockOnDidCloseWebView' in m && '__mockLogger' in m ); } -/** - * Type guard for the WebView provider passed to registerWebViewProvider. Used to obtain a properly - * typed provider from the mock without type assertions. - */ -function isIWebViewProvider(x: unknown): x is IWebViewProvider { - if (x === undefined || (typeof x === 'object' && !(x instanceof Object))) return false; - if (typeof x !== 'object') return false; - if (!('getWebView' in x)) return false; - return typeof x.getWebView === 'function'; -} - if (!isPapiBackendTestMock(papiBackendMock)) throw new Error('Expected mocked @papi/backend'); -const { __mockRegisterWebViewProvider, __mockOpenWebView, __mockLogger } = papiBackendMock; +const { + __mockRegisterWebViewProvider, + __mockRegisterCommand, + __mockOpenWebView, + __mockSelectProject, + __mockGetOpenWebViewDefinition, + __mockOnDidOpenWebView, + __mockOnDidCloseWebView, + __mockLogger, +} = papiBackendMock; describe('main', () => { const mainWebViewType = 'interlinearizer.mainWebView'; + afterEach(() => deactivate()); + beforeEach(() => { - jest.clearAllMocks(); + __mockRegisterWebViewProvider.mockResolvedValue({ dispose: jest.fn() }); + __mockRegisterCommand.mockResolvedValue({ dispose: jest.fn() }); + __mockOpenWebView.mockResolvedValue('mock-webview-id'); + __mockSelectProject.mockResolvedValue(undefined); + __mockGetOpenWebViewDefinition.mockResolvedValue(undefined); + __mockOnDidOpenWebView.mockReturnValue(jest.fn()); + __mockOnDidCloseWebView.mockReturnValue(jest.fn()); }); describe('activate', () => { @@ -64,36 +80,24 @@ describe('main', () => { ); }); - it('adds the registration to the activation context', async () => { - const mockRegistration = { dispose: jest.fn() }; - jest.mocked(__mockRegisterWebViewProvider).mockResolvedValue(mockRegistration); - const context = createTestActivationContext(); - - await activate(context); - - expect(context.registrations.unsubscribers.size).toBe(1); - }); - - it('opens the WebView after registration', async () => { + it('registers the interlinearizer.openForWebView command', async () => { const context = createTestActivationContext(); await activate(context); - expect(__mockOpenWebView).toHaveBeenCalledWith(mainWebViewType, undefined, { - existingId: '?', - }); + expect(__mockRegisterCommand).toHaveBeenCalledWith( + 'interlinearizer.openForWebView', + expect.any(Function), + expect.any(Object), + ); }); - it('catches and logs when openWebView throws', async () => { - const openError = new Error('WebView open failed'); - __mockOpenWebView.mockRejectedValue(openError); + it('adds all four registrations to the activation context', async () => { const context = createTestActivationContext(); await activate(context); - expect(__mockLogger.error).toHaveBeenCalledWith( - `Failed to open ${mainWebViewType} WebView: ${openError}`, - ); + expect(context.registrations.unsubscribers.size).toBe(4); }); it('logs activation start and finish', async () => { @@ -109,20 +113,35 @@ describe('main', () => { }); describe('mainWebViewProvider.getWebView', () => { + type WebViewProvider = { + getWebView(saved: SavedWebViewDefinition, opts?: object): Promise; + }; + + function isWebViewProvider(x: unknown): x is WebViewProvider { + return ( + !!x && typeof x === 'object' && 'getWebView' in x && typeof x.getWebView === 'function' + ); + } + + /** Retrieves the provider registered with the platform and asserts it exists. */ + function getRegisteredProvider(): WebViewProvider { + const raw = jest.mocked(__mockRegisterWebViewProvider).mock.calls[0]?.[1]; + if (!isWebViewProvider(raw)) throw new Error('Expected registered provider'); + return raw; + } + it('returns WebView definition when webViewType matches', async () => { const context = createTestActivationContext(); await activate(context); - const rawProvider = jest.mocked(__mockRegisterWebViewProvider).mock.calls[0]?.[1]; - expect(rawProvider).toBeDefined(); - if (!isIWebViewProvider(rawProvider)) throw new Error('Expected registered provider'); + const provider = getRegisteredProvider(); const savedWebView: SavedWebViewDefinition = { id: 'test-webview-id', webViewType: mainWebViewType, }; - const result = await rawProvider.getWebView(savedWebView, {}, 'test-nonce'); + const result = await provider.getWebView(savedWebView, {}); expect(result).toMatchObject({ ...savedWebView, @@ -132,23 +151,339 @@ describe('main', () => { }); }); + it('propagates projectId from options into the WebView definition', async () => { + const context = createTestActivationContext(); + await activate(context); + + const provider = getRegisteredProvider(); + const savedWebView: SavedWebViewDefinition = { + id: 'test-webview-id', + webViewType: mainWebViewType, + }; + + const options: InterlinearizerOpenOptions = { projectId: 'my-project' }; + const result = await provider.getWebView(savedWebView, options); + + expect(result).toMatchObject({ projectId: 'my-project' }); + }); + + it('falls back to savedWebView.projectId when options has no projectId', async () => { + const context = createTestActivationContext(); + await activate(context); + + const provider = getRegisteredProvider(); + const savedWebView: SavedWebViewDefinition = { + id: 'test-webview-id', + webViewType: mainWebViewType, + projectId: 'saved-project', + }; + + const result = await provider.getWebView(savedWebView, {}); + + expect(result).toMatchObject({ projectId: 'saved-project' }); + }); + it('throws when webViewType does not match', async () => { const context = createTestActivationContext(); await activate(context); - const rawProvider = jest.mocked(__mockRegisterWebViewProvider).mock.calls[0]?.[1]; - expect(rawProvider).toBeDefined(); - if (!isIWebViewProvider(rawProvider)) throw new Error('Expected registered provider'); + const provider = getRegisteredProvider(); const savedWebView: SavedWebViewDefinition = { id: 'other-id', webViewType: 'other.webView', }; - await expect(rawProvider.getWebView(savedWebView, {}, 'test-nonce')).rejects.toThrow( + await expect(provider.getWebView(savedWebView, {})).rejects.toThrow( `${mainWebViewType} provider received request to provide a ${savedWebView.webViewType} WebView`, ); }); + + it('falls back to savedWebView.projectId when options is undefined', async () => { + const context = createTestActivationContext(); + await activate(context); + + const provider = getRegisteredProvider(); + const savedWebView: SavedWebViewDefinition = { + id: 'test-webview-id', + webViewType: mainWebViewType, + projectId: 'saved-project', + }; + + const result = await provider.getWebView(savedWebView, undefined); + + expect(result).toMatchObject({ projectId: 'saved-project' }); + }); + }); + + function isCallable(f: unknown): f is (...args: unknown[]) => unknown { + return typeof f === 'function'; + } + + function findRegisteredHandler( + commandName: string, + ): ((...args: unknown[]) => unknown) | undefined { + const call = jest.mocked(__mockRegisterCommand).mock.calls.find((c) => c[0] === commandName); + const rawHandler: unknown = call?.[1]; + return isCallable(rawHandler) ? rawHandler : undefined; + } + + describe('interlinearizer.openForWebView command', () => { + async function getOpenForWebViewHandler(): Promise< + (webViewId?: string) => Promise + > { + const context = createTestActivationContext(); + await activate(context); + const rawHandler = findRegisteredHandler('interlinearizer.openForWebView'); + if (!rawHandler) throw new Error('Handler not found for interlinearizer.openForWebView'); + return async (webViewId?: string): Promise => { + const result: unknown = await rawHandler(webViewId); + return typeof result === 'string' ? result : undefined; + }; + } + + it('looks up the projectId from the given WebView and opens the Interlinearizer', async () => { + __mockGetOpenWebViewDefinition.mockResolvedValue({ + id: 'some-webview', + webViewType: 'someExtension.view', + projectId: 'project-from-webview', + }); + const openForWebView = await getOpenForWebViewHandler(); + + await openForWebView('some-webview'); + + expect(__mockGetOpenWebViewDefinition).toHaveBeenCalledWith('some-webview'); + expect(__mockSelectProject).not.toHaveBeenCalled(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ projectId: 'project-from-webview' }), + ); + }); + + it('shows a project picker when the WebView has no projectId', async () => { + __mockGetOpenWebViewDefinition.mockResolvedValue({ + id: 'some-webview', + webViewType: 'someExtension.view', + }); + __mockSelectProject.mockResolvedValue('picker-project'); + const openForWebView = await getOpenForWebViewHandler(); + + await openForWebView('some-webview'); + + expect(__mockSelectProject).toHaveBeenCalledTimes(1); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ projectId: 'picker-project' }), + ); + }); + + it('shows a project picker when the WebView definition is not found', async () => { + __mockGetOpenWebViewDefinition.mockResolvedValue(undefined); + __mockSelectProject.mockResolvedValue('picker-project'); + const openForWebView = await getOpenForWebViewHandler(); + + await openForWebView('nonexistent-webview'); + + expect(__mockSelectProject).toHaveBeenCalledTimes(1); + }); + + it('shows a project picker when no webViewId is provided', async () => { + __mockSelectProject.mockResolvedValue('picker-project'); + const openForWebView = await getOpenForWebViewHandler(); + + await openForWebView(); + + expect(__mockGetOpenWebViewDefinition).not.toHaveBeenCalled(); + expect(__mockSelectProject).toHaveBeenCalledTimes(1); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ projectId: 'picker-project' }), + ); + }); + + it('returns undefined when the user cancels the project picker', async () => { + __mockGetOpenWebViewDefinition.mockResolvedValue(undefined); + __mockSelectProject.mockResolvedValue(undefined); + const openForWebView = await getOpenForWebViewHandler(); + + const result = await openForWebView('some-webview'); + + expect(result).toBeUndefined(); + expect(__mockOpenWebView).not.toHaveBeenCalled(); + }); + + it('returns the WebView ID when the Interlinearizer opens successfully', async () => { + __mockGetOpenWebViewDefinition.mockResolvedValue({ + id: 'src-webview', + webViewType: 'someExtension.view', + projectId: 'my-project', + }); + __mockOpenWebView.mockResolvedValue('interlinearizer-webview-id'); + const openForWebView = await getOpenForWebViewHandler(); + + const result = await openForWebView('src-webview'); + + expect(result).toBe('interlinearizer-webview-id'); + }); + }); + + describe('WebView lifecycle event subscriptions', () => { + it('subscribes to onDidOpenWebView and onDidCloseWebView during activation', async () => { + const context = createTestActivationContext(); + + await activate(context); + + expect(__mockOnDidOpenWebView).toHaveBeenCalledTimes(1); + expect(__mockOnDidCloseWebView).toHaveBeenCalledTimes(1); + }); + + function getOpenWebViewCallback(): (event: { webView: SavedWebViewDefinition }) => void { + const cb: unknown = __mockOnDidOpenWebView.mock.calls[0]?.[0]; + if (!isCallable(cb)) throw new Error('onDidOpenWebView callback not found'); + return (event) => cb(event); + } + + function getCloseWebViewCallback(): (event: { webView: SavedWebViewDefinition }) => void { + const cb: unknown = __mockOnDidCloseWebView.mock.calls[0]?.[0]; + if (!isCallable(cb)) throw new Error('onDidCloseWebView callback not found'); + return (event) => cb(event); + } + + describe('onDidOpenWebView callback', () => { + it('adds the webView to the project map so subsequent opens reuse the 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(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ existingId: 'tab-from-event', projectId: 'my-project' }), + ); + }); + + it('ignores webViews with a non-matching webViewType', async () => { + __mockSelectProject.mockResolvedValue('my-project'); + const context = createTestActivationContext(); + await activate(context); + + getOpenWebViewCallback()({ + webView: { id: 'other-tab', webViewType: 'other.webView', projectId: 'my-project' }, + }); + + await findRegisteredHandler('interlinearizer.openForWebView')?.(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.not.objectContaining({ existingId: expect.any(String) }), + ); + }); + + it('ignores webViews with no projectId', async () => { + __mockSelectProject.mockResolvedValue('my-project'); + const context = createTestActivationContext(); + await activate(context); + + getOpenWebViewCallback()({ + webView: { id: 'no-project-tab', webViewType: mainWebViewType }, + }); + + await findRegisteredHandler('interlinearizer.openForWebView')?.(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.not.objectContaining({ existingId: expect.any(String) }), + ); + }); + }); + + describe('onDidCloseWebView callback', () => { + it('removes the project map entry so the next open creates a new tab', async () => { + __mockSelectProject.mockResolvedValue('my-project'); + const context = createTestActivationContext(); + await activate(context); + + getOpenWebViewCallback()({ + webView: { id: 'my-tab', webViewType: mainWebViewType, projectId: 'my-project' }, + }); + getCloseWebViewCallback()({ + webView: { id: 'my-tab', webViewType: mainWebViewType, projectId: 'my-project' }, + }); + + await findRegisteredHandler('interlinearizer.openForWebView')?.(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.not.objectContaining({ existingId: expect.any(String) }), + ); + }); + + it('does not remove the project map entry when a different webView closes', async () => { + __mockSelectProject.mockResolvedValue('my-project'); + const context = createTestActivationContext(); + await activate(context); + + getOpenWebViewCallback()({ + webView: { id: 'my-tab', webViewType: mainWebViewType, projectId: 'my-project' }, + }); + getCloseWebViewCallback()({ + webView: { id: 'other-tab', webViewType: mainWebViewType, projectId: 'my-project' }, + }); + + await findRegisteredHandler('interlinearizer.openForWebView')?.(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ existingId: 'my-tab' }), + ); + }); + + it('ignores close events for webViews with a non-matching webViewType', async () => { + __mockSelectProject.mockResolvedValue('my-project'); + const context = createTestActivationContext(); + await activate(context); + + getOpenWebViewCallback()({ + webView: { id: 'my-tab', webViewType: mainWebViewType, projectId: 'my-project' }, + }); + getCloseWebViewCallback()({ + webView: { id: 'my-tab', webViewType: 'other.webView', projectId: 'my-project' }, + }); + + await findRegisteredHandler('interlinearizer.openForWebView')?.(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ existingId: 'my-tab' }), + ); + }); + + it('ignores close events for webViews with no projectId', async () => { + __mockSelectProject.mockResolvedValue('my-project'); + const context = createTestActivationContext(); + await activate(context); + + getOpenWebViewCallback()({ + webView: { id: 'my-tab', webViewType: mainWebViewType, projectId: 'my-project' }, + }); + getCloseWebViewCallback()({ webView: { id: 'my-tab', webViewType: mainWebViewType } }); + + await findRegisteredHandler('interlinearizer.openForWebView')?.(); + expect(__mockOpenWebView).toHaveBeenCalledWith( + mainWebViewType, + undefined, + expect.objectContaining({ existingId: 'my-tab' }), + ); + }); + }); }); describe('deactivate', () => { diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index f47bd69a..7eb9c8e6 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -1,53 +1,80 @@ -import { useMemo } from 'react'; -import type { InterlinearData } from 'interlinearizer'; -import { InterlinearXmlParser } from './parsers/interlinearXmlParser'; - -/** Test interlinear XML bundled at build time (from test-data/Interlinear_en_MAT.xml). */ -import testXml from '../test-data/Interlinear_en_MAT.xml?raw'; - -/** Result of parsing the bundled test XML: either data or an error message. */ -type ParseResult = { data: InterlinearData; error: undefined } | { data: undefined; error: string }; +import type { WebViewProps } from '@papi/core'; +import { useProjectData } from '@papi/frontend/react'; +import { isPlatformError } from 'platform-bible-utils'; /** - * Main interlinearizer WebView. Parses the bundled test XML into the interlinear model and displays - * the result as raw JSON. No PAPI commands or file loading—everything is self-contained. - * - * Parser is created inside useMemo so parsing runs once per mount. + * Fetches and displays the USJ book data for the given project and scripture reference. Shows a + * loading indicator while data is in flight, an error message if the fetch fails or returns no + * data, and the raw JSON of the book otherwise. */ -globalThis.webViewComponent = function InterlinearizerWebView() { - const { data: parsed, error: parseError } = useMemo((): ParseResult => { - const parser = new InterlinearXmlParser(); - try { - const data = parser.parse(testXml); - return { data, error: undefined }; - } catch (err) { - return { data: undefined, error: err instanceof Error ? err.message : String(err) }; - } - }, []); +function ProjectBookFetcher({ + projectId, + scrRef, +}: { + projectId: string; + scrRef: ReturnType[0]; +}) { + const [bookResult, , isLoading] = useProjectData('platformScripture.USJ_Book', projectId).BookUSJ( + scrRef, + undefined, + ); + + let bookUsj: typeof bookResult | undefined; + let bookError: string | undefined; + + if (isPlatformError(bookResult)) { + bookError = bookResult.message; + } else if (!isLoading && bookResult === undefined) { + bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`; + } else { + bookUsj = bookResult; + } return ( -
-

Interlinearizer

+ <>

- Raw JSON of the model parsed from test-data/Interlinear_en_MAT.xml. + {scrRef.book} · project {projectId}

- {parseError && ( + {bookError && (
-

Parse error

-
-            {parseError}
+          

Error loading book

+
+            {bookError}
           
)} - {parsed && ( - <> -

Parsed interlinear data (JSON):

-
-            {JSON.stringify(parsed, undefined, 2)}
-          
- + {!bookError && ( +
+          {isLoading ? 'Loading…' : JSON.stringify(bookUsj, undefined, 2)}
+        
+ )} + + ); +} + +/** + * Root WebView component for the Interlinearizer. Reads the scroll-group scripture reference and + * delegates book fetching to {@link ProjectBookFetcher}. Shows a placeholder when no projectId is + * provided (i.e. the WebView was opened without a project). + */ +globalThis.webViewComponent = function InterlinearizerWebView({ + projectId, + useWebViewScrollGroupScrRef, +}: WebViewProps) { + const [scrRef] = useWebViewScrollGroupScrRef(); + + return ( +
+

Interlinearizer

+ + {projectId ? ( + + ) : ( +

+ Open this WebView from a Paratext project to load its source book. +

)}
); diff --git a/src/main.ts b/src/main.ts index be9a26ce..0b57c532 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import papi, { logger } from '@papi/backend'; import type { ExecutionActivationContext, IWebViewProvider, + OpenWebViewOptions, SavedWebViewDefinition, WebViewDefinition, } from '@papi/core'; @@ -9,22 +10,31 @@ import interlinearizerReact from './interlinearizer.web-view?inline'; import interlinearizerStyles from './interlinearizer.web-view.scss?inline'; /** - * WebView type identifier for the interlinearizer. Used when registering the provider and when + * WebView type identifier for the Interlinearizer. Used when registering the provider and when * opening the WebView from the platform. */ const mainWebViewType = 'interlinearizer.mainWebView'; -/** WebView provider that provides the interlinearizer React WebView when Platform.Bible requests it. */ +/** Options passed to `openWebView` when opening the Interlinearizer. */ +export interface InterlinearizerOpenOptions extends OpenWebViewOptions { + projectId?: string; +} + +/** WebView provider that provides the Interlinearizer React WebView when Platform.Bible requests it. */ const mainWebViewProvider: IWebViewProvider = { /** - * Returns the interlinearizer WebView definition (React component + styles) for the given saved + * Returns the Interlinearizer WebView definition (React component + styles) for the given saved * definition. Rejects if the requested webViewType does not match this provider's type. * * @param savedWebView - Platform-provided definition (webViewType, etc.). + * @param openWebViewOptions - Options passed by the caller; may include a projectId to link. * @returns WebView definition with title, content, and styles, or undefined. - * @throws {Error} When savedWebView.webViewType is not the interlinearizer type. + * @throws {Error} When savedWebView.webViewType is not the Interlinearizer type. */ - async getWebView(savedWebView: SavedWebViewDefinition): Promise { + async getWebView( + savedWebView: SavedWebViewDefinition, + openWebViewOptions?: InterlinearizerOpenOptions, + ): Promise { if (savedWebView.webViewType !== mainWebViewType) { throw new Error( `${mainWebViewType} provider received request to provide a ${savedWebView.webViewType} WebView`, @@ -32,6 +42,7 @@ const mainWebViewProvider: IWebViewProvider = { } return { ...savedWebView, + projectId: openWebViewOptions?.projectId ?? savedWebView.projectId, title: 'Interlinearizer', content: interlinearizerReact, styles: interlinearizerStyles, @@ -40,11 +51,50 @@ const mainWebViewProvider: IWebViewProvider = { }; /** - * Extension entry point. Registers the interlinearizer WebView provider and opens the WebView. + * Tracks the WebView ID opened for each project so subsequent opens of the same project bring that + * tab to front instead of opening a duplicate. Populated and pruned via `onDidOpenWebView` and + * `onDidCloseWebView` subscriptions registered during activation. + */ +const openWebViewsByProject = new Map(); + +/** + * Opens the Interlinearizer WebView for the given project. If no projectId is provided, shows a + * project picker dialog. Each project gets its own tab; reopening an already-open project brings + * that tab to front. Returns the WebView ID, or undefined if the user cancels. + */ +async function openInterlinearizer(projectId?: string): Promise { + const resolvedProjectId = + projectId ?? + (await papi.dialogs.selectProject({ + title: '%interlinearizer_dialog_open_title%', + prompt: '%interlinearizer_dialog_open_prompt%', + })); + + if (!resolvedProjectId) return undefined; + + const options: InterlinearizerOpenOptions = { + existingId: openWebViewsByProject.get(resolvedProjectId), + projectId: resolvedProjectId, + }; + return papi.webViews.openWebView(mainWebViewType, undefined, options); +} + +/** + * Opens the Interlinearizer for the project associated with the given WebView. Called from the + * WebView context menu, which passes the tab's WebView ID as the argument. + */ +async function openInterlinearizerForWebView(webViewId?: string): Promise { + if (!webViewId) return openInterlinearizer(); + const webViewDefinition = await papi.webViews.getOpenWebViewDefinition(webViewId); + return openInterlinearizer(webViewDefinition?.projectId); +} + +/** + * Extension entry point. Registers the Interlinearizer WebView provider and the open command. * Called by the platform when the extension is loaded. * - * @param context - Activation context; used to register the WebView provider so the platform can - * clean it up on deactivation. + * @param context - Activation context; used to register disposables so the platform can clean them + * up on deactivation. */ export async function activate(context: ExecutionActivationContext): Promise { logger.debug('Interlinearizer extension is activating!'); @@ -54,13 +104,46 @@ export async function activate(context: ExecutionActivationContext): Promise { + if (webView.webViewType !== mainWebViewType || !webView.projectId) return; + openWebViewsByProject.set(webView.projectId, webView.id); + }); - try { - await papi.webViews.openWebView(mainWebViewType, undefined, { existingId: '?' }); - } catch (err) { - logger.error(`Failed to open ${mainWebViewType} WebView: ${err}`); - } + const webViewCloseUnsubscriber = papi.webViews.onDidCloseWebView(({ webView }) => { + if (webView.webViewType !== mainWebViewType || !webView.projectId) return; + if (openWebViewsByProject.get(webView.projectId) === webView.id) + openWebViewsByProject.delete(webView.projectId); + }); + + context.registrations.add( + mainWebViewProviderRegistration, + openForWebViewCommandRegistration, + webViewOpenUnsubscriber, + webViewCloseUnsubscriber, + ); logger.debug('Interlinearizer extension finished activating!'); } @@ -72,6 +155,7 @@ export async function activate(context: ExecutionActivationContext): Promise { + openWebViewsByProject.clear(); logger.debug('Interlinearizer extension is deactivating!'); return true; } diff --git a/src/parsers/interlinearXmlParser.ts b/src/parsers/interlinearXmlParser.ts index 06f6eeec..63f0dc9d 100644 --- a/src/parsers/interlinearXmlParser.ts +++ b/src/parsers/interlinearXmlParser.ts @@ -1,12 +1,69 @@ import { X2jOptions, XMLParser } from 'fast-xml-parser'; -import type { - LexemeData, - PunctuationData, - ClusterData, - StringRange, - InterlinearData, - VerseData, -} from 'interlinearizer'; + +/** Character range in source text (Index, Length). */ +export interface StringRange { + /** Start index of the range in the source text (0-based). */ + Index: number; + /** Number of characters in the range. */ + Length: number; +} + +/** Data on the interlinearization of a single lexeme. */ +export interface LexemeData { + /** ID of the lexeme (e.g. from Lexicon; XML attribute Id). */ + LexemeId: string; + /** ID of the sense/gloss used for this lexeme (XML attribute GlossId). */ + SenseId: string; +} + +/** Data on the interlinearization of a cluster. */ +export interface ClusterData { + /** Character range this cluster occupies in the verse text. */ + TextRange: StringRange; + /** Lexemes in this cluster, in order. */ + Lexemes: LexemeData[]; + /** Slash-joined LexemeIds for this cluster (e.g. "Word:a/Word:b"). */ + LexemesId: string; + /** Unique cluster id: LexemesId plus TextRange (e.g. "Word:a/Word:b/21-3"). */ + Id: string; + /** Excluded flag. See [pt9-xml.md](./pt9-xml.md) for details. */ + Excluded: boolean; +} + +/** Data on punctuation change. */ +export interface PunctuationData { + /** Character range this punctuation occupies in the verse text. */ + TextRange: StringRange; + /** Punctuation text before the change (or empty). */ + BeforeText: string; + /** Punctuation text after the change (or empty). */ + AfterText: string; +} + +/** Interlinear data for a single verse. */ +export interface VerseData { + /** Hash of verse text when approved; empty string if not approved. */ + Hash: string; + /** Lexeme clusters in this verse. */ + Clusters: ClusterData[]; + /** Punctuation changes in this verse. */ + Punctuations: PunctuationData[]; +} + +/** Root interlinear data: book + verses. */ +export interface InterlinearData { + /** Source text / project name (e.g. from InterlinearData ScrTextName attribute). */ + ScrTextName: string; + /** Language code or name for the glosses. */ + GlossLanguage: string; + /** Book id (e.g. "RUT", "MAT"). */ + BookId: string; + /** + * Verse data keyed by verse reference (e.g. "RUT 3:1"). Exactly one entry per reference; the + * parser rejects XML that contains duplicate verse references. + */ + Verses: Record; +} /** Range: Index and Length attributes. */ interface ParsedRange { diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 75736ff6..8583e038 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -2,74 +2,14 @@ * @file Extension type declaration file. Platform.Bible shares this with other extensions. Types * exposed here (and in papi-shared-types) are available to other extensions. */ -/** - * Interlinear types (InterlinearData, VerseData, ClusterData, etc.) are the public API for - * interlinear data. The XML parser in src/parsers/interlinearXmlParser.ts consumes raw - * fast-xml-parser output internally and returns objects conforming to these types. - */ -declare module 'interlinearizer' { - /** Character range in source text (Index, Length). */ - export interface StringRange { - /** Start index of the range in the source text (0-based). */ - Index: number; - /** Number of characters in the range. */ - Length: number; - } - - /** Data on the interlinearization of a single lexeme. */ - export interface LexemeData { - /** ID of the lexeme (e.g. from Lexicon; XML attribute Id). */ - LexemeId: string; - /** ID of the sense/gloss used for this lexeme (XML attribute GlossId). */ - SenseId: string; - } - - /** Data on the interlinearization of a cluster. */ - export interface ClusterData { - /** Character range this cluster occupies in the verse text. */ - TextRange: StringRange; - /** Lexemes in this cluster, in order. */ - Lexemes: LexemeData[]; - /** Slash-joined LexemeIds for this cluster (e.g. "Word:a/Word:b"). */ - LexemesId: string; - /** Unique cluster id: LexemesId plus TextRange (e.g. "Word:a/Word:b/21-3"). */ - Id: string; - /** Excluded flag. See [pt9-xml.md](../parsers/pt9-xml.md) for details. */ - Excluded: boolean; - } - - /** Data on punctuation change. */ - export interface PunctuationData { - /** Character range this punctuation occupies in the verse text. */ - TextRange: StringRange; - /** Punctuation text before the change (or empty). */ - BeforeText: string; - /** Punctuation text after the change (or empty). */ - AfterText: string; - } - - /** Interlinear data for a single verse. */ - export interface VerseData { - /** Hash of verse text when approved; empty string if not approved. */ - Hash: string; - /** Lexeme clusters in this verse. */ - Clusters: ClusterData[]; - /** Punctuation changes in this verse. */ - Punctuations: PunctuationData[]; - } - /** Root interlinear data: book + verses. */ - export interface InterlinearData { - /** Source text / project name (e.g. from InterlinearData ScrTextName attribute). */ - ScrTextName: string; - /** Language code or name for the glosses. */ - GlossLanguage: string; - /** Book id (e.g. "RUT", "MAT"). */ - BookId: string; +declare module 'papi-shared-types' { + export interface CommandHandlers { /** - * Verse data keyed by verse reference (e.g. "RUT 3:1"). Exactly one entry per reference; the - * parser rejects XML that contains duplicate verse references. + * Opens the Interlinearizer for the project associated with the given WebView ID. Called from + * WebView context menus, which pass the tab's WebView ID as the argument. Falls back to a + * project picker dialog if the WebView has no project or no ID is given. */ - Verses: Record; + 'interlinearizer.openForWebView': (webViewId?: string) => Promise; } }