diff --git a/AGENTS.md b/AGENTS.md index 4685ffb84..357af3f67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla [src/main.ts](src/main.ts) — called by Platform.Bible on activation. Exports two lifecycle functions: -- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` network event emitter, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidUpdateWebView` / `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, the `interlinearizer.onUndoFromNotification` and `interlinearizer.onAnalysisChanged` network event emitters, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidUpdateWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation. - `deactivate()` — clears `openWebViewsByProject`, lets go of every book `bookService` keeps, 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. diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index e55019208..411c26ce3 100644 --- a/src/__tests__/hooks/useInterlinearizerBookData.test.ts +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -5,6 +5,7 @@ import { useProjectSetting } from '@papi/frontend/react'; import { act, renderHook, waitFor } from '@testing-library/react'; import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book } from 'interlinearizer'; +import { useEvent } from 'platform-bible-react'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; import { getMockedSendCommand, makeSegment, makeWordToken } from '../test-helpers'; @@ -297,4 +298,73 @@ describe('useInterlinearizerBookData', () => { expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); }); }); + + describe('when Scripture text is edited', () => { + /** Stands in for the network event the platform relays a Scripture edit on. */ + const ANALYSIS_CHANGED_EVENT = () => () => true; + + beforeEach(() => { + jest.mocked(papi.network.getNetworkEvent).mockReturnValue(ANALYSIS_CHANGED_EVENT); + }); + + /** Relays an analysis change to the hook, as the platform does. */ + function emitChange(sourceProjectId: string, bookId: string): void { + const handler = [...jest.mocked(useEvent).mock.calls] + .reverse() + .find(([event]) => event === ANALYSIS_CHANGED_EVENT)?.[1]; + if (typeof handler !== 'function') throw new Error('nothing listens for the change event'); + act(() => handler({ sourceProjectId, changeId: 'change-1', scriptureEdit: { bookId } })); + } + + it('listens for analysis changes', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(papi.network.getNetworkEvent).toHaveBeenCalledWith( + 'interlinearizer.onAnalysisChanged', + ); + }); + + it('fetches the book again when its own text is edited', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK)); + const revised: Book = { ...TEST_BOOK, textVersion: 'v2' }; + serveBooks(revised); + + emitChange('test-project', 'GEN'); + + await waitFor(() => expect(result.current.book).toEqual(revised)); + }); + + it('fetches nothing more when another project is edited', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + emitChange('other-project', 'GEN'); + + expect(result.current.isLoading).toBe(false); + expect(mockSendCommand).toHaveBeenCalledTimes(1); + }); + + it('fetches nothing more when another book is edited', async () => { + const { result } = renderBookData(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + emitChange('test-project', 'EXO'); + + expect(result.current.isLoading).toBe(false); + expect(mockSendCommand).toHaveBeenCalledTimes(1); + }); + + it('fetches the book again when the book it moved to is edited', async () => { + const { result, rerender } = renderBookData(); + await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK)); + rerender({ scrRef: EXO_1_1 }); + await waitFor(() => expect(result.current.book).toEqual(EXO_BOOK)); + + emitChange('test-project', 'EXO'); + + await waitFor(() => expect(mockSendCommand).toHaveBeenCalledTimes(3)); + }); + }); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index f47663ce2..5be293661 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -260,6 +260,58 @@ describe('main', () => { expect(emitter.emit).toHaveBeenCalledWith({ notificationId: 'notification-1' }); }); + describe('interlinearizer.onAnalysisChanged', () => { + /** Gives each event its own emitter, as the platform does. */ + function emittersByEvent() { + const emitters = new Map(); + __mockCreateNetworkEventEmitterAsync.mockImplementation(async (eventType: string) => { + const emitter = { emit: jest.fn(), dispose: jest.fn() }; + emitters.set(eventType, emitter); + return emitter; + }); + return emitters; + } + + it('emits the project and book of an edit to the open book', async () => { + const emitters = emittersByEvent(); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onOpenBookTextChanged).mock.calls[0]; + + listener('src-project', 'GEN'); + + expect(emitters.get('interlinearizer.onAnalysisChanged')?.emit).toHaveBeenCalledWith({ + sourceProjectId: 'src-project', + changeId: expect.any(String), + scriptureEdit: { bookId: 'GEN' }, + }); + }); + + it('gives each emitted change its own id', async () => { + const emitters = emittersByEvent(); + await activate(createTestActivationContext()); + const [listener] = jest.mocked(bookService.onOpenBookTextChanged).mock.calls[0]; + + listener('src-project', 'GEN'); + listener('src-project', 'GEN'); + + const emit = emitters.get('interlinearizer.onAnalysisChanged')?.emit; + expect(emit?.mock.calls[0][0].changeId).not.toBe(emit?.mock.calls[1][0].changeId); + }); + + it('disposes the emitter and stops listening on deactivation', async () => { + const emitters = emittersByEvent(); + const unsubscribe = jest.fn(() => true); + jest.mocked(bookService.onOpenBookTextChanged).mockReturnValue(unsubscribe); + const context = createTestActivationContext(); + await activate(context); + + await context.registrations.runAllUnsubscribers(); + + expect(emitters.get('interlinearizer.onAnalysisChanged')?.dispose).toHaveBeenCalled(); + expect(unsubscribe).toHaveBeenCalled(); + }); + }); + it('registers the WebView provider with a callable getWebView handler', async () => { const context = createTestActivationContext(); await activate(context); diff --git a/src/__tests__/services/bookService.test.ts b/src/__tests__/services/bookService.test.ts index e6c6d3a86..3acdd691d 100644 --- a/src/__tests__/services/bookService.test.ts +++ b/src/__tests__/services/bookService.test.ts @@ -12,6 +12,7 @@ import { getSegments, getTokenizedBook, getWritingSystem, + onOpenBookTextChanged, } from '../../services/bookService'; import type { BookOutline, SegmentPage } from '../../types/segment-pages'; import { buildBookOutline } from '../../utils/book-outline'; @@ -533,3 +534,238 @@ describe('closeAllProjects', () => { expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); }); }); + +describe('onOpenBookTextChanged', () => { + const unsubscribers: Array<() => boolean> = []; + + /** A listener registered for the test, removed once it ends. */ + function listen() { + const listener = jest.fn(); + unsubscribers.push(onOpenBookTextChanged(listener)); + return listener; + } + + beforeEach(() => { + segmentStandIns(); + }); + + afterEach(() => { + unsubscribers.splice(0).forEach((unsubscribe) => unsubscribe()); + }); + + it('reports an edit to the outlined book with its project and book', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + }); + + it('reports an edit to every listener', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listeners = [listen(), listen()]; + await getBookOutline('src', 'GEN'); + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + + listeners.forEach((listener) => expect(listener).toHaveBeenCalledTimes(1)); + }); + + it('reads the edited text when the book is next requested', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + await getBookOutline('src', 'GEN'); + version = 'v2'; + + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en'); + }); + + it('reads the edited text when the held book heard of the edit first', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + version = 'v2'; + + project.subscriptions[1].deliver(usj('GEN', version)); + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en'); + }); + + it('still reports an edit after other books have been read in between', async () => { + let version = 'v1'; + const project = serveProject((bookId) => usj(bookId, version)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + await getTokenizedBook('src', 'EXO'); + await getTokenizedBook('src', 'LEV'); + version = 'v2'; + + project.subscriptions[0].deliver(usj('GEN', version)); + await settle(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en'); + }); + + it('keeps the book held for another when the outlined book is edited', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + await getTokenizedBook('src', 'EXO'); + const held = project.subscriptions.at(-1); + + project.subscriptions[0].deliver(usj('GEN', 'v2')); + await settle(); + + expect(held?.unsubscribe).not.toHaveBeenCalled(); + }); + + it('stops a watch that fails to read its book', async () => { + const project = serveProject(); + const outline = getBookOutline('src', 'GEN'); + await settle(); + const [failed, held] = project.subscriptions; + + failed.deliver(platformError('busy')); + held.deliver(usj('GEN')); + await outline; + await settle(); + + expect(failed.unsubscribe).toHaveBeenCalled(); + }); + + it('watches the book again on the next outline once its watch fails to read it', async () => { + const project = serveProject(); + const listener = listen(); + const outline = getBookOutline('src', 'GEN'); + await settle(); + const [failed, held] = project.subscriptions; + failed.deliver(platformError('busy')); + held.deliver(usj('GEN')); + await outline; + await settle(); + + await getBookOutline('src', 'GEN'); + await settle(); + const watch = project.subscriptions[2]; + watch.deliver(usj('GEN')); + watch.deliver(usj('GEN', 'v2')); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'GEN'); + }); + + it('keeps the newer watch when a replaced watch fails to read its book', async () => { + const project = serveProject(); + getBookOutline('src', 'GEN').catch(() => undefined); + await settle(); + const replaced = project.subscriptions[0]; + getBookOutline('src', 'EXO').catch(() => undefined); + await settle(); + project.subscriptions.filter((s) => s.bookId === 'EXO').forEach((s) => s.deliver(usj('EXO'))); + const subscriptionCount = project.subscriptions.length; + + replaced.deliver(platformError('busy')); + await settle(); + await getBookOutline('src', 'EXO'); + + expect(project.subscriptions).toHaveLength(subscriptionCount); + }); + + it('reports nothing for a book only read whole', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = listen(); + await getTokenizedBook('src', 'GEN'); + await getSegments('src', 'GEN', 0, 1); + + project.subscriptions.forEach((subscription) => subscription.deliver(usj('GEN', 'v2'))); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('watches an outlined book once however often it is outlined', async () => { + const project = serveProject((bookId) => usj(bookId)); + + await getBookOutline('src', 'GEN'); + await getBookOutline('src', 'GEN'); + + expect(project.subscriptions).toHaveLength(2); + }); + + it('stops watching the previous book once another is outlined', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + const [first] = project.subscriptions; + + await getBookOutline('src', 'EXO'); + await settle(); + + expect(first.unsubscribe).toHaveBeenCalled(); + }); + + it('reports edits to the newly outlined book', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = listen(); + await getBookOutline('src', 'GEN'); + await getBookOutline('src', 'EXO'); + const exo = project.subscriptions.find((s) => s.bookId === 'EXO'); + + exo?.deliver(usj('EXO', 'v2')); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('src', 'EXO'); + }); + + it("stops watching the project's outlined book once it closes", async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + + await closeProject('src'); + + expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); + }); + + it('closes a project that has an outlined book but nothing held', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + project.subscriptions[1].deliver(usj('GEN', 'v2')); + await settle(); + + await closeProject('src'); + + expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); + }); + + it('stops watching every outlined book once all projects close', async () => { + const project = serveProject((bookId) => usj(bookId)); + await getBookOutline('src', 'GEN'); + await getBookOutline('other', 'EXO'); + + await closeAllProjects(); + + expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true); + }); + + it('does not call a listener once it is removed', async () => { + const project = serveProject((bookId) => usj(bookId)); + const listener = jest.fn(); + const unsubscribe = onOpenBookTextChanged(listener); + await getBookOutline('src', 'GEN'); + + unsubscribe(); + project.subscriptions[0].deliver(usj('GEN', 'v2')); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index b70b36ec7..d667b6e79 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -907,8 +907,7 @@ function InterlinearizerLoaderInner({ /** * Which list the side panel is showing, if it is open at all. Tab-scoped rather than a project - * setting: two tabs on one project are routinely opened to look at different things, and a panel - * one of them opened has no business appearing in the other. + * setting, since it is how the tab is laid out, not something the project holds. */ const [sidePanel, setSidePanel] = useWebViewState( 'sidePanel', diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts index 8d243265e..d908185cb 100644 --- a/src/hooks/useInterlinearizerBookData.ts +++ b/src/hooks/useInterlinearizerBookData.ts @@ -2,8 +2,10 @@ import papi, { logger } from '@papi/frontend'; import { useProjectSetting } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book } from 'interlinearizer'; +import type { NetworkEvents } from 'papi-shared-types'; +import { useEvent } from 'platform-bible-react'; import { isPlatformError } from 'platform-bible-utils'; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { perfMark, perfTime, perfTrack } from '../utils/perf-marks'; /** Arguments for the {@link useInterlinearizerBookData} hook. */ @@ -45,7 +47,8 @@ interface LoadedBook { /** * Fetches the tokenized book for the given project and scripture reference, once the project's - * language tag has loaded, and again whenever the tag changes or a reload is asked for. + * language tag has loaded, and again whenever the tag changes, the book's Scripture text is edited, + * or a reload is asked for. */ export default function useInterlinearizerBookData({ projectId, @@ -63,6 +66,20 @@ export default function useInterlinearizerBookData({ const [reloadCount, setReloadCount] = useState(0); const reload = useCallback(() => setReloadCount((count) => count + 1), []); + const analysisChangedEvent = useMemo( + () => papi.network.getNetworkEvent('interlinearizer.onAnalysisChanged'), + [], + ); + useEvent( + analysisChangedEvent, + useCallback( + ({ sourceProjectId, scriptureEdit }: NetworkEvents['interlinearizer.onAnalysisChanged']) => { + if (sourceProjectId === projectId && scriptureEdit.bookId === scrRef.book) reload(); + }, + [projectId, scrRef.book, reload], + ), + ); + const key = `${projectId}:${scrRef.book}:${writingSystem}:${reloadCount}`; const [loaded, setLoaded] = useState(); useEffect(() => { diff --git a/src/main.ts b/src/main.ts index b70c08845..de66e23d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1295,6 +1295,17 @@ export async function activate(context: ExecutionActivationContext): Promise + analysisChangedEmitter.emit({ + sourceProjectId, + changeId: crypto.randomUUID(), + scriptureEdit: { bookId }, + }), + ); + const openLexiconChooserCommandRegistration = await papi.commands.registerCommand( 'interlinearizer.openLexiconChooser', // Handled entirely in the WebView; backend registration makes the command known to the platform. @@ -1379,6 +1390,8 @@ export async function activate(context: ExecutionActivationContext): Promise(); /** Tells a request whether its project closed while it was under way. */ const closeCounts = new Map(); +/** + * Each source project's open book, the one whose outline was last asked for, watched apart from + * {@link heldBooks} since reading other books replaces the held one. + */ +const openBooks = new Map(); + +/** Receives the source project and book of each edit to an open book's text. */ +type OpenBookTextListener = (sourceProjectId: string, bookId: string) => void; + +const openBookTextListeners = new Set(); + /** * Resolves the writing system tag of the source project's text, `und` when the project declares * none. @@ -184,9 +195,51 @@ function segmentsOf( return held.segmented.segments; } +/** + * Calls `listener` whenever the Scripture text of a source project's open book changes, until the + * returned function is called. A project's open book is the one whose outline was last asked for, + * and a book requested once the listener is called reads the edit. + */ +export function onOpenBookTextChanged(listener: OpenBookTextListener): () => boolean { + openBookTextListeners.add(listener); + return () => openBookTextListeners.delete(listener); +} + +/** + * Makes `bookId` the source project's open book, watching it in place of the previous open book. A + * no-op when it is already the open book. + */ +function watchOpenBook(sourceProjectId: string, bookId: string): void { + const open = openBooks.get(sourceProjectId); + if (open?.bookId === bookId) return; + open?.watch.stop(); + const watch = watchBookText(sourceProjectId, bookId, () => { + // Either watch may hear of the edit first, so the held text goes before any listener refetches. + const held = heldBooks.get(sourceProjectId); + if (held?.bookId === bookId) releaseBook(sourceProjectId, held.watch); + openBookTextListeners.forEach((listener) => listener(sourceProjectId, bookId)); + }); + // A read failure reaches whoever requests the text. Forgetting the watch lets the next outline + // watch again; stopping it ends a subscription an error delivery leaves open. + watch.text.catch(() => { + if (openBooks.get(sourceProjectId)?.watch !== watch) return; + openBooks.delete(sourceProjectId); + watch.stop(); + }); + openBooks.set(sourceProjectId, { bookId, watch }); +} + +function closeOpenBook(sourceProjectId: string): Promise { + const open = openBooks.get(sourceProjectId); + if (!open) return Promise.resolve(); + openBooks.delete(sourceProjectId); + return open.watch.stop(); +} + /** * Outlines a book of the source project: its segments, segmented by the boundaries - * `segmentationJson` names, each without its text. + * `segmentationJson` names, each without its text. The book becomes the project's open book, whose + * edits {@link onOpenBookTextChanged} reports. * * @returns The `BookOutline` as JSON, or `undefined` when the project has no text for the book. * @throws If the book cannot be read or tokenized, or `segmentationJson` is not a JSON @@ -197,6 +250,7 @@ export async function getBookOutline( bookId: string, segmentationJson?: string, ): Promise { + watchOpenBook(sourceProjectId, bookId); const held = await heldBook(sourceProjectId, bookId); const book = await held.book; if (!book) return undefined; @@ -236,16 +290,23 @@ export async function getSegments( return perfTime('getSegments:serialize', () => JSON.stringify(page)); } -/** Lets go of the book kept for the source project, and of any a request under way would keep. */ +/** + * Lets go of the book kept for the source project, and of any a request under way would keep, and + * stops watching its open book. + */ export async function closeProject(sourceProjectId: string): Promise { closeCounts.set(sourceProjectId, (closeCounts.get(sourceProjectId) ?? 0) + 1); const held = heldBooks.get(sourceProjectId); - if (held) await releaseBook(sourceProjectId, held.watch); + await Promise.all([ + held && releaseBook(sourceProjectId, held.watch), + closeOpenBook(sourceProjectId), + ]); } -/** Lets go of every book kept. */ +/** Lets go of every book kept, and stops watching every open book. */ export async function closeAllProjects(): Promise { - await Promise.all( - [...heldBooks].map(([sourceProjectId, { watch }]) => releaseBook(sourceProjectId, watch)), - ); + await Promise.all([ + ...[...heldBooks].map(([sourceProjectId, { watch }]) => releaseBook(sourceProjectId, watch)), + ...[...openBooks.keys()].map(closeOpenBook), + ]); } diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index e17666785..24754cd83 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -10,6 +10,17 @@ declare module 'papi-shared-types' { * naming the notification so the WebView that sent it can undo the edit it offered. */ 'interlinearizer.onUndoFromNotification': { notificationId: string | number }; + /** + * Emitted when the text of a source project's open book (the one last outlined) changes, so + * views showing it refetch it. Edits to other books emit nothing. + */ + 'interlinearizer.onAnalysisChanged': { + sourceProjectId: string; + /** Unique to this change. */ + changeId: string; + /** A Scripture edit, naming the book to refetch whole. */ + scriptureEdit: { bookId: string }; + }; } /** Project-level settings contributed by the Interlinearizer extension. */