From 698187a7f0f64e2035758e18f1b815a04cc06c4d Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Mon, 19 Oct 2026 21:01:28 -0700 Subject: [PATCH 01/10] Move Save and Save As into the host (#433) Save and Save As no longer send the analysis over the bus. The WebView settles its draft writes, then one host command copies the stored draft into the interlinear project and replies with the project's summary. saveAnalysis, which carried the whole analysis both ways, is removed. Three commands become two: saveDraftToProject covers Save and Save As onto an existing project, taking the draft's configuration as one object so that overwrite sets the project's languages and target in the same write it used to need a second call for; saveDraftAsProject covers Save As to a new project, creating and filling it in one call where a failed second call used to leave an empty project behind. Each reports its own failure, so a failed Save As to a new project no longer reads as a failed save of an existing one. useDraftProject owns the save end to end through commitDraft: it settles the draft's writes through a DebouncedWriter, which sends any scheduled write, waits for every write in flight, and sends a failed write once more, so the stored draft is the one in memory before the host copies it; it then runs the copy and marks the draft synced only when the copy landed. A save started while another runs does nothing, and a draft replaced while the writes settle is not saved. While a save runs, the toolbar shows it and New, Open, Save, Save As, and Wipe tell the user to wait, so the draft the copy reads is the one the user chose to save. A Save whose project no longer exists says so. The copy reads the stored draft strictly: a damaged shard or envelope fails the save instead of writing an emptied book into the project. A wiped book's shard that cannot be deleted no longer fails the draft write that stored everything else; it stays journaled for a later save to retry, so one stuck deletion cannot fail every Save after it. The perf harness times saveDraftToProject in place of saveAnalysis and records the request size as the bytes sent, so the Phase 1 checkpoint keeps a number to compare against the 74.9 MB baseline. Co-Authored-By: Claude Fable 5.1 --- contributions/localizedStrings.json | 3 + e2e-tests/tests/perf/app-perf.spec.ts | 2 +- e2e-tests/tests/perf/view-driver.ts | 6 +- perf/summary/cli.ts | 2 +- .../components/InterlinearizerLoader.test.tsx | 171 +++++-- .../components/modals/ProjectModals.test.tsx | 325 ++++++++------ src/__tests__/hooks/useDraftProject.test.ts | 417 ++++++++++++++++-- src/__tests__/main.test.ts | 192 ++++---- src/__tests__/services/projectStorage.test.ts | 333 +++++++++++++- src/__tests__/utils/debounced-writer.test.ts | 198 +++++++++ src/components/InterlinearizerLoader.tsx | 88 ++-- src/components/modals/ProjectModals.tsx | 166 +++---- src/hooks/useDraftProject.ts | 170 +++---- src/main.ts | 220 ++++++--- src/services/draftRepository.ts | 51 ++- src/services/projectStorage.ts | 140 +++++- src/types/analysis-repository.ts | 16 +- src/types/interlinearizer.d.ts | 65 ++- src/utils/debounced-writer.ts | 98 ++++ 19 files changed, 2014 insertions(+), 649 deletions(-) create mode 100644 src/__tests__/utils/debounced-writer.test.ts create mode 100644 src/utils/debounced-writer.ts diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index fa4556def..1ab6f7c24 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -324,6 +324,9 @@ "%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.", "%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again.", "%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. Please try again.", + "%interlinearizer_error_save_project_missing%": "The project you were saving to no longer exists. Use Save As to save your work to another project.", + "%interlinearizer_info_save_in_progress%": "A save is in progress. Try again when it finishes.", + "%interlinearizer_status_saving%": "Saving...", "%interlinearizer_pt9Import_name%": "Paratext 9 Interlinear", "%interlinearizer_pt9Import_description%": "Imported from this project's Paratext 9 interlinear data. Read-only; synced from the Paratext 9 files.", diff --git a/e2e-tests/tests/perf/app-perf.spec.ts b/e2e-tests/tests/perf/app-perf.spec.ts index 38fbbb102..49636e89a 100644 --- a/e2e-tests/tests/perf/app-perf.spec.ts +++ b/e2e-tests/tests/perf/app-perf.spec.ts @@ -407,7 +407,7 @@ async function measureTier( const bytes = detailBytes(measures, 'save'); if (bytes !== undefined) saves.webView.addValue('save-bytes', bytes); // eslint-disable-next-line no-await-in-loop - saves.addHostCall(HOST_CALLS.saveAnalysis, await takeHostEntries(page), measures); + saves.addHostCall(HOST_CALLS.saveDraftToProject, await takeHostEntries(page), measures); } result.save = saves.summary(); await recordMemory('afterSave'); diff --git a/e2e-tests/tests/perf/view-driver.ts b/e2e-tests/tests/perf/view-driver.ts index f65507224..9ca27acc4 100644 --- a/e2e-tests/tests/perf/view-driver.ts +++ b/e2e-tests/tests/perf/view-driver.ts @@ -43,10 +43,10 @@ export const HOST_CALLS = { command: 'getProjectsForSource', phasePrefixes: ['getProjectsForSource'], }, - saveAnalysis: { + saveDraftToProject: { measure: 'save', - command: 'saveAnalysis', - phasePrefixes: ['saveAnalysis', 'updateAnalysis'], + command: 'saveDraftToProject', + phasePrefixes: ['saveDraftToProject', 'getDraft', 'updateAnalysis'], }, } satisfies Record; diff --git a/perf/summary/cli.ts b/perf/summary/cli.ts index 8732c07fe..52815c876 100644 --- a/perf/summary/cli.ts +++ b/perf/summary/cli.ts @@ -107,7 +107,7 @@ const METRIC_LABELS: Record = { autosave: 'Autosave', 'projects-fetch': 'Fetch saved projects', save: 'Save', - 'save-bytes': 'Analysis sent', + 'save-bytes': 'Request sent', 'concordance-read': 'Read every book', 'concordance-entries': 'Build entries', parseDraft: 'Parse draft', diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index fc7029a49..86726bd44 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -403,7 +403,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({ * Minimal ProjectModals stand-in that drives modal state and active-project state through the * same `useWebViewState` hook the real component uses, so tests can assert on state transitions * without mounting the full modal tree. Accepts (and mostly ignores) the draft-related props the - * loader passes (`hasUnsavedWork`, `getDraftSnapshot`, `loadFromProject`, `markSynced`); + * loader passes (`hasUnsavedWork`, `getDraftSnapshot`, `loadFromProject`, `commitDraft`); * `hasUnsavedWork` is surfaced as a `data-*` attribute so tests can assert the loader feeds it * the combined committed-and-pending unsaved state. */ @@ -426,7 +426,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({ hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; loadFromProject: (project: OpenableProject) => void; - markSynced: () => void; + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; onImportPt9: () => void; onOpenImport: (project: MockProject) => void; openRequest?: { project: MockProject; requestId: number }; @@ -797,6 +797,7 @@ function mockProjectBookIds(bookIds: string[] | undefined, isLoading = false): v describe('InterlinearizerLoader', () => { beforeEach(() => { mockProjectBookIds(undefined); + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); capturedInterlinearizerProps = undefined; capturedStoreProps = undefined; interlinearizerMountCount = 0; @@ -3140,19 +3141,28 @@ describe('InterlinearizerLoader', () => { result?.updateWebViewDefinition.mockClear(); await userEvent.click(screen.getByTestId('tab-toolbar-save')); - // Save sends the draft's boundary delta (not the "null" clear sentinel) alongside the - // analysis, so the project's boundaries match the analysis just written. - expect(mockSendCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', + // Save first writes the pending autosave, which carries the boundary delta, and only then + // has the host copy the stored draft into the project. + const commands = mockSendCommand.mock.calls.map(([command]) => command); + const saveIndex = commands.indexOf('interlinearizer.saveDraftToProject'); + const flushIndex = commands.slice(0, saveIndex).lastIndexOf('interlinearizer.saveDraft'); + expect(flushIndex).toBeGreaterThanOrEqual(0); + const flushed = mockSendCommand.mock.calls[flushIndex]; + expect(JSON.parse(String(flushed[2])).segmentation).toEqual({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + expect(mockSendCommand.mock.calls[saveIndex]).toEqual([ + 'interlinearizer.saveDraftToProject', + testProjectId, 'proj-1', - JSON.stringify(emptyAnalysis()), - JSON.stringify({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }), + ]); + // The draft still held what the save copied, so the unsaved marker cleared. + await waitFor(() => + expect(result?.updateWebViewDefinition).toHaveBeenCalledWith({ + title: 'Interlinearizer: WEB', + }), ); - // markSynced received the draft's exact analysis and segmentation references, so its - // identity guard matched and the unsaved marker cleared. - expect(result?.updateWebViewDefinition).toHaveBeenCalledWith({ - title: 'Interlinearizer: WEB', - }); }); /** Serves a stored draft carrying `segmentation` from `getDraft`. */ @@ -3895,7 +3905,7 @@ describe('InterlinearizerLoader', () => { }); describe('save command', () => { - it('saves the draft analysis to the active project when Save is clicked with an active project', async () => { + it('has the host save the stored draft to the active project, sending no analysis', async () => { const draftAnalysis = emptyAnalysis(); draftAnalysis.tokenAnalyses.push({ ...FIXTURE_STAMPS, @@ -3910,22 +3920,75 @@ describe('InterlinearizerLoader', () => { renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), ); + mockSendCommand.mockClear(); await userEvent.click(screen.getByTestId('tab-toolbar-save')); - expect(mockSendCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', + // Nothing was pending, so the stored draft is already current: Save's first command is the + // host copy, carrying only ids. The draft write that follows is markSynced's clean flag. + expect(mockSendCommand.mock.calls[0]).toEqual([ + 'interlinearizer.saveDraftToProject', + testProjectId, 'proj-1', - JSON.stringify(draftAnalysis), - // The draft has no custom boundaries, so Save sends "null" to clear any stored ones. - 'null', + ]); + }); + + it('writes the pending autosave before the host saves the stored draft', async () => { + await act(async () => + renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), ); + const edited = emptyAnalysis(); + edited.tokenAnalyses.push({ ...FIXTURE_STAMPS, id: 't1', surfaceText: 'In', gloss: {} }); + act(() => { + capturedStoreProps?.onSave?.(edited); + }); + mockSendCommand.mockClear(); + + // Save lands inside the autosave debounce, so the edit has not been written yet. + await userEvent.click(screen.getByTestId('tab-toolbar-save')); + + const [flush, save] = mockSendCommand.mock.calls; + expect(flush[0]).toBe('interlinearizer.saveDraft'); + expect(JSON.parse(String(flush[2])).analysis).toEqual(edited); + expect(save).toEqual(['interlinearizer.saveDraftToProject', testProjectId, 'proj-1']); }); - it('refreshes the cached activeProject updatedAt from the saveAnalysis response', async () => { - // saveAnalysis returns the persisted project carrying the storage-refreshed updatedAt; every + it('does not save to the project when the pending autosave fails', async () => { + let result: ReturnType | undefined; + await act(async () => { + result = renderLoader({ + useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }), + }); + }); + act(() => { + capturedStoreProps?.onSave?.(emptyAnalysis()); + }); + // Every draft write fails, the one sent again after the first failure included. + const error = new Error('disk full'); + mockSendCommand.mockImplementation(async (...args) => { + if (args[0] === 'interlinearizer.saveDraft') throw error; + return JSON.stringify(emptyDraft(testProjectId)); + }); + + await userEvent.click(screen.getByTestId('tab-toolbar-save')); + + expect(jest.mocked(logger.error)).toHaveBeenCalledWith( + 'Interlinearizer: failed to save draft to project', + error, + ); + expect( + mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraftToProject'), + ).toHaveLength(0); + // The project was never written, so the draft stays dirty. + expect(result?.updateWebViewDefinition).toHaveBeenLastCalledWith({ + title: 'Interlinearizer: WEB ●', + }); + }); + + it('refreshes the cached activeProject updatedAt from the saveDraftToProject response', async () => { + // saveDraftToProject returns the persisted project's summary carrying the storage-refreshed updatedAt; every // other command keeps its default (the empty-draft load) so the editor still mounts. mockSendCommand.mockImplementation(async (...args) => - args[0] === 'interlinearizer.saveAnalysis' + args[0] === 'interlinearizer.saveDraftToProject' ? JSON.stringify({ ...STUB_ACTIVE_PROJECT, updatedAt: '2026-02-02T00:00:00Z' }) : JSON.stringify(emptyDraft(testProjectId)), ); @@ -3952,12 +4015,12 @@ describe('InterlinearizerLoader', () => { ); }); - it('leaves the cached activeProject untouched when the saveAnalysis response is malformed', async () => { + it('leaves the cached activeProject untouched when the saveDraftToProject response is malformed', async () => { const seeded: MockProject = { ...STUB_ACTIVE_PROJECT, updatedAt: '2026-01-01T00:00:00Z' }; - // saveAnalysis returns a payload with no `updatedAt` (e.g. an unexpected shape), so there is + // saveDraftToProject returns a payload with no `updatedAt` (e.g. an unexpected shape), so there is // nothing to fold in; other commands keep the default empty-draft load. mockSendCommand.mockImplementation(async (...args) => - args[0] === 'interlinearizer.saveAnalysis' + args[0] === 'interlinearizer.saveDraftToProject' ? JSON.stringify({ notAProject: true }) : JSON.stringify(emptyDraft(testProjectId)), ); @@ -3977,12 +4040,12 @@ describe('InterlinearizerLoader', () => { ); }); - it('leaves the cache untouched and the draft dirty when saveAnalysis reports the project is gone', async () => { + it('leaves the cache untouched and the draft dirty when saveDraftToProject reports the project is gone', async () => { const seeded: MockProject = { ...STUB_ACTIVE_PROJECT, updatedAt: '2026-01-01T00:00:00Z' }; - // saveAnalysis resolves to `undefined` (the project no longer exists), so the loader has no + // saveDraftToProject resolves to `undefined` (the project no longer exists), so the loader has no // response body to parse and the cache is left as-is. mockSendCommand.mockImplementation(async (...args) => - args[0] === 'interlinearizer.saveAnalysis' + args[0] === 'interlinearizer.saveDraftToProject' ? undefined : JSON.stringify(emptyDraft(testProjectId)), ); @@ -4007,6 +4070,50 @@ describe('InterlinearizerLoader', () => { ); // Nothing was persisted, so the draft must stay dirty: the marker is never cleared. expect(updateWebViewDefinition).not.toHaveBeenCalledWith({ title: 'Interlinearizer: WEB' }); + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ + message: '%interlinearizer_error_save_project_missing%', + severity: 'error', + }); + }); + + it('shows the save running and holds New, Open, Save As, and Wipe until it ends', async () => { + let finishSave!: (summary: string) => void; + mockSendCommand.mockImplementation((...args) => + args[0] === 'interlinearizer.saveDraftToProject' + ? new Promise((resolve) => { + finishSave = resolve; + }) + : Promise.resolve(JSON.stringify(emptyDraft(testProjectId))), + ); + await act(async () => + renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), + ); + expect(screen.queryByTestId('saving-indicator')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('tab-toolbar-save')); + + expect(screen.getByTestId('saving-indicator')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('tab-toolbar-new-project')); + await userEvent.click(screen.getByTestId('tab-toolbar-save-as')); + await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); + await userEvent.click(screen.getByTestId('tab-toolbar-save')); + expect(screen.getByTestId('project-modals')).toHaveAttribute('data-modal', 'none'); + expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument(); + // Each held command says why it did nothing; the save itself was sent once. + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(4); + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ + message: '%interlinearizer_info_save_in_progress%', + severity: 'info', + }); + expect( + mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraftToProject'), + ).toHaveLength(1); + + await act(async () => finishSave(JSON.stringify(STUB_ACTIVE_PROJECT))); + + await waitFor(() => expect(screen.queryByTestId('saving-indicator')).not.toBeInTheDocument()); + await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); + expect(screen.getByTestId('wipe-modal-panel')).toBeInTheDocument(); }); it('marks the draft synced after a successful Save, clearing the tab unsaved marker', async () => { @@ -4027,7 +4134,9 @@ describe('InterlinearizerLoader', () => { updateWebViewDefinition?.mockClear(); await userEvent.click(screen.getByTestId('tab-toolbar-save')); - expect(updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer: WEB' }); + await waitFor(() => + expect(updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer: WEB' }), + ); }); it('shows the tab unsaved marker for in-progress typing before the gloss commits', async () => { @@ -4092,7 +4201,7 @@ describe('InterlinearizerLoader', () => { ); }); - it('logs an error when the saveAnalysis command rejects during Save', async () => { + it('logs an error when the saveDraftToProject command rejects during Save', async () => { await act(async () => renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), ); @@ -4118,7 +4227,7 @@ describe('InterlinearizerLoader', () => { expect(screen.getByTestId('save-as-modal')).toBeInTheDocument(); // Nothing was saved to a project since there is no Save target. expect( - mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveAnalysis'), + mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraftToProject'), ).toHaveLength(0); }); diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index 7c4e24d74..d2939eef3 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -3,7 +3,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import papi from '@papi/frontend'; +import papi, { logger } from '@papi/frontend'; import type { DraftProject } from 'interlinearizer'; import type { ModalState } from '../../../components/modals/ProjectModals'; import ProjectModals from '../../../components/modals/ProjectModals'; @@ -69,12 +69,6 @@ const MOCK_DRAFT: DraftProject = { suggestedDescription: 'Suggested Desc', }; -/** The same draft carrying a custom boundary delta, for the Save As segmentation branches. */ -const MOCK_DRAFT_WITH_SEGMENTATION: DraftProject = { - ...MOCK_DRAFT, - segmentation: MOCK_SEGMENTATION, -}; - jest.mock('../../../components/modals/SelectInterlinearProjectModal', () => ({ __esModule: true, SelectInterlinearProjectModal: ({ @@ -291,9 +285,9 @@ type ModalsOverrides = Partial<{ defaultAnalysisLanguage: string; hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; loadFromProject: jest.Mock; newDraft: jest.Mock; - markSynced: jest.Mock; modal: ModalState; onImportPt9: jest.Mock; onOpenImport: jest.Mock; @@ -307,14 +301,20 @@ type ModalsOverrides = Partial<{ * test only specifies what it cares about. */ function buildProps(overrides: ModalsOverrides = {}) { + const getDraftSnapshot = overrides.getDraftSnapshot ?? (() => MOCK_DRAFT); return { activeProject: overrides.activeProject, defaultAnalysisLanguage: overrides.defaultAnalysisLanguage, hasUnsavedWork: overrides.hasUnsavedWork ?? false, - getDraftSnapshot: overrides.getDraftSnapshot ?? (() => MOCK_DRAFT), + getDraftSnapshot, + commitDraft: + overrides.commitDraft ?? + (async (copy) => { + const snapshot = getDraftSnapshot(); + return snapshot ? copy(snapshot) : false; + }), loadFromProject: overrides.loadFromProject ?? jest.fn(), newDraft: overrides.newDraft ?? jest.fn(), - markSynced: overrides.markSynced ?? jest.fn(), modal: overrides.modal ?? 'none', onImportPt9: overrides.onImportPt9 ?? jest.fn(), onOpenImport: overrides.onOpenImport ?? jest.fn(), @@ -325,6 +325,35 @@ function buildProps(overrides: ModalsOverrides = {}) { }; } +/** + * A `commitDraft` that runs the copy on `snapshot` at once and records what each copy resolved, so + * a test can tell a save the draft would count as landed from one it would not. + */ +function recordingCommitDraft(snapshot: DraftProject = MOCK_DRAFT) { + const outcomes: boolean[] = []; + const commitDraft = jest.fn(async (copy: (s: DraftProject) => Promise) => { + const landed = await copy(snapshot); + outcomes.push(landed); + return landed; + }); + return { commitDraft, outcomes }; +} + +/** + * A `commitDraft` that holds the save until the returned `release` runs the copy on `MOCK_DRAFT`, + * standing in for the draft's writes still settling. + */ +function heldCommitDraft() { + let release!: () => void; + const commitDraft = jest.fn( + (copy: (s: DraftProject) => Promise) => + new Promise((resolve) => { + release = () => resolve(copy(MOCK_DRAFT)); + }), + ); + return { commitDraft, release: () => release() }; +} + /** * Builds a `useWebViewState` stub with optional spies on the setter and/or reset for the * `'activeProject'` key, so a test can assert how the active project was updated or cleared. Any @@ -809,105 +838,72 @@ describe('ProjectModals', () => { }); describe('save as flow', () => { - it('creates a new project, writes the analysis, marks synced, and closes', async () => { + it('has the host create a project from the stored draft through commitDraft, and closes', async () => { jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); - const markSynced = jest.fn(); + const { commitDraft, outcomes } = recordingCommitDraft(); const setModal = jest.fn(); - render(); + render(); await userEvent.click(screen.getByTestId('saveas-new')); - await waitFor(() => - expect(papi.commands.sendCommand).toHaveBeenCalledWith( - 'interlinearizer.createProject', - 'source-proj', - ['en'], - undefined, - 'NewName', - 'NewDesc', - ), - ); - // The draft has no custom boundaries, so the boundary argument is the "null" clear sentinel. + await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + // One command carrying only metadata: the host copies the analysis from the stored draft. + expect(papi.commands.sendCommand).toHaveBeenCalledTimes(1); expect(papi.commands.sendCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', - 'proj-1', - JSON.stringify(emptyAnalysis()), - 'null', + 'interlinearizer.saveDraftAsProject', + 'source-proj', + ['en'], + undefined, + 'NewName', + 'NewDesc', ); - expect(markSynced).toHaveBeenCalledTimes(1); - expect(markSynced).toHaveBeenCalledWith(MOCK_DRAFT.analysis, undefined); - expect(setModal).toHaveBeenCalledWith('none'); + // The copy landed, so the draft counts the save. + expect(outcomes).toEqual([true]); }); - it('sends the draft segmentation delta when saving as a new project', async () => { + it('sends nothing until the draft lets the save run', async () => { + const { commitDraft, release } = heldCommitDraft(); jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); - const markSynced = jest.fn(); - render( - MOCK_DRAFT_WITH_SEGMENTATION, - })} - />, - ); + render(); await userEvent.click(screen.getByTestId('saveas-new')); + expect(commitDraft).toHaveBeenCalledTimes(1); + expect(papi.commands.sendCommand).not.toHaveBeenCalled(); - await waitFor(() => - expect(papi.commands.sendCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', - 'proj-1', - JSON.stringify(emptyAnalysis()), - JSON.stringify(MOCK_SEGMENTATION), - ), - ); - // markSynced receives the exact persisted references so its identity guard can match. - expect(markSynced).toHaveBeenCalledWith( - MOCK_DRAFT_WITH_SEGMENTATION.analysis, - MOCK_SEGMENTATION, + await act(async () => release()); + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveDraftAsProject', + 'source-proj', + ['en'], + undefined, + 'NewName', + 'NewDesc', ); }); - it('caches only the summary of the server-returned project (with its refreshed updatedAt) on Save As New', async () => { - const setActiveProject = jest.fn(); - // saveAnalysis returns a FULL project (analysis included) with a bumped updatedAt. - const refreshedFull = { - ...MOCK_FULL_PROJECT, - updatedAt: '2026-06-15T09:00:00Z', - }; - jest - .mocked(papi.commands.sendCommand) - // createProject returns the freshly created project, then saveAnalysis returns the same - // project with its post-analysis-write updatedAt bumped. - .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT)) - .mockResolvedValueOnce(JSON.stringify(refreshedFull)); + it('does not save as a new project when the draft cannot settle its writes', async () => { render( Promise.reject(new Error('disk full')), })} />, ); await userEvent.click(screen.getByTestId('saveas-new')); - // Expected value is `refreshedFull` projected to a summary: the bumped updatedAt is kept, the - // analysis dropped. await waitFor(() => - expect(setActiveProject).toHaveBeenCalledWith({ - ...MOCK_PROJECT, - updatedAt: '2026-06-15T09:00:00Z', - }), + expect(logger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to save draft as new project', + expect.any(Error), + ), ); - expect(setActiveProject.mock.calls[0][0]).not.toHaveProperty('analysis'); + expect(papi.commands.sendCommand).not.toHaveBeenCalled(); }); - it('caches the created project summary when the Save As New analysis write returns no project', async () => { + it('caches only the summary of the created project on Save As New', async () => { const setActiveProject = jest.fn(); - // createProject returns a FULL project; saveAnalysis resolves undefined (blanket default), so - // there is no refreshed payload to prefer and the created project's summary is cached. jest .mocked(papi.commands.sendCommand) .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT)); @@ -926,58 +922,97 @@ describe('ProjectModals', () => { expect(setActiveProject.mock.calls[0][0]).not.toHaveProperty('analysis'); }); - it('notifies and does not mark synced when create returns a non-project shape', async () => { + it('notifies and reports the save as not landed when save as new returns a non-project shape', async () => { jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); - const markSynced = jest.fn(); - render(); + const { commitDraft, outcomes } = recordingCommitDraft(); + const setModal = jest.fn(); + render(); await userEvent.click(screen.getByTestId('saveas-new')); await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); - expect(markSynced).not.toHaveBeenCalled(); + expect(outcomes).toEqual([false]); + expect(setModal).not.toHaveBeenCalled(); }); - it('does not crash when saving a new project rejects', async () => { + it('logs and keeps the modal open when saving a new project rejects', async () => { jest.mocked(papi.commands.sendCommand).mockRejectedValueOnce(new Error('boom')); - const markSynced = jest.fn(); - render(); + const setModal = jest.fn(); + render(); await userEvent.click(screen.getByTestId('saveas-new')); - await waitFor(() => expect(papi.commands.sendCommand).toHaveBeenCalled()); - expect(markSynced).not.toHaveBeenCalled(); + await waitFor(() => + expect(logger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to save draft as new project', + expect.any(Error), + ), + ); + expect(setModal).not.toHaveBeenCalled(); }); - it('overwrites an existing project, marks synced, and closes', async () => { - const markSynced = jest.fn(); + it('has the host copy the stored draft into an existing project through commitDraft, and closes', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + const { commitDraft, outcomes } = recordingCommitDraft(); const setModal = jest.fn(); - render(); + render(); + + await userEvent.click(screen.getByTestId('saveas-overwrite')); + + await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveDraftToProject', + 'source-proj', + 'proj-1', + { analysisLanguages: ['en'], targetProjectId: undefined }, + ); + expect(outcomes).toEqual([true]); + }); + + it('sends no overwrite until the draft lets the save run', async () => { + const { commitDraft, release } = heldCommitDraft(); + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + render(); + + await userEvent.click(screen.getByTestId('saveas-overwrite')); + expect(commitDraft).toHaveBeenCalledTimes(1); + expect(papi.commands.sendCommand).not.toHaveBeenCalled(); + + await act(async () => release()); + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveDraftToProject', + 'source-proj', + 'proj-1', + { analysisLanguages: ['en'], targetProjectId: undefined }, + ); + }); + + it('does not overwrite an existing project when the draft cannot settle its writes', async () => { + render( + Promise.reject(new Error('disk full')), + })} + />, + ); await userEvent.click(screen.getByTestId('saveas-overwrite')); - // The draft has no custom boundaries, so "null" clears any the target had stored. await waitFor(() => - expect(papi.commands.sendCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', - 'proj-1', - JSON.stringify(emptyAnalysis()), - 'null', + expect(logger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to overwrite project with draft', + expect.any(Error), ), ); - expect(markSynced).toHaveBeenCalledTimes(1); - expect(markSynced).toHaveBeenCalledWith(MOCK_DRAFT.analysis, undefined); - expect(setModal).toHaveBeenCalledWith('none'); + expect(papi.commands.sendCommand).not.toHaveBeenCalled(); }); it('caches only the summary of the server-returned project (with its refreshed updatedAt) on overwrite', async () => { const setActiveProject = jest.fn(); - // updateProjectMetadata returns a FULL project (analysis included) with a bumped updatedAt. + // The host returns a FULL project (analysis included) with a bumped updatedAt. const refreshedFull = { ...MOCK_FULL_PROJECT, updatedAt: '2026-06-15T09:00:00Z' }; - jest - .mocked(papi.commands.sendCommand) - // saveAnalysis resolves void, then updateProjectMetadata returns the refreshed project. - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(JSON.stringify(refreshedFull)); + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(refreshedFull)); render( { expect(setActiveProject.mock.calls[0][0]).not.toHaveProperty('analysis'); }); - it('falls back to the pre-save summary with the draft config when overwrite metadata returns no project', async () => { + it('logs and keeps the modal open when the overwrite payload is malformed', async () => { const setActiveProject = jest.fn(); - // Both commands resolve undefined (the blanket default), so the update payload is missing. + const setModal = jest.fn(); + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); render( , @@ -1014,25 +1051,28 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('saveas-overwrite')); - // With no server payload the active project is rebuilt from the target plus the draft's - // config (languages carried over, target cleared since the draft has none). await waitFor(() => - expect(setActiveProject).toHaveBeenCalledWith({ - ...MOCK_PROJECT, - analysisLanguages: ['en'], - targetProjectId: undefined, - }), + expect(logger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to overwrite project with draft', + expect.any(Error), + ), ); + expect(setActiveProject).not.toHaveBeenCalled(); + expect(setModal).not.toHaveBeenCalled(); }); - it('sends the draft segmentation delta when overwriting an existing project', async () => { - const markSynced = jest.fn(); + it('tells the user and leaves the modal open when the overwritten project is gone', async () => { + const setActiveProject = jest.fn(); + const { commitDraft, outcomes } = recordingCommitDraft(); + const setModal = jest.fn(); + // The command resolves undefined (the blanket default): the project no longer exists. render( MOCK_DRAFT_WITH_SEGMENTATION, + commitDraft, + setModal, + useWebViewState: makeWebViewStateWithActiveProjectSpies({ set: setActiveProject }), })} />, ); @@ -1040,21 +1080,18 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('saveas-overwrite')); await waitFor(() => - expect(papi.commands.sendCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', - 'proj-1', - JSON.stringify(emptyAnalysis()), - JSON.stringify(MOCK_SEGMENTATION), - ), - ); - // markSynced receives the exact persisted references so its identity guard can match. - expect(markSynced).toHaveBeenCalledWith( - MOCK_DRAFT_WITH_SEGMENTATION.analysis, - MOCK_SEGMENTATION, + expect(papi.notifications.send).toHaveBeenCalledWith({ + message: '%interlinearizer_error_save_project_missing%', + severity: 'error', + }), ); + expect(setActiveProject).not.toHaveBeenCalled(); + // Nothing landed, so the draft stays dirty. + expect(outcomes).toEqual([false]); + expect(setModal).not.toHaveBeenCalled(); }); - it('reconciles the overwritten project metadata with the draft config', async () => { + it('sends the draft config with the overwrite so the host sets it in the same write', async () => { const draftWithConfig: DraftProject = { sourceProjectId: 'source-proj', modelVersion: CURRENT_MODEL_VERSION, @@ -1071,29 +1108,33 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('saveas-overwrite')); - // The project keeps its own name/description but adopts the draft's analysis languages and - // alignment target. + // One command carries the draft's analysis languages and alignment target; the project's + // own name and description are not sent, so the host keeps them. await waitFor(() => expect(papi.commands.sendCommand).toHaveBeenCalledWith( - 'interlinearizer.updateProjectMetadata', + 'interlinearizer.saveDraftToProject', + 'source-proj', 'proj-1', - 'My Project', - undefined, - ['sw', 'fr'], - 'tgt-9', + { analysisLanguages: ['sw', 'fr'], targetProjectId: 'tgt-9' }, ), ); + expect(papi.commands.sendCommand).toHaveBeenCalledTimes(1); }); - it('does not crash when overwriting rejects', async () => { + it('logs and keeps the modal open when overwriting rejects', async () => { jest.mocked(papi.commands.sendCommand).mockRejectedValueOnce(new Error('boom')); - const markSynced = jest.fn(); - render(); + const setModal = jest.fn(); + render(); await userEvent.click(screen.getByTestId('saveas-overwrite')); - await waitFor(() => expect(papi.commands.sendCommand).toHaveBeenCalled()); - expect(markSynced).not.toHaveBeenCalled(); + await waitFor(() => + expect(logger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to overwrite project with draft', + expect.any(Error), + ), + ); + expect(setModal).not.toHaveBeenCalled(); }); it('calls setModal with none when the save-as modal is closed', async () => { diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index b144ba86b..10193feb4 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -4,7 +4,11 @@ import papi, { logger } from '@papi/frontend'; import { act, renderHook, waitFor } from '@testing-library/react'; import type { DraftProject, TextAnalysis } from 'interlinearizer'; import { FIXTURE_STAMPS } from '../test-helpers'; -import useDraftProject, { type DraftContent, type StepSummary } from '../../hooks/useDraftProject'; +import useDraftProject, { + type DraftContent, + type StepSummary, + type UseDraftProjectResult, +} from '../../hooks/useDraftProject'; import { emptyAnalysis } from '../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; @@ -86,6 +90,13 @@ function lastSavedDraft(): DraftProject { return JSON.parse(json); } +/** Saves the draft as a Save whose host copy lands would, so the draft reads clean. */ +async function saveNow(result: { current: UseDraftProjectResult }): Promise { + await act(async () => { + await result.current.commitDraft(async () => true); + }); +} + describe('useDraftProject', () => { beforeEach(() => { jest.mocked(logger.error).mockImplementation(() => {}); @@ -192,6 +203,332 @@ describe('useDraftProject', () => { }); }); + describe('commitDraft', () => { + /** Counts the draft writes sent so far. */ + function saveDraftCount(): number { + return mockSendCommand.mock.calls.filter((c) => c[0] === 'interlinearizer.saveDraft').length; + } + + /** + * Serves `getDraft` as {@link mockGetDraftResolves} does, but holds each draft write until the + * returned function settles it. + */ + function holdDraftWrites(): (outcome: 'resolve' | 'reject') => void { + const pending: { resolve: () => void; reject: (e: Error) => void }[] = []; + mockSendCommand.mockImplementation((...args: Parameters) => { + if (args[0] === 'interlinearizer.getDraft') + return Promise.resolve(JSON.stringify(makeDraft())); + if (args[0] !== 'interlinearizer.saveDraft') return Promise.resolve(undefined); + return new Promise((resolve, reject) => { + pending.push({ resolve: () => resolve(undefined), reject }); + }); + }); + return (outcome) => + pending + .splice(0) + .forEach((write) => + outcome === 'resolve' ? write.resolve() : write.reject(new Error('disk full')), + ); + } + + /** A copy that resolves `landed`, with `seen` holding the snapshot it was given. */ + function recordingCopy(landed = true) { + const seen: { snapshot?: DraftProject } = {}; + const copy = jest.fn(async (snapshot: DraftProject) => { + seen.snapshot = snapshot; + return landed; + }); + return { copy, seen }; + } + + it('writes a pending autosave before the copy runs, passing it the draft it wrote', async () => { + const { result } = await renderLoaded(); + jest.useFakeTimers(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('pending')); + }); + const { copy, seen } = recordingCopy(); + + await act(async () => { + await result.current.commitDraft(copy); + }); + jest.useRealTimers(); + + expect(copy).toHaveBeenCalledTimes(1); + expect(seen.snapshot?.analysis.tokenAnalyses[0].id).toBe('pending'); + // The first write carried the edit; the second is the clean flag once the copy landed. + expect(saveDraftCount()).toBe(2); + expect(lastSavedDraft().dirty).toBe(false); + expect(result.current.dirty).toBe(false); + }); + + it('does not write the flushed autosave a second time when its debounce would have fired', async () => { + const { result } = await renderLoaded(); + jest.useFakeTimers(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('pending')); + }); + + await act(async () => { + await result.current.commitDraft(async () => false); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + expect(saveDraftCount()).toBe(1); + }); + + it('sends no write when no autosave is pending', async () => { + const { result } = await renderLoaded(); + const { copy, seen } = recordingCopy(false); + + await act(async () => { + await result.current.commitDraft(copy); + }); + + expect(saveDraftCount()).toBe(0); + expect(seen.snapshot).toBe(result.current.getDraftSnapshot()); + }); + + it('waits for a draft write already in flight to land before copying', async () => { + const settle = holdDraftWrites(); + const { result } = await renderLoaded(); + jest.useFakeTimers(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('in-flight')); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + const { copy } = recordingCopy(); + + let commit: Promise | undefined; + act(() => { + commit = result.current.commitDraft(copy); + }); + await act(async () => {}); + expect(saveDraftCount()).toBe(1); + expect(copy).not.toHaveBeenCalled(); + + await act(async () => { + settle('resolve'); + await commit; + }); + expect(copy).toHaveBeenCalledTimes(1); + }); + + it('writes an edit made while the writes settle before copying', async () => { + const settle = holdDraftWrites(); + const { result } = await renderLoaded(); + jest.useFakeTimers(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('first')); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + const { copy, seen } = recordingCopy(); + let commit: Promise | undefined; + act(() => { + commit = result.current.commitDraft(copy); + }); + // A second edit lands while the first write is still in flight. + act(() => { + result.current.autosaveAnalysis(analysisWithToken('second')); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + await act(async () => { + settle('resolve'); + await commit; + }); + + // Both writes landed before the copy ran, and the copy saw the draft the second one wrote. + expect(seen.snapshot?.analysis.tokenAnalyses[0].id).toBe('second'); + expect( + mockSendCommand.mock.calls.filter((c) => c[0] === 'interlinearizer.saveDraft')[1][2], + ).toContain('second'); + expect(result.current.dirty).toBe(false); + }); + + it('sends the draft again when the last write failed, so a later Save is not stuck', async () => { + const settle = holdDraftWrites(); + const { result } = await renderLoaded(); + jest.useFakeTimers(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('retried')); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + await act(async () => { + settle('reject'); + }); + expect(saveDraftCount()).toBe(1); + const { copy } = recordingCopy(false); + + let commit: Promise | undefined; + act(() => { + commit = result.current.commitDraft(copy); + }); + + expect(saveDraftCount()).toBe(2); + await act(async () => { + settle('resolve'); + await commit; + }); + expect(lastSavedDraft().analysis.tokenAnalyses[0].id).toBe('retried'); + expect(copy).toHaveBeenCalledTimes(1); + }); + + it('rejects without copying when the write sent again fails too', async () => { + const settle = holdDraftWrites(); + const { result } = await renderLoaded(); + jest.useFakeTimers(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('doomed')); + }); + jest.useRealTimers(); + const { copy } = recordingCopy(); + + let commit: Promise | undefined; + act(() => { + commit = result.current.commitDraft(copy); + }); + await act(async () => { + settle('reject'); + }); + expect(saveDraftCount()).toBe(2); + + await act(async () => { + settle('reject'); + await expect(commit).rejects.toThrow('disk full'); + }); + expect(copy).not.toHaveBeenCalled(); + expect(result.current.isSaving).toBe(false); + expect(result.current.dirty).toBe(true); + }); + + it('resolves false and copies nothing when the draft is replaced while its writes settle', async () => { + const settle = holdDraftWrites(); + const { result } = await renderLoaded(); + jest.useFakeTimers(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('replaced')); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + const { copy } = recordingCopy(); + let commit: Promise | undefined; + act(() => { + commit = result.current.commitDraft(copy); + }); + + act(() => { + result.current.wipeAll(); + }); + let landed: boolean | undefined; + await act(async () => { + settle('resolve'); + landed = await commit; + }); + + expect(landed).toBe(false); + expect(copy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'Interlinearizer: the draft was replaced during a save, so nothing was saved', + ); + }); + + it('reports the save running until the copy settles', async () => { + const { result } = await renderLoaded(); + let finish!: (landed: boolean) => void; + const copy = () => + new Promise((resolve) => { + finish = resolve; + }); + expect(result.current.isSaving).toBe(false); + + let commit: Promise | undefined; + act(() => { + commit = result.current.commitDraft(copy); + }); + await act(async () => {}); + expect(result.current.isSaving).toBe(true); + + await act(async () => { + finish(true); + await commit; + }); + expect(result.current.isSaving).toBe(false); + }); + + it('resolves false without copying while another save is in flight', async () => { + const { result } = await renderLoaded(); + let finish!: (landed: boolean) => void; + const first = jest.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { copy: second } = recordingCopy(); + + let firstCommit: Promise | undefined; + let secondCommit: Promise | undefined; + act(() => { + firstCommit = result.current.commitDraft(first); + }); + await act(async () => {}); + act(() => { + secondCommit = result.current.commitDraft(second); + }); + + await expect(secondCommit).resolves.toBe(false); + expect(second).not.toHaveBeenCalled(); + await act(async () => { + finish(true); + await firstCommit; + }); + }); + + it('leaves the draft dirty when the copy reports that nothing landed', async () => { + const { result } = await renderLoaded(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('unsaved')); + }); + + let landed: boolean | undefined; + await act(async () => { + landed = await result.current.commitDraft(async () => false); + }); + + expect(landed).toBe(false); + expect(result.current.dirty).toBe(true); + }); + + it('rejects and ends the save when the copy rejects', async () => { + const { result } = await renderLoaded(); + + await act(async () => { + await expect( + result.current.commitDraft(() => Promise.reject(new Error('host down'))), + ).rejects.toThrow('host down'); + }); + + expect(result.current.isSaving).toBe(false); + }); + }); + describe('autosaveSegmentation', () => { it('stores the boundary delta on the draft, marks it dirty, and persists it', async () => { const { result } = await renderLoaded(); @@ -538,84 +875,74 @@ describe('useDraftProject', () => { }); }); - describe('markSynced', () => { + describe('marking the draft synced after a save', () => { it('clears dirty and persists the synced draft without bumping the version', async () => { const { result } = await renderLoaded(); // Make the draft dirty first so the transition to synced is observable. - const synced = analysisWithToken('tok-sync'); act(() => { - result.current.autosaveAnalysis(synced); + result.current.autosaveAnalysis(analysisWithToken('tok-sync')); }); expect(result.current.dirty).toBe(true); const versionBefore = result.current.draftVersion; - act(() => { - result.current.markSynced(synced, undefined); - }); + await saveNow(result); expect(result.current.dirty).toBe(false); expect(result.current.draftVersion).toBe(versionBefore); expect(lastSavedDraft().dirty).toBe(false); }); - it('leaves the draft dirty when an edit landed since the saved snapshot', async () => { + it('leaves the draft dirty when an edit landed during the copy', async () => { const { result } = await renderLoaded(); - // The analysis that a Save captured and persisted. - const savedSnapshot = analysisWithToken('tok-saved'); - act(() => { - result.current.autosaveAnalysis(savedSnapshot); - }); - // A newer edit lands during the save round-trip, replacing the ref's analysis. act(() => { - result.current.autosaveAnalysis(analysisWithToken('tok-newer')); + result.current.autosaveAnalysis(analysisWithToken('tok-saved')); }); - expect(result.current.dirty).toBe(true); - // Syncing against the now-stale snapshot must not clear the unsaved-changes flag. - act(() => { - result.current.markSynced(savedSnapshot, undefined); + await act(async () => { + await result.current.commitDraft(async () => { + // A newer edit lands during the save round-trip, replacing the ref's analysis. + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-newer')); + }); + return true; + }); }); expect(result.current.dirty).toBe(true); }); - it('leaves the draft dirty when a segmentation edit landed since the saved snapshot', async () => { + it('leaves the draft dirty when a segmentation edit landed during the copy', async () => { const { result } = await renderLoaded(); - // Dirty the draft with the analysis a Save would capture and persist. - const savedAnalysis = analysisWithToken('tok-saved'); - act(() => { - result.current.autosaveAnalysis(savedAnalysis); - }); - // A boundary edit lands during the save round-trip. It carries the analysis over by - // reference, so only the segmentation identity distinguishes the ref from the snapshot. - act(() => { - result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); - }); - expect(result.current.dirty).toBe(true); - - // The snapshot was taken before the boundary edit (default segmentation), so syncing against - // it must not clear the unsaved-changes flag over the un-persisted boundary change. act(() => { - result.current.markSynced(savedAnalysis, undefined); + result.current.autosaveAnalysis(analysisWithToken('tok-saved')); + }); + + await act(async () => { + await result.current.commitDraft(async () => { + // A boundary edit carries the analysis over by reference, so only the segmentation + // identity distinguishes the ref from the snapshot the copy saved. + act(() => { + result.current.autosaveSegmentation({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + }); + return true; + }); }); expect(result.current.dirty).toBe(true); }); - it('clears dirty when synced with the matching segmentation reference', async () => { + it('clears dirty over a boundary edit the copy saved', async () => { const { result } = await renderLoaded(); const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; act(() => { result.current.autosaveSegmentation(delta); }); expect(result.current.dirty).toBe(true); - const snapshot = result.current.getDraftSnapshot(); - if (!snapshot) throw new Error('expected the draft to be loaded'); - // Save persisted exactly what the ref holds, so the sync clears the unsaved-changes flag. - act(() => { - result.current.markSynced(snapshot.analysis, snapshot.segmentation); - }); + await saveNow(result); expect(result.current.dirty).toBe(false); const saved = lastSavedDraft(); @@ -934,7 +1261,7 @@ describe('useDraftProject', () => { act(() => result.current.wipeAll()); act(() => result.current.autosaveAnalysis(saved)); - act(() => result.current.markSynced(saved, undefined)); + await saveNow(result); act(() => { result.current.undo(); }); @@ -1163,7 +1490,7 @@ describe('useDraftProject', () => { const saved = analysisWithToken('tok-saved'); act(() => result.current.autosaveAnalysis(saved)); - act(() => result.current.markSynced(saved, undefined)); + await saveNow(result); act(() => result.current.autosaveAnalysis(analysisWithToken('tok-after-save'))); act(() => result.current.undo()); @@ -1191,7 +1518,7 @@ describe('useDraftProject', () => { act(() => result.current.autosaveAnalysis(analysisWithToken('tok-first'))); act(() => result.current.autosaveAnalysis(saved)); - act(() => result.current.markSynced(saved, undefined)); + await saveNow(result); act(() => result.current.loadFromProject({ analysis: analysisWithToken('tok-open'), diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 245906c19..aa4626892 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -4,11 +4,12 @@ import type { OpenWebViewOptions, SavedWebViewDefinition, WebViewDefinition } fr import papiBackendMock from '@papi/backend'; import { activate, deactivate } from '@main'; import type { InterlinearizerOpenOptions } from '@main'; +import type { ProjectConfig } from 'interlinearizer'; import * as bookService from '../services/bookService'; import * as hostPerf from '../services/hostPerf'; import * as projectStorage from '../services/projectStorage'; import * as pt9ImportService from '../services/pt9ImportService'; -import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; +import { emptyDraft } from '../types/empty-factories'; import { toProjectSummary } from '../types/interlinear-project-summary'; import { createTestActivationContext, makeStubProject } from './test-helpers'; @@ -179,11 +180,23 @@ const getCreateEditableCopyHandler = () => const getGetProjectHandler = () => activateAndGetHandler<(id: string) => Promise>('interlinearizer.getProject'); -/** Activates the extension and returns the `interlinearizer.saveAnalysis` handler. */ -const getSaveAnalysisHandler = () => +/** Activates the extension and returns the `interlinearizer.saveDraftToProject` handler. */ +const getSaveDraftToProjectHandler = () => activateAndGetHandler< - (id: string, analysisJson: string, segmentationJson?: string) => Promise - >('interlinearizer.saveAnalysis'); + (sourceProjectId: string, id: string, config?: ProjectConfig) => Promise + >('interlinearizer.saveDraftToProject'); + +/** Activates the extension and returns the `interlinearizer.saveDraftAsProject` handler. */ +const getSaveDraftAsProjectHandler = () => + activateAndGetHandler< + ( + sourceProjectId: string, + analysisLanguages: string[], + targetProjectId?: string, + name?: string, + description?: string, + ) => Promise + >('interlinearizer.saveDraftAsProject'); /** Activates the extension and returns the `interlinearizer.getDraft` handler. */ const getGetDraftHandler = () => @@ -368,7 +381,8 @@ describe('main', () => { 'interlinearizer.openForWebView', 'interlinearizer.createProject', 'interlinearizer.getProject', - 'interlinearizer.saveAnalysis', + 'interlinearizer.saveDraftToProject', + 'interlinearizer.saveDraftAsProject', 'interlinearizer.getProjectsForSource', 'interlinearizer.getDraft', 'interlinearizer.getTokenizedBook', @@ -1215,134 +1229,132 @@ describe('main', () => { }); }); - describe('interlinearizer.saveAnalysis command', () => { - const mockUpdateAnalysis = jest.mocked(projectStorage.updateAnalysis); - const stubAnalysis = { - ...emptyAnalysis(), - tokenAnalyses: [{ id: 'ta-1', surfaceText: 'In', gloss: { en: 'in' } }], - }; + describe('interlinearizer.saveDraftToProject command', () => { + const mockSaveDraftToProject = jest.mocked(projectStorage.saveDraftToProject); - it('registers the interlinearizer.saveAnalysis command', async () => { + it('registers the interlinearizer.saveDraftToProject command', async () => { const context = createTestActivationContext(); await activate(context); expect(__mockRegisterCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', + 'interlinearizer.saveDraftToProject', expect.any(Function), expect.any(Object), ); }); - it('delegates to projectStorage.updateAnalysis with the parsed analysis and returns undefined when the project is gone', async () => { - mockUpdateAnalysis.mockResolvedValue(undefined); - const handler = await getSaveAnalysisHandler(); + it("returns the saved project's summary, without its analysis, as JSON", async () => { + const saved = { + ...makeStubProject('proj-id'), + updatedAt: '2026-06-15T09:00:00Z', + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }; + mockSaveDraftToProject.mockResolvedValue(saved); + const handler = await getSaveDraftToProjectHandler(); - const result = await handler('proj-id', JSON.stringify(stubAnalysis)); + const result = await handler('src-project', 'proj-id'); - // No segmentationJson passed ⇒ the 4th arg is undefined (leave stored boundaries unchanged). - expect(mockUpdateAnalysis).toHaveBeenCalledWith( + // No analysis languages passed, so no config reaches storage: Save leaves it unchanged. + expect(mockSaveDraftToProject).toHaveBeenCalledWith( expect.anything(), + 'src-project', 'proj-id', - stubAnalysis, undefined, ); - expect(result).toBeUndefined(); + expect(result).toBe(JSON.stringify(toProjectSummary(saved))); }); - it('returns the saved project (carrying the refreshed updatedAt) as JSON when the update succeeds', async () => { - const saved = { ...makeStubProject('proj-id'), updatedAt: '2026-06-15T09:00:00Z' }; - mockUpdateAnalysis.mockResolvedValue(saved); - const handler = await getSaveAnalysisHandler(); - - const result = await handler('proj-id', JSON.stringify(stubAnalysis)); + it('forwards the config to set', async () => { + mockSaveDraftToProject.mockResolvedValue(makeStubProject('proj-id')); + const handler = await getSaveDraftToProjectHandler(); - // The command serializes the storage layer's refreshed project so the WebView can read the new - // Modified time without a second fetch. - expect(result).toBe(JSON.stringify(saved)); - }); - - it('logs the error, sends an error notification, and rethrows when storage throws', async () => { - mockUpdateAnalysis.mockRejectedValue(new Error('disk full')); - const handler = await getSaveAnalysisHandler(); + await handler('src-project', 'proj-id', { + analysisLanguages: ['fr'], + targetProjectId: 'tgt', + }); - await expect(handler('proj-id', JSON.stringify(stubAnalysis))).rejects.toThrow('disk full'); - expect(__mockLogger.error).toHaveBeenCalledWith( - 'Interlinearizer: failed to save analysis', - expect.any(Error), - ); - expect(__mockNotificationsSend).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }), + expect(mockSaveDraftToProject).toHaveBeenCalledWith( + expect.anything(), + 'src-project', + 'proj-id', + { + analysisLanguages: ['fr'], + targetProjectId: 'tgt', + }, ); }); - it('logs the error, sends an error notification, and rethrows when analysisJson is not valid JSON', async () => { - const handler = await getSaveAnalysisHandler(); + it('returns undefined when the project is gone', async () => { + mockSaveDraftToProject.mockResolvedValue(undefined); + const handler = await getSaveDraftToProjectHandler(); - await expect(handler('proj-id', 'not-json')).rejects.toThrow(SyntaxError); - expect(__mockLogger.error).toHaveBeenCalledWith( - 'Interlinearizer: failed to save analysis', - expect.any(SyntaxError), - ); - expect(__mockNotificationsSend).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }), - ); - expect(mockUpdateAnalysis).not.toHaveBeenCalled(); + await expect(handler('src-project', 'proj-id')).resolves.toBeUndefined(); }); - it('logs the error, sends an error notification, and rethrows when analysisJson does not conform to TextAnalysis', async () => { - const handler = await getSaveAnalysisHandler(); + it('logs the error, sends an error notification, and rethrows when storage throws', async () => { + mockSaveDraftToProject.mockRejectedValue(new Error('disk full')); + const handler = await getSaveDraftToProjectHandler(); - await expect(handler('proj-id', JSON.stringify({ segmentAnalyses: [] }))).rejects.toThrow( - TypeError, - ); + await expect(handler('src-project', 'proj-id')).rejects.toThrow('disk full'); expect(__mockLogger.error).toHaveBeenCalledWith( - 'Interlinearizer: failed to save analysis', - expect.any(TypeError), - ); - expect(__mockNotificationsSend).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }), + 'Interlinearizer: failed to save the draft to a project', + expect.any(Error), ); - expect(mockUpdateAnalysis).not.toHaveBeenCalled(); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: '%interlinearizer_error_save_analysis_failed%', + severity: 'error', + }); }); + }); - it('passes a parsed segmentation delta through to updateAnalysis', async () => { - mockUpdateAnalysis.mockResolvedValue(undefined); - const handler = await getSaveAnalysisHandler(); - const segmentation = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + describe('interlinearizer.saveDraftAsProject command', () => { + const mockSaveDraftAsNewProject = jest.mocked(projectStorage.saveDraftAsNewProject); - await handler('proj-id', JSON.stringify(stubAnalysis), JSON.stringify(segmentation)); + it('registers the interlinearizer.saveDraftAsProject command', async () => { + const context = createTestActivationContext(); - expect(mockUpdateAnalysis).toHaveBeenCalledWith( - expect.anything(), - 'proj-id', - stubAnalysis, - segmentation, + await activate(context); + + expect(__mockRegisterCommand).toHaveBeenCalledWith( + 'interlinearizer.saveDraftAsProject', + expect.any(Function), + expect.any(Object), ); }); - it('passes null through to updateAnalysis to clear boundaries when segmentationJson is "null"', async () => { - mockUpdateAnalysis.mockResolvedValue(undefined); - const handler = await getSaveAnalysisHandler(); + it("forwards the new project's config and returns its summary, without its analysis, as JSON", async () => { + const created = { ...makeStubProject('new-id'), name: 'Name', targetProjectId: 'tgt' }; + mockSaveDraftAsNewProject.mockResolvedValue(created); + const handler = await getSaveDraftAsProjectHandler(); - await handler('proj-id', JSON.stringify(stubAnalysis), 'null'); + const result = await handler('src-project', ['fr'], 'tgt', 'Name', 'Desc'); - expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect(mockSaveDraftAsNewProject).toHaveBeenCalledWith( expect.anything(), - 'proj-id', - stubAnalysis, - // eslint-disable-next-line no-null/no-null -- asserting the clear-boundaries sentinel is forwarded - null, + 'src-project', + ['fr'], + 'tgt', + 'Name', + 'Desc', ); + expect(result).toBe(JSON.stringify(toProjectSummary(created))); }); - it('rethrows when segmentationJson does not conform to SegmentationDelta', async () => { - const handler = await getSaveAnalysisHandler(); + it('logs the error, sends an error notification, and rethrows when storage throws', async () => { + mockSaveDraftAsNewProject.mockRejectedValue(new Error('disk full')); + const handler = await getSaveDraftAsProjectHandler(); - await expect( - handler('proj-id', JSON.stringify(stubAnalysis), JSON.stringify({ bogus: true })), - ).rejects.toThrow(TypeError); - expect(mockUpdateAnalysis).not.toHaveBeenCalled(); + await expect(handler('src-project', ['en'])).rejects.toThrow('disk full'); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to save the draft as a new project', + expect.any(Error), + ); + // The project was never created, so the notification says so rather than naming a save. + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: '%interlinearizer_error_create_project_failed%', + severity: 'error', + }); }); }); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 3801a539a..3196e43d9 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -13,11 +13,14 @@ import { hasDraft, resetQueuesForTesting, saveDraft, + saveDraftAsNewProject, + saveDraftToProject, savePt9Import, sweepPendingCleanup, updateAnalysis, updateProjectMetadata, } from '../../services/projectStorage'; +import { summarizeProject } from '../../services/projectSummaries'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { removeBookFromAnalysis } from '../../utils/analysis-book'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; @@ -1301,6 +1304,212 @@ describe('projectStorage', () => { }); }); + describe('saving the draft into a project', () => { + const SAVE_TIME = '2026-03-01T12:00:00.000Z'; + const SEGMENTATION = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + + /** Backs storage with a map, so a draft saved here can be read back by the copy. */ + function backStorageWithMap(): Map { + const stored = new Map(); + __mockReadUserData.mockImplementation(async (_t: unknown, key: unknown) => { + if (typeof key !== 'string' || !stored.has(key)) throw enoentError(); + return stored.get(key); + }); + __mockWriteUserData.mockImplementation(async (_t: unknown, key: unknown, json: unknown) => { + if (typeof key === 'string' && typeof json === 'string') stored.set(key, json); + }); + __mockDeleteUserData.mockImplementation(async (_t: unknown, key: unknown) => { + if (typeof key === 'string') stored.delete(key); + }); + return stored; + } + + /** Stores `project`'s record in `stored` and lists it, as creating it would. */ + function storeListedProject(stored: Map, project: InterlinearProject): void { + stored.set(`project:${project.id}`, JSON.stringify(project)); + stored.set('projectSummaries', storedSummaries(project)); + } + + /** Reads project `id` back from `stored`. */ + function storedProject(stored: Map, id: string): unknown { + const raw = stored.get(`project:${id}`); + return raw === undefined ? undefined : JSON.parse(raw); + } + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(SAVE_TIME)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('saveDraftToProject', () => { + it("copies the stored draft's analysis and boundaries into the project, keeping its metadata", async () => { + const stored = backStorageWithMap(); + const project = { + ...makeStubProject('proj-id'), + name: 'Kept', + sourceProjectId: 'src-proj', + }; + storeListedProject(stored, project); + const draft = { ...makeDraftSpanningBooks('src-proj', 'GEN'), segmentation: SEGMENTATION }; + await saveDraft(token, 'src-proj', draft); + + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + + const expected = { + ...project, + analysis: draft.analysis, + segmentation: SEGMENTATION, + updatedAt: SAVE_TIME, + }; + expect(result).toEqual(expected); + expect(storedProject(stored, 'proj-id')).toEqual(expected); + }); + + it('clears the boundaries the project stored when the draft has none', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, { ...makeStubProject('proj-id'), segmentation: SEGMENTATION }); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN')); + + await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(storedProject(stored, 'proj-id')).not.toHaveProperty('segmentation'); + }); + + it('sets the analysis languages and target in the same write when config is given', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, makeStubProject('proj-id')); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN')); + + await saveDraftToProject(token, 'src-proj', 'proj-id', { + analysisLanguages: ['sw', 'fr'], + targetProjectId: 'tgt-9', + }); + + expect( + __mockWriteUserData.mock.calls.filter(([, key]) => key === 'project:proj-id'), + ).toHaveLength(1); + expect(storedProject(stored, 'proj-id')).toMatchObject({ + analysisLanguages: ['sw', 'fr'], + targetProjectId: 'tgt-9', + }); + }); + + it('clears the target when the config given has none', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, { ...makeStubProject('proj-id'), targetProjectId: 'tgt-old' }); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN')); + + await saveDraftToProject(token, 'src-proj', 'proj-id', { analysisLanguages: ['en'] }); + + expect(storedProject(stored, 'proj-id')).not.toHaveProperty('targetProjectId'); + }); + + it('leaves the configuration unchanged when no config is given', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, { + ...makeStubProject('proj-id'), + analysisLanguages: ['de'], + targetProjectId: 'tgt-old', + }); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN')); + + await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(storedProject(stored, 'proj-id')).toMatchObject({ + analysisLanguages: ['de'], + targetProjectId: 'tgt-old', + }); + }); + + it('copies a draft write queued before it', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, makeStubProject('proj-id')); + const draft = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); + + // Neither call is awaited before the next starts, as when a WebView's last autosave and its + // Save cross the bus back to back. + const write = saveDraft(token, 'src-proj', draft); + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + await write; + + expect(result?.analysis).toEqual(draft.analysis); + }); + + it('returns undefined and writes no project when the project does not exist', async () => { + const stored = backStorageWithMap(); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN')); + + const result = await saveDraftToProject(token, 'src-proj', 'missing'); + + expect(result).toBeUndefined(); + expect(stored.has('project:missing')).toBe(false); + }); + }); + + describe('saveDraftAsNewProject', () => { + it("creates an indexed project holding the stored draft's analysis and boundaries", async () => { + const stored = backStorageWithMap(); + const draft = { ...makeDraftSpanningBooks('src-proj', 'GEN'), segmentation: SEGMENTATION }; + await saveDraft(token, 'src-proj', draft); + + const result = await saveDraftAsNewProject( + token, + 'src-proj', + ['fr'], + 'tgt-proj', + 'Name', + 'Desc', + ); + + const expected = { + id: '00000000-0000-0000-0000-000000000001', + modelVersion: CURRENT_MODEL_VERSION, + createdAt: SAVE_TIME, + updatedAt: SAVE_TIME, + name: 'Name', + description: 'Desc', + sourceProjectId: 'src-proj', + targetProjectId: 'tgt-proj', + analysisLanguages: ['fr'], + analysis: draft.analysis, + segmentation: SEGMENTATION, + links: [], + }; + expect(result).toEqual(expected); + expect(storedProject(stored, expected.id)).toEqual(expected); + expect(JSON.parse(stored.get('projectSummaries') ?? '{}').summaries).toEqual([ + summarizeProject(expected), + ]); + }); + + it('stores no boundaries when the draft has none', async () => { + backStorageWithMap(); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN')); + + const result = await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect(result).not.toHaveProperty('segmentation'); + }); + + it('reports an invariant violation the stored draft carries', async () => { + backStorageWithMap(); + await saveDraft(token, 'src-proj', { + ...emptyDraft('src-proj'), + analysis: analysisWithDanglingLink(), + }); + + await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: draft of src-proj on save as has 1 token-layer danglingLink violation(s): GEN 1:1:0', + ); + }); + }); + }); + describe('getDraft', () => { it('returns the parsed stored draft read from the draft key', async () => { const stored = { ...emptyDraft('src-proj'), analysisLanguages: ['fr'], dirty: true }; @@ -1487,6 +1696,103 @@ describe('projectStorage', () => { ); }); + describe('with unreadable content set to throw', () => { + /** Serves what was written, except `key`, which reads as `value`. */ + function serveWrittenExcept(key: string, value: string) { + __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { + if (k === key) return value; + const written = __mockWriteUserData.mock.calls.findLast(([, w]) => w === k); + if (!written) throw enoentError(); + return written[2]; + }); + } + + it('rejects when a shard is not valid JSON, instead of loading the book empty', async () => { + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + serveWrittenExcept('draft:src-proj:analysis:JHN', '{not json'); + + await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( + 'Interlinearizer: draft for source project src-proj has unreadable analysis shards for JHN', + ); + }); + + it('rejects when a shard the manifest names is missing', async () => { + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { + const written = __mockWriteUserData.mock.calls.findLast(([, w]) => w === k); + if (!written || k === 'draft:src-proj:analysis:JHN') throw enoentError(); + return written[2]; + }); + + await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( + 'unreadable analysis shards for JHN', + ); + }); + + it('rejects when the stored draft fails validation, instead of loading it empty', async () => { + __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { + if (k === 'draft:src-proj') return JSON.stringify({ modelVersion: 1, nope: true }); + throw enoentError(); + }); + + await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( + 'Interlinearizer: stored draft failed validation; resetting to empty draft', + ); + expect(__mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('rejects when the stored draft holds a link with no id', async () => { + const inline = { + ...emptyDraft('src-proj'), + analysis: withoutLinkIds(makeDraftSpanningBooks('src-proj', 'GEN').analysis), + }; + __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { + if (k === 'draft:src-proj') return JSON.stringify(inline); + throw enoentError(); + }); + + await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( + 'has links without ids; loading it empty', + ); + }); + + it('loads a readable draft as the lenient read does', async () => { + const draft = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); + await saveDraft(token, 'src-proj', draft); + serveWrittenExcept('none', ''); + + await expect(getDraft(token, 'src-proj', 'throw')).resolves.toEqual({ + ...draft, + analysis: expect.objectContaining({ + tokenAnalysisLinks: expect.arrayContaining(draft.analysis.tokenAnalysisLinks), + }), + }); + }); + + it('leaves the project untouched when Save finds the stored draft damaged', async () => { + const stored = new Map(); + const project = makeStubProject('proj-id'); + stored.set('project:proj-id', JSON.stringify(project)); + __mockWriteUserData.mockImplementation(async (_t: unknown, key: unknown, json: unknown) => { + if (typeof key === 'string' && typeof json === 'string') stored.set(key, json); + }); + __mockDeleteUserData.mockImplementation(async (_t: unknown, key: unknown) => { + if (typeof key === 'string') stored.delete(key); + }); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + __mockReadUserData.mockImplementation(async (_t: unknown, key: unknown) => { + if (typeof key !== 'string' || !stored.has(key)) throw enoentError(); + return stored.get(key); + }); + + await expect(saveDraftToProject(token, 'src-proj', 'proj-id')).rejects.toThrow( + 'unreadable analysis shards for JHN', + ); + expect(JSON.parse(stored.get('project:proj-id') ?? '')).toEqual(project); + }); + }); + it('propagates a shard read failure that is not a missing key', async () => { const draft = makeDraftSpanningBooks('src-proj', 'GEN'); await saveDraft(token, 'src-proj', draft); @@ -1743,14 +2049,21 @@ describe('projectStorage', () => { await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); }); - it('propagates a shard delete failure that is not a missing key', async () => { + it('saves past a shard delete failure that is not a missing key, logging it', async () => { const draft = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); await saveDraft(token, 'src-proj', draft); - __mockDeleteUserData.mockRejectedValue(new Error('permission denied')); + const failure = new Error('permission denied'); + __mockDeleteUserData.mockRejectedValue(failure); draft.analysis = removeBookFromAnalysis(draft.analysis, 'JHN'); - await expect(saveDraft(token, 'src-proj', draft)).rejects.toThrow('permission denied'); + // The draft landed, so the save is no failure: a caller taking it for one would resend the + // same draft for nothing. + await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not delete the orphaned analysis shards JHN of src-proj; a later save retries:', + failure, + ); }); it("stops listing a wiped book before deleting that book's shard", async () => { @@ -1942,7 +2255,7 @@ describe('projectStorage', () => { // The envelope write has to land, or the manifest would still name JHN and rediscover the // orphan on its own, leaving the journal untested. draft.analysis = removeBookFromAnalysis(draft.analysis, 'JHN'); - await expect(saveDraft(token, 'src-proj', draft)).rejects.toThrow('permission denied'); + await saveDraft(token, 'src-proj', draft); expect(stored.get('draft:src-proj')).not.toContain('JHN'); // Stands in for an extension-host restart, leaving the journal as the orphan's only name. @@ -1958,7 +2271,7 @@ describe('projectStorage', () => { expect(__mockDeleteUserData).toHaveBeenCalledWith(token, 'draft:src-proj:analysis:JHN'); }); - it('reports the shard deletion failure, not a journal cleanup that also failed', async () => { + it('logs the shard deletion failure and a journal cleanup that also failed, and saves', async () => { const draft = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); await saveDraft(token, 'src-proj', draft); __mockDeleteUserData.mockImplementation(async (_t: unknown, key: unknown) => { @@ -1975,7 +2288,11 @@ describe('projectStorage', () => { draft.analysis = removeBookFromAnalysis(draft.analysis, 'JHN'); - await expect(saveDraft(token, 'src-proj', draft)).rejects.toThrow('shard delete failed'); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not delete the orphaned analysis shards JHN of src-proj; a later save retries:', + expect.any(Error), + ); expect(__mockLogger.warn).toHaveBeenCalledWith( 'Interlinearizer: could not update the shard journal for src-proj:', expect.any(Error), @@ -2124,7 +2441,7 @@ describe('projectStorage', () => { __mockDeleteUserData.mockRejectedValue(new Error('permission denied')); loaded.analysis = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis; - await expect(saveDraft(token, 'src-proj', loaded)).rejects.toThrow('permission denied'); + await saveDraft(token, 'src-proj', loaded); __mockDeleteUserData.mockResolvedValue(undefined); loaded.analysis = removeBookFromAnalysis(loaded.analysis, 'JHN'); @@ -2141,7 +2458,7 @@ describe('projectStorage', () => { __mockDeleteUserData.mockRejectedValue(new Error('permission denied')); draft.analysis = removeBookFromAnalysis(draft.analysis, 'JHN'); - await expect(saveDraft(token, 'src-proj', draft)).rejects.toThrow('permission denied'); + await saveDraft(token, 'src-proj', draft); __mockDeleteUserData.mockReset(); __mockDeleteUserData.mockResolvedValue(undefined); diff --git a/src/__tests__/utils/debounced-writer.test.ts b/src/__tests__/utils/debounced-writer.test.ts new file mode 100644 index 000000000..02dbe090d --- /dev/null +++ b/src/__tests__/utils/debounced-writer.test.ts @@ -0,0 +1,198 @@ +import { DebouncedWriter } from '../../utils/debounced-writer'; + +/** One write handed to the writer, settled by the test. */ +type HeldWrite = { value: string; resolve: () => void; reject: (e: Error) => void }; + +/** A writer over writes the test settles by hand, with every failure recorded. */ +function heldWriter(delayMs = 100) { + const writes: HeldWrite[] = []; + const failures: unknown[] = []; + const writer = new DebouncedWriter({ + write: (value) => + new Promise((resolve, reject) => { + writes.push({ value, resolve, reject }); + }), + delayMs, + onFailure: (e) => failures.push(e), + }); + return { writer, writes, failures }; +} + +/** Lets every promise reaction queued so far run. */ +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('DebouncedWriter', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('writes a scheduled value once the delay passes with no later value', () => { + const { writer, writes } = heldWriter(); + + writer.schedule('a'); + writer.schedule('b'); + jest.advanceTimersByTime(99); + expect(writes).toHaveLength(0); + + jest.advanceTimersByTime(1); + expect(writes.map((w) => w.value)).toEqual(['b']); + }); + + it('drops a scheduled value on cancel', () => { + const { writer, writes } = heldWriter(); + + writer.schedule('a'); + writer.cancel(); + jest.advanceTimersByTime(100); + + expect(writes).toHaveLength(0); + }); + + it('writes at once on writeNow, dropping the scheduled value', () => { + const { writer, writes } = heldWriter(); + + writer.schedule('a'); + writer.writeNow('b'); + jest.advanceTimersByTime(100); + + expect(writes.map((w) => w.value)).toEqual(['b']); + }); + + it('sends the scheduled value at once on flush, and nothing when none is scheduled', () => { + const { writer, writes } = heldWriter(); + + writer.flush(); + writer.schedule('a'); + writer.flush(); + jest.advanceTimersByTime(100); + + expect(writes.map((w) => w.value)).toEqual(['a']); + }); + + it('reports each failed write', async () => { + const { writer, writes, failures } = heldWriter(); + const failure = new Error('disk full'); + + writer.writeNow('a'); + writes[0].reject(failure); + await flushMicrotasks(); + + expect(failures).toEqual([failure]); + }); + + describe('settle', () => { + it('resolves at once when nothing was ever written', async () => { + const { writer } = heldWriter(); + + await expect(writer.settle()).resolves.toBeUndefined(); + }); + + it('sends the scheduled value and resolves once it lands', async () => { + const { writer, writes } = heldWriter(); + writer.schedule('a'); + + let settled = false; + const settle = (async () => { + await writer.settle(); + settled = true; + })(); + expect(writes.map((w) => w.value)).toEqual(['a']); + await flushMicrotasks(); + expect(settled).toBe(false); + + writes[0].resolve(); + await settle; + expect(settled).toBe(true); + }); + + it('waits for a write in flight', async () => { + const { writer, writes } = heldWriter(); + writer.writeNow('a'); + + let settled = false; + const settle = (async () => { + await writer.settle(); + settled = true; + })(); + await flushMicrotasks(); + expect(settled).toBe(false); + + writes[0].resolve(); + await settle; + expect(settled).toBe(true); + }); + + it('waits for a value written while it waits, and sends one scheduled meanwhile', async () => { + const { writer, writes } = heldWriter(); + writer.writeNow('a'); + let settled = false; + const settle = (async () => { + await writer.settle(); + settled = true; + })(); + + writer.writeNow('b'); + writes[0].resolve(); + await flushMicrotasks(); + expect(settled).toBe(false); + + writer.schedule('c'); + writes[1].resolve(); + await flushMicrotasks(); + expect(settled).toBe(false); + expect(writes.map((w) => w.value)).toEqual(['a', 'b', 'c']); + + writes[2].resolve(); + await settle; + expect(settled).toBe(true); + }); + + it('sends the last value again when its write failed', async () => { + const { writer, writes } = heldWriter(); + writer.writeNow('a'); + writes[0].reject(new Error('disk full')); + await flushMicrotasks(); + + const settle = writer.settle(); + expect(writes.map((w) => w.value)).toEqual(['a', 'a']); + + writes[1].resolve(); + await expect(settle).resolves.toBeUndefined(); + }); + + it('rejects with the failure when the value sent again fails too', async () => { + const { writer, writes } = heldWriter(); + writer.writeNow('a'); + writes[0].reject(new Error('first')); + await flushMicrotasks(); + + const settle = writer.settle(); + const second = new Error('second'); + writes[1].reject(second); + + await expect(settle).rejects.toBe(second); + }); + + it('does not count a failure of a write that a later write superseded', async () => { + const { writer, writes } = heldWriter(); + writer.writeNow('a'); + writer.writeNow('b'); + writes[0].reject(new Error('stale')); + writes[1].resolve(); + await flushMicrotasks(); + + const settle = writer.settle(); + + expect(writes).toHaveLength(2); + await expect(settle).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index d667b6e79..76b50ca12 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -10,6 +10,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup, + Spinner, TabToolbar, UNDO_REDO_BUTTONS_STRING_KEYS, UndoRedoButtons, @@ -297,6 +298,7 @@ const STRING_KEYS = [ '%interlinearizer_staleNotice_reviewGlosses%', '%interlinearizer_staleNotice_nextFreeTranslation%', '%interlinearizer_staleNotice_dismiss%', + '%interlinearizer_status_saving%', '%interlinearizer_tabTitle%', ] as const satisfies `%${string}%`[]; @@ -467,7 +469,8 @@ function InterlinearizerLoaderInner({ loadFromProject, newDraft, getDraftSnapshot, - markSynced, + isSaving, + commitDraft, wipeBook, wipeAll, reanchorBook, @@ -1336,8 +1339,6 @@ function InterlinearizerLoaderInner({ [activeProject, copyGuard], ); - const isSavingRef = useRef(false); - /** * Saves the current draft to the active project. When no project is active there is nothing to * save to yet, so it opens Save As instead. Errors are logged; the backend surfaces the @@ -1348,33 +1349,25 @@ function InterlinearizerLoaderInner({ setModal('saveAs'); return; } - /* v8 ignore next -- re-entry guard; handles simultaneous saves during async round-trip */ - if (isSavingRef.current) return; - isSavingRef.current = true; - const snapshot = getDraftSnapshot(); - /* v8 ignore next 4 -- save is only reachable once the editor (and draft) have loaded */ - if (!snapshot) { - isSavingRef.current = false; - return; - } try { - const analysisJson = JSON.stringify(snapshot.analysis); - const savedJson = await perfTrack( - 'save', - papi.commands.sendCommand( - 'interlinearizer.saveAnalysis', - activeProject.id, - analysisJson, - // Send the draft's boundary state on every Save; `null` clears any stored boundaries so a - // reverted segmentation propagates to the project rather than leaving it stale. - // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries - JSON.stringify(snapshot.segmentation ?? null), - ), - { bytes: analysisJson.length }, - ); - // A successful save returns the saved project JSON; `undefined` means the project no longer - // exists, so nothing was persisted — leave the draft dirty rather than marking it clean. - if (savedJson) { + await commitDraft(async () => { + const savedJson = await perfTrack( + 'save', + papi.commands.sendCommand( + 'interlinearizer.saveDraftToProject', + projectId, + activeProject.id, + ), + // The bytes the Save sends over the bus. + { bytes: JSON.stringify([projectId, activeProject.id]).length }, + ); + // `undefined` means the project no longer exists, so nothing was persisted. + if (!savedJson) { + await papi.notifications + .send({ message: '%interlinearizer_error_save_project_missing%', severity: 'error' }) + .catch(() => {}); + return false; + } // Fold the response's refreshed `updatedAt` into the cached active project so the picker // sort and the metadata modal's Modified time stay current without a reload; extract it // defensively so an unexpectedly shaped payload leaves the cache untouched. @@ -1386,14 +1379,12 @@ function InterlinearizerLoaderInner({ if (typeof refreshedUpdatedAt === 'string') { setActiveProject({ ...activeProject, updatedAt: refreshedUpdatedAt }); } - markSynced(snapshot.analysis, snapshot.segmentation); - } + return true; + }); } catch (e) { logger.error('Interlinearizer: failed to save draft to project', e); - } finally { - isSavingRef.current = false; } - }, [activeProject, getDraftSnapshot, markSynced, setActiveProject, setModal]); + }, [activeProject, commitDraft, projectId, setActiveProject, setModal]); /** * Performs the confirmed wipe for the chosen scope (current book or whole draft) and dismisses @@ -1579,6 +1570,22 @@ function InterlinearizerLoaderInner({ item.command === 'interlinearizer.openSaveAsModal' || item.command === 'interlinearizer.wipe'); if (draftCommandsInert) return; + // A save copies the draft as it stood when it began, so nothing may replace or save the draft + // until it ends. The toolbar shows the save running; the notification says why the command + // did nothing. + const heldWhileSaving = + isSaving && + (item.command === 'interlinearizer.openSelectProjectModal' || + item.command === 'interlinearizer.openNewProjectModal' || + item.command === 'interlinearizer.save' || + item.command === 'interlinearizer.openSaveAsModal' || + item.command === 'interlinearizer.wipe'); + if (heldWhileSaving) { + papi.notifications + .send({ message: '%interlinearizer_info_save_in_progress%', severity: 'info' }) + .catch(() => {}); + return; + } if (item.command === 'interlinearizer.openSelectProjectModal') { setModal('select'); } else if (item.command === 'interlinearizer.openNewProjectModal') { @@ -1613,6 +1620,7 @@ function InterlinearizerLoaderInner({ handleUndo, handleRedo, isImportView, + isSaving, setSidePanel, ], ); @@ -1863,6 +1871,16 @@ function InterlinearizerLoaderInner({ isLoaded && !isImportLoading ? ( // One child, since the toolbar lays its end area out in reverse.
+ {isSaving && ( + + + {localizedStrings['%interlinearizer_status_saving%']} + + )} {!isImportView && ( runPt9Import('import')} onOpenImport={openImportedProject} diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index 541743028..54df0460f 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -1,6 +1,6 @@ import type { UseWebViewStateHook } from '@papi/core'; import papi, { logger } from '@papi/frontend'; -import type { DraftProject, SegmentationDelta, TextAnalysis } from 'interlinearizer'; +import type { DraftProject } from 'interlinearizer'; import { useCallback, useEffect, useState } from 'react'; import type { NewDraftConfig, OpenableProject } from '../../hooks/useDraftProject'; import useSubmitGuard from '../../hooks/useSubmitGuard'; @@ -47,16 +47,15 @@ type PendingReplace = * When true, New / Open are gated behind the discard confirmation so neither kind of unsaved work * is silently lost by the draft-replacing swap. When false, Save As reports the active project as * having nothing to save rather than offering a write that would change nothing. - * @param props.getDraftSnapshot - Returns the latest draft envelope (analysis + config) to persist - * on Save As. + * @param props.getDraftSnapshot - Returns the latest draft envelope (analysis + config). + * @param props.commitDraft - Runs a Save As through the draft: once the stored draft is current, + * runs the given copy with the draft snapshot and marks the draft synced when the copy reports it + * landed. Resolves what the copy resolved, or `false` when no save could run. * @param props.loadFromProject - Loads a project's analysis + config into the draft (the "Open" * flow). * @param props.newDraft - Seeds the in-memory draft with empty analysis and the given config; * called before the New flow's backend round-trip so the editor is ready regardless of whether * persistence succeeds. - * @param props.markSynced - Marks the draft as saved (clears `dirty`) after a successful Save As, - * given the analysis and boundary delta that were persisted; a no-op if an edit landed during the - * save. * @param props.modal - Which modal is currently open. * @param props.onImportPt9 - Called when the user chooses "Import from Paratext 9" in the select * modal; the caller owns the import run and its modal. @@ -78,9 +77,9 @@ export default function ProjectModals({ defaultAnalysisLanguage, hasUnsavedWork, getDraftSnapshot, + commitDraft, loadFromProject, newDraft, - markSynced, modal, onImportPt9, onOpenImport, @@ -93,12 +92,9 @@ export default function ProjectModals({ defaultAnalysisLanguage?: string; hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; loadFromProject: (project: OpenableProject) => void; newDraft: (config: NewDraftConfig) => void; - markSynced: ( - savedAnalysis: TextAnalysis, - savedSegmentation: SegmentationDelta | undefined, - ) => void; modal: ModalState; onImportPt9: () => void; onOpenImport: ( @@ -410,118 +406,86 @@ export default function ProjectModals({ const handleCancelReplace = useCallback(() => setPendingReplace(undefined), []); /** - * Saves the current draft as a brand-new project: creates the project with the draft's languages - * / target, writes the draft's analysis and segment boundaries into it, then makes it the active - * Save target and clears the dirty flag. Backend commands surface their own error notifications; - * here we only log. + * Saves the current draft as a brand-new project: has the host create the project with the + * draft's languages / target and copy the stored draft's analysis and segment boundaries into it, + * then makes it the active Save target and closes. Backend commands surface their own error + * notifications; here we only log. * * @param name - Trimmed project name, or `undefined`. * @param description - Trimmed project description, or `undefined`. */ const handleSaveAsNew = useCallback( async (name?: string, description?: string) => { - const snapshot = getDraftSnapshot(); - /* v8 ignore next -- the Save As modal is only open once the draft has loaded */ - if (!snapshot) return; - try { - const createdJson = await papi.commands.sendCommand( - 'interlinearizer.createProject', - projectId, - snapshot.analysisLanguages, - snapshot.targetProjectId, - name, - description, - ); - const created: unknown = JSON.parse(createdJson); - if (!isInterlinearProjectSummary(created)) { - await papi.notifications - .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) - .catch(() => {}); - return; - } - - const savedJson = await papi.commands.sendCommand( - 'interlinearizer.saveAnalysis', - created.id, - JSON.stringify(snapshot.analysis), - // Carry the draft's boundary state into the new project; `null` (the clear sentinel) is - // harmless on a freshly created project, which stores no boundaries yet. - // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries - JSON.stringify(snapshot.segmentation ?? null), - ); - // `saveAnalysis` bumps `updatedAt` past the creation time it was born with, so prefer the - // project it returns; fall back to the freshly created object if the payload is malformed. - const parsedSaved: unknown = savedJson ? JSON.parse(savedJson) : undefined; - setActiveProject( - toProjectSummary(isInterlinearProjectSummary(parsedSaved) ? parsedSaved : created), - ); - markSynced(snapshot.analysis, snapshot.segmentation); - setModal('none'); + const saved = await commitDraft(async (snapshot) => { + const createdJson = await papi.commands.sendCommand( + 'interlinearizer.saveDraftAsProject', + projectId, + snapshot.analysisLanguages, + snapshot.targetProjectId, + name, + description, + ); + const created: unknown = JSON.parse(createdJson); + if (!isInterlinearProjectSummary(created)) { + await papi.notifications + .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) + .catch(() => {}); + return false; + } + setActiveProject(toProjectSummary(created)); + return true; + }); + if (saved) setModal('none'); } catch (e) { logger.error('Interlinearizer: failed to save draft as new project', e); } }, - [getDraftSnapshot, projectId, setActiveProject, markSynced, setModal], + [commitDraft, projectId, setActiveProject, setModal], ); /** - * Overwrites an existing project with the current draft: writes the draft's analysis and segment - * boundaries into the chosen project, reconciles the project's declared config (analysis - * languages / alignment target) with the draft so the metadata matches the glosses now stored in - * it, makes it the active Save target, and clears the dirty flag. The backend surfaces its own - * error notification; here we only log. + * Overwrites an existing project with the current draft: has the host copy the stored draft's + * analysis and segment boundaries into the chosen project in one write that also sets the + * project's declared config (analysis languages / alignment target) from the draft, so the + * metadata matches the glosses now stored in it. The project's name and description are + * preserved: overwriting keeps the target's identity. Then makes it the active Save target and + * closes. A project that no longer exists is reported, with the draft left dirty and the modal + * open. The backend surfaces its own error notification; here we only log. */ const handleOverwrite = useCallback( async (project: InterlinearProjectSummary) => { - const snapshot = getDraftSnapshot(); - /* v8 ignore next -- the Save As modal is only open once the draft has loaded */ - if (!snapshot) return; - try { - await papi.commands.sendCommand( - 'interlinearizer.saveAnalysis', - project.id, - JSON.stringify(snapshot.analysis), - // Send the draft's boundary state so the overwritten project's boundaries match the - // analysis just written; `null` clears any boundaries the target had stored before. - // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries - JSON.stringify(snapshot.segmentation ?? null), - ); - // Push the draft's analysis languages / alignment target onto the project so its declared - // metadata stays consistent with the glosses just written (mirroring how Save As → New - // carries the draft's config into the created project). The project's name and description - // are intentionally preserved — overwriting keeps the target's identity. - const updatedJson = await papi.commands.sendCommand( - 'interlinearizer.updateProjectMetadata', - project.id, - project.name, - project.description, - snapshot.analysisLanguages, - snapshot.targetProjectId, - ); - // Prefer the server-returned project (it carries the refreshed `updatedAt`) as the new - // active project; fall back to the pre-save object with the draft's config if the payload is - // missing or malformed so the Save still completes. - const parsedUpdated: unknown = updatedJson ? JSON.parse(updatedJson) : undefined; - setActiveProject( - isInterlinearProjectSummary(parsedUpdated) - ? toProjectSummary(parsedUpdated) - : { - ...project, - analysisLanguages: snapshot.analysisLanguages, - // Assign explicitly (rather than a conditional spread) so a target binding on the - // overwritten project is cleared when the draft has none, matching what was persisted. - targetProjectId: snapshot.targetProjectId, - }, - ); - markSynced(snapshot.analysis, snapshot.segmentation); - setModal('none'); + const saved = await commitDraft(async (snapshot) => { + const savedJson = await papi.commands.sendCommand( + 'interlinearizer.saveDraftToProject', + projectId, + project.id, + { + analysisLanguages: snapshot.analysisLanguages, + targetProjectId: snapshot.targetProjectId, + }, + ); + // `undefined` means the project no longer exists, so nothing was persisted. + if (!savedJson) { + await papi.notifications + .send({ message: '%interlinearizer_error_save_project_missing%', severity: 'error' }) + .catch(() => {}); + return false; + } + const parsedSaved: unknown = JSON.parse(savedJson); + if (!isInterlinearProjectSummary(parsedSaved)) + throw new Error('Interlinearizer: the saved project came back in an unexpected shape'); + // The server-returned project carries the refreshed `updatedAt`. + setActiveProject(toProjectSummary(parsedSaved)); + return true; + }); + if (saved) setModal('none'); } catch (e) { logger.error('Interlinearizer: failed to overwrite project with draft', e); } }, - [getDraftSnapshot, setActiveProject, markSynced, setModal], + [commitDraft, projectId, setActiveProject, setModal], ); /** Called when the user dismisses the Save As modal without saving. */ diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index c3c1b0bb0..dc812998c 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -5,10 +5,11 @@ import type { SegmentationDelta, TextAnalysis, } from 'interlinearizer'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../types/model-version'; import { removeBookFromAnalysis, removeBookFromSegmentation } from '../utils/analysis-book'; +import { DebouncedWriter } from '../utils/debounced-writer'; import { perfTime, perfTrack } from '../utils/perf-marks'; import { isEmptyDelta } from '../utils/segmentation'; import { @@ -177,6 +178,20 @@ export type UseDraftProjectResult = { * `undefined` before the initial load completes. */ getDraftSnapshot: () => DraftProject | undefined; + /** + * True while {@link UseDraftProjectResult.commitDraft} runs. Wholesale replacements (New / Open / + * Wipe) must be held off while it is, so the draft being saved stays the one the save copies. + */ + isSaving: boolean; + /** + * Saves the draft to a project: once the stored draft holds the snapshot passed to `copy`, runs + * `copy`, which has the host copy the stored draft and resolves whether that copy landed; when it + * did, the draft is marked synced unless an edit landed meanwhile. Resolves what `copy` resolved, + * or `false` without copying when no draft has loaded, when another save is in flight, or when + * the draft was replaced while its writes settled. Rejects, leaving the draft dirty, when a draft + * write fails or `copy` rejects. + */ + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; /** * Persists an edited analysis into the draft and marks it dirty, recording `location` — the token * ref or segment id the edit was made at, if one place — for its undo step. Wire as the editor's @@ -208,18 +223,6 @@ export type UseDraftProjectResult = { * clean baseline, so the unsaved-changes indicator clears. The active project is left untouched. */ wipeAll: () => void; - /** - * Marks the draft as synced (not dirty) after a successful Save / Save As — but only when the - * draft has not changed since the snapshot that was persisted. Pass the exact analysis and - * boundary delta that were written (an `undefined` boundary delta means the draft had the default - * segmentation); if a later auto-save replaced either (an edit made during the save round-trip), - * the draft is left dirty so the unsaved-changes indicator and the next Save reflect that - * un-persisted edit rather than being cleared against a now-stale snapshot. - */ - markSynced: ( - savedAnalysis: TextAnalysis, - savedSegmentation: SegmentationDelta | undefined, - ) => void; canUndo: boolean; canRedo: boolean; /** @@ -314,32 +317,32 @@ export default function useDraftProject( const platformLanguageRef = useRef(platformLanguage); platformLanguageRef.current = platformLanguage; - // Pending debounced-autosave timer. Flushed on unmount/source change (so the last edit is not - // lost), and canceled on any wholesale replacement so stale keystroke data is never written - // after a New / Open / Wipe. - const autosaveTimeoutRef = useRef | undefined>(undefined); - - /** - * Persists `draft` to storage, fire-and-forget. The backend surfaces an error notification on - * failure; here we only log so a rejected write never throws into a render or event handler. - * - * This is the only persistence path — there is no retry on blur or unmount. If storage is - * unavailable during editing the backend sends one error notification per failed write; should - * that notification itself fail, edits in that window are silently lost on the next refresh. The - * `dirty` flag is set optimistically before the write, not in response to its outcome. - */ - const persist = useCallback( - (draft: DraftProject) => { - const json = perfTime('autosave-serialize', () => JSON.stringify(draft)); - perfTrack( - 'autosave', - papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json), - { bytes: json.length }, - ).catch((e) => logger.error('Interlinearizer: failed to save draft', e)); - }, + // Every change to the draft ref goes through this writer, so settling it is what makes the + // stored draft the one in memory. The backend notifies the user of a failed write; here it is + // only logged, since the writer never throws into a render or event handler. The `dirty` flag + // is set before the write, not in response to its outcome. + const writer = useMemo( + () => + new DebouncedWriter({ + write: (draft) => { + const json = perfTime('autosave-serialize', () => JSON.stringify(draft)); + return perfTrack( + 'autosave', + papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json), + { bytes: json.length }, + ); + }, + delayMs: AUTOSAVE_DEBOUNCE_MS, + onFailure: (e) => logger.error('Interlinearizer: failed to save draft', e), + }), [sourceProjectId], ); + const [isSaving, setIsSaving] = useState(false); + const isSavingRef = useRef(false); + // Counts wholesale replacements, so a save can tell whether one landed while its writes settled. + const replacementCountRef = useRef(0); + useEffect(() => { let canceled = false; setIsDraftLoading(true); @@ -376,14 +379,10 @@ export default function useDraftProject( load(); return () => { canceled = true; - if (autosaveTimeoutRef.current !== undefined) { - clearTimeout(autosaveTimeoutRef.current); - autosaveTimeoutRef.current = undefined; - // Flush the pending write so the last edit before unmount/source-change is not lost. - if (draftRef.current) persist(draftRef.current); - } + // The last edit before unmount or a source change is written rather than lost. + writer.flush(); }; - }, [persist, setHistory, sourceProjectId]); + }, [writer, setHistory, sourceProjectId]); const getDraftSnapshot = useCallback(() => draftRef.current, []); @@ -402,22 +401,18 @@ export default function useDraftProject( */ const applyReplacement = useCallback( (next: DraftProject, history: UndoHistory) => { - // Cancel any pending debounced autosave so stale keystroke data is not written after a - // wholesale replacement (New / Open / Wipe). - if (autosaveTimeoutRef.current !== undefined) { - clearTimeout(autosaveTimeoutRef.current); - autosaveTimeoutRef.current = undefined; - } draftRef.current = next; + replacementCountRef.current += 1; setHistory(history); // A fresh history numbers its states anew, voiding the clean states of the one it replaces. if (history.stateCount === 0) cleanStatesRef.current.clear(); if (!next.dirty) cleanStatesRef.current.add(history.state); - persist(next); + // Written at once, dropping any scheduled keystroke write of the draft replaced. + writer.writeNow(next); setDirty(next.dirty); setDraftVersion((v) => v + 1); }, - [persist, setHistory], + [writer, setHistory], ); /** @@ -428,15 +423,11 @@ export default function useDraftProject( const writeDraft = useCallback( (next: DraftProject) => { draftRef.current = next; - // Debounce writes so rapid keystrokes don't queue unbounded commands to the backend. - if (autosaveTimeoutRef.current !== undefined) clearTimeout(autosaveTimeoutRef.current); - autosaveTimeoutRef.current = setTimeout(() => { - autosaveTimeoutRef.current = undefined; - persist(next); - }, AUTOSAVE_DEBOUNCE_MS); + // Debounced so rapid keystrokes do not queue unbounded commands to the backend. + writer.schedule(next); setDirty(next.dirty); }, - [persist], + [writer], ); /** @@ -578,32 +569,58 @@ export default function useDraftProject( ); }, [applyReplacement]); + /** + * Marks the draft synced once `saved` has been copied into a project, unless an edit landed + * meanwhile: then the draft stays dirty, so the indicator and the next Save reflect that edit. + */ const markSynced = useCallback( - (savedAnalysis: TextAnalysis, savedSegmentation: SegmentationDelta | undefined) => { + (saved: DraftProject) => { const { current } = draftRef; - /* v8 ignore next -- save is only reachable from the mounted editor */ + /* v8 ignore next -- a save runs only once the draft has loaded */ if (!current) return; - // If an edit landed during the save round-trip, the auto-save has already swapped a newer - // analysis or boundary delta (a fresh object) into the ref and marked the draft dirty. Leave - // it dirty so the next Save reflects that un-persisted edit rather than clearing against the - // stale snapshot. Both fields are compared: a boundary edit carries `analysis` over by - // reference, so checking analysis alone would wrongly clear dirty over a segmentation change. - if (current.analysis !== savedAnalysis || current.segmentation !== savedSegmentation) return; - - // Cancel any pending debounced autosave before persisting the clean state so a stale - // {dirty: true} timer cannot fire after this and overwrite the {dirty: false} record. - if (autosaveTimeoutRef.current !== undefined) { - clearTimeout(autosaveTimeoutRef.current); - autosaveTimeoutRef.current = undefined; - } + // An edit during the save swaps a fresh analysis or boundary delta into the ref. Both are + // compared: a boundary edit carries `analysis` over by reference. + if (current.analysis !== saved.analysis || current.segmentation !== saved.segmentation) + return; + const next: DraftProject = { ...current, dirty: false }; draftRef.current = next; cleanStatesRef.current = new Set([historyRef.current.state]); - persist(next); + // Written at once, so no scheduled dirty write can land after the clean record. + writer.writeNow(next); setDirty(false); }, - [persist], + [writer], + ); + + const commitDraft = useCallback( + async (copy: (snapshot: DraftProject) => Promise): Promise => { + /* v8 ignore next -- a save is only reachable once the draft has loaded */ + if (!draftRef.current) return false; + if (isSavingRef.current) return false; + isSavingRef.current = true; + setIsSaving(true); + try { + const replacements = replacementCountRef.current; + await writer.settle(); + // Settled, so storage holds the draft the ref holds now, edits during the wait included. + const snapshot = draftRef.current; + if (replacementCountRef.current !== replacements) { + logger.warn( + 'Interlinearizer: the draft was replaced during a save, so nothing was saved', + ); + return false; + } + const landed = await copy(snapshot); + if (landed) markSynced(snapshot); + return landed; + } finally { + isSavingRef.current = false; + setIsSaving(false); + } + }, + [markSynced, writer], ); /** @@ -698,13 +715,14 @@ export default function useDraftProject( segmentationVersion, dirty, getDraftSnapshot, + isSaving, + commitDraft, autosaveAnalysis, autosaveSegmentation, loadFromProject, newDraft, wipeBook, wipeAll, - markSynced, canUndo, canRedo, getHistoryRevision, diff --git a/src/main.ts b/src/main.ts index 94c9f0695..3e3e8b353 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,8 +7,8 @@ import type { SavedWebViewDefinition, WebViewDefinition, } from '@papi/core'; -import type { SegmentationDelta } from 'interlinearizer'; -import { formatReplacementString } from 'platform-bible-utils'; +import type { ProjectConfig } from 'interlinearizer'; +import { formatReplacementString, type LocalizeKey } from 'platform-bible-utils'; import interlinearizerReact from './interlinearizer.web-view?inline'; import interlinearizerStyles from './interlinearizer.web-view.scss?inline'; import * as bookService from './services/bookService'; @@ -16,7 +16,8 @@ import { readHostPerf, setHostPerfMarks } from './services/hostPerf'; import * as projectStorage from './services/projectStorage'; import * as pt9ImportService from './services/pt9ImportService'; import type { Pt9ImportResult } from './services/pt9ImportService'; -import { isDraftProject, isSegmentationDelta, isTextAnalysis } from './types/type-guards'; +import { toProjectSummary } from './types/interlinear-project-summary'; +import { isDraftProject } from './types/type-guards'; import { perfTime, perfTimeAsync } from './utils/perf-marks'; // #region WebView provider @@ -389,68 +390,97 @@ async function getInterlinearProject(interlinearProjectId: string): Promise { + logger.error(`Interlinearizer: failed to ${description}`, e); + await papi.notifications.send({ message, severity: 'error' }).catch(() => {}); +} + +/** + * Copies the source project's stored draft into an existing interlinearizer project: Save, and Save + * As onto an existing project. The WebView writes its pending autosave first, so the stored draft + * is the one it means to save. A stored draft that cannot be read whole is never copied. * - * @param interlinearProjectId - UUID of the interlinearizer project to update. - * @param analysisJson - JSON-stringified `TextAnalysis` to persist. - * @param segmentationJson - Optional JSON-stringified `SegmentationDelta` to persist, or `"null"` - * to clear stored boundaries. Omit to leave the project's existing boundaries unchanged. - * @returns The saved `InterlinearProject` (carrying the refreshed `updatedAt`) as a JSON string, or - * `undefined` if no project with the given ID exists. Returned so the WebView can refresh its - * cached Modified time without a second read. - * @throws If JSON parsing, validation, or storage fails. The error is logged and an error - * notification is sent before rethrowing so the frontend `catch` block can suppress it without a - * second notification. + * @param sourceProjectId - Platform.Bible source project ID whose draft to copy. + * @param interlinearProjectId - UUID of the interlinearizer project to save into. + * @param config - The configuration to set on the project in the same write; omit to leave the + * project's configuration unchanged. + * @returns The saved project's `InterlinearProjectSummary` (carrying the refreshed `updatedAt`) as + * a JSON string, or `undefined` if no project with the given ID exists. + * @throws If the project is a Paratext 9 import, which is read-only, if the stored draft is + * unreadable, or if storage fails. The error is logged and an error notification is sent before + * rethrowing. */ -async function saveInterlinearAnalysis( +async function saveDraftToProject( + sourceProjectId: string, interlinearProjectId: string, - analysisJson: string, - segmentationJson?: string, + config?: ProjectConfig, ): Promise { try { - return await perfTimeAsync('saveAnalysis', async () => { - const analysis = perfTime('saveAnalysis:parse', () => JSON.parse(analysisJson)); - if (!isTextAnalysis(analysis)) { - throw new TypeError( - 'saveInterlinearAnalysis: analysisJson does not conform to TextAnalysis', - ); - } - // undefined ⇒ leave boundaries unchanged; null ⇒ clear them; an object ⇒ set them. - let segmentation: SegmentationDelta | null | undefined; - if (segmentationJson !== undefined) { - const parsed: unknown = JSON.parse(segmentationJson); - // eslint-disable-next-line no-null/no-null -- JSON.parse('null') yields null, the clear sentinel - if (parsed === null) { - // eslint-disable-next-line no-null/no-null -- explicit "clear boundaries" sentinel from the WebView - segmentation = null; - } else if (isSegmentationDelta(parsed)) { - segmentation = parsed; - } else { - throw new TypeError( - 'saveInterlinearAnalysis: segmentationJson does not conform to SegmentationDelta', - ); - } - } - const updated = await projectStorage.updateAnalysis( + return await perfTimeAsync('saveDraftToProject', async () => { + const saved = await projectStorage.saveDraftToProject( executionToken, + sourceProjectId, interlinearProjectId, - analysis, - segmentation, + config, ); - return updated - ? perfTime('saveAnalysis:serialize', () => JSON.stringify(updated)) - : undefined; + return saved ? JSON.stringify(toProjectSummary(saved)) : undefined; }); } catch (e) { - logger.error('Interlinearizer: failed to save analysis', e); - await papi.notifications - .send({ - message: '%interlinearizer_error_save_analysis_failed%', - severity: 'error', - }) - .catch(() => {}); + await reportSaveFailure( + e, + 'save the draft to a project', + '%interlinearizer_error_save_analysis_failed%', + ); + throw e; + } +} + +/** + * Creates an interlinearizer project holding a copy of the source project's stored draft: Save As + * to a new project. The WebView writes its pending autosave first, so the stored draft is the one + * it means to save. A stored draft that cannot be read whole creates no project. + * + * @param sourceProjectId - Platform.Bible source project ID whose draft to copy. + * @param analysisLanguages - BCP 47 tags for the new project's analysis languages. + * @param targetProjectId - Optional Platform.Bible project ID of the target text. + * @param name - Optional user-facing name for the project. + * @param description - Optional user-facing description for the project. + * @returns The created project's `InterlinearProjectSummary` as a JSON string. + * @throws If the stored draft is unreadable or storage fails. The error is logged and an error + * notification is sent before rethrowing. + */ +async function saveDraftAsProject( + sourceProjectId: string, + analysisLanguages: string[], + targetProjectId?: string, + name?: string, + description?: string, +): Promise { + try { + return await perfTimeAsync('saveDraftAsProject', async () => { + const created = await projectStorage.saveDraftAsNewProject( + executionToken, + sourceProjectId, + analysisLanguages, + targetProjectId, + name, + description, + ); + return JSON.stringify(toProjectSummary(created)); + }); + } catch (e) { + await reportSaveFailure( + e, + 'save the draft as a new project', + '%interlinearizer_error_create_project_failed%', + ); throw e; } } @@ -790,37 +820,94 @@ export async function activate(context: ExecutionActivationContext): Promise { - return this.readDraft(); + async exportAll(unreadable: UnreadableContentPolicy = 'empty'): Promise { + return this.readDraft(unreadable === 'throw'); } /** @@ -160,13 +165,13 @@ export class DraftRepository implements AnalysisRepository { * whether or not this instance loaded the draft, unless that load could not use the book's * shard. * - * A save that fails leaves no storage call of its own in flight. + * A save that fails leaves no storage call of its own in flight. A wiped book's shard that could + * not be deleted is no failure of the save: the draft has landed, and the shard stays journaled + * for a later save to retry. * * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read while checking the stored draft; nothing is written. * @throws If storage rejects a write. - * @throws If deleting a wiped book's shard rejects; the shard stays journaled for a later save to - * retry. */ async replaceAll(draft: DraftProject): Promise { const { sourceProjectId, storage, shardContents: saved, unreadableShards: unreadable } = this; @@ -246,9 +251,16 @@ export class DraftRepository implements AnalysisRepository { ); // With the envelope naming every shard this save added, only a failed deletion is still owed. - // If recording it fails too, the shard error wins: it is the one worth surfacing, and the next - // save retries the journal either way. + // The draft itself has landed, so the failure is logged rather than thrown: a caller that took + // it for a failed save would send the same draft again for nothing. If recording it fails too, + // the shard failure is the one worth logging, and the next save retries the journal either way. const failed = deletions.find((outcome) => outcome.status === 'rejected'); + if (failed?.status === 'rejected') { + this.logger.warn( + `Interlinearizer: could not delete the orphaned analysis shards ${undeleted.join(', ')} of ${sourceProjectId}; a later save retries:`, + failed.reason, + ); + } try { await perfTimeAsync('saveDraft:write', () => this.writeShardJournal({ adding: [], removing: undeleted }), @@ -260,20 +272,24 @@ export class DraftRepository implements AnalysisRepository { e, ); } - - if (failed?.status === 'rejected') throw failed.reason; } /** * Loads the stored draft as {@link exportAll} describes. Loading a sharded draft also resets the - * shard contents and unreadable books this instance keeps. + * shard contents and unreadable books this instance keeps, unless `strict` rejects the load. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft's `modelVersion` is higher than this build's. + * @throws {Error} If `strict` and the draft or a shard is unreadable. * @throws If storage rejects a read. */ - private async readDraft(): Promise { + private async readDraft(strict: boolean): Promise { const { sourceProjectId, storage } = this; + /** Rejects an unreadable record when strict; otherwise logs it, and the caller loads it empty. */ + const unreadable = (message: string) => { + if (strict) throw new Error(message); + this.logger.warn(message); + }; const raw = await perfTimeAsync('getDraft:read', () => storage.read(draftKey(sourceProjectId))); if (raw === undefined) return emptyDraft(sourceProjectId); const parsed: unknown = perfTime('getDraft:parse', () => JSON.parse(raw)); @@ -286,13 +302,13 @@ export class DraftRepository implements AnalysisRepository { 'analysis' in parsed && hasLinkWithoutId(parsed.analysis) ) { - this.logger.warn( + unreadable( `Interlinearizer: draft for source project ${sourceProjectId} has links without ids; loading it empty`, ); return emptyDraft(sourceProjectId); } if (!isDraftProject(parsed) || parsed.sourceProjectId !== sourceProjectId) { - this.logger.warn('Interlinearizer: stored draft failed validation; resetting to empty draft'); + unreadable('Interlinearizer: stored draft failed validation; resetting to empty draft'); return emptyDraft(sourceProjectId); } const { analysisBooks, ...draft } = withoutShardManifest(parsed); @@ -335,6 +351,11 @@ export class DraftRepository implements AnalysisRepository { const loadedByBook = booksToRead .map((bookCode, i) => ({ bookCode, ...shards[i] })) .filter(({ bookCode, state }) => state !== 'absent' || !orphaned.includes(bookCode)); + const unusable = loadedByBook.filter(({ state }) => state !== 'loaded'); + if (strict && unusable.length > 0) + throw new Error( + `Interlinearizer: draft for source project ${sourceProjectId} has unreadable analysis shards for ${unusable.map(({ bookCode }) => bookCode).join(', ')}`, + ); perfTime('getDraft:compare', () => { this.shardContents = new Map( loadedByBook @@ -342,9 +363,7 @@ export class DraftRepository implements AnalysisRepository { .map(({ bookCode, analysis }) => [bookCode, JSON.stringify(analysis)]), ); }); - this.unreadableShards = new Set( - loadedByBook.filter(({ state }) => state !== 'loaded').map(({ bookCode }) => bookCode), - ); + this.unreadableShards = new Set(unusable.map(({ bookCode }) => bookCode)); return perfTime('getDraft:parse', () => { // Checked once merged: a link and its payload can sit in different shards, so a violation // is only real of the whole. diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index f090134cb..84d7a1d32 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -3,10 +3,11 @@ import type { ExecutionToken } from '@papi/core'; import type { DraftProject, InterlinearProject, + ProjectConfig, SegmentationDelta, TextAnalysis, } from 'interlinearizer'; -import type { KeyValueStorage } from '../types/analysis-repository'; +import type { KeyValueStorage, UnreadableContentPolicy } from '../types/analysis-repository'; import { emptyAnalysis } from '../types/empty-factories'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import { assertSupportedModelVersion, CURRENT_MODEL_VERSION } from '../types/model-version'; @@ -409,10 +410,78 @@ export async function createProject( name?: string, description?: string, ): Promise { - const id = crypto.randomUUID(); + const project = buildNewProject( + sourceProjectId, + analysisLanguages, + targetProjectId, + name, + description, + emptyAnalysis(), + ); + await persistNewProject(token, project); + return project; +} + +/** + * Creates a new interlinearizer project holding a copy of the source project's stored draft: its + * analysis and its custom segment boundaries. The project's configuration comes from the arguments, + * as in {@link createProject}, since the stored draft may not yet carry the analysis languages the + * WebView seeded in memory. + * + * The caller must first have written the draft it means to save; whatever the draft holds when its + * queued read runs is what the project receives. + * + * @param token - The execution token for storage access. + * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. + * @param analysisLanguages - BCP 47 tags for the project's analysis languages. Required and must + * contain at least one entry. + * @param targetProjectId - Optional Platform.Bible project ID of the target text, as in + * {@link createProject}. + * @param name - Optional user-facing name for the project. + * @param description - Optional user-facing description for the project. + * @throws {SyntaxError} If the draft's or the stored project summaries' value contains invalid + * JSON. + * @throws {Error} If the stored draft or the stored project summaries were written by a newer + * build, or the summaries are corrupt. + * @throws {Error} If any part of the stored draft is unreadable; no project is created. + * @throws If a storage read or write rejects as in {@link getDraft} and {@link createProject}. + */ +export async function saveDraftAsNewProject( + token: ExecutionToken, + sourceProjectId: string, + analysisLanguages: string[], + targetProjectId?: string, + name?: string, + description?: string, +): Promise { + const { analysis, segmentation } = await getDraft(token, sourceProjectId, 'throw'); + reportAnalysisViolations(logger, analysis, `draft of ${sourceProjectId} on save as`); + const project = buildNewProject( + sourceProjectId, + analysisLanguages, + targetProjectId, + name, + description, + analysis, + segmentation, + ); + await persistNewProject(token, project); + return project; +} + +/** Builds a new project record, with a fresh id and both times set to now. */ +function buildNewProject( + sourceProjectId: string, + analysisLanguages: string[], + targetProjectId: string | undefined, + name: string | undefined, + description: string | undefined, + analysis: TextAnalysis, + segmentation?: SegmentationDelta, +): InterlinearProject { const now = new Date().toISOString(); - const project: InterlinearProject = { - id, + return { + id: crypto.randomUUID(), modelVersion: CURRENT_MODEL_VERSION, createdAt: now, updatedAt: now, @@ -421,12 +490,10 @@ export async function createProject( sourceProjectId, ...(targetProjectId !== undefined && { targetProjectId }), analysisLanguages, - analysis: emptyAnalysis(), + analysis, + ...(segmentation !== undefined && { segmentation }), ...(targetProjectId !== undefined && { links: [] }), }; - - await persistNewProject(token, project); - return project; } /** @@ -677,13 +744,16 @@ export function getProjectSummariesForSource( /** * Replaces the analysis of an existing interlinearizer project, and optionally its custom segment - * boundaries, in one atomic write. Refreshes `updatedAt` to the current time. + * boundaries and its configuration, in one atomic write. Refreshes `updatedAt` to the current + * time. * * @param token - The execution token for storage access. * @param id - The interlinearizer project UUID to update. * @param analysis - The new `TextAnalysis` to persist. * @param segmentation - The new boundary delta to persist (`SegmentationDelta`), `null` to clear * any stored boundaries, or `undefined` to leave the project's existing boundaries unchanged. + * @param config - The new analysis languages and target to persist, a missing target clearing the + * binding, or `undefined` to leave the project's configuration unchanged. * @returns The updated project record, or `undefined` if no project with the given ID exists or it * is unlisted. * @throws {SyntaxError} If the project's storage value contains invalid JSON. @@ -698,6 +768,7 @@ export async function updateAnalysis( id: string, analysis: TextAnalysis, segmentation?: SegmentationDelta | null, + config?: ProjectConfig, ): Promise { return enqueueProjectOp(id, async () => { const raw = await perfTimeAsync('updateAnalysis:read', () => readRecord(token, projectKey(id))); @@ -716,6 +787,11 @@ export async function updateAnalysis( // eslint-disable-next-line no-null/no-null -- null is the explicit "clear stored boundaries" sentinel if (segmentation === null) delete updated.segmentation; else if (segmentation !== undefined) updated.segmentation = segmentation; + if (config) { + updated.analysisLanguages = config.analysisLanguages; + if (config.targetProjectId === undefined) delete updated.targetProjectId; + else updated.targetProjectId = config.targetProjectId; + } await perfTimeAsync('updateAnalysis:write', () => papi.storage.writeUserData(token, projectKey(id), JSON.stringify(updated)), ); @@ -724,6 +800,39 @@ export async function updateAnalysis( }); } +/** + * Copies the source project's stored draft into an existing interlinearizer project: the draft's + * analysis replaces the project's, and the draft's custom segment boundaries replace the project's, + * cleared when the draft has none. Refreshes `updatedAt` to the current time. The project's name + * and description are untouched; its configuration changes only when `config` is given. + * + * The caller must first have written the draft it means to save; whatever the draft holds when its + * queued read runs is what the project receives. A stored draft that cannot be read whole is never + * copied: the project keeps its analysis. + * + * @param token - The execution token for storage access. + * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. + * @param id - The interlinearizer project UUID to save into. + * @param config - The analysis languages and target to persist alongside, as in + * {@link updateAnalysis}. + * @returns The updated project record, or `undefined` if no project with the given ID exists. + * @throws {SyntaxError} If the draft's or the project's storage value contains invalid JSON. + * @throws {Error} If the stored draft or project was written by a newer build; nothing is written. + * @throws {Error} If any part of the stored draft is unreadable; nothing is written. + * @throws {Error} If the project is a Paratext 9 import, as in {@link updateAnalysis}. + * @throws If a storage read or write rejects as in {@link getDraft} and {@link updateAnalysis}. + */ +export async function saveDraftToProject( + token: ExecutionToken, + sourceProjectId: string, + id: string, + config?: ProjectConfig, +): Promise { + const { analysis, segmentation } = await getDraft(token, sourceProjectId, 'throw'); + // eslint-disable-next-line no-null/no-null -- null clears the project's stored boundaries + return updateAnalysis(token, id, analysis, segmentation ?? null, config); +} + /** * Updates the metadata of an existing interlinearizer project. Refreshes `updatedAt` to the current * time. @@ -850,21 +959,25 @@ export async function hasDraft(token: ExecutionToken, sourceProjectId: string): * A book whose shard is missing or unusable loads as empty, and {@link saveDraft} then leaves that * shard and its manifest entry alone rather than treating the book as wiped, until a save carries * analyses for that book again. A shard holding a link with no id is unusable, and a draft storing - * its analysis inline with such a link loads as a fresh empty draft; either is logged. + * its analysis inline with such a link loads as a fresh empty draft; either is logged. With + * `unreadable` set to `'throw'`, each of these rejects instead, so a copy never stands in for the + * stored content. * * A save interrupted before its envelope landed leaves shards the stored manifest does not name. A * book first analyzed in that save still reopens with its work rather than empty. * * @throws {SyntaxError} If the draft's storage value contains invalid JSON. * @throws {Error} If the stored draft's `modelVersion` is higher than this build's. + * @throws {Error} If any part of the stored draft is unreadable and `unreadable` is `'throw'`. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason. */ export async function getDraft( token: ExecutionToken, sourceProjectId: string, + unreadable?: UnreadableContentPolicy, ): Promise { return enqueueSerialized(draftQueues, sourceProjectId, async () => { - return draftRepositoryFor(token, sourceProjectId).exportAll(); + return draftRepositoryFor(token, sourceProjectId).exportAll(unreadable); }); } @@ -890,9 +1003,8 @@ export async function getDraft( * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason while checking the * stored draft; nothing is written. - * @throws If `papi.storage.writeUserData` rejects. - * @throws If deleting a wiped book's shard rejects for any non-ENOENT reason; the shard stays - * journaled for a later save to retry. + * @throws If `papi.storage.writeUserData` rejects. A wiped book's shard that could not be deleted + * is no failure of the save: it stays journaled for a later save to retry. */ export async function saveDraft( token: ExecutionToken, diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 85b5bf8d6..40974729e 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -14,22 +14,32 @@ export interface WarningLogger { warn(message: string, ...details: unknown[]): void; } +/** What a load does with stored content it cannot read. */ +export type UnreadableContentPolicy = + /** Loads it as empty and logs it, so one damaged record never hides the rest. */ + | 'empty' + /** Rejects, so nothing mistakes the empty stand-in for the stored content. */ + | 'throw'; + /** * Reads and writes one source project's draft. Every call is async and takes and returns only * JSON-plain values, so an implementation may sit behind a network boundary or another language. */ export interface AnalysisRepository { /** - * Loads the whole stored draft. + * Loads the whole stored draft. Stored content that cannot be read loads as empty unless + * `unreadable` is `'throw'`. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft was written by a newer build. + * @throws {Error} If any stored content cannot be read and `unreadable` is `'throw'`. * @throws If storage rejects a read. */ - exportAll(): Promise; + exportAll(unreadable?: UnreadableContentPolicy): Promise; /** - * Saves the whole draft, replacing the stored one. + * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when + * cleanup after it fails. * * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read or a write. diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index eea1c47af..790b2b850 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -200,27 +200,52 @@ declare module 'papi-shared-types' { 'interlinearizer.getProject': (interlinearProjectId: string) => Promise; /** - * Persists an updated `TextAnalysis` (and optionally custom segment boundaries) for an - * interlinearizer project. Called from the WebView on Save so analysis and boundary changes - * survive tab restores and project switches. + * Copies the source project's stored draft, its analysis and custom segment boundaries, into an + * existing interlinearizer project. Called from the WebView on Save and on Save As onto an + * existing project, once its pending autosave has been written, so no analysis crosses the + * bus. * - * @param interlinearProjectId - UUID of the interlinearizer project to update. - * @param analysisJson - JSON-stringified `TextAnalysis` to persist. - * @param segmentationJson - JSON-stringified `SegmentationDelta` to persist, or the string - * `"null"` to clear any stored custom boundaries. Omit entirely to leave the project's - * existing boundaries unchanged. - * @returns The saved `InterlinearProject` (with its refreshed `updatedAt`) as a JSON string, or - * `undefined` if no project with the given ID exists. The WebView uses the refreshed - * `updatedAt` to keep the cached Modified time current after a Save. - * @throws If JSON parsing or storage fails. Error is logged and an error notification is sent - * before rethrowing so callers do not need to send a second notification. - */ - 'interlinearizer.saveAnalysis': ( + * @param sourceProjectId - Platform.Bible source project ID whose draft to copy. + * @param interlinearProjectId - UUID of the interlinearizer project to save into. + * @param config - The configuration to set on the project in the same write, so Save As onto an + * existing project makes its declared configuration match the glosses it now holds; omit to + * leave the configuration unchanged, as Save does. + * @returns The saved project's `InterlinearProjectSummary` (with its refreshed `updatedAt`) as + * a JSON string, or `undefined` if no project with the given ID exists. The WebView uses the + * refreshed `updatedAt` to keep the cached Modified time current after a Save. + * @throws If the project is a Paratext 9 import, which is read-only, or if the stored draft + * cannot be read whole, or if storage fails. Error is logged and an error notification is + * sent before rethrowing so callers do not need to send a second notification. + */ + 'interlinearizer.saveDraftToProject': ( + sourceProjectId: string, interlinearProjectId: string, - analysisJson: string, - segmentationJson?: string, + config?: import('interlinearizer').ProjectConfig, ) => Promise; + /** + * Creates an interlinearizer project holding a copy of the source project's stored draft, its + * analysis and custom segment boundaries. Called from the WebView on Save As to a new project, + * once its pending autosave has been written. + * + * @param sourceProjectId - Platform.Bible source project ID whose draft to copy. + * @param analysisLanguages - BCP 47 tags for the new project's analysis languages. + * @param targetProjectId - Optional Platform.Bible project ID of the target text. + * @param name - Optional user-facing name for the project. + * @param description - Optional user-facing description for the project. + * @returns The created project's `InterlinearProjectSummary` as a JSON string. + * @throws If the stored draft cannot be read whole, or if storage fails. Error is logged and an + * error notification is sent before rethrowing so callers do not need to send a second + * notification. + */ + 'interlinearizer.saveDraftAsProject': ( + sourceProjectId: string, + analysisLanguages: string[], + targetProjectId?: string, + name?: string, + description?: string, + ) => Promise; + /** * Returns the draft working buffer for the given source project, serialized as a JSON string. * Creates a fresh empty draft when none has been written. The WebView loads this on mount to @@ -1590,6 +1615,12 @@ declare module 'interlinearizer' { }; } + /** + * The configuration a project declares for its analysis: the languages its glosses are written + * in, and the target text it aligns to, if any. + */ + export type ProjectConfig = Pick; + /** * The always-present, auto-saved working buffer for a single source project. Decoupled from the * user's saved {@link InterlinearProject}s: it is stored under its own `draft:{sourceProjectId}` diff --git a/src/utils/debounced-writer.ts b/src/utils/debounced-writer.ts new file mode 100644 index 000000000..a447cc952 --- /dev/null +++ b/src/utils/debounced-writer.ts @@ -0,0 +1,98 @@ +/** What a {@link DebouncedWriter} is built from. */ +export type DebouncedWriterOptions = { + /** Sends one value to storage; its rejection is what the writer counts as a failed write. */ + write: (value: T) => Promise; + /** Milliseconds a scheduled write waits for a later value before it is sent. */ + delayMs: number; + /** Hears each write that fails. */ + onFailure: (error: unknown) => void; +}; + +/** + * Debounces writes of a value to storage and can settle them: once {@link settle} resolves, every + * value handed to the writer has been superseded or stored, so what storage holds is the last value + * given. A write that failed is sent again on settle, so one failure never leaves storage behind + * for good. + */ +export class DebouncedWriter { + private readonly options: DebouncedWriterOptions; + + private timer: ReturnType | undefined; + + private scheduled: T | undefined; + + private latest: Promise = Promise.resolve(); + + private lastValue: T | undefined; + + private failed = false; + + private failure: unknown; + + constructor(options: DebouncedWriterOptions) { + this.options = options; + } + + /** Writes `value` once the delay passes with no later call; a later call takes its place. */ + schedule(value: T): void { + this.cancel(); + this.scheduled = value; + this.timer = setTimeout(() => { + this.timer = undefined; + this.scheduled = undefined; + this.writeNow(value); + }, this.options.delayMs); + } + + /** Drops the scheduled write, if any. */ + cancel(): void { + if (this.timer !== undefined) clearTimeout(this.timer); + this.timer = undefined; + this.scheduled = undefined; + } + + /** Writes `value` at once, dropping any scheduled write. */ + writeNow(value: T): void { + this.cancel(); + const write = this.options.write(value); + this.latest = write; + this.lastValue = value; + this.failed = false; + write.catch((e: unknown) => { + if (this.latest === write) { + this.failed = true; + this.failure = e; + } + this.options.onFailure(e); + }); + } + + /** Sends the scheduled write at once, if there is one. */ + flush(): void { + const { scheduled } = this; + if (this.timer !== undefined && scheduled !== undefined) this.writeNow(scheduled); + } + + /** + * Resolves once storage holds the last value given: any scheduled write is sent, every write in + * flight has landed, and a write that failed has been sent again. A value scheduled while this + * waits is written too before it resolves. + * + * @throws The failure, when the write sent again fails as well. + */ + async settle(): Promise { + let resent = false; + for (;;) { + this.flush(); + if (this.failed && this.lastValue !== undefined) { + if (resent) throw this.failure; + resent = true; + this.writeNow(this.lastValue); + } + const { latest } = this; + // eslint-disable-next-line no-await-in-loop -- each round waits for the write it sent + await latest.catch(() => {}); + if (this.timer === undefined && this.latest === latest && !this.failed) return; + } + } +} From 72fd355a39df91af03ba321930347f3fc496084a Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Tue, 20 Oct 2026 10:55:34 -0700 Subject: [PATCH 02/10] Address review of the host Save (#433) Save no longer sends the draft again once the copy lands. The host marks the stored draft clean itself, rewriting only its envelope, and only when no draft write reached it after the copy read the draft. The WebView flips its own flag and writes the clean draft only when a write went out during the Save, as an edit undone mid-save does. The perf harness no longer waits for an autosave after each Save. A book of the draft that cannot be read no longer fails every Save. The copy names such books instead of throwing: Save keeps the project's stored analysis and boundaries for them, Save As leaves them out, and either tells the user which books. A draft whose envelope is unreadable still fails, since no book in it can be vouched for, and its first autosave rewrites the envelope. AnalysisRepository: exportAll is lenient again, as 1b had it; exportForCopy reports the books it could not read; markClean records the stored draft as saved. The saveDraftToProject command metadata describes its config object rather than two positional parameters, and commitDraft documents its rejections with @throws. "Saving..." uses the ellipsis character like the other progress strings, a test comment no longer names hook internals, and the gating and flush tests are split per side. Co-Authored-By: Claude Opus 5.5 --- contributions/localizedStrings.json | 4 +- e2e-tests/tests/perf/app-perf.spec.ts | 4 - .../components/InterlinearizerLoader.test.tsx | 32 +- src/__tests__/hooks/useDraftProject.test.ts | 90 ++++- src/__tests__/main.test.ts | 64 +++- .../services/draftRepository.test.ts | 141 ++++++++ src/__tests__/services/projectStorage.test.ts | 316 ++++++++++++------ src/__tests__/utils/debounced-writer.test.ts | 36 +- src/hooks/useDraftProject.ts | 26 +- src/main.ts | 56 +++- src/services/draftRepository.ts | 95 ++++-- src/services/projectStorage.ts | 185 ++++++++-- src/types/analysis-repository.ts | 44 ++- src/utils/debounced-writer.ts | 13 + 14 files changed, 885 insertions(+), 221 deletions(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 1ab6f7c24..2a54e3b3b 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -326,7 +326,9 @@ "%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. Please try again.", "%interlinearizer_error_save_project_missing%": "The project you were saving to no longer exists. Use Save As to save your work to another project.", "%interlinearizer_info_save_in_progress%": "A save is in progress. Try again when it finishes.", - "%interlinearizer_status_saving%": "Saving...", + "%interlinearizer_status_saving%": "Saving…", + "%interlinearizer_warning_save_unreadableBooksKept%": "Some books in your working draft could not be read, so the project keeps its saved analysis for them: {books}", + "%interlinearizer_warning_saveAs_unreadableBooksOmitted%": "Some books in your working draft could not be read and were left out of the new project: {books}", "%interlinearizer_pt9Import_name%": "Paratext 9 Interlinear", "%interlinearizer_pt9Import_description%": "Imported from this project's Paratext 9 interlinear data. Read-only; synced from the Paratext 9 files.", diff --git a/e2e-tests/tests/perf/app-perf.spec.ts b/e2e-tests/tests/perf/app-perf.spec.ts index 49636e89a..a1feb6218 100644 --- a/e2e-tests/tests/perf/app-perf.spec.ts +++ b/e2e-tests/tests/perf/app-perf.spec.ts @@ -397,12 +397,8 @@ async function measureTier( await takeHostEntries(page); // eslint-disable-next-line no-await-in-loop await openProjectMenuItem(frame, /^Save(?! As)/); - unsavedFrame = frame; // eslint-disable-next-line no-await-in-loop const measures = await waitForMeasure(frame, 'save'); - // A save then persists the draft as clean, and that re-render would close the next run's menu. - // eslint-disable-next-line no-await-in-loop - await waitForAutosave(frame); saves.webView.add(measures, ['save']); const bytes = detailBytes(measures, 'save'); if (bytes !== undefined) saves.webView.addValue('save-bytes', bytes); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 86726bd44..a1780485b 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -3924,11 +3924,10 @@ describe('InterlinearizerLoader', () => { await userEvent.click(screen.getByTestId('tab-toolbar-save')); // Nothing was pending, so the stored draft is already current: Save's first command is the - // host copy, carrying only ids. The draft write that follows is markSynced's clean flag. - expect(mockSendCommand.mock.calls[0]).toEqual([ - 'interlinearizer.saveDraftToProject', - testProjectId, - 'proj-1', + // host copy, carrying only ids. The host marks the stored draft clean itself, so no draft + // write follows. + expect(mockSendCommand.mock.calls).toEqual([ + ['interlinearizer.saveDraftToProject', testProjectId, 'proj-1'], ]); }); @@ -4000,7 +3999,7 @@ describe('InterlinearizerLoader', () => { 'data-active-project-updated', ); - // Dirty the draft so the post-save markSynced flips dirty back off, forcing the re-render that + // Dirty the draft so the save that lands flips dirty back off, forcing the re-render that // reflects the newly cached updatedAt down to the ProjectModals stub. act(() => { capturedStoreProps?.onSave?.(emptyAnalysis()); @@ -4076,7 +4075,11 @@ describe('InterlinearizerLoader', () => { }); }); - it('shows the save running and holds New, Open, Save As, and Wipe until it ends', async () => { + /** + * Renders the loader on an active project and starts a Save whose copy the host holds until the + * returned function lands it. + */ + async function startHeldSave(): Promise<() => Promise> { let finishSave!: (summary: string) => void; mockSendCommand.mockImplementation((...args) => args[0] === 'interlinearizer.saveDraftToProject' @@ -4088,15 +4091,19 @@ describe('InterlinearizerLoader', () => { await act(async () => renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), ); - expect(screen.queryByTestId('saving-indicator')).not.toBeInTheDocument(); - await userEvent.click(screen.getByTestId('tab-toolbar-save')); + return () => act(async () => finishSave(JSON.stringify(STUB_ACTIVE_PROJECT))); + } + + it('shows the save running, and holds New, Save, Save As, and Wipe while it does', async () => { + await startHeldSave(); expect(screen.getByTestId('saving-indicator')).toBeInTheDocument(); await userEvent.click(screen.getByTestId('tab-toolbar-new-project')); await userEvent.click(screen.getByTestId('tab-toolbar-save-as')); await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); await userEvent.click(screen.getByTestId('tab-toolbar-save')); + expect(screen.getByTestId('project-modals')).toHaveAttribute('data-modal', 'none'); expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument(); // Each held command says why it did nothing; the save itself was sent once. @@ -4108,12 +4115,17 @@ describe('InterlinearizerLoader', () => { expect( mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraftToProject'), ).toHaveLength(1); + }); + + it('shows no save running, and lets the held commands through, once the save ends', async () => { + const finishSave = await startHeldSave(); - await act(async () => finishSave(JSON.stringify(STUB_ACTIVE_PROJECT))); + await finishSave(); await waitFor(() => expect(screen.queryByTestId('saving-indicator')).not.toBeInTheDocument()); await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); expect(screen.getByTestId('wipe-modal-panel')).toBeInTheDocument(); + expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); }); it('marks the draft synced after a successful Save, clearing the tab unsaved marker', async () => { diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index 10193feb4..cfdc2672d 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -97,6 +97,11 @@ async function saveNow(result: { current: UseDraftProjectResult }): Promise c[0] === 'interlinearizer.saveDraft').length; +} + describe('useDraftProject', () => { beforeEach(() => { jest.mocked(logger.error).mockImplementation(() => {}); @@ -204,11 +209,6 @@ describe('useDraftProject', () => { }); describe('commitDraft', () => { - /** Counts the draft writes sent so far. */ - function saveDraftCount(): number { - return mockSendCommand.mock.calls.filter((c) => c[0] === 'interlinearizer.saveDraft').length; - } - /** * Serves `getDraft` as {@link mockGetDraftResolves} does, but holds each draft write until the * returned function settles it. @@ -256,9 +256,7 @@ describe('useDraftProject', () => { expect(copy).toHaveBeenCalledTimes(1); expect(seen.snapshot?.analysis.tokenAnalyses[0].id).toBe('pending'); - // The first write carried the edit; the second is the clean flag once the copy landed. - expect(saveDraftCount()).toBe(2); - expect(lastSavedDraft().dirty).toBe(false); + expect(saveDraftCount()).toBe(1); expect(result.current.dirty).toBe(false); }); @@ -876,7 +874,7 @@ describe('useDraftProject', () => { }); describe('marking the draft synced after a save', () => { - it('clears dirty and persists the synced draft without bumping the version', async () => { + it('clears dirty without bumping the version', async () => { const { result } = await renderLoaded(); // Make the draft dirty first so the transition to synced is observable. act(() => { @@ -889,7 +887,75 @@ describe('useDraftProject', () => { expect(result.current.dirty).toBe(false); expect(result.current.draftVersion).toBe(versionBefore); - expect(lastSavedDraft().dirty).toBe(false); + }); + + it('sends no draft write once the copy lands, since the host marks the stored draft clean', async () => { + const { result } = await renderLoaded(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-sync')); + }); + await act(async () => { + await result.current.commitDraft(async () => false); + }); + const writesBeforeSave = saveDraftCount(); + + await saveNow(result); + + expect(saveDraftCount()).toBe(writesBeforeSave); + }); + + it('writes the clean draft when an edit undone during the copy was already written', async () => { + const { result } = await renderLoaded(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-saved')); + }); + + await act(async () => { + await result.current.commitDraft(async () => { + jest.useFakeTimers(); + // An edit whose write goes out before the copy lands, then undone back to the snapshot. + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-undone')); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + act(() => { + result.current.undo(); + }); + jest.useRealTimers(); + return true; + }); + }); + + expect(result.current.dirty).toBe(false); + const stored = lastSavedDraft(); + expect(stored.analysis.tokenAnalyses[0].id).toBe('tok-saved'); + expect(stored.dirty).toBe(false); + }); + + it('writes the clean draft when an edit undone during the copy is still waiting to be written', async () => { + const { result } = await renderLoaded(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-saved')); + }); + + await act(async () => { + await result.current.commitDraft(async () => { + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-undone')); + }); + act(() => { + result.current.undo(); + }); + return true; + }); + }); + + expect(result.current.dirty).toBe(false); + const stored = lastSavedDraft(); + expect(stored.analysis.tokenAnalyses[0].id).toBe('tok-saved'); + expect(stored.dirty).toBe(false); }); it('leaves the draft dirty when an edit landed during the copy', async () => { @@ -945,9 +1011,7 @@ describe('useDraftProject', () => { await saveNow(result); expect(result.current.dirty).toBe(false); - const saved = lastSavedDraft(); - expect(saved.dirty).toBe(false); - expect(saved.segmentation).toEqual(delta); + expect(lastSavedDraft().segmentation).toEqual(delta); }); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index aa4626892..d62641c78 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -1250,7 +1250,7 @@ describe('main', () => { updatedAt: '2026-06-15T09:00:00Z', segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, }; - mockSaveDraftToProject.mockResolvedValue(saved); + mockSaveDraftToProject.mockResolvedValue({ project: saved, unreadableBooks: [] }); const handler = await getSaveDraftToProjectHandler(); const result = await handler('src-project', 'proj-id'); @@ -1263,10 +1263,49 @@ describe('main', () => { undefined, ); expect(result).toBe(JSON.stringify(toProjectSummary(saved))); + // Every book was read, so nothing is reported. + expect(__mockNotificationsSend).not.toHaveBeenCalled(); + }); + + it('warns which books of the draft could not be read and kept their saved analysis', async () => { + mockSaveDraftToProject.mockResolvedValue({ + project: makeStubProject('proj-id'), + unreadableBooks: ['JHN', 'ACT'], + }); + __mockGetLocalizedString.mockResolvedValue('Kept: {books}'); + const handler = await getSaveDraftToProjectHandler(); + + await handler('src-project', 'proj-id'); + + expect(__mockGetLocalizedString).toHaveBeenCalledWith({ + localizeKey: '%interlinearizer_warning_save_unreadableBooksKept%', + }); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: 'Kept: JHN, ACT', + severity: 'warning', + }); + }); + + it('still returns the saved project when the unreadable-books warning cannot be sent', async () => { + const saved = makeStubProject('proj-id'); + mockSaveDraftToProject.mockResolvedValue({ project: saved, unreadableBooks: ['JHN'] }); + __mockGetLocalizedString.mockRejectedValue(new Error('localization unavailable')); + const handler = await getSaveDraftToProjectHandler(); + + await expect(handler('src-project', 'proj-id')).resolves.toBe( + JSON.stringify(toProjectSummary(saved)), + ); + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not report the unreadable books of a saved draft', + expect.any(Error), + ); }); it('forwards the config to set', async () => { - mockSaveDraftToProject.mockResolvedValue(makeStubProject('proj-id')); + mockSaveDraftToProject.mockResolvedValue({ + project: makeStubProject('proj-id'), + unreadableBooks: [], + }); const handler = await getSaveDraftToProjectHandler(); await handler('src-project', 'proj-id', { @@ -1325,7 +1364,7 @@ describe('main', () => { it("forwards the new project's config and returns its summary, without its analysis, as JSON", async () => { const created = { ...makeStubProject('new-id'), name: 'Name', targetProjectId: 'tgt' }; - mockSaveDraftAsNewProject.mockResolvedValue(created); + mockSaveDraftAsNewProject.mockResolvedValue({ project: created, unreadableBooks: [] }); const handler = await getSaveDraftAsProjectHandler(); const result = await handler('src-project', ['fr'], 'tgt', 'Name', 'Desc'); @@ -1341,6 +1380,25 @@ describe('main', () => { expect(result).toBe(JSON.stringify(toProjectSummary(created))); }); + it('warns which books of the draft could not be read and were left out', async () => { + mockSaveDraftAsNewProject.mockResolvedValue({ + project: makeStubProject('new-id'), + unreadableBooks: ['JHN'], + }); + __mockGetLocalizedString.mockResolvedValue('Left out: {books}'); + const handler = await getSaveDraftAsProjectHandler(); + + await handler('src-project', ['en']); + + expect(__mockGetLocalizedString).toHaveBeenCalledWith({ + localizeKey: '%interlinearizer_warning_saveAs_unreadableBooksOmitted%', + }); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: 'Left out: JHN', + severity: 'warning', + }); + }); + it('logs the error, sends an error notification, and rethrows when storage throws', async () => { mockSaveDraftAsNewProject.mockRejectedValue(new Error('disk full')); const handler = await getSaveDraftAsProjectHandler(); diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts index fbe1eaf35..7cdc06d6e 100644 --- a/src/__tests__/services/draftRepository.test.ts +++ b/src/__tests__/services/draftRepository.test.ts @@ -138,4 +138,145 @@ describe('DraftRepository', () => { `Interlinearizer: analysis shard ${draftAnalysisKey('src', 'GEN')} is not valid JSON; treating as empty`, ); }); + + describe('exportForCopy', () => { + it('exports a readable draft whole, naming no book', async () => { + const { storage } = memoryStorage(); + await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); + + await expect( + new DraftRepository(storage, 'src', silentLogger()).exportForCopy(), + ).resolves.toEqual({ draft: twoBookDraft(), unreadableBooks: [] }); + }); + + it('names a book whose shard is unusable, and exports the rest', async () => { + const { storage, records } = memoryStorage(); + await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); + records.set(draftAnalysisKey('src', 'GEN'), 'not json'); + + const { draft, unreadableBooks } = await new DraftRepository( + storage, + 'src', + silentLogger(), + ).exportForCopy(); + + expect(unreadableBooks).toEqual(['GEN']); + expect(draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']); + }); + + it('names a book whose shard the manifest lists but storage lacks', async () => { + const { storage, records } = memoryStorage(); + await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); + records.delete(draftAnalysisKey('src', 'EXO')); + + const { unreadableBooks } = await new DraftRepository( + storage, + 'src', + silentLogger(), + ).exportForCopy(); + + expect(unreadableBooks).toEqual(['EXO']); + }); + + it('exports a fresh empty draft when none is stored', async () => { + const { storage } = memoryStorage(); + + await expect( + new DraftRepository(storage, 'src', silentLogger()).exportForCopy(), + ).resolves.toEqual({ draft: emptyDraft('src'), unreadableBooks: [] }); + }); + + it('exports an inline draft whole, naming no book', async () => { + const inline = twoBookDraft(); + const { storage } = memoryStorage({ [draftKey('src')]: JSON.stringify(inline) }); + + await expect( + new DraftRepository(storage, 'src', silentLogger()).exportForCopy(), + ).resolves.toEqual({ draft: inline, unreadableBooks: [] }); + }); + + it('rejects a draft that fails validation rather than exporting it empty', async () => { + const { storage } = memoryStorage({ [draftKey('src')]: JSON.stringify({ nope: true }) }); + + await expect( + new DraftRepository(storage, 'src', silentLogger()).exportForCopy(), + ).rejects.toThrow( + 'Interlinearizer: stored draft failed validation; resetting to empty draft', + ); + }); + + it('rejects a draft holding a link with no id inline', async () => { + const inline = JSON.stringify(twoBookDraft()).replace(/"id":"tl-[A-Z]+",/g, ''); + const { storage } = memoryStorage({ [draftKey('src')]: inline }); + + await expect( + new DraftRepository(storage, 'src', silentLogger()).exportForCopy(), + ).rejects.toThrow('has links without ids'); + }); + }); + + describe('markClean', () => { + it('records the stored draft as clean, rewriting only its envelope', async () => { + const { storage, records, writes } = memoryStorage(); + await new DraftRepository(storage, 'src', silentLogger()).replaceAll({ + ...twoBookDraft(), + dirty: true, + }); + writes.length = 0; + + await new DraftRepository(storage, 'src', silentLogger()).markClean(); + + expect(writes).toEqual([draftKey('src')]); + await expect( + new DraftRepository(storage, 'src', silentLogger()).exportAll(), + ).resolves.toEqual({ ...twoBookDraft(), dirty: false }); + expect(JSON.parse(records.get(draftKey('src')) ?? '{}').analysisBooks).toEqual([ + 'GEN', + 'EXO', + ]); + }); + + it('writes nothing when no draft is stored', async () => { + const { storage, writes } = memoryStorage(); + + await new DraftRepository(storage, 'src', silentLogger()).markClean(); + + expect(writes).toEqual([]); + }); + + it('writes nothing when the stored draft is already clean', async () => { + const { storage, writes } = memoryStorage(); + await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); + writes.length = 0; + + await new DraftRepository(storage, 'src', silentLogger()).markClean(); + + expect(writes).toEqual([]); + }); + + it('refuses a draft written by a newer build, writing nothing', async () => { + const newer = JSON.stringify({ + ...emptyDraft('src'), + dirty: true, + modelVersion: CURRENT_MODEL_VERSION + 1, + }); + const { storage, writes } = memoryStorage({ [draftKey('src')]: newer }); + + await expect(new DraftRepository(storage, 'src', silentLogger()).markClean()).rejects.toThrow( + /newer than this build supports/, + ); + expect(writes).toEqual([]); + }); + + it('refuses a draft that fails validation, writing nothing', async () => { + const { storage, writes } = memoryStorage({ + [draftKey('src')]: JSON.stringify({ ...emptyDraft('other'), dirty: true }), + }); + + await expect(new DraftRepository(storage, 'src', silentLogger()).markClean()).rejects.toThrow( + 'failed validation; not marking it clean', + ); + expect(writes).toEqual([]); + }); + }); }); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 3196e43d9..69ebb8166 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -1364,7 +1364,7 @@ describe('projectStorage', () => { segmentation: SEGMENTATION, updatedAt: SAVE_TIME, }; - expect(result).toEqual(expected); + expect(result).toEqual({ project: expected, unreadableBooks: [] }); expect(storedProject(stored, 'proj-id')).toEqual(expected); }); @@ -1435,7 +1435,159 @@ describe('projectStorage', () => { const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); await write; - expect(result?.analysis).toEqual(draft.analysis); + expect(result?.project.analysis).toEqual(draft.analysis); + }); + + it("keeps the project's analysis and boundaries for a book the draft cannot read", async () => { + const stored = backStorageWithMap(); + const savedEarlier = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); + const jhnBoundary = { tokenRef: 'JHN 1:1!4', surfaceText: 'word-JHN' }; + storeListedProject(stored, { + ...makeStubProject('proj-id'), + analysis: savedEarlier.analysis, + segmentation: { removedVerseStarts: ['JHN 1:2:0'], addedStarts: [jhnBoundary] }, + }); + const edited = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); + edited.analysis.tokenAnalyses[0].gloss = { en: 'edited-GEN' }; + await saveDraft(token, 'src-proj', { ...edited, segmentation: SEGMENTATION }); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(result?.unreadableBooks).toEqual(['JHN']); + const project = storedProject(stored, 'proj-id'); + expect(project).toMatchObject({ + analysis: { + tokenAnalyses: [ + expect.objectContaining({ gloss: { en: 'edited-GEN' } }), + expect.objectContaining({ id: 'analysis-JHN' }), + ], + }, + segmentation: { + removedVerseStarts: ['GEN 1:2:0', 'JHN 1:2:0'], + addedStarts: [jhnBoundary], + }, + }); + }); + + it('clears the boundaries when neither the draft nor the kept book has any', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, { + ...makeStubProject('proj-id'), + analysis: makeDraftSpanningBooks('src-proj', 'JHN').analysis, + segmentation: SEGMENTATION, + }); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + + await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(storedProject(stored, 'proj-id')).not.toHaveProperty('segmentation'); + }); + + it('saves after Wipe All although a wiped book could not be read', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, makeStubProject('proj-id')); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + const loaded = await getDraft(token, 'src-proj'); + await saveDraft(token, 'src-proj', { ...loaded, analysis: emptyAnalysis() }); + + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(result?.unreadableBooks).toEqual(['JHN']); + }); + + it('rejects, writing no project, when the stored draft itself is unreadable', async () => { + const stored = backStorageWithMap(); + const project = makeStubProject('proj-id'); + storeListedProject(stored, project); + stored.set('draft:src-proj', JSON.stringify({ nope: true })); + + await expect(saveDraftToProject(token, 'src-proj', 'proj-id')).rejects.toThrow( + 'failed validation', + ); + expect(storedProject(stored, 'proj-id')).toEqual(project); + }); + + it('marks a draft left from an earlier session clean once the project holds it', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, makeStubProject('proj-id')); + stored.set( + 'draft:src-proj', + JSON.stringify({ ...makeDraftSpanningBooks('src-proj', 'GEN'), dirty: true }), + ); + + await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect((await getDraft(token, 'src-proj')).dirty).toBe(false); + }); + + it('marks the stored draft clean once the project holds it', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, makeStubProject('proj-id')); + await saveDraft(token, 'src-proj', { + ...makeDraftSpanningBooks('src-proj', 'GEN'), + dirty: true, + }); + + await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect((await getDraft(token, 'src-proj')).dirty).toBe(false); + }); + + it('leaves the stored draft dirty when a draft write was sent after the copy read it', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, makeStubProject('proj-id')); + await saveDraft(token, 'src-proj', { + ...makeDraftSpanningBooks('src-proj', 'GEN'), + dirty: true, + }); + const later = { ...makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'), dirty: true }; + + // The later write is sent while the copy is in flight, behind its read. + const save = saveDraftToProject(token, 'src-proj', 'proj-id'); + const write = saveDraft(token, 'src-proj', later); + await Promise.all([save, write]); + + expect(await getDraft(token, 'src-proj')).toMatchObject({ + dirty: true, + analysis: later.analysis, + }); + }); + + it('does not mark the draft clean when the project is gone', async () => { + backStorageWithMap(); + await saveDraft(token, 'src-proj', { + ...makeDraftSpanningBooks('src-proj', 'GEN'), + dirty: true, + }); + + await saveDraftToProject(token, 'src-proj', 'missing'); + + expect((await getDraft(token, 'src-proj')).dirty).toBe(true); + }); + + it('logs a failure to mark the draft clean and still reports the save', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, makeStubProject('proj-id')); + await saveDraft(token, 'src-proj', { + ...makeDraftSpanningBooks('src-proj', 'GEN'), + dirty: true, + }); + const failure = new Error('disk full'); + __mockWriteUserData.mockImplementation(async (_t: unknown, key: unknown, json: unknown) => { + if (key === 'draft:src-proj') throw failure; + if (typeof key === 'string' && typeof json === 'string') stored.set(key, json); + }); + + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(result?.project.id).toBe('proj-id'); + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not mark the draft of src-proj clean after saving it:', + failure, + ); }); it('returns undefined and writes no project when the project does not exist', async () => { @@ -1449,6 +1601,40 @@ describe('projectStorage', () => { }); }); + describe('updateAnalysis keeping books', () => { + it("keeps the project's own boundaries throughout when told to leave them", async () => { + const stored = backStorageWithMap(); + const boundaries = { + removedVerseStarts: ['GEN 1:2:0', 'JHN 1:2:0'], + addedStarts: [], + }; + storeListedProject(stored, { + ...makeStubProject('proj-id'), + analysis: makeDraftSpanningBooks('src-proj', 'JHN').analysis, + segmentation: boundaries, + }); + + await updateAnalysis( + token, + 'proj-id', + makeDraftSpanningBooks('src-proj', 'GEN').analysis, + undefined, + undefined, + ['JHN'], + ); + + expect(storedProject(stored, 'proj-id')).toMatchObject({ + analysis: { + tokenAnalysisLinks: [ + expect.objectContaining({ id: 'link-GEN' }), + expect.objectContaining({ id: 'link-JHN' }), + ], + }, + segmentation: boundaries, + }); + }); + }); + describe('saveDraftAsNewProject', () => { it("creates an indexed project holding the stored draft's analysis and boundaries", async () => { const stored = backStorageWithMap(); @@ -1478,7 +1664,7 @@ describe('projectStorage', () => { segmentation: SEGMENTATION, links: [], }; - expect(result).toEqual(expected); + expect(result).toEqual({ project: expected, unreadableBooks: [] }); expect(storedProject(stored, expected.id)).toEqual(expected); expect(JSON.parse(stored.get('projectSummaries') ?? '{}').summaries).toEqual([ summarizeProject(expected), @@ -1489,9 +1675,32 @@ describe('projectStorage', () => { backStorageWithMap(); await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN')); - const result = await saveDraftAsNewProject(token, 'src-proj', ['en']); + const { project } = await saveDraftAsNewProject(token, 'src-proj', ['en']); - expect(result).not.toHaveProperty('segmentation'); + expect(project).not.toHaveProperty('segmentation'); + }); + + it('leaves out a book the draft cannot read, and names it', async () => { + const stored = backStorageWithMap(); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + + const { project, unreadableBooks } = await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect(unreadableBooks).toEqual(['JHN']); + expect(project.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['link-GEN']); + }); + + it('marks the stored draft clean once the project is created', async () => { + backStorageWithMap(); + await saveDraft(token, 'src-proj', { + ...makeDraftSpanningBooks('src-proj', 'GEN'), + dirty: true, + }); + + await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect((await getDraft(token, 'src-proj')).dirty).toBe(false); }); it('reports an invariant violation the stored draft carries', async () => { @@ -1696,103 +1905,6 @@ describe('projectStorage', () => { ); }); - describe('with unreadable content set to throw', () => { - /** Serves what was written, except `key`, which reads as `value`. */ - function serveWrittenExcept(key: string, value: string) { - __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { - if (k === key) return value; - const written = __mockWriteUserData.mock.calls.findLast(([, w]) => w === k); - if (!written) throw enoentError(); - return written[2]; - }); - } - - it('rejects when a shard is not valid JSON, instead of loading the book empty', async () => { - await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); - serveWrittenExcept('draft:src-proj:analysis:JHN', '{not json'); - - await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( - 'Interlinearizer: draft for source project src-proj has unreadable analysis shards for JHN', - ); - }); - - it('rejects when a shard the manifest names is missing', async () => { - await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); - __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { - const written = __mockWriteUserData.mock.calls.findLast(([, w]) => w === k); - if (!written || k === 'draft:src-proj:analysis:JHN') throw enoentError(); - return written[2]; - }); - - await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( - 'unreadable analysis shards for JHN', - ); - }); - - it('rejects when the stored draft fails validation, instead of loading it empty', async () => { - __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { - if (k === 'draft:src-proj') return JSON.stringify({ modelVersion: 1, nope: true }); - throw enoentError(); - }); - - await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( - 'Interlinearizer: stored draft failed validation; resetting to empty draft', - ); - expect(__mockLogger.warn).not.toHaveBeenCalled(); - }); - - it('rejects when the stored draft holds a link with no id', async () => { - const inline = { - ...emptyDraft('src-proj'), - analysis: withoutLinkIds(makeDraftSpanningBooks('src-proj', 'GEN').analysis), - }; - __mockReadUserData.mockImplementation(async (_t: unknown, k: unknown) => { - if (k === 'draft:src-proj') return JSON.stringify(inline); - throw enoentError(); - }); - - await expect(getDraft(token, 'src-proj', 'throw')).rejects.toThrow( - 'has links without ids; loading it empty', - ); - }); - - it('loads a readable draft as the lenient read does', async () => { - const draft = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); - await saveDraft(token, 'src-proj', draft); - serveWrittenExcept('none', ''); - - await expect(getDraft(token, 'src-proj', 'throw')).resolves.toEqual({ - ...draft, - analysis: expect.objectContaining({ - tokenAnalysisLinks: expect.arrayContaining(draft.analysis.tokenAnalysisLinks), - }), - }); - }); - - it('leaves the project untouched when Save finds the stored draft damaged', async () => { - const stored = new Map(); - const project = makeStubProject('proj-id'); - stored.set('project:proj-id', JSON.stringify(project)); - __mockWriteUserData.mockImplementation(async (_t: unknown, key: unknown, json: unknown) => { - if (typeof key === 'string' && typeof json === 'string') stored.set(key, json); - }); - __mockDeleteUserData.mockImplementation(async (_t: unknown, key: unknown) => { - if (typeof key === 'string') stored.delete(key); - }); - await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); - stored.set('draft:src-proj:analysis:JHN', '{not json'); - __mockReadUserData.mockImplementation(async (_t: unknown, key: unknown) => { - if (typeof key !== 'string' || !stored.has(key)) throw enoentError(); - return stored.get(key); - }); - - await expect(saveDraftToProject(token, 'src-proj', 'proj-id')).rejects.toThrow( - 'unreadable analysis shards for JHN', - ); - expect(JSON.parse(stored.get('project:proj-id') ?? '')).toEqual(project); - }); - }); - it('propagates a shard read failure that is not a missing key', async () => { const draft = makeDraftSpanningBooks('src-proj', 'GEN'); await saveDraft(token, 'src-proj', draft); diff --git a/src/__tests__/utils/debounced-writer.test.ts b/src/__tests__/utils/debounced-writer.test.ts index 02dbe090d..9c6f0c2f1 100644 --- a/src/__tests__/utils/debounced-writer.test.ts +++ b/src/__tests__/utils/debounced-writer.test.ts @@ -66,15 +66,45 @@ describe('DebouncedWriter', () => { expect(writes.map((w) => w.value)).toEqual(['b']); }); - it('sends the scheduled value at once on flush, and nothing when none is scheduled', () => { + it('sends the scheduled value at once on flush', () => { const { writer, writes } = heldWriter(); - writer.flush(); writer.schedule('a'); writer.flush(); - jest.advanceTimersByTime(100); expect(writes.map((w) => w.value)).toEqual(['a']); + jest.advanceTimersByTime(100); + expect(writes).toHaveLength(1); + }); + + it('sends nothing on flush when no value is scheduled', () => { + const { writer, writes } = heldWriter(); + + writer.flush(); + + expect(writes).toHaveLength(0); + }); + + it('counts the writes it has sent', () => { + const { writer } = heldWriter(); + writer.writeNow('a'); + writer.schedule('b'); + expect(writer.writesSent).toBe(1); + + jest.advanceTimersByTime(100); + + expect(writer.writesSent).toBe(2); + }); + + it('reports whether a write is waiting out its delay', () => { + const { writer } = heldWriter(); + expect(writer.isScheduled).toBe(false); + + writer.schedule('a'); + expect(writer.isScheduled).toBe(true); + + jest.advanceTimersByTime(100); + expect(writer.isScheduled).toBe(false); }); it('reports each failed write', async () => { diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index dc812998c..05881b720 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -185,11 +185,15 @@ export type UseDraftProjectResult = { isSaving: boolean; /** * Saves the draft to a project: once the stored draft holds the snapshot passed to `copy`, runs - * `copy`, which has the host copy the stored draft and resolves whether that copy landed; when it - * did, the draft is marked synced unless an edit landed meanwhile. Resolves what `copy` resolved, - * or `false` without copying when no draft has loaded, when another save is in flight, or when - * the draft was replaced while its writes settled. Rejects, leaving the draft dirty, when a draft - * write fails or `copy` rejects. + * `copy`, which has the host copy the stored draft into a project and mark the stored draft + * clean, and resolves whether the copy landed. When it did, the draft is marked synced unless an + * edit landed meanwhile. Resolves what `copy` resolved, or `false` without copying when no draft + * has loaded, when another save is in flight, or when the draft was replaced while its writes + * settled. + * + * @throws If a draft write fails, so the stored draft lags the one in memory; the draft stays + * dirty and nothing is copied. + * @throws Whatever `copy` throws; the draft stays dirty. */ commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; /** @@ -572,9 +576,11 @@ export default function useDraftProject( /** * Marks the draft synced once `saved` has been copied into a project, unless an edit landed * meanwhile: then the draft stays dirty, so the indicator and the next Save reflect that edit. + * The copy marks the stored draft clean itself unless a draft write reached storage after it, so + * the clean record is written from here only when `wroteSince` says one may have. */ const markSynced = useCallback( - (saved: DraftProject) => { + (saved: DraftProject, wroteSince: boolean) => { const { current } = draftRef; /* v8 ignore next -- a save runs only once the draft has loaded */ if (!current) return; @@ -587,8 +593,9 @@ export default function useDraftProject( const next: DraftProject = { ...current, dirty: false }; draftRef.current = next; cleanStatesRef.current = new Set([historyRef.current.state]); - // Written at once, so no scheduled dirty write can land after the clean record. - writer.writeNow(next); + // An edit undone during the save matches the snapshot yet was written or scheduled, so the + // stored draft may hold it. Written at once, so no scheduled dirty write lands after it. + if (wroteSince) writer.writeNow(next); setDirty(false); }, [writer], @@ -612,8 +619,9 @@ export default function useDraftProject( ); return false; } + const writesBefore = writer.writesSent; const landed = await copy(snapshot); - if (landed) markSynced(snapshot); + if (landed) markSynced(snapshot, writer.writesSent !== writesBefore || writer.isScheduled); return landed; } finally { isSavingRef.current = false; diff --git a/src/main.ts b/src/main.ts index 3e3e8b353..eaff55f66 100644 --- a/src/main.ts +++ b/src/main.ts @@ -402,10 +402,28 @@ async function reportSaveFailure( await papi.notifications.send({ message, severity: 'error' }).catch(() => {}); } +/** + * Tells the user which books of the saved draft could not be read, sending `message` with them + * listed. Sends nothing when every book was read; a notification that fails is dropped. + */ +async function reportUnreadableBooks(books: string[], message: LocalizeKey): Promise { + if (books.length === 0) return; + try { + const template = await papi.localization.getLocalizedString({ localizeKey: message }); + await papi.notifications.send({ + message: formatReplacementString(template, { books: books.join(', ') }), + severity: 'warning', + }); + } catch (e) { + logger.warn('Interlinearizer: could not report the unreadable books of a saved draft', e); + } +} + /** * Copies the source project's stored draft into an existing interlinearizer project: Save, and Save * As onto an existing project. The WebView writes its pending autosave first, so the stored draft - * is the one it means to save. A stored draft that cannot be read whole is never copied. + * is the one it means to save. A book of the draft that cannot be read keeps the project's saved + * analysis, and the user is told which. * * @param sourceProjectId - Platform.Bible source project ID whose draft to copy. * @param interlinearProjectId - UUID of the interlinearizer project to save into. @@ -430,7 +448,12 @@ async function saveDraftToProject( interlinearProjectId, config, ); - return saved ? JSON.stringify(toProjectSummary(saved)) : undefined; + if (!saved) return undefined; + await reportUnreadableBooks( + saved.unreadableBooks, + '%interlinearizer_warning_save_unreadableBooksKept%', + ); + return JSON.stringify(toProjectSummary(saved.project)); }); } catch (e) { await reportSaveFailure( @@ -445,7 +468,8 @@ async function saveDraftToProject( /** * Creates an interlinearizer project holding a copy of the source project's stored draft: Save As * to a new project. The WebView writes its pending autosave first, so the stored draft is the one - * it means to save. A stored draft that cannot be read whole creates no project. + * it means to save. A book of the draft that cannot be read is left out, and the user is told + * which. * * @param sourceProjectId - Platform.Bible source project ID whose draft to copy. * @param analysisLanguages - BCP 47 tags for the new project's analysis languages. @@ -473,7 +497,11 @@ async function saveDraftAsProject( name, description, ); - return JSON.stringify(toProjectSummary(created)); + await reportUnreadableBooks( + created.unreadableBooks, + '%interlinearizer_warning_saveAs_unreadableBooksOmitted%', + ); + return JSON.stringify(toProjectSummary(created.project)); }); } catch (e) { await reportSaveFailure( @@ -840,18 +868,18 @@ export async function activate(context: ExecutionActivationContext): Promise { + return (await this.readDraft(false)).draft; + } + + /** + * Loads the stored draft as {@link exportAll} does, naming each book whose shard is missing or + * unusable instead of leaving its absence to pass for a wipe. A draft never saved exports as a + * fresh empty one. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft's `modelVersion` is higher than this build's. - * @throws {Error} If the draft or a shard is unreadable and `unreadable` is `'throw'`. + * @throws {Error} If the draft fails validation, belongs to another source project, or holds a + * link with no id inline, since then no book in it can be vouched for. * @throws If storage rejects a read. */ - async exportAll(unreadable: UnreadableContentPolicy = 'empty'): Promise { - return this.readDraft(unreadable === 'throw'); + async exportForCopy(): Promise { + return this.readDraft(true); + } + + /** + * Records the stored draft as clean, rewriting only its envelope. A draft never saved, or already + * clean, is left alone. + * + * @throws {SyntaxError} If the draft's stored value contains invalid JSON; nothing is written. + * @throws {Error} If the stored draft was written by a newer build, fails validation, or belongs + * to another source project; nothing is written. + * @throws If storage rejects the read or the write. + */ + async markClean(): Promise { + const { sourceProjectId, storage } = this; + const raw = await storage.read(draftKey(sourceProjectId)); + if (raw === undefined) return; + const parsed: unknown = JSON.parse(raw); + assertSupportedModelVersion(parsed, `draft for source project ${sourceProjectId}`); + if (!isDraftProject(parsed) || parsed.sourceProjectId !== sourceProjectId) + throw new Error( + `Interlinearizer: stored draft for source project ${sourceProjectId} failed validation; not marking it clean`, + ); + if (!parsed.dirty) return; + await storage.write( + draftKey(sourceProjectId), + JSON.stringify({ ...parsed, modelVersion: CURRENT_MODEL_VERSION, dirty: false }), + ); } /** @@ -275,23 +312,28 @@ export class DraftRepository implements AnalysisRepository { } /** - * Loads the stored draft as {@link exportAll} describes. Loading a sharded draft also resets the - * shard contents and unreadable books this instance keeps, unless `strict` rejects the load. + * Loads the stored draft as {@link exportAll} describes, with the books whose shard could not be + * read. Loading a sharded draft also resets the shard contents and unreadable books this instance + * keeps. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft's `modelVersion` is higher than this build's. - * @throws {Error} If `strict` and the draft or a shard is unreadable. + * @throws {Error} If `strictEnvelope` and the draft itself is unreadable. * @throws If storage rejects a read. */ - private async readDraft(strict: boolean): Promise { + private async readDraft(strictEnvelope: boolean): Promise { const { sourceProjectId, storage } = this; - /** Rejects an unreadable record when strict; otherwise logs it, and the caller loads it empty. */ - const unreadable = (message: string) => { - if (strict) throw new Error(message); + /** + * Rejects an unreadable draft when `strictEnvelope`; otherwise logs it, and the draft loads as + * empty. + */ + const unreadable = (message: string): DraftExport => { + if (strictEnvelope) throw new Error(message); this.logger.warn(message); + return { draft: emptyDraft(sourceProjectId), unreadableBooks: [] }; }; const raw = await perfTimeAsync('getDraft:read', () => storage.read(draftKey(sourceProjectId))); - if (raw === undefined) return emptyDraft(sourceProjectId); + if (raw === undefined) return { draft: emptyDraft(sourceProjectId), unreadableBooks: [] }; const parsed: unknown = perfTime('getDraft:parse', () => JSON.parse(raw)); // Refuse a newer build's draft before validating it. A later shape need not pass this build's // guard, and failing validation is what resets the draft to empty. @@ -302,15 +344,14 @@ export class DraftRepository implements AnalysisRepository { 'analysis' in parsed && hasLinkWithoutId(parsed.analysis) ) { - unreadable( + return unreadable( `Interlinearizer: draft for source project ${sourceProjectId} has links without ids; loading it empty`, ); - return emptyDraft(sourceProjectId); - } - if (!isDraftProject(parsed) || parsed.sourceProjectId !== sourceProjectId) { - unreadable('Interlinearizer: stored draft failed validation; resetting to empty draft'); - return emptyDraft(sourceProjectId); } + if (!isDraftProject(parsed) || parsed.sourceProjectId !== sourceProjectId) + return unreadable( + 'Interlinearizer: stored draft failed validation; resetting to empty draft', + ); const { analysisBooks, ...draft } = withoutShardManifest(parsed); const describeDraft = `draft for source project ${sourceProjectId} on load`; // A draft with no manifest carries its analysis inline, with no shards to read. @@ -318,7 +359,7 @@ export class DraftRepository implements AnalysisRepository { perfTime('getDraft:parse', () => reportAnalysisViolations(this.logger, draft.analysis, describeDraft), ); - return draft; + return { draft, unreadableBooks: [] }; } const keyOf = (bookCode: string) => draftAnalysisKey(sourceProjectId, bookCode); // A save interrupted before its envelope landed leaves shards this manifest never came to name. @@ -351,11 +392,9 @@ export class DraftRepository implements AnalysisRepository { const loadedByBook = booksToRead .map((bookCode, i) => ({ bookCode, ...shards[i] })) .filter(({ bookCode, state }) => state !== 'absent' || !orphaned.includes(bookCode)); - const unusable = loadedByBook.filter(({ state }) => state !== 'loaded'); - if (strict && unusable.length > 0) - throw new Error( - `Interlinearizer: draft for source project ${sourceProjectId} has unreadable analysis shards for ${unusable.map(({ bookCode }) => bookCode).join(', ')}`, - ); + const unreadableBooks = loadedByBook + .filter(({ state }) => state !== 'loaded') + .map(({ bookCode }) => bookCode); perfTime('getDraft:compare', () => { this.shardContents = new Map( loadedByBook @@ -363,13 +402,13 @@ export class DraftRepository implements AnalysisRepository { .map(({ bookCode, analysis }) => [bookCode, JSON.stringify(analysis)]), ); }); - this.unreadableShards = new Set(unusable.map(({ bookCode }) => bookCode)); + this.unreadableShards = new Set(unreadableBooks); return perfTime('getDraft:parse', () => { // Checked once merged: a link and its payload can sit in different shards, so a violation // is only real of the whole. const analysis = mergeAnalyses(shards.map((shard) => shard.analysis)); reportAnalysisViolations(this.logger, analysis, describeDraft); - return { ...draft, analysis }; + return { draft: { ...draft, analysis }, unreadableBooks }; }); } diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 84d7a1d32..06cae7dd1 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -7,13 +7,19 @@ import type { SegmentationDelta, TextAnalysis, } from 'interlinearizer'; -import type { KeyValueStorage, UnreadableContentPolicy } from '../types/analysis-repository'; +import type { DraftExport, KeyValueStorage } from '../types/analysis-repository'; import { emptyAnalysis } from '../types/empty-factories'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import { assertSupportedModelVersion, CURRENT_MODEL_VERSION } from '../types/model-version'; import { hasLinkWithoutId, isStringArray } from '../types/type-guards'; +import { + mergeAnalyses, + removeBookFromSegmentation, + splitAnalysisByBook, +} from '../utils/analysis-book'; import { reportAnalysisViolations } from '../utils/analysis-violations'; import { perfTime, perfTimeAsync } from '../utils/perf-marks'; +import { segmentationForBook } from '../utils/segmentation'; import { DraftRepository } from './draftRepository'; import { ProjectSummaries, summarizeProject } from './projectSummaries'; import { draftKey, projectKey } from './storageKeys'; @@ -60,6 +66,22 @@ const draftQueues = new Map>(); */ const draftRepositories = new Map(); +/** + * How many draft writes each source project has been sent, so a copy into a project can tell + * whether the draft it read is still the stored one. + */ +const draftWriteCounts = new Map(); + +/** A project that received a copy of the stored draft, and the books the copy could not read. */ +export type SavedDraftCopy = { + project: InterlinearProject; + /** + * Book codes whose stored draft analysis could not be read. Save keeps the project's own analysis + * and boundaries for these; Save As leaves them out of the new project. + */ + unreadableBooks: string[]; +}; + /** Extension user data as {@link KeyValueStorage}, reading a never-written key as absent. */ function userDataStorage(token: ExecutionToken): KeyValueStorage { return { @@ -429,7 +451,9 @@ export async function createProject( * WebView seeded in memory. * * The caller must first have written the draft it means to save; whatever the draft holds when its - * queued read runs is what the project receives. + * queued read runs is what the project receives. A book whose stored draft analysis cannot be read + * is left out rather than copied as empty, and named in the result. Once the project is created, + * the stored draft is marked clean unless a draft write was sent after it was read. * * @param token - The execution token for storage access. * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. @@ -443,7 +467,7 @@ export async function createProject( * JSON. * @throws {Error} If the stored draft or the stored project summaries were written by a newer * build, or the summaries are corrupt. - * @throws {Error} If any part of the stored draft is unreadable; no project is created. + * @throws {Error} If the stored draft itself is unreadable; no project is created. * @throws If a storage read or write rejects as in {@link getDraft} and {@link createProject}. */ export async function saveDraftAsNewProject( @@ -453,8 +477,9 @@ export async function saveDraftAsNewProject( targetProjectId?: string, name?: string, description?: string, -): Promise { - const { analysis, segmentation } = await getDraft(token, sourceProjectId, 'throw'); +): Promise { + const { draft, unreadableBooks, writesBefore } = await readDraftForCopy(token, sourceProjectId); + const { analysis, segmentation } = draft; reportAnalysisViolations(logger, analysis, `draft of ${sourceProjectId} on save as`); const project = buildNewProject( sourceProjectId, @@ -466,7 +491,8 @@ export async function saveDraftAsNewProject( segmentation, ); await persistNewProject(token, project); - return project; + await markCopiedDraftClean(token, sourceProjectId, writesBefore); + return { project, unreadableBooks }; } /** Builds a new project record, with a fresh id and both times set to now. */ @@ -754,6 +780,8 @@ export function getProjectSummariesForSource( * any stored boundaries, or `undefined` to leave the project's existing boundaries unchanged. * @param config - The new analysis languages and target to persist, a missing target clearing the * binding, or `undefined` to leave the project's configuration unchanged. + * @param keepBooks - Book codes whose stored analysis and boundaries the project keeps, in place of + * whatever `analysis` and `segmentation` hold for them. Empty when omitted. * @returns The updated project record, or `undefined` if no project with the given ID exists or it * is unlisted. * @throws {SyntaxError} If the project's storage value contains invalid JSON. @@ -769,24 +797,34 @@ export async function updateAnalysis( analysis: TextAnalysis, segmentation?: SegmentationDelta | null, config?: ProjectConfig, + keepBooks: readonly string[] = [], ): Promise { return enqueueProjectOp(id, async () => { const raw = await perfTimeAsync('updateAnalysis:read', () => readRecord(token, projectKey(id))); if (raw === undefined) return undefined; const project = perfTime('updateAnalysis:parse', () => parseProject(raw, id)); if (project.pt9Import) throw pt9ImportReadOnlyError(id); + const kept = keepBooks.length === 0 ? undefined : keepStoredBooks(project, keepBooks); + const savedAnalysis = kept ? mergeAnalyses([analysis, kept.analysis]) : analysis; perfTime('updateAnalysis:parse', () => - reportAnalysisViolations(logger, analysis, `project ${id} on save`), + reportAnalysisViolations(logger, savedAnalysis, `project ${id} on save`), ); const updated: InterlinearProject = { ...project, modelVersion: CURRENT_MODEL_VERSION, - analysis, + analysis: savedAnalysis, updatedAt: new Date().toISOString(), }; + const savedSegmentation = kept + ? withBoundariesOf( + // eslint-disable-next-line no-null/no-null -- null is the explicit "clear stored boundaries" sentinel + segmentation === undefined ? project.segmentation : (segmentation ?? undefined), + kept, + ) + : segmentation; // eslint-disable-next-line no-null/no-null -- null is the explicit "clear stored boundaries" sentinel - if (segmentation === null) delete updated.segmentation; - else if (segmentation !== undefined) updated.segmentation = segmentation; + if (savedSegmentation === null) delete updated.segmentation; + else if (savedSegmentation !== undefined) updated.segmentation = savedSegmentation; if (config) { updated.analysisLanguages = config.analysisLanguages; if (config.targetProjectId === undefined) delete updated.targetProjectId; @@ -800,6 +838,53 @@ export async function updateAnalysis( }); } +/** A project's stored analysis and boundaries for some of its books. */ +type StoredBooks = { + books: readonly string[]; + analysis: TextAnalysis; + segmentation: SegmentationDelta | undefined; +}; + +/** What `project` stores for `books`: their analysis, and the boundaries anchored in them. */ +function keepStoredBooks(project: InterlinearProject, books: readonly string[]): StoredBooks { + const byBook = splitAnalysisByBook(project.analysis); + const boundaries = books.flatMap((book) => segmentationForBook(project.segmentation, book) ?? []); + return { + books, + analysis: mergeAnalyses(books.flatMap((book) => byBook.get(book) ?? [])), + segmentation: + boundaries.length === 0 + ? undefined + : { + removedVerseStarts: boundaries.flatMap((delta) => delta.removedVerseStarts), + addedStarts: boundaries.flatMap((delta) => delta.addedStarts), + }, + }; +} + +/** + * `delta` with the boundaries of the kept books replaced by those stored for them, or `null` when + * no boundary remains. + */ +function withBoundariesOf( + delta: SegmentationDelta | undefined, + kept: StoredBooks, +): SegmentationDelta | null { + const others = kept.books.reduce( + (remaining, book) => removeBookFromSegmentation(remaining, book), + delta, + ); + const removedVerseStarts = [ + ...(others?.removedVerseStarts ?? []), + ...(kept.segmentation?.removedVerseStarts ?? []), + ]; + const addedStarts = [...(others?.addedStarts ?? []), ...(kept.segmentation?.addedStarts ?? [])]; + return removedVerseStarts.length === 0 && addedStarts.length === 0 + ? // eslint-disable-next-line no-null/no-null -- null clears the project's stored boundaries + null + : { removedVerseStarts, addedStarts }; +} + /** * Copies the source project's stored draft into an existing interlinearizer project: the draft's * analysis replaces the project's, and the draft's custom segment boundaries replace the project's, @@ -807,18 +892,20 @@ export async function updateAnalysis( * and description are untouched; its configuration changes only when `config` is given. * * The caller must first have written the draft it means to save; whatever the draft holds when its - * queued read runs is what the project receives. A stored draft that cannot be read whole is never - * copied: the project keeps its analysis. + * queued read runs is what the project receives. A book whose stored draft analysis cannot be read + * keeps the project's stored analysis and boundaries, and is named in the result. Once the project + * is written, the stored draft is marked clean unless a draft write was sent after it was read. * * @param token - The execution token for storage access. * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. * @param id - The interlinearizer project UUID to save into. * @param config - The analysis languages and target to persist alongside, as in * {@link updateAnalysis}. - * @returns The updated project record, or `undefined` if no project with the given ID exists. + * @returns The saved project, or `undefined` if no project with the given ID exists or it is + * unlisted. * @throws {SyntaxError} If the draft's or the project's storage value contains invalid JSON. * @throws {Error} If the stored draft or project was written by a newer build; nothing is written. - * @throws {Error} If any part of the stored draft is unreadable; nothing is written. + * @throws {Error} If the stored draft itself is unreadable; nothing is written. * @throws {Error} If the project is a Paratext 9 import, as in {@link updateAnalysis}. * @throws If a storage read or write rejects as in {@link getDraft} and {@link updateAnalysis}. */ @@ -827,10 +914,20 @@ export async function saveDraftToProject( sourceProjectId: string, id: string, config?: ProjectConfig, -): Promise { - const { analysis, segmentation } = await getDraft(token, sourceProjectId, 'throw'); - // eslint-disable-next-line no-null/no-null -- null clears the project's stored boundaries - return updateAnalysis(token, id, analysis, segmentation ?? null, config); +): Promise { + const { draft, unreadableBooks, writesBefore } = await readDraftForCopy(token, sourceProjectId); + const project = await updateAnalysis( + token, + id, + draft.analysis, + // eslint-disable-next-line no-null/no-null -- null clears the project's stored boundaries + draft.segmentation ?? null, + config, + unreadableBooks, + ); + if (!project) return undefined; + await markCopiedDraftClean(token, sourceProjectId, writesBefore); + return { project, unreadableBooks }; } /** @@ -959,28 +1056,66 @@ export async function hasDraft(token: ExecutionToken, sourceProjectId: string): * A book whose shard is missing or unusable loads as empty, and {@link saveDraft} then leaves that * shard and its manifest entry alone rather than treating the book as wiped, until a save carries * analyses for that book again. A shard holding a link with no id is unusable, and a draft storing - * its analysis inline with such a link loads as a fresh empty draft; either is logged. With - * `unreadable` set to `'throw'`, each of these rejects instead, so a copy never stands in for the - * stored content. + * its analysis inline with such a link loads as a fresh empty draft; either is logged. * * A save interrupted before its envelope landed leaves shards the stored manifest does not name. A * book first analyzed in that save still reopens with its work rather than empty. * * @throws {SyntaxError} If the draft's storage value contains invalid JSON. * @throws {Error} If the stored draft's `modelVersion` is higher than this build's. - * @throws {Error} If any part of the stored draft is unreadable and `unreadable` is `'throw'`. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason. */ export async function getDraft( token: ExecutionToken, sourceProjectId: string, - unreadable?: UnreadableContentPolicy, ): Promise { return enqueueSerialized(draftQueues, sourceProjectId, async () => { - return draftRepositoryFor(token, sourceProjectId).exportAll(unreadable); + return draftRepositoryFor(token, sourceProjectId).exportAll(); }); } +/** + * Reads the stored draft for copying into a project, in turn with the source's draft writes. + * + * @returns The draft and the books it could not read, with the number of draft writes sent before + * this read, which {@link markCopiedDraftClean} compares to tell whether a write followed it. + * @throws As {@link DraftRepository.exportForCopy} does. + */ +function readDraftForCopy( + token: ExecutionToken, + sourceProjectId: string, +): Promise { + // Counted when the read is queued: every write sent before it is queued ahead of it. + const writesBefore = draftWriteCounts.get(sourceProjectId) ?? 0; + return enqueueSerialized(draftQueues, sourceProjectId, async () => ({ + ...(await draftRepositoryFor(token, sourceProjectId).exportForCopy()), + writesBefore, + })); +} + +/** + * Marks the stored draft clean now that a copy of it has landed in a project, unless a draft write + * was sent after the copy read it. A failure is logged rather than thrown: the project holds the + * copy either way, and the draft only reopens marked unsaved. + */ +async function markCopiedDraftClean( + token: ExecutionToken, + sourceProjectId: string, + writesBefore: number, +): Promise { + try { + await enqueueSerialized(draftQueues, sourceProjectId, async () => { + if ((draftWriteCounts.get(sourceProjectId) ?? 0) !== writesBefore) return; + await draftRepositoryFor(token, sourceProjectId).markClean(); + }); + } catch (e) { + logger.warn( + `Interlinearizer: could not mark the draft of ${sourceProjectId} clean after saving it:`, + e, + ); + } +} + /** * Writes the draft working buffer for a source project, replacing any existing draft. Writes are * serialized per source project, so a WebView's rapid auto-saves cannot persist out of order. The @@ -1011,6 +1146,7 @@ export async function saveDraft( sourceProjectId: string, draft: DraftProject, ): Promise { + draftWriteCounts.set(sourceProjectId, (draftWriteCounts.get(sourceProjectId) ?? 0) + 1); // Invariant violations go unreported here: this runs on every edit, so one would log per keystroke. await enqueueSerialized(draftQueues, sourceProjectId, async () => { await draftRepositoryFor(token, sourceProjectId).replaceAll(draft); @@ -1028,4 +1164,5 @@ export function resetQueuesForTesting(): void { projectQueues.clear(); draftQueues.clear(); draftRepositories.clear(); + draftWriteCounts.clear(); } diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 40974729e..0ed91ff42 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -14,12 +14,16 @@ export interface WarningLogger { warn(message: string, ...details: unknown[]): void; } -/** What a load does with stored content it cannot read. */ -export type UnreadableContentPolicy = - /** Loads it as empty and logs it, so one damaged record never hides the rest. */ - | 'empty' - /** Rejects, so nothing mistakes the empty stand-in for the stored content. */ - | 'throw'; +/** A stored draft as read for copying elsewhere, with the books it could not vouch for. */ +export type DraftExport = { + /** The draft, holding no analysis for the books in `unreadableBooks`. */ + draft: DraftProject; + /** + * Book codes whose stored analysis could not be read, so the draft carries none for them. A copy + * must not take that absence for the book having been wiped. + */ + unreadableBooks: string[]; +}; /** * Reads and writes one source project's draft. Every call is async and takes and returns only @@ -27,15 +31,25 @@ export type UnreadableContentPolicy = */ export interface AnalysisRepository { /** - * Loads the whole stored draft. Stored content that cannot be read loads as empty unless - * `unreadable` is `'throw'`. + * Loads the whole stored draft. Stored content that cannot be read loads as empty, so one damaged + * record never hides the rest. + * + * @throws {SyntaxError} If the draft's stored value contains invalid JSON. + * @throws {Error} If the stored draft was written by a newer build. + * @throws If storage rejects a read. + */ + exportAll(): Promise; + + /** + * Loads the whole stored draft for copying into a project, naming the books whose analysis could + * not be read rather than passing them off as empty. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft was written by a newer build. - * @throws {Error} If any stored content cannot be read and `unreadable` is `'throw'`. + * @throws {Error} If the draft itself is unreadable, so no book in it can be vouched for. * @throws If storage rejects a read. */ - exportAll(unreadable?: UnreadableContentPolicy): Promise; + exportForCopy(): Promise; /** * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when @@ -45,4 +59,14 @@ export interface AnalysisRepository { * @throws If storage rejects a read or a write. */ replaceAll(draft: DraftProject): Promise; + + /** + * Records the stored draft as saved to its project, leaving its content as it stands. A draft + * never saved, or already clean, is left alone. + * + * @throws {Error} If the stored draft was written by a newer build, or is unreadable; nothing is + * written. + * @throws If storage rejects a read or a write. + */ + markClean(): Promise; } diff --git a/src/utils/debounced-writer.ts b/src/utils/debounced-writer.ts index a447cc952..c5105ab81 100644 --- a/src/utils/debounced-writer.ts +++ b/src/utils/debounced-writer.ts @@ -29,10 +29,22 @@ export class DebouncedWriter { private failure: unknown; + private sent = 0; + constructor(options: DebouncedWriterOptions) { this.options = options; } + /** How many writes have been sent so far, so a caller can tell whether any went out since. */ + get writesSent(): number { + return this.sent; + } + + /** Whether a write is waiting out its delay. */ + get isScheduled(): boolean { + return this.timer !== undefined; + } + /** Writes `value` once the delay passes with no later call; a later call takes its place. */ schedule(value: T): void { this.cancel(); @@ -55,6 +67,7 @@ export class DebouncedWriter { writeNow(value: T): void { this.cancel(); const write = this.options.write(value); + this.sent += 1; this.latest = write; this.lastValue = value; this.failed = false; From 37135089aa9c718d9e0d893b4c02772bb7931f96 Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Tue, 20 Oct 2026 14:11:50 -0700 Subject: [PATCH 03/10] Let a wipe release unreadable draft books (#433) A wipe now lets go of a book whose stored draft analysis could not be read. Before, the draft repository held such a book through every save that carried nothing for it, Wipe Book and Wipe All included, so Save kept the project's copy of it and warned on every Save until the user edited inside it. Each draft write now names the books released since the draft was loaded: Wipe Book adds its book, and Wipe All, New and Open release all of them. Releasing a book again is harmless, so every write carries the whole set and a failed write needs no retry of its own; a load starts the set afresh, since holds come from a load and book codes repeat across sources. AnalysisRepository.replaceAll and the saveDraft command take the released books as an optional argument. Save As leaves out the boundaries of a book it could not read along with its analysis, matching what it tells the user. The unreadable books warning names analyses linked to no book in words rather than by their storage name. A test now covers an edit and its undo that are both written before the copy lands, the case only the count of writes sent can catch. It runs on fake timers for its whole body, so each write fires when the test advances the clock and never on real time. Co-Authored-By: Claude Opus 5.5 --- contributions/localizedStrings.json | 1 + src/__tests__/hooks/useDraftProject.test.ts | 148 ++++++++++++++++++ src/__tests__/main.test.ts | 80 +++++++++- .../services/draftRepository.test.ts | 48 ++++++ src/__tests__/services/projectStorage.test.ts | 33 ++++ src/hooks/useDraftProject.ts | 35 ++++- src/main.ts | 50 ++++-- src/services/draftRepository.ts | 10 +- src/services/projectStorage.ts | 20 ++- src/types/analysis-repository.ts | 12 +- src/types/interlinearizer.d.ts | 10 +- 11 files changed, 417 insertions(+), 30 deletions(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 2a54e3b3b..4a6ebe17a 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -329,6 +329,7 @@ "%interlinearizer_status_saving%": "Saving…", "%interlinearizer_warning_save_unreadableBooksKept%": "Some books in your working draft could not be read, so the project keeps its saved analysis for them: {books}", "%interlinearizer_warning_saveAs_unreadableBooksOmitted%": "Some books in your working draft could not be read and were left out of the new project: {books}", + "%interlinearizer_unreadable_unlinkedAnalyses%": "analyses not linked to any book", "%interlinearizer_pt9Import_name%": "Paratext 9 Interlinear", "%interlinearizer_pt9Import_description%": "Imported from this project's Paratext 9 interlinear data. Read-only; synced from the Paratext 9 files.", diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index cfdc2672d..c7d5e0456 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -934,6 +934,45 @@ describe('useDraftProject', () => { expect(stored.dirty).toBe(false); }); + it('writes the clean draft when an edit and its undo were both written before the copy landed', async () => { + const { result } = await renderLoaded(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-saved')); + }); + // Every timer from here on runs on the test's clock, so each write fires exactly when the + // test advances past its debounce and never on its own. + jest.useFakeTimers(); + try { + await act(async () => { + await result.current.commitDraft(async () => { + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-undone')); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + act(() => { + result.current.undo(); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + // Both writes have gone out, and none is waiting: only the count of writes sent tells + // the save that the stored draft moved after the copy read it. + expect(jest.getTimerCount()).toBe(0); + return true; + }); + }); + } finally { + jest.useRealTimers(); + } + + expect(result.current.dirty).toBe(false); + const stored = lastSavedDraft(); + expect(stored.analysis.tokenAnalyses[0].id).toBe('tok-saved'); + expect(stored.dirty).toBe(false); + }); + it('writes the clean draft when an edit undone during the copy is still waiting to be written', async () => { const { result } = await renderLoaded(); act(() => { @@ -1030,6 +1069,115 @@ describe('useDraftProject', () => { }); }); + describe('released books', () => { + /** The books each draft write released, `undefined` where it released none. */ + function releasedPerWrite(): unknown[] { + return mockSendCommand.mock.calls + .filter((call) => call[0] === 'interlinearizer.saveDraft') + .map((call) => call[3]); + } + + it('releases nothing with an edit', async () => { + const { result } = await renderLoaded(); + jest.useFakeTimers(); + try { + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => { + jest.advanceTimersByTime(300); + }); + } finally { + jest.useRealTimers(); + } + + expect(releasedPerWrite()).toEqual([undefined]); + }); + + it('releases the wiped book with a book wipe', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.wipeBook('JHN')); + + expect(releasedPerWrite()).toEqual([['JHN']]); + }); + + it('releases every book with a whole-draft wipe', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.wipeAll()); + + expect(releasedPerWrite()).toEqual(['all']); + }); + + it('releases every book with New', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.newDraft({ analysisLanguages: ['en'] })); + + expect(releasedPerWrite()).toEqual(['all']); + }); + + it('releases every book with Open', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.loadFromProject({ analysis: emptyAnalysis(), analysisLanguages: ['en'] }), + ); + + expect(releasedPerWrite()).toEqual(['all']); + }); + + it('sends every release since the load with each later write', async () => { + const { result } = await renderLoaded(); + act(() => result.current.wipeBook('JHN')); + await act(async () => {}); + + act(() => result.current.wipeBook('GEN')); + + expect(releasedPerWrite()).toEqual([['JHN'], ['JHN', 'GEN']]); + }); + + it('sends the releases with an edit made after a wipe', async () => { + const { result } = await renderLoaded(); + act(() => result.current.wipeBook('JHN')); + await act(async () => {}); + jest.useFakeTimers(); + try { + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => { + jest.advanceTimersByTime(300); + }); + } finally { + jest.useRealTimers(); + } + + expect(releasedPerWrite()).toEqual([['JHN'], ['JHN']]); + }); + + it('releases every book when a book wipe follows a whole-draft wipe', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.wipeAll()); + act(() => result.current.wipeBook('JHN')); + + expect(releasedPerWrite()).toEqual(['all', 'all']); + }); + + it("drops another source's releases when the source changes", async () => { + const view = renderHook(({ source }) => useDraftProject(source, PLATFORM_LANGUAGE), { + initialProps: { source: SOURCE_PROJECT_ID }, + }); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + act(() => view.result.current.wipeBook('JHN')); + await act(async () => {}); + + view.rerender({ source: 'source-project-2' }); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + act(() => view.result.current.wipeBook('GEN')); + + expect(releasedPerWrite().at(-1)).toEqual(['GEN']); + }); + }); + describe('undo history', () => { it('drops the history when the source changes', async () => { const view = renderHook(({ source }) => useDraftProject(source, PLATFORM_LANGUAGE), { diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index d62641c78..1c4259ad9 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -204,9 +204,9 @@ const getGetDraftHandler = () => /** Activates the extension and returns the `interlinearizer.saveDraft` handler. */ const getSaveDraftHandler = () => - activateAndGetHandler<(sourceProjectId: string, draftJson: string) => Promise>( - 'interlinearizer.saveDraft', - ); + activateAndGetHandler< + (sourceProjectId: string, draftJson: string, releasedBooks?: unknown) => Promise + >('interlinearizer.saveDraft'); /** * Retrieves the callback passed to onDidOpenWebView during the most recent activate() call. @@ -1272,7 +1272,12 @@ describe('main', () => { project: makeStubProject('proj-id'), unreadableBooks: ['JHN', 'ACT'], }); - __mockGetLocalizedString.mockResolvedValue('Kept: {books}'); + __mockGetLocalizedString.mockImplementation( + async ({ localizeKey }: { localizeKey: string }) => + localizeKey === '%interlinearizer_unreadable_unlinkedAnalyses%' + ? 'analyses not linked to any book' + : 'Kept: {books}', + ); const handler = await getSaveDraftToProjectHandler(); await handler('src-project', 'proj-id'); @@ -1286,6 +1291,27 @@ describe('main', () => { }); }); + it('names analyses linked to no book in words rather than by their storage name', async () => { + mockSaveDraftToProject.mockResolvedValue({ + project: makeStubProject('proj-id'), + unreadableBooks: ['GEN', 'no book'], + }); + __mockGetLocalizedString.mockImplementation( + async ({ localizeKey }: { localizeKey: string }) => + localizeKey === '%interlinearizer_unreadable_unlinkedAnalyses%' + ? 'analyses not linked to any book' + : 'Kept: {books}', + ); + const handler = await getSaveDraftToProjectHandler(); + + await handler('src-project', 'proj-id'); + + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: 'Kept: GEN, analyses not linked to any book', + severity: 'warning', + }); + }); + it('still returns the saved project when the unreadable-books warning cannot be sent', async () => { const saved = makeStubProject('proj-id'); mockSaveDraftToProject.mockResolvedValue({ project: saved, unreadableBooks: ['JHN'] }); @@ -1385,7 +1411,12 @@ describe('main', () => { project: makeStubProject('new-id'), unreadableBooks: ['JHN'], }); - __mockGetLocalizedString.mockResolvedValue('Left out: {books}'); + __mockGetLocalizedString.mockImplementation( + async ({ localizeKey }: { localizeKey: string }) => + localizeKey === '%interlinearizer_unreadable_unlinkedAnalyses%' + ? 'analyses not linked to any book' + : 'Left out: {books}', + ); const handler = await getSaveDraftAsProjectHandler(); await handler('src-project', ['en']); @@ -1614,7 +1645,44 @@ describe('main', () => { await handler('src-project', JSON.stringify(stubDraft)); - expect(mockSaveDraft).toHaveBeenCalledWith(expect.anything(), 'src-project', stubDraft); + expect(mockSaveDraft).toHaveBeenCalledWith( + expect.anything(), + 'src-project', + stubDraft, + undefined, + ); + }); + + it('forwards the books the write releases', async () => { + mockSaveDraft.mockResolvedValue(undefined); + const handler = await getSaveDraftHandler(); + + await handler('src-project', JSON.stringify(stubDraft), ['JHN']); + await handler('src-project', JSON.stringify(stubDraft), 'all'); + + expect(mockSaveDraft).toHaveBeenNthCalledWith( + 1, + expect.anything(), + 'src-project', + stubDraft, + ['JHN'], + ); + expect(mockSaveDraft).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'src-project', + stubDraft, + 'all', + ); + }); + + it('rejects released books that are neither book codes nor "all", writing nothing', async () => { + const handler = await getSaveDraftHandler(); + + await expect(handler('src-project', JSON.stringify(stubDraft), 'JHN')).rejects.toThrow( + 'releasedBooks is neither book codes nor "all"', + ); + expect(mockSaveDraft).not.toHaveBeenCalled(); }); it('logs the error, sends an error notification, and rethrows when storage throws', async () => { diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts index 7cdc06d6e..dd246985d 100644 --- a/src/__tests__/services/draftRepository.test.ts +++ b/src/__tests__/services/draftRepository.test.ts @@ -279,4 +279,52 @@ describe('DraftRepository', () => { expect(writes).toEqual([]); }); }); + + describe('releasing unreadable books', () => { + /** + * Storage holding the two-book draft with GEN's shard unusable, and a repository that loaded + * it. + */ + async function loadedWithUnreadableGen() { + const memory = memoryStorage(); + await new DraftRepository(memory.storage, 'src', silentLogger()).replaceAll(twoBookDraft()); + memory.records.set(draftAnalysisKey('src', 'GEN'), 'not json'); + const repository = new DraftRepository(memory.storage, 'src', silentLogger()); + const draft = await repository.exportAll(); + return { ...memory, repository, draft }; + } + + it('keeps an unreadable book the save carries nothing for, unless released', async () => { + const { repository, draft, records } = await loadedWithUnreadableGen(); + + await repository.replaceAll(draft); + + expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); + }); + + it('wipes an unreadable book the save releases by name', async () => { + const { repository, draft, records } = await loadedWithUnreadableGen(); + + await repository.replaceAll(draft, ['GEN']); + + expect(records.has(draftAnalysisKey('src', 'GEN'))).toBe(false); + expect(JSON.parse(records.get(draftKey('src')) ?? '{}').analysisBooks).toEqual(['EXO']); + }); + + it('wipes every unreadable book when the save releases all', async () => { + const { repository, draft, records } = await loadedWithUnreadableGen(); + + await repository.replaceAll(draft, 'all'); + + expect(records.has(draftAnalysisKey('src', 'GEN'))).toBe(false); + }); + + it('keeps an unreadable book another book is released for', async () => { + const { repository, draft, records } = await loadedWithUnreadableGen(); + + await repository.replaceAll(draft, ['EXO']); + + expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); + }); + }); }); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 69ebb8166..0de741396 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -1498,6 +1498,23 @@ describe('projectStorage', () => { expect(result?.unreadableBooks).toEqual(['JHN']); }); + it('drops a book the draft could not read once a wipe released it', async () => { + const stored = backStorageWithMap(); + storeListedProject(stored, { + ...makeStubProject('proj-id'), + analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, + }); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + const loaded = await getDraft(token, 'src-proj'); + await saveDraft(token, 'src-proj', { ...loaded, analysis: emptyAnalysis() }, 'all'); + + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(result?.unreadableBooks).toEqual([]); + expect(result?.project.analysis.tokenAnalysisLinks).toEqual([]); + }); + it('rejects, writing no project, when the stored draft itself is unreadable', async () => { const stored = backStorageWithMap(); const project = makeStubProject('proj-id'); @@ -1691,6 +1708,22 @@ describe('projectStorage', () => { expect(project.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['link-GEN']); }); + it('leaves out the boundaries of a book the draft cannot read', async () => { + const stored = backStorageWithMap(); + await saveDraft(token, 'src-proj', { + ...makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'), + segmentation: { removedVerseStarts: ['GEN 1:2:0', 'JHN 1:2:0'], addedStarts: [] }, + }); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + + const { project } = await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect(project.segmentation).toEqual({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + }); + it('marks the stored draft clean once the project is created', async () => { backStorageWithMap(); await saveDraft(token, 'src-proj', { diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index 05881b720..923cecf2d 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -6,6 +6,7 @@ import type { TextAnalysis, } from 'interlinearizer'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReleasedBooks } from '../types/analysis-repository'; import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../types/model-version'; import { removeBookFromAnalysis, removeBookFromSegmentation } from '../utils/analysis-book'; @@ -28,6 +29,12 @@ import { /** Milliseconds to wait after the last keystroke before flushing an autosave write. */ const AUTOSAVE_DEBOUNCE_MS = 300; +/** Both sets of released books together. */ +function mergeReleased(pending: ReleasedBooks | undefined, more: ReleasedBooks): ReleasedBooks { + if (pending === 'all' || more === 'all') return 'all'; + return [...new Set([...(pending ?? []), ...more])]; +} + /** The part of a draft its undo history covers. */ export type DraftContent = Readonly<{ analysis: TextAnalysis; @@ -321,6 +328,11 @@ export default function useDraftProject( const platformLanguageRef = useRef(platformLanguage); platformLanguageRef.current = platformLanguage; + // The books wipes and wholesale replacements have let go of since the draft was loaded, which the + // host may discard if it could not read them. Every draft write carries them all: releasing a + // book again is harmless, so a write that fails needs no retry of its own. + const releasedRef = useRef(undefined); + // Every change to the draft ref goes through this writer, so settling it is what makes the // stored draft the one in memory. The backend notifies the user of a failed write; here it is // only logged, since the writer never throws into a render or event handler. The `dirty` flag @@ -329,10 +341,18 @@ export default function useDraftProject( () => new DebouncedWriter({ write: (draft) => { + const released = releasedRef.current; const json = perfTime('autosave-serialize', () => JSON.stringify(draft)); return perfTrack( 'autosave', - papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json), + released === undefined + ? papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json) + : papi.commands.sendCommand( + 'interlinearizer.saveDraft', + sourceProjectId, + json, + released === 'all' ? 'all' : [...released], + ), { bytes: json.length }, ); }, @@ -349,6 +369,9 @@ export default function useDraftProject( useEffect(() => { let canceled = false; + // A load starts the holds afresh, and book codes repeat across source projects, so no earlier + // release carries over. + releasedRef.current = undefined; setIsDraftLoading(true); setHistory(emptyHistory()); @@ -401,10 +424,12 @@ export default function useDraftProject( /** * Applies a wholesale draft replacement, keeping `history` as its undo history: update the ref, - * persist, refresh `dirty`, and bump the remount counter so the editor reseeds. + * persist, refresh `dirty`, and bump the remount counter so the editor reseeds. `released` names + * the books the replacement lets go of, so the host may discard what it could not read of them. */ const applyReplacement = useCallback( - (next: DraftProject, history: UndoHistory) => { + (next: DraftProject, history: UndoHistory, released: ReleasedBooks) => { + releasedRef.current = mergeReleased(releasedRef.current, released); draftRef.current = next; replacementCountRef.current += 1; setHistory(history); @@ -504,6 +529,7 @@ export default function useDraftProject( dirty: false, }, emptyHistory(), + 'all', ); }, [applyReplacement, sourceProjectId], @@ -524,6 +550,7 @@ export default function useDraftProject( dirty: false, }, emptyHistory(), + 'all', ); }, [applyReplacement, sourceProjectId], @@ -551,6 +578,7 @@ export default function useDraftProject( recordStep(historyRef.current, contentOf(current), { summary: { kind: 'wipeBook', book: bookCode }, }), + [bookCode], ); }, [applyReplacement], @@ -570,6 +598,7 @@ export default function useDraftProject( applyReplacement( next, recordStep(historyRef.current, contentOf(current), { summary: { kind: 'wipeAll' } }), + 'all', ); }, [applyReplacement]); diff --git a/src/main.ts b/src/main.ts index eaff55f66..0be121dc3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,8 @@ import * as projectStorage from './services/projectStorage'; import * as pt9ImportService from './services/pt9ImportService'; import type { Pt9ImportResult } from './services/pt9ImportService'; import { toProjectSummary } from './types/interlinear-project-summary'; -import { isDraftProject } from './types/type-guards'; +import { isDraftProject, isStringArray } from './types/type-guards'; +import { BOOKLESS_PARTITION } from './utils/analysis-book'; import { perfTime, perfTimeAsync } from './utils/perf-marks'; // #region WebView provider @@ -404,14 +405,21 @@ async function reportSaveFailure( /** * Tells the user which books of the saved draft could not be read, sending `message` with them - * listed. Sends nothing when every book was read; a notification that fails is dropped. + * listed, and naming analyses linked to no book in words rather than by their storage name. Sends + * nothing when every book was read; a notification that fails is dropped. */ async function reportUnreadableBooks(books: string[], message: LocalizeKey): Promise { if (books.length === 0) return; try { - const template = await papi.localization.getLocalizedString({ localizeKey: message }); + const [template, unlinked] = await Promise.all([ + papi.localization.getLocalizedString({ localizeKey: message }), + papi.localization.getLocalizedString({ + localizeKey: '%interlinearizer_unreadable_unlinkedAnalyses%', + }), + ]); + const named = books.map((book) => (book === BOOKLESS_PARTITION ? unlinked : book)); await papi.notifications.send({ - message: formatReplacementString(template, { books: books.join(', ') }), + message: formatReplacementString(template, { books: named.join(', ') }), severity: 'warning', }); } catch (e) { @@ -617,19 +625,29 @@ async function getSegments( * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. - * @throws If JSON parsing, validation, or storage fails, or if the stored draft was written by a - * newer build and so must not be overwritten. The error is logged and an error notification is - * sent before rethrowing so the frontend `catch` block can suppress it without a second - * notification. + * @param releasedBooks - The books whose unreadable stored analysis the WebView has let go of since + * it loaded the draft, by a wipe or a wholesale replacement, or `'all'`; omitted, such a book is + * kept. Naming a book again is harmless. + * @throws If JSON parsing, validation, or storage fails, if `releasedBooks` is neither book codes + * nor `'all'`, or if the stored draft was written by a newer build and so must not be + * overwritten. The error is logged and an error notification is sent before rethrowing so the + * frontend `catch` block can suppress it without a second notification. */ -async function saveInterlinearDraft(sourceProjectId: string, draftJson: string): Promise { +async function saveInterlinearDraft( + sourceProjectId: string, + draftJson: string, + releasedBooks?: string[] | 'all', +): Promise { try { await perfTimeAsync('saveDraft', async () => { const draft = perfTime('saveDraft:parse', () => JSON.parse(draftJson)); if (!isDraftProject(draft)) { throw new TypeError('saveInterlinearDraft: draftJson does not conform to DraftProject'); } - await projectStorage.saveDraft(executionToken, sourceProjectId, draft); + if (releasedBooks !== undefined && releasedBooks !== 'all' && !isStringArray(releasedBooks)) { + throw new TypeError('saveInterlinearDraft: releasedBooks is neither book codes nor "all"'); + } + await projectStorage.saveDraft(executionToken, sourceProjectId, draft, releasedBooks); }); } catch (e) { logger.error('Interlinearizer: failed to save draft', e); @@ -1108,6 +1126,18 @@ export async function activate(context: ExecutionActivationContext): Promise { + async replaceAll(draft: DraftProject, released?: ReleasedBooks): Promise { const { sourceProjectId, storage, shardContents: saved, unreadableShards: unreadable } = this; + // A released book is no longer held, so a draft carrying nothing for it wipes its shard. + if (released === 'all') unreadable.clear(); + else released?.forEach((bookCode) => unreadable.delete(bookCode)); const { analysis } = draft; const storedBooks = await perfTimeAsync('saveDraft:read', () => this.assertStoredDraftIsWritable(), diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 06cae7dd1..c484ef0d1 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -7,7 +7,7 @@ import type { SegmentationDelta, TextAnalysis, } from 'interlinearizer'; -import type { DraftExport, KeyValueStorage } from '../types/analysis-repository'; +import type { DraftExport, KeyValueStorage, ReleasedBooks } from '../types/analysis-repository'; import { emptyAnalysis } from '../types/empty-factories'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import { assertSupportedModelVersion, CURRENT_MODEL_VERSION } from '../types/model-version'; @@ -452,8 +452,9 @@ export async function createProject( * * The caller must first have written the draft it means to save; whatever the draft holds when its * queued read runs is what the project receives. A book whose stored draft analysis cannot be read - * is left out rather than copied as empty, and named in the result. Once the project is created, - * the stored draft is marked clean unless a draft write was sent after it was read. + * is left out with its boundaries rather than copied as empty, and named in the result. Once the + * project is created, the stored draft is marked clean unless a draft write was sent after it was + * read. * * @param token - The execution token for storage access. * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. @@ -479,7 +480,11 @@ export async function saveDraftAsNewProject( description?: string, ): Promise { const { draft, unreadableBooks, writesBefore } = await readDraftForCopy(token, sourceProjectId); - const { analysis, segmentation } = draft; + const { analysis } = draft; + const segmentation = unreadableBooks.reduce( + (delta, bookCode) => removeBookFromSegmentation(delta, bookCode), + draft.segmentation, + ); reportAnalysisViolations(logger, analysis, `draft of ${sourceProjectId} on save as`); const project = buildNewProject( sourceProjectId, @@ -1135,6 +1140,10 @@ async function markCopiedDraftClean( * * A save that fails leaves no write of its own in flight, so the next one never overlaps it. * + * A book whose stored analysis could not be read is kept while the draft carries none for it, so a + * draft that loaded it empty does not wipe it by being saved. `released` names the books a wipe or + * a wholesale replacement lets go of, so the draft's saying nothing for them wipes them after all. + * * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason while checking the * stored draft; nothing is written. @@ -1145,11 +1154,12 @@ export async function saveDraft( token: ExecutionToken, sourceProjectId: string, draft: DraftProject, + released?: ReleasedBooks, ): Promise { draftWriteCounts.set(sourceProjectId, (draftWriteCounts.get(sourceProjectId) ?? 0) + 1); // Invariant violations go unreported here: this runs on every edit, so one would log per keystroke. await enqueueSerialized(draftQueues, sourceProjectId, async () => { - await draftRepositoryFor(token, sourceProjectId).replaceAll(draft); + await draftRepositoryFor(token, sourceProjectId).replaceAll(draft, released); }); } diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 0ed91ff42..7b3cd6199 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -14,6 +14,13 @@ export interface WarningLogger { warn(message: string, ...details: unknown[]): void; } +/** + * The books a draft write lets go of whatever stored analysis could not be read for them, as a wipe + * or a wholesale replacement of the draft does: the named books, or `'all'`. A book named here that + * was read, never stored, or already released is unaffected, so naming one again is harmless. + */ +export type ReleasedBooks = readonly string[] | 'all'; + /** A stored draft as read for copying elsewhere, with the books it could not vouch for. */ export type DraftExport = { /** The draft, holding no analysis for the books in `unreadableBooks`. */ @@ -53,12 +60,13 @@ export interface AnalysisRepository { /** * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when - * cleanup after it fails. + * cleanup after it fails. A book whose stored analysis could not be read is kept while the draft + * carries none for it, unless `released` names it. * * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read or a write. */ - replaceAll(draft: DraftProject): Promise; + replaceAll(draft: DraftProject, released?: ReleasedBooks): Promise; /** * Records the stored draft as saved to its project, leaving its content as it stands. A draft diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 790b2b850..53b4f0550 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -310,11 +310,19 @@ declare module 'papi-shared-types' { * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. + * @param releasedBooks - The books whose unreadable stored analysis the WebView has let go of + * since it loaded the draft, by a wipe or a wholesale replacement, or `'all'`. Naming a book + * again is harmless. Omitted, a book the stored draft could not read is kept while the draft + * carries nothing for it. * @throws If JSON parsing, validation, or storage fails. The error is logged and an error * notification is sent before rethrowing so callers do not need to send a second * notification. */ - 'interlinearizer.saveDraft': (sourceProjectId: string, draftJson: string) => Promise; + 'interlinearizer.saveDraft': ( + sourceProjectId: string, + draftJson: string, + releasedBooks?: string[] | 'all', + ) => Promise; /** * Turns the extension host's timing entries on or off, discarding any it holds. Off until From 45630838687295c51564206fba07bb1517e98e10 Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Wed, 21 Oct 2026 07:42:24 -0700 Subject: [PATCH 04/10] Make wiping an unreadable draft book undoable (#433) Wiping a book whose stored draft analysis could not be read deleted that analysis, so undoing the wipe could not bring the hold back, and the next Save emptied the project's copy of the book without a word. A wipe now changes only the draft: the draft records its wiped books, Wipe Book adding its book and Wipe All, New and Open recording all of them, as part of the content the undo history restores. The host keeps an unreadable book's stored analysis whatever the draft says, stores the wiped books as those it actually holds unread, and a Save empties only the ones the draft has wiped. The release argument on saveDraft and AnalysisRepository.replaceAll is gone. A failed update of the shard journal no longer fails a draft save whose content has landed; it is logged, and the next save retries it. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/hooks/useDraftProject.test.ts | 121 +++++++++--------- src/__tests__/main.test.ts | 45 +------ .../services/draftRepository.test.ts | 68 ++++++++-- src/__tests__/services/projectStorage.test.ts | 38 ++++-- src/__tests__/types/type-guards.test.ts | 15 ++- src/hooks/useDraftProject.ts | 76 +++++------ src/main.ts | 36 +----- src/services/draftRepository.ts | 40 +++--- src/services/projectStorage.ts | 22 ++-- src/types/analysis-repository.ts | 16 +-- src/types/interlinearizer.d.ts | 18 +-- src/types/type-guards.ts | 1 + 12 files changed, 260 insertions(+), 236 deletions(-) diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index c7d5e0456..eeb5c5cc8 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -1069,80 +1069,56 @@ describe('useDraftProject', () => { }); }); - describe('released books', () => { - /** The books each draft write released, `undefined` where it released none. */ - function releasedPerWrite(): unknown[] { - return mockSendCommand.mock.calls - .filter((call) => call[0] === 'interlinearizer.saveDraft') - .map((call) => call[3]); - } - - it('releases nothing with an edit', async () => { + describe('wiped books', () => { + it('records the wiped book with a book wipe, and writes it', async () => { const { result } = await renderLoaded(); - jest.useFakeTimers(); - try { - act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); - act(() => { - jest.advanceTimersByTime(300); - }); - } finally { - jest.useRealTimers(); - } - expect(releasedPerWrite()).toEqual([undefined]); + act(() => result.current.wipeBook('JHN')); + + expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); + expect(lastSavedDraft().wipedBooks).toEqual(['JHN']); }); - it('releases the wiped book with a book wipe', async () => { + it('adds each wiped book to those already wiped, once', async () => { const { result } = await renderLoaded(); + act(() => result.current.wipeBook('JHN')); + act(() => result.current.wipeBook('GEN')); act(() => result.current.wipeBook('JHN')); - expect(releasedPerWrite()).toEqual([['JHN']]); + expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN', 'GEN']); }); - it('releases every book with a whole-draft wipe', async () => { + it('records every book as wiped with a whole-draft wipe', async () => { const { result } = await renderLoaded(); act(() => result.current.wipeAll()); + act(() => result.current.wipeBook('JHN')); - expect(releasedPerWrite()).toEqual(['all']); + expect(result.current.getDraftSnapshot()?.wipedBooks).toBe('all'); }); - it('releases every book with New', async () => { + it('starts New and Open with every book wiped', async () => { const { result } = await renderLoaded(); act(() => result.current.newDraft({ analysisLanguages: ['en'] })); + expect(result.current.getDraftSnapshot()?.wipedBooks).toBe('all'); - expect(releasedPerWrite()).toEqual(['all']); - }); - - it('releases every book with Open', async () => { - const { result } = await renderLoaded(); - + act(() => result.current.wipeBook('JHN')); act(() => result.current.loadFromProject({ analysis: emptyAnalysis(), analysisLanguages: ['en'] }), ); - - expect(releasedPerWrite()).toEqual(['all']); - }); - - it('sends every release since the load with each later write', async () => { - const { result } = await renderLoaded(); - act(() => result.current.wipeBook('JHN')); - await act(async () => {}); - - act(() => result.current.wipeBook('GEN')); - - expect(releasedPerWrite()).toEqual([['JHN'], ['JHN', 'GEN']]); + expect(result.current.getDraftSnapshot()?.wipedBooks).toBe('all'); }); - it('sends the releases with an edit made after a wipe', async () => { + it('takes the wipe back with its undo, and writes that', async () => { const { result } = await renderLoaded(); act(() => result.current.wipeBook('JHN')); - await act(async () => {}); jest.useFakeTimers(); try { - act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => { + result.current.undo(); + }); act(() => { jest.advanceTimersByTime(300); }); @@ -1150,31 +1126,56 @@ describe('useDraftProject', () => { jest.useRealTimers(); } - expect(releasedPerWrite()).toEqual([['JHN'], ['JHN']]); + expect(result.current.getDraftSnapshot()).not.toHaveProperty('wipedBooks'); + expect(lastSavedDraft()).not.toHaveProperty('wipedBooks'); }); - it('releases every book when a book wipe follows a whole-draft wipe', async () => { + it('restores the wipe with its redo', async () => { const { result } = await renderLoaded(); + act(() => result.current.wipeBook('JHN')); + act(() => { + result.current.undo(); + }); - act(() => result.current.wipeAll()); + act(() => { + result.current.redo(); + }); + + expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); + }); + + it('keeps the wiped books through an edit', async () => { + const { result } = await renderLoaded(); act(() => result.current.wipeBook('JHN')); - expect(releasedPerWrite()).toEqual(['all', 'all']); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + + expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); }); - it("drops another source's releases when the source changes", async () => { - const view = renderHook(({ source }) => useDraftProject(source, PLATFORM_LANGUAGE), { - initialProps: { source: SOURCE_PROJECT_ID }, - }); - await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); - act(() => view.result.current.wipeBook('JHN')); - await act(async () => {}); + it('keeps the wiped books through a re-anchoring pass', async () => { + const { result } = await renderLoaded(); + act(() => result.current.wipeBook('JHN')); + const reanchored = analysisWithToken('tok-reanchored'); - view.rerender({ source: 'source-project-2' }); - await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); - act(() => view.result.current.wipeBook('GEN')); + act(() => + result.current.reanchorBook('GEN', (content) => ({ + analysis: reanchored, + segmentation: content.segmentation, + })), + ); + + const draft = result.current.getDraftSnapshot(); + expect(draft?.analysis).toBe(reanchored); + expect(draft?.wipedBooks).toEqual(['JHN']); + }); + + it('loads the wiped books the stored draft records', async () => { + mockGetDraftResolves(makeDraft({ wipedBooks: ['JHN'] })); + + const { result } = await renderLoaded(); - expect(releasedPerWrite().at(-1)).toEqual(['GEN']); + expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); }); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 1c4259ad9..93747de9f 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -204,9 +204,9 @@ const getGetDraftHandler = () => /** Activates the extension and returns the `interlinearizer.saveDraft` handler. */ const getSaveDraftHandler = () => - activateAndGetHandler< - (sourceProjectId: string, draftJson: string, releasedBooks?: unknown) => Promise - >('interlinearizer.saveDraft'); + activateAndGetHandler<(sourceProjectId: string, draftJson: string) => Promise>( + 'interlinearizer.saveDraft', + ); /** * Retrieves the callback passed to onDidOpenWebView during the most recent activate() call. @@ -1645,44 +1645,7 @@ describe('main', () => { await handler('src-project', JSON.stringify(stubDraft)); - expect(mockSaveDraft).toHaveBeenCalledWith( - expect.anything(), - 'src-project', - stubDraft, - undefined, - ); - }); - - it('forwards the books the write releases', async () => { - mockSaveDraft.mockResolvedValue(undefined); - const handler = await getSaveDraftHandler(); - - await handler('src-project', JSON.stringify(stubDraft), ['JHN']); - await handler('src-project', JSON.stringify(stubDraft), 'all'); - - expect(mockSaveDraft).toHaveBeenNthCalledWith( - 1, - expect.anything(), - 'src-project', - stubDraft, - ['JHN'], - ); - expect(mockSaveDraft).toHaveBeenNthCalledWith( - 2, - expect.anything(), - 'src-project', - stubDraft, - 'all', - ); - }); - - it('rejects released books that are neither book codes nor "all", writing nothing', async () => { - const handler = await getSaveDraftHandler(); - - await expect(handler('src-project', JSON.stringify(stubDraft), 'JHN')).rejects.toThrow( - 'releasedBooks is neither book codes nor "all"', - ); - expect(mockSaveDraft).not.toHaveBeenCalled(); + expect(mockSaveDraft).toHaveBeenCalledWith(expect.anything(), 'src-project', stubDraft); }); it('logs the error, sends an error notification, and rethrows when storage throws', async () => { diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts index dd246985d..cc48723ca 100644 --- a/src/__tests__/services/draftRepository.test.ts +++ b/src/__tests__/services/draftRepository.test.ts @@ -280,7 +280,7 @@ describe('DraftRepository', () => { }); }); - describe('releasing unreadable books', () => { + describe('wiped books', () => { /** * Storage holding the two-book draft with GEN's shard unusable, and a repository that loaded * it. @@ -294,37 +294,77 @@ describe('DraftRepository', () => { return { ...memory, repository, draft }; } - it('keeps an unreadable book the save carries nothing for, unless released', async () => { + /** The `wipedBooks` the stored envelope records, if any. */ + function storedWipedBooks(records: Map): unknown { + return JSON.parse(records.get(draftKey('src')) ?? '{}').wipedBooks; + } + + it('keeps an unreadable book the draft has wiped, deleting nothing', async () => { const { repository, draft, records } = await loadedWithUnreadableGen(); - await repository.replaceAll(draft); + await repository.replaceAll({ ...draft, wipedBooks: ['GEN'] }); expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); + expect(storedWipedBooks(records)).toEqual(['GEN']); }); - it('wipes an unreadable book the save releases by name', async () => { + it('stores every held book for a draft that wiped all', async () => { const { repository, draft, records } = await loadedWithUnreadableGen(); - await repository.replaceAll(draft, ['GEN']); + await repository.replaceAll({ ...draft, wipedBooks: 'all' }); - expect(records.has(draftAnalysisKey('src', 'GEN'))).toBe(false); - expect(JSON.parse(records.get(draftKey('src')) ?? '{}').analysisBooks).toEqual(['EXO']); + expect(storedWipedBooks(records)).toEqual(['GEN']); }); - it('wipes every unreadable book when the save releases all', async () => { + it('stores no wiped book that is not held for being unreadable', async () => { const { repository, draft, records } = await loadedWithUnreadableGen(); - await repository.replaceAll(draft, 'all'); + await repository.replaceAll({ ...draft, wipedBooks: ['EXO', 'LEV'] }); - expect(records.has(draftAnalysisKey('src', 'GEN'))).toBe(false); + expect(storedWipedBooks(records)).toBeUndefined(); }); - it('keeps an unreadable book another book is released for', async () => { - const { repository, draft, records } = await loadedWithUnreadableGen(); + it('loads the wiped books it stored', async () => { + const { repository, draft, storage } = await loadedWithUnreadableGen(); + await repository.replaceAll({ ...draft, wipedBooks: ['GEN'] }); - await repository.replaceAll(draft, ['EXO']); + const reloaded = await new DraftRepository(storage, 'src', silentLogger()).exportAll(); - expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); + expect(reloaded.wipedBooks).toEqual(['GEN']); + }); + + it('names an unreadable book for a copy only while the draft has not wiped it', async () => { + const { repository, draft, storage } = await loadedWithUnreadableGen(); + const forCopy = () => new DraftRepository(storage, 'src', silentLogger()).exportForCopy(); + expect((await forCopy()).unreadableBooks).toEqual(['GEN']); + + await repository.replaceAll({ ...draft, wipedBooks: ['GEN'] }); + expect((await forCopy()).unreadableBooks).toEqual([]); + + await repository.replaceAll(draft); + expect((await forCopy()).unreadableBooks).toEqual(['GEN']); + }); + + it('names no unreadable book for a copy of a draft that wiped all', async () => { + const { draft, records, storage } = await loadedWithUnreadableGen(); + records.set( + draftKey('src'), + JSON.stringify({ + ...JSON.parse(records.get(draftKey('src')) ?? '{}'), + ...draft, + analysis: emptyAnalysis(), + analysisBooks: ['GEN', 'EXO'], + wipedBooks: 'all', + }), + ); + + const { unreadableBooks } = await new DraftRepository( + storage, + 'src', + silentLogger(), + ).exportForCopy(); + + expect(unreadableBooks).toEqual([]); }); }); }); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 0de741396..dd16e89f7 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -1498,7 +1498,7 @@ describe('projectStorage', () => { expect(result?.unreadableBooks).toEqual(['JHN']); }); - it('drops a book the draft could not read once a wipe released it', async () => { + it('empties a book the draft could not read once the draft wipes it, and keeps it again once the wipe is undone', async () => { const stored = backStorageWithMap(); storeListedProject(stored, { ...makeStubProject('proj-id'), @@ -1507,12 +1507,24 @@ describe('projectStorage', () => { await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); stored.set('draft:src-proj:analysis:JHN', '{not json'); const loaded = await getDraft(token, 'src-proj'); - await saveDraft(token, 'src-proj', { ...loaded, analysis: emptyAnalysis() }, 'all'); + const keepsJhn = async () => + (await getProject(token, 'proj-id'))?.analysis.tokenAnalysisLinks.some( + ({ id }) => id === 'link-JHN', + ); - const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + await saveDraft(token, 'src-proj', { ...loaded, wipedBooks: ['JHN'] }); + const wiped = await saveDraftToProject(token, 'src-proj', 'proj-id'); + expect(wiped?.unreadableBooks).toEqual([]); + expect(await keepsJhn()).toBe(false); - expect(result?.unreadableBooks).toEqual([]); - expect(result?.project.analysis.tokenAnalysisLinks).toEqual([]); + storeListedProject(stored, { + ...makeStubProject('proj-id'), + analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, + }); + await saveDraft(token, 'src-proj', loaded); + const undone = await saveDraftToProject(token, 'src-proj', 'proj-id'); + expect(undone?.unreadableBooks).toEqual(['JHN']); + expect(await keepsJhn()).toBe(true); }); it('rejects, writing no project, when the stored draft itself is unreadable', async () => { @@ -2444,12 +2456,22 @@ describe('projectStorage', () => { ); }); - it('propagates a journal cleanup failure when the deletions all succeeded', async () => { + it('saves past a journal cleanup failure once the content has landed, logging it', async () => { const draft = makeDraftSpanningBooks('src-proj', 'GEN'); __mockReadUserData.mockRejectedValue(enoentError()); - __mockDeleteUserData.mockRejectedValue(new Error('journal delete failed')); + const failure = new Error('journal delete failed'); + __mockDeleteUserData.mockRejectedValue(failure); - await expect(saveDraft(token, 'src-proj', draft)).rejects.toThrow('journal delete failed'); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'draft:src-proj', + expect.stringContaining('"analysisBooks":["GEN"]'), + ); + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not update the shard journal for src-proj:', + failure, + ); }); it('propagates a journal read failure that is not a missing key', async () => { diff --git a/src/__tests__/types/type-guards.test.ts b/src/__tests__/types/type-guards.test.ts index 3e22e0d48..a4ee25a13 100644 --- a/src/__tests__/types/type-guards.test.ts +++ b/src/__tests__/types/type-guards.test.ts @@ -1,8 +1,9 @@ import type { AssignmentStatus, PhraseAnalysisLink, TextAnalysis } from 'interlinearizer'; import { FIXTURE_STAMPS } from '../test-helpers'; -import { emptyAnalysis } from '../../types/empty-factories'; +import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { hasLinkWithoutId, + isDraftProject, isPt9ImportProvenance, isStringArray, isTextAnalysis, @@ -535,6 +536,18 @@ describe('isPt9ImportProvenance', () => { }); }); +describe('isDraftProject', () => { + it('accepts a draft whose wiped books are book codes or all', () => { + expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: ['JHN'] })).toBe(true); + expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: 'all' })).toBe(true); + }); + + it('rejects a draft whose wiped books are neither book codes nor all', () => { + expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: 'JHN' })).toBe(false); + expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: [1] })).toBe(false); + }); +}); + describe('isStringArray', () => { it('accepts an array of strings', () => { expect(isStringArray(['GEN', 'EXO'])).toBe(true); diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index 923cecf2d..b90b50079 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -6,7 +6,6 @@ import type { TextAnalysis, } from 'interlinearizer'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ReleasedBooks } from '../types/analysis-repository'; import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../types/model-version'; import { removeBookFromAnalysis, removeBookFromSegmentation } from '../utils/analysis-book'; @@ -29,43 +28,56 @@ import { /** Milliseconds to wait after the last keystroke before flushing an autosave write. */ const AUTOSAVE_DEBOUNCE_MS = 300; -/** Both sets of released books together. */ -function mergeReleased(pending: ReleasedBooks | undefined, more: ReleasedBooks): ReleasedBooks { - if (pending === 'all' || more === 'all') return 'all'; - return [...new Set([...(pending ?? []), ...more])]; -} - /** The part of a draft its undo history covers. */ export type DraftContent = Readonly<{ analysis: TextAnalysis; segmentation: SegmentationDelta | undefined; + /** The books wiped from the draft, as {@link DraftProject} records them. */ + wipedBooks?: DraftProject['wipedBooks']; }>; function contentOf(draft: DraftProject): DraftContent { - return { analysis: draft.analysis, segmentation: draft.segmentation }; + return { + analysis: draft.analysis, + segmentation: draft.segmentation, + ...(draft.wipedBooks !== undefined && { wipedBooks: draft.wipedBooks }), + }; } function sameContent(a: DraftContent, b: DraftContent): boolean { - return a.analysis === b.analysis && a.segmentation === b.segmentation; + return ( + a.analysis === b.analysis && a.segmentation === b.segmentation && a.wipedBooks === b.wipedBooks + ); } /** Returns `draft` holding `content`, marked `dirty` or not. */ function withContent( draft: DraftProject, - { analysis, segmentation }: DraftContent, + { analysis, segmentation, wipedBooks }: DraftContent, dirty: boolean, ): DraftProject { const next: DraftProject = { ...draft, analysis, dirty }; if (segmentation !== undefined) next.segmentation = segmentation; else delete next.segmentation; + if (wipedBooks !== undefined) next.wipedBooks = wipedBooks; + else delete next.wipedBooks; return next; } +/** `wipedBooks` with `bookCode` added. */ +function withWipedBook( + wipedBooks: DraftProject['wipedBooks'], + bookCode: string, +): NonNullable { + if (wipedBooks === 'all') return 'all'; + return wipedBooks?.includes(bookCode) ? wipedBooks : [...(wipedBooks ?? []), bookCode]; +} + const memoizedPasses = new WeakMap, BookPass>(); /** * Returns `pass` answering each analysis and boundary delta pair with one result, however often - * run, and the same function for the same `pass`. + * run, and the same function for the same `pass`. The wiped books pass through unchanged. */ function memoizePass(pass: BookPass): BookPass { const cached = memoizedPasses.get(pass); @@ -82,7 +94,8 @@ function memoizePass(pass: BookPass): BookPass { result = pass(content); bySegmentation.set(content.segmentation, result); } - return result; + const { wipedBooks } = content; + return wipedBooks === undefined ? result : { ...result, wipedBooks }; }; memoizedPasses.set(pass, memoized); return memoized; @@ -328,11 +341,6 @@ export default function useDraftProject( const platformLanguageRef = useRef(platformLanguage); platformLanguageRef.current = platformLanguage; - // The books wipes and wholesale replacements have let go of since the draft was loaded, which the - // host may discard if it could not read them. Every draft write carries them all: releasing a - // book again is harmless, so a write that fails needs no retry of its own. - const releasedRef = useRef(undefined); - // Every change to the draft ref goes through this writer, so settling it is what makes the // stored draft the one in memory. The backend notifies the user of a failed write; here it is // only logged, since the writer never throws into a render or event handler. The `dirty` flag @@ -341,18 +349,10 @@ export default function useDraftProject( () => new DebouncedWriter({ write: (draft) => { - const released = releasedRef.current; const json = perfTime('autosave-serialize', () => JSON.stringify(draft)); return perfTrack( 'autosave', - released === undefined - ? papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json) - : papi.commands.sendCommand( - 'interlinearizer.saveDraft', - sourceProjectId, - json, - released === 'all' ? 'all' : [...released], - ), + papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json), { bytes: json.length }, ); }, @@ -369,9 +369,6 @@ export default function useDraftProject( useEffect(() => { let canceled = false; - // A load starts the holds afresh, and book codes repeat across source projects, so no earlier - // release carries over. - releasedRef.current = undefined; setIsDraftLoading(true); setHistory(emptyHistory()); @@ -424,12 +421,10 @@ export default function useDraftProject( /** * Applies a wholesale draft replacement, keeping `history` as its undo history: update the ref, - * persist, refresh `dirty`, and bump the remount counter so the editor reseeds. `released` names - * the books the replacement lets go of, so the host may discard what it could not read of them. + * persist, refresh `dirty`, and bump the remount counter so the editor reseeds. */ const applyReplacement = useCallback( - (next: DraftProject, history: UndoHistory, released: ReleasedBooks) => { - releasedRef.current = mergeReleased(releasedRef.current, released); + (next: DraftProject, history: UndoHistory) => { draftRef.current = next; replacementCountRef.current += 1; setHistory(history); @@ -527,9 +522,9 @@ export default function useDraftProject( ...(project.segmentation !== undefined && { segmentation: project.segmentation }), analysis: project.analysis, dirty: false, + wipedBooks: 'all', }, emptyHistory(), - 'all', ); }, [applyReplacement, sourceProjectId], @@ -548,9 +543,9 @@ export default function useDraftProject( }), analysis: emptyAnalysis(), dirty: false, + wipedBooks: 'all', }, emptyHistory(), - 'all', ); }, [applyReplacement, sourceProjectId], @@ -566,10 +561,12 @@ export default function useDraftProject( // state keyed by book, so keeping them would re-apply the wiped book's merges/splits on // reload. Clear the field when nothing remains for any other book. const segmentation = removeBookFromSegmentation(current.segmentation, bookCode); + // Recorded as wiped, so a Save empties the book even where the stored draft could not read it. const next: DraftProject = { ...current, analysis: removeBookFromAnalysis(current.analysis, bookCode), dirty: true, + wipedBooks: withWipedBook(current.wipedBooks, bookCode), }; if (segmentation !== undefined) next.segmentation = segmentation; else delete next.segmentation; @@ -578,7 +575,6 @@ export default function useDraftProject( recordStep(historyRef.current, contentOf(current), { summary: { kind: 'wipeBook', book: bookCode }, }), - [bookCode], ); }, [applyReplacement], @@ -593,12 +589,16 @@ export default function useDraftProject( // to save an empty draft. The active project is left untouched, so a subsequent Save still // targets it. Custom segment boundaries are working state, so a whole-draft wipe clears them too. // (Per-book wipe stays dirty, as it is a partial edit the user will usually want to save.) - const next: DraftProject = { ...current, analysis: emptyAnalysis(), dirty: false }; + const next: DraftProject = { + ...current, + analysis: emptyAnalysis(), + dirty: false, + wipedBooks: 'all', + }; delete next.segmentation; applyReplacement( next, recordStep(historyRef.current, contentOf(current), { summary: { kind: 'wipeAll' } }), - 'all', ); }, [applyReplacement]); diff --git a/src/main.ts b/src/main.ts index 0be121dc3..cbc88f430 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import * as projectStorage from './services/projectStorage'; import * as pt9ImportService from './services/pt9ImportService'; import type { Pt9ImportResult } from './services/pt9ImportService'; import { toProjectSummary } from './types/interlinear-project-summary'; -import { isDraftProject, isStringArray } from './types/type-guards'; +import { isDraftProject } from './types/type-guards'; import { BOOKLESS_PARTITION } from './utils/analysis-book'; import { perfTime, perfTimeAsync } from './utils/perf-marks'; @@ -625,29 +625,19 @@ async function getSegments( * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. - * @param releasedBooks - The books whose unreadable stored analysis the WebView has let go of since - * it loaded the draft, by a wipe or a wholesale replacement, or `'all'`; omitted, such a book is - * kept. Naming a book again is harmless. - * @throws If JSON parsing, validation, or storage fails, if `releasedBooks` is neither book codes - * nor `'all'`, or if the stored draft was written by a newer build and so must not be - * overwritten. The error is logged and an error notification is sent before rethrowing so the - * frontend `catch` block can suppress it without a second notification. + * @throws If JSON parsing, validation, or storage fails, or if the stored draft was written by a + * newer build and so must not be overwritten. The error is logged and an error notification is + * sent before rethrowing so the frontend `catch` block can suppress it without a second + * notification. */ -async function saveInterlinearDraft( - sourceProjectId: string, - draftJson: string, - releasedBooks?: string[] | 'all', -): Promise { +async function saveInterlinearDraft(sourceProjectId: string, draftJson: string): Promise { try { await perfTimeAsync('saveDraft', async () => { const draft = perfTime('saveDraft:parse', () => JSON.parse(draftJson)); if (!isDraftProject(draft)) { throw new TypeError('saveInterlinearDraft: draftJson does not conform to DraftProject'); } - if (releasedBooks !== undefined && releasedBooks !== 'all' && !isStringArray(releasedBooks)) { - throw new TypeError('saveInterlinearDraft: releasedBooks is neither book codes nor "all"'); - } - await projectStorage.saveDraft(executionToken, sourceProjectId, draft, releasedBooks); + await projectStorage.saveDraft(executionToken, sourceProjectId, draft); }); } catch (e) { logger.error('Interlinearizer: failed to save draft', e); @@ -1126,18 +1116,6 @@ export async function activate(context: ExecutionActivationContext): Promise { - return this.readDraft(true); + const { draft, unreadableBooks } = await this.readDraft(true); + const { wipedBooks } = draft; + return { + draft, + unreadableBooks: + wipedBooks === 'all' + ? [] + : unreadableBooks.filter((bookCode) => !wipedBooks?.includes(bookCode)), + }; } /** @@ -200,8 +207,11 @@ export class DraftRepository implements AnalysisRepository { * * Recovery survives a restart. The next load finds a shard the envelope never came to name, and a * later save retries a failed deletion. A book dropped from the analysis leaves no shard behind, - * whether or not this instance loaded the draft, unless that load could not use the book's shard - * and `released` does not name the book. + * whether or not this instance loaded the draft, unless that load could not use the book's + * shard. + * + * The draft's `wipedBooks` is stored as the books it names, or all of them, that are held for + * being unreadable, so it never outlives the shards it speaks for. * * A save that fails leaves no storage call of its own in flight. A wiped book's shard that could * not be deleted is no failure of the save: the draft has landed, and the shard stays journaled @@ -211,11 +221,8 @@ export class DraftRepository implements AnalysisRepository { * @throws If storage rejects a read while checking the stored draft; nothing is written. * @throws If storage rejects a write. */ - async replaceAll(draft: DraftProject, released?: ReleasedBooks): Promise { + async replaceAll(draft: DraftProject): Promise { const { sourceProjectId, storage, shardContents: saved, unreadableShards: unreadable } = this; - // A released book is no longer held, so a draft carrying nothing for it wipes its shard. - if (released === 'all') unreadable.clear(); - else released?.forEach((bookCode) => unreadable.delete(bookCode)); const { analysis } = draft; const storedBooks = await perfTimeAsync('saveDraft:read', () => this.assertStoredDraftIsWritable(), @@ -261,11 +268,15 @@ export class DraftRepository implements AnalysisRepository { ), ); + const { wipedBooks, ...rest } = draft; + const wipedHeld = + wipedBooks === 'all' ? held : held.filter((bookCode) => wipedBooks?.includes(bookCode)); const envelope: StoredDraft = { - ...draft, + ...rest, modelVersion: CURRENT_MODEL_VERSION, analysis: emptyAnalysis(), analysisBooks: [...byBook.keys(), ...held], + ...(wipedHeld.length > 0 && { wipedBooks: wipedHeld }), }; await perfTimeAsync('saveDraft:write', () => storage.write(draftKey(sourceProjectId), JSON.stringify(envelope)), @@ -292,9 +303,9 @@ export class DraftRepository implements AnalysisRepository { ); // With the envelope naming every shard this save added, only a failed deletion is still owed. - // The draft itself has landed, so the failure is logged rather than thrown: a caller that took - // it for a failed save would send the same draft again for nothing. If recording it fails too, - // the shard failure is the one worth logging, and the next save retries the journal either way. + // The draft itself has landed, so neither a failed deletion nor a failed journal update is + // thrown: a caller that took either for a failed save would send the same draft again for + // nothing. Both are logged, and the next save retries the journal either way. const failed = deletions.find((outcome) => outcome.status === 'rejected'); if (failed?.status === 'rejected') { this.logger.warn( @@ -307,7 +318,6 @@ export class DraftRepository implements AnalysisRepository { this.writeShardJournal({ adding: [], removing: undeleted }), ); } catch (e) { - if (!failed) throw e; this.logger.warn( `Interlinearizer: could not update the shard journal for ${sourceProjectId}:`, e, diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index c484ef0d1..8056e6bf5 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -7,7 +7,7 @@ import type { SegmentationDelta, TextAnalysis, } from 'interlinearizer'; -import type { DraftExport, KeyValueStorage, ReleasedBooks } from '../types/analysis-repository'; +import type { DraftExport, KeyValueStorage } from '../types/analysis-repository'; import { emptyAnalysis } from '../types/empty-factories'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import { assertSupportedModelVersion, CURRENT_MODEL_VERSION } from '../types/model-version'; @@ -897,9 +897,11 @@ function withBoundariesOf( * and description are untouched; its configuration changes only when `config` is given. * * The caller must first have written the draft it means to save; whatever the draft holds when its - * queued read runs is what the project receives. A book whose stored draft analysis cannot be read - * keeps the project's stored analysis and boundaries, and is named in the result. Once the project - * is written, the stored draft is marked clean unless a draft write was sent after it was read. + * queued read runs is what the project receives. A book whose stored draft analysis cannot be read, + * and which the draft has not wiped, keeps the project's stored analysis and boundaries, and is + * named in the result: a copy never discards project analysis the draft cannot vouch for, whichever + * project it overwrites. Once the project is written, the stored draft is marked clean unless a + * draft write was sent after it was read. * * @param token - The execution token for storage access. * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. @@ -1082,8 +1084,8 @@ export async function getDraft( /** * Reads the stored draft for copying into a project, in turn with the source's draft writes. * - * @returns The draft and the books it could not read, with the number of draft writes sent before - * this read, which {@link markCopiedDraftClean} compares to tell whether a write followed it. + * @returns The draft and the unwiped books it could not read, with the number of draft writes sent + * for the source before this read was queued. * @throws As {@link DraftRepository.exportForCopy} does. */ function readDraftForCopy( @@ -1141,8 +1143,9 @@ async function markCopiedDraftClean( * A save that fails leaves no write of its own in flight, so the next one never overlaps it. * * A book whose stored analysis could not be read is kept while the draft carries none for it, so a - * draft that loaded it empty does not wipe it by being saved. `released` names the books a wipe or - * a wholesale replacement lets go of, so the draft's saying nothing for them wipes them after all. + * draft that loaded it empty does not wipe it by being saved. Wiping such a book from the draft + * deletes nothing: the draft records the wipe in its `wipedBooks`, which undoing the wipe reverses, + * and a copy into a project reads it. * * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason while checking the @@ -1154,12 +1157,11 @@ export async function saveDraft( token: ExecutionToken, sourceProjectId: string, draft: DraftProject, - released?: ReleasedBooks, ): Promise { draftWriteCounts.set(sourceProjectId, (draftWriteCounts.get(sourceProjectId) ?? 0) + 1); // Invariant violations go unreported here: this runs on every edit, so one would log per keystroke. await enqueueSerialized(draftQueues, sourceProjectId, async () => { - await draftRepositoryFor(token, sourceProjectId).replaceAll(draft, released); + await draftRepositoryFor(token, sourceProjectId).replaceAll(draft); }); } diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 7b3cd6199..1fa8366c6 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -14,20 +14,13 @@ export interface WarningLogger { warn(message: string, ...details: unknown[]): void; } -/** - * The books a draft write lets go of whatever stored analysis could not be read for them, as a wipe - * or a wholesale replacement of the draft does: the named books, or `'all'`. A book named here that - * was read, never stored, or already released is unaffected, so naming one again is harmless. - */ -export type ReleasedBooks = readonly string[] | 'all'; - /** A stored draft as read for copying elsewhere, with the books it could not vouch for. */ export type DraftExport = { /** The draft, holding no analysis for the books in `unreadableBooks`. */ draft: DraftProject; /** - * Book codes whose stored analysis could not be read, so the draft carries none for them. A copy - * must not take that absence for the book having been wiped. + * Book codes whose stored analysis could not be read and which the draft has not wiped, so the + * draft carries none for them. A copy must not take that absence for the book having been wiped. */ unreadableBooks: string[]; }; @@ -61,12 +54,13 @@ export interface AnalysisRepository { /** * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when * cleanup after it fails. A book whose stored analysis could not be read is kept while the draft - * carries none for it, unless `released` names it. + * carries none for it, so a draft that loaded it empty never wipes it, whatever the draft's + * `wipedBooks` says. * * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read or a write. */ - replaceAll(draft: DraftProject, released?: ReleasedBooks): Promise; + replaceAll(draft: DraftProject): Promise; /** * Records the stored draft as saved to its project, leaving its content as it stands. A draft diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 53b4f0550..49b68c715 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -310,19 +310,11 @@ declare module 'papi-shared-types' { * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. - * @param releasedBooks - The books whose unreadable stored analysis the WebView has let go of - * since it loaded the draft, by a wipe or a wholesale replacement, or `'all'`. Naming a book - * again is harmless. Omitted, a book the stored draft could not read is kept while the draft - * carries nothing for it. * @throws If JSON parsing, validation, or storage fails. The error is logged and an error * notification is sent before rethrowing so callers do not need to send a second * notification. */ - 'interlinearizer.saveDraft': ( - sourceProjectId: string, - draftJson: string, - releasedBooks?: string[] | 'all', - ) => Promise; + 'interlinearizer.saveDraft': (sourceProjectId: string, draftJson: string) => Promise; /** * Turns the extension host's timing entries on or off, discarding any it holds. Off until @@ -1692,6 +1684,14 @@ declare module 'interlinearizer' { * project on Save. See {@link SegmentationDelta}. */ segmentation?: SegmentationDelta; + + /** + * Books the user has wiped from the draft, or `'all'`, so a Save treats them as emptied even + * where the stored draft could not read their analysis. Absent, a book the stored draft could + * not read keeps the project's saved analysis. Part of the undoable content, so undoing a wipe + * restores it. + */ + wipedBooks?: string[] | 'all'; } // --------------------------------------------------------------------------- diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts index becb38acc..cfb1b954d 100644 --- a/src/types/type-guards.ts +++ b/src/types/type-guards.ts @@ -323,6 +323,7 @@ export function isDraftProject(value: unknown): value is DraftProject { (!('suggestedName' in value) || typeof value.suggestedName === 'string') && (!('suggestedDescription' in value) || typeof value.suggestedDescription === 'string') && (!('segmentation' in value) || isSegmentationDelta(value.segmentation)) && + (!('wipedBooks' in value) || value.wipedBooks === 'all' || isStringArray(value.wipedBooks)) && 'analysis' in value && isTextAnalysis(value.analysis) ); From bbd3926492c0ad7a3c3cb3dba952de9e51cb2393 Mon Sep 17 00:00:00 2001 From: Jason Naylor Date: Wed, 21 Oct 2026 11:41:48 -0700 Subject: [PATCH 05/10] Fail fast on unreadable draft books (#433) Stop saving around a draft book whose stored analysis cannot be read, and refuse instead. Each review round had found another path through the partial save that kept a project's copy of such a book; the case needs a torn write or outside damage to arise, so it is not worth that machinery. Refuse a draft write that carries analysis for an unreadable book, so an edit can never hide the damage, and refuse Save to an existing project while any book is unreadable, so the project is never touched. Save As to a new project still leaves such a book out, so the rest of the draft can be kept. New and Open discard the unreadable analysis, which is how the user recovers; each refusal tells the user which books and how, once until a write succeeds. Drop the draft's wiped books, the copy that kept a project's books, and the clean-draft write after an edit undone during a Save: the draft simply stays marked unsaved until the next Save. Co-Authored-By: Claude Opus 5.5 --- contributions/localizedStrings.json | 3 +- src/__tests__/hooks/useDraftProject.test.ts | 189 ++++-------------- src/__tests__/main.test.ts | 107 +++++----- .../services/draftRepository.test.ts | 77 ++----- src/__tests__/services/projectStorage.test.ts | 172 +++++----------- src/__tests__/types/type-guards.test.ts | 15 +- src/__tests__/utils/debounced-writer.test.ts | 22 -- src/hooks/useDraftProject.ts | 92 +++------ src/main.ts | 110 ++++++---- src/services/draftRepository.ts | 48 +++-- src/services/projectStorage.ts | 139 ++++--------- src/types/analysis-repository.ts | 36 +++- src/types/interlinearizer.d.ts | 22 +- src/types/type-guards.ts | 1 - src/utils/debounced-writer.ts | 13 -- 15 files changed, 386 insertions(+), 660 deletions(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 4a6ebe17a..fa87a06ed 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -327,7 +327,8 @@ "%interlinearizer_error_save_project_missing%": "The project you were saving to no longer exists. Use Save As to save your work to another project.", "%interlinearizer_info_save_in_progress%": "A save is in progress. Try again when it finishes.", "%interlinearizer_status_saving%": "Saving…", - "%interlinearizer_warning_save_unreadableBooksKept%": "Some books in your working draft could not be read, so the project keeps its saved analysis for them: {books}", + "%interlinearizer_error_save_unreadableBooks%": "Your working draft could not be read for {books}, so it was not saved to the project. Use Save As to keep your other work in a new project, then open a project or start a new draft to recover.", + "%interlinearizer_error_saveDraft_unreadableBooks%": "Your working draft could not be read for {books}, so changes there cannot be kept. Use Save As to keep your other work in a new project, then open a project or start a new draft to recover.", "%interlinearizer_warning_saveAs_unreadableBooksOmitted%": "Some books in your working draft could not be read and were left out of the new project: {books}", "%interlinearizer_unreadable_unlinkedAnalyses%": "analyses not linked to any book", diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index eeb5c5cc8..cc20bee10 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -904,97 +904,31 @@ describe('useDraftProject', () => { expect(saveDraftCount()).toBe(writesBeforeSave); }); - it('writes the clean draft when an edit undone during the copy was already written', async () => { + it('marks the draft clean without a write when an edit undone during the copy restores what was saved', async () => { const { result } = await renderLoaded(); act(() => { result.current.autosaveAnalysis(analysisWithToken('tok-saved')); }); + let writesDuringCopy = 0; await act(async () => { await result.current.commitDraft(async () => { - jest.useFakeTimers(); - // An edit whose write goes out before the copy lands, then undone back to the snapshot. + const before = saveDraftCount(); act(() => { result.current.autosaveAnalysis(analysisWithToken('tok-undone')); }); - act(() => { - jest.advanceTimersByTime(300); - }); act(() => { result.current.undo(); }); - jest.useRealTimers(); + writesDuringCopy = saveDraftCount() - before; return true; }); }); expect(result.current.dirty).toBe(false); - const stored = lastSavedDraft(); - expect(stored.analysis.tokenAnalyses[0].id).toBe('tok-saved'); - expect(stored.dirty).toBe(false); - }); - - it('writes the clean draft when an edit and its undo were both written before the copy landed', async () => { - const { result } = await renderLoaded(); - act(() => { - result.current.autosaveAnalysis(analysisWithToken('tok-saved')); - }); - // Every timer from here on runs on the test's clock, so each write fires exactly when the - // test advances past its debounce and never on its own. - jest.useFakeTimers(); - try { - await act(async () => { - await result.current.commitDraft(async () => { - act(() => { - result.current.autosaveAnalysis(analysisWithToken('tok-undone')); - }); - act(() => { - jest.advanceTimersByTime(300); - }); - act(() => { - result.current.undo(); - }); - act(() => { - jest.advanceTimersByTime(300); - }); - // Both writes have gone out, and none is waiting: only the count of writes sent tells - // the save that the stored draft moved after the copy read it. - expect(jest.getTimerCount()).toBe(0); - return true; - }); - }); - } finally { - jest.useRealTimers(); - } - - expect(result.current.dirty).toBe(false); - const stored = lastSavedDraft(); - expect(stored.analysis.tokenAnalyses[0].id).toBe('tok-saved'); - expect(stored.dirty).toBe(false); - }); - - it('writes the clean draft when an edit undone during the copy is still waiting to be written', async () => { - const { result } = await renderLoaded(); - act(() => { - result.current.autosaveAnalysis(analysisWithToken('tok-saved')); - }); - - await act(async () => { - await result.current.commitDraft(async () => { - act(() => { - result.current.autosaveAnalysis(analysisWithToken('tok-undone')); - }); - act(() => { - result.current.undo(); - }); - return true; - }); - }); - - expect(result.current.dirty).toBe(false); - const stored = lastSavedDraft(); - expect(stored.analysis.tokenAnalyses[0].id).toBe('tok-saved'); - expect(stored.dirty).toBe(false); + // The copy's own sync sends nothing; the undone edit's write is still on its debounce. + expect(writesDuringCopy).toBe(0); + expect(saveDraftCount()).toBe(1); }); it('leaves the draft dirty when an edit landed during the copy', async () => { @@ -1069,113 +1003,62 @@ describe('useDraftProject', () => { }); }); - describe('wiped books', () => { - it('records the wiped book with a book wipe, and writes it', async () => { - const { result } = await renderLoaded(); - - act(() => result.current.wipeBook('JHN')); - - expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); - expect(lastSavedDraft().wipedBooks).toEqual(['JHN']); - }); + describe('starting the draft over', () => { + /** Whether each draft write sent so far said the draft was started over. */ + function startedOverPerWrite(): unknown[] { + return mockSendCommand.mock.calls + .filter((call) => call[0] === 'interlinearizer.saveDraft') + .map((call) => call[3]); + } - it('adds each wiped book to those already wiped, once', async () => { + it('says nothing of starting over with an edit or a wipe', async () => { const { result } = await renderLoaded(); act(() => result.current.wipeBook('JHN')); - act(() => result.current.wipeBook('GEN')); - act(() => result.current.wipeBook('JHN')); + act(() => result.current.wipeAll()); - expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN', 'GEN']); + expect(startedOverPerWrite()).toEqual([undefined, undefined]); }); - it('records every book as wiped with a whole-draft wipe', async () => { + it('says the draft was started over with New', async () => { const { result } = await renderLoaded(); - act(() => result.current.wipeAll()); - act(() => result.current.wipeBook('JHN')); + act(() => result.current.newDraft({ analysisLanguages: ['en'] })); - expect(result.current.getDraftSnapshot()?.wipedBooks).toBe('all'); + expect(startedOverPerWrite()).toEqual([true]); }); - it('starts New and Open with every book wiped', async () => { + it('says the draft was started over with Open', async () => { const { result } = await renderLoaded(); - act(() => result.current.newDraft({ analysisLanguages: ['en'] })); - expect(result.current.getDraftSnapshot()?.wipedBooks).toBe('all'); - - act(() => result.current.wipeBook('JHN')); act(() => result.current.loadFromProject({ analysis: emptyAnalysis(), analysisLanguages: ['en'] }), ); - expect(result.current.getDraftSnapshot()?.wipedBooks).toBe('all'); - }); - - it('takes the wipe back with its undo, and writes that', async () => { - const { result } = await renderLoaded(); - act(() => result.current.wipeBook('JHN')); - jest.useFakeTimers(); - try { - act(() => { - result.current.undo(); - }); - act(() => { - jest.advanceTimersByTime(300); - }); - } finally { - jest.useRealTimers(); - } - - expect(result.current.getDraftSnapshot()).not.toHaveProperty('wipedBooks'); - expect(lastSavedDraft()).not.toHaveProperty('wipedBooks'); - }); - - it('restores the wipe with its redo', async () => { - const { result } = await renderLoaded(); - act(() => result.current.wipeBook('JHN')); - act(() => { - result.current.undo(); - }); - - act(() => { - result.current.redo(); - }); - - expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); - }); - it('keeps the wiped books through an edit', async () => { - const { result } = await renderLoaded(); - act(() => result.current.wipeBook('JHN')); - - act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); - - expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); + expect(startedOverPerWrite()).toEqual([true]); }); - it('keeps the wiped books through a re-anchoring pass', async () => { + it('keeps saying so with every later write', async () => { const { result } = await renderLoaded(); - act(() => result.current.wipeBook('JHN')); - const reanchored = analysisWithToken('tok-reanchored'); + act(() => result.current.newDraft({ analysisLanguages: ['en'] })); - act(() => - result.current.reanchorBook('GEN', (content) => ({ - analysis: reanchored, - segmentation: content.segmentation, - })), - ); + act(() => result.current.wipeAll()); - const draft = result.current.getDraftSnapshot(); - expect(draft?.analysis).toBe(reanchored); - expect(draft?.wipedBooks).toEqual(['JHN']); + expect(startedOverPerWrite()).toEqual([true, true]); }); - it('loads the wiped books the stored draft records', async () => { - mockGetDraftResolves(makeDraft({ wipedBooks: ['JHN'] })); + it('stops saying so once the draft loads again', async () => { + const view = renderHook(({ source }) => useDraftProject(source, PLATFORM_LANGUAGE), { + initialProps: { source: SOURCE_PROJECT_ID }, + }); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + act(() => view.result.current.newDraft({ analysisLanguages: ['en'] })); - const { result } = await renderLoaded(); + view.rerender({ source: 'source-project-2' }); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + act(() => view.result.current.wipeAll()); - expect(result.current.getDraftSnapshot()?.wipedBooks).toEqual(['JHN']); + expect(startedOverPerWrite().at(-1)).toBeUndefined(); }); }); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 93747de9f..10bc45458 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -9,6 +9,7 @@ import * as bookService from '../services/bookService'; import * as hostPerf from '../services/hostPerf'; import * as projectStorage from '../services/projectStorage'; import * as pt9ImportService from '../services/pt9ImportService'; +import { UnreadableBooksError } from '../types/analysis-repository'; import { emptyDraft } from '../types/empty-factories'; import { toProjectSummary } from '../types/interlinear-project-summary'; import { createTestActivationContext, makeStubProject } from './test-helpers'; @@ -204,9 +205,9 @@ const getGetDraftHandler = () => /** Activates the extension and returns the `interlinearizer.saveDraft` handler. */ const getSaveDraftHandler = () => - activateAndGetHandler<(sourceProjectId: string, draftJson: string) => Promise>( - 'interlinearizer.saveDraft', - ); + activateAndGetHandler< + (sourceProjectId: string, draftJson: string, discardUnreadable?: boolean) => Promise + >('interlinearizer.saveDraft'); /** * Retrieves the callback passed to onDidOpenWebView during the most recent activate() call. @@ -1250,7 +1251,7 @@ describe('main', () => { updatedAt: '2026-06-15T09:00:00Z', segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, }; - mockSaveDraftToProject.mockResolvedValue({ project: saved, unreadableBooks: [] }); + mockSaveDraftToProject.mockResolvedValue(saved); const handler = await getSaveDraftToProjectHandler(); const result = await handler('src-project', 'proj-id'); @@ -1263,75 +1264,48 @@ describe('main', () => { undefined, ); expect(result).toBe(JSON.stringify(toProjectSummary(saved))); - // Every book was read, so nothing is reported. expect(__mockNotificationsSend).not.toHaveBeenCalled(); }); - it('warns which books of the draft could not be read and kept their saved analysis', async () => { - mockSaveDraftToProject.mockResolvedValue({ - project: makeStubProject('proj-id'), - unreadableBooks: ['JHN', 'ACT'], - }); + it('tells the user which books stopped the copy and how to recover, and rethrows', async () => { + const refusal = new UnreadableBooksError('unreadable', ['JHN', 'no book']); + mockSaveDraftToProject.mockRejectedValue(refusal); __mockGetLocalizedString.mockImplementation( async ({ localizeKey }: { localizeKey: string }) => localizeKey === '%interlinearizer_unreadable_unlinkedAnalyses%' ? 'analyses not linked to any book' - : 'Kept: {books}', + : 'Not saved: {books}', ); const handler = await getSaveDraftToProjectHandler(); - await handler('src-project', 'proj-id'); + await expect(handler('src-project', 'proj-id')).rejects.toBe(refusal); expect(__mockGetLocalizedString).toHaveBeenCalledWith({ - localizeKey: '%interlinearizer_warning_save_unreadableBooksKept%', + localizeKey: '%interlinearizer_error_save_unreadableBooks%', }); + // Analyses linked to no book are named in words, not by their storage name. + expect(__mockNotificationsSend).toHaveBeenCalledTimes(1); expect(__mockNotificationsSend).toHaveBeenCalledWith({ - message: 'Kept: JHN, ACT', - severity: 'warning', - }); - }); - - it('names analyses linked to no book in words rather than by their storage name', async () => { - mockSaveDraftToProject.mockResolvedValue({ - project: makeStubProject('proj-id'), - unreadableBooks: ['GEN', 'no book'], - }); - __mockGetLocalizedString.mockImplementation( - async ({ localizeKey }: { localizeKey: string }) => - localizeKey === '%interlinearizer_unreadable_unlinkedAnalyses%' - ? 'analyses not linked to any book' - : 'Kept: {books}', - ); - const handler = await getSaveDraftToProjectHandler(); - - await handler('src-project', 'proj-id'); - - expect(__mockNotificationsSend).toHaveBeenCalledWith({ - message: 'Kept: GEN, analyses not linked to any book', - severity: 'warning', + message: 'Not saved: JHN, analyses not linked to any book', + severity: 'error', }); }); - it('still returns the saved project when the unreadable-books warning cannot be sent', async () => { - const saved = makeStubProject('proj-id'); - mockSaveDraftToProject.mockResolvedValue({ project: saved, unreadableBooks: ['JHN'] }); + it('logs a refusal notice that cannot be sent, and still rethrows', async () => { + const refusal = new UnreadableBooksError('unreadable', ['JHN']); + mockSaveDraftToProject.mockRejectedValue(refusal); __mockGetLocalizedString.mockRejectedValue(new Error('localization unavailable')); const handler = await getSaveDraftToProjectHandler(); - await expect(handler('src-project', 'proj-id')).resolves.toBe( - JSON.stringify(toProjectSummary(saved)), - ); + await expect(handler('src-project', 'proj-id')).rejects.toBe(refusal); expect(__mockLogger.warn).toHaveBeenCalledWith( - 'Interlinearizer: could not report the unreadable books of a saved draft', + 'Interlinearizer: could not report the unreadable books of a draft', expect.any(Error), ); }); it('forwards the config to set', async () => { - mockSaveDraftToProject.mockResolvedValue({ - project: makeStubProject('proj-id'), - unreadableBooks: [], - }); + mockSaveDraftToProject.mockResolvedValue(makeStubProject('proj-id')); const handler = await getSaveDraftToProjectHandler(); await handler('src-project', 'proj-id', { @@ -1390,7 +1364,7 @@ describe('main', () => { it("forwards the new project's config and returns its summary, without its analysis, as JSON", async () => { const created = { ...makeStubProject('new-id'), name: 'Name', targetProjectId: 'tgt' }; - mockSaveDraftAsNewProject.mockResolvedValue({ project: created, unreadableBooks: [] }); + mockSaveDraftAsNewProject.mockResolvedValue({ project: created, omittedBooks: [] }); const handler = await getSaveDraftAsProjectHandler(); const result = await handler('src-project', ['fr'], 'tgt', 'Name', 'Desc'); @@ -1409,7 +1383,7 @@ describe('main', () => { it('warns which books of the draft could not be read and were left out', async () => { mockSaveDraftAsNewProject.mockResolvedValue({ project: makeStubProject('new-id'), - unreadableBooks: ['JHN'], + omittedBooks: ['JHN'], }); __mockGetLocalizedString.mockImplementation( async ({ localizeKey }: { localizeKey: string }) => @@ -1645,7 +1619,40 @@ describe('main', () => { await handler('src-project', JSON.stringify(stubDraft)); - expect(mockSaveDraft).toHaveBeenCalledWith(expect.anything(), 'src-project', stubDraft); + expect(mockSaveDraft).toHaveBeenCalledWith(expect.anything(), 'src-project', stubDraft, { + discardUnreadable: false, + }); + }); + + it('discards unreadable analysis only for a draft started over', async () => { + mockSaveDraft.mockResolvedValue(undefined); + const handler = await getSaveDraftHandler(); + + await handler('src-project', JSON.stringify(stubDraft), true); + + expect(mockSaveDraft).toHaveBeenCalledWith(expect.anything(), 'src-project', stubDraft, { + discardUnreadable: true, + }); + }); + + it('tells the user once which books a refused write was for, until a write succeeds', async () => { + __mockGetLocalizedString.mockResolvedValue('Changes lost: {books}'); + const handler = await getSaveDraftHandler(); + const refusal = new UnreadableBooksError('unreadable', ['JHN']); + mockSaveDraft.mockRejectedValueOnce(refusal).mockRejectedValueOnce(refusal); + + await expect(handler('src-project', JSON.stringify(stubDraft))).rejects.toBe(refusal); + await expect(handler('src-project', JSON.stringify(stubDraft))).rejects.toBe(refusal); + expect(__mockNotificationsSend).toHaveBeenCalledTimes(1); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: 'Changes lost: JHN', + severity: 'error', + }); + + mockSaveDraft.mockResolvedValueOnce(undefined).mockRejectedValueOnce(refusal); + await handler('src-project', JSON.stringify(stubDraft)); + await expect(handler('src-project', JSON.stringify(stubDraft))).rejects.toBe(refusal); + expect(__mockNotificationsSend).toHaveBeenCalledTimes(2); }); it('logs the error, sends an error notification, and rethrows when storage throws', async () => { diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts index cc48723ca..247345859 100644 --- a/src/__tests__/services/draftRepository.test.ts +++ b/src/__tests__/services/draftRepository.test.ts @@ -4,6 +4,7 @@ import type { DraftProject, TextAnalysis } from 'interlinearizer'; import { DraftRepository } from '../../services/draftRepository'; import { draftAnalysisKey, draftKey } from '../../services/storageKeys'; import type { KeyValueStorage, WarningLogger } from '../../types/analysis-repository'; +import { UnreadableBooksError } from '../../types/analysis-repository'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; import { FIXTURE_STAMPS, makePhraseLink } from '../test-helpers'; @@ -280,7 +281,7 @@ describe('DraftRepository', () => { }); }); - describe('wiped books', () => { + describe('unreadable books', () => { /** * Storage holding the two-book draft with GEN's shard unusable, and a repository that loaded * it. @@ -294,77 +295,43 @@ describe('DraftRepository', () => { return { ...memory, repository, draft }; } - /** The `wipedBooks` the stored envelope records, if any. */ - function storedWipedBooks(records: Map): unknown { - return JSON.parse(records.get(draftKey('src')) ?? '{}').wipedBooks; - } - - it('keeps an unreadable book the draft has wiped, deleting nothing', async () => { + it('keeps an unreadable book the draft carries nothing for', async () => { const { repository, draft, records } = await loadedWithUnreadableGen(); - await repository.replaceAll({ ...draft, wipedBooks: ['GEN'] }); + await repository.replaceAll({ ...draft, analysis: emptyAnalysis() }); expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); - expect(storedWipedBooks(records)).toEqual(['GEN']); }); - it('stores every held book for a draft that wiped all', async () => { - const { repository, draft, records } = await loadedWithUnreadableGen(); - - await repository.replaceAll({ ...draft, wipedBooks: 'all' }); - - expect(storedWipedBooks(records)).toEqual(['GEN']); - }); - - it('stores no wiped book that is not held for being unreadable', async () => { - const { repository, draft, records } = await loadedWithUnreadableGen(); + it('refuses a draft carrying analysis for an unreadable book, writing nothing', async () => { + const { repository, records, writes } = await loadedWithUnreadableGen(); + writes.length = 0; - await repository.replaceAll({ ...draft, wipedBooks: ['EXO', 'LEV'] }); + const refusal = repository.replaceAll(twoBookDraft()); - expect(storedWipedBooks(records)).toBeUndefined(); + await expect(refusal).rejects.toThrow(UnreadableBooksError); + await expect(refusal).rejects.toMatchObject({ books: ['GEN'] }); + expect(writes).toEqual([]); + expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); }); - it('loads the wiped books it stored', async () => { - const { repository, draft, storage } = await loadedWithUnreadableGen(); - await repository.replaceAll({ ...draft, wipedBooks: ['GEN'] }); + it('writes over an unreadable book once the draft is started over', async () => { + const { repository, records } = await loadedWithUnreadableGen(); - const reloaded = await new DraftRepository(storage, 'src', silentLogger()).exportAll(); + await repository.replaceAll(twoBookDraft(), { discardUnreadable: true }); - expect(reloaded.wipedBooks).toEqual(['GEN']); + expect(records.get(draftAnalysisKey('src', 'GEN'))).not.toBe('not json'); }); - it('names an unreadable book for a copy only while the draft has not wiped it', async () => { - const { repository, draft, storage } = await loadedWithUnreadableGen(); - const forCopy = () => new DraftRepository(storage, 'src', silentLogger()).exportForCopy(); - expect((await forCopy()).unreadableBooks).toEqual(['GEN']); - - await repository.replaceAll({ ...draft, wipedBooks: ['GEN'] }); - expect((await forCopy()).unreadableBooks).toEqual([]); - - await repository.replaceAll(draft); - expect((await forCopy()).unreadableBooks).toEqual(['GEN']); - }); + it('wipes an unreadable book the draft carries nothing for once the draft is started over', async () => { + const { repository, draft, records } = await loadedWithUnreadableGen(); - it('names no unreadable book for a copy of a draft that wiped all', async () => { - const { draft, records, storage } = await loadedWithUnreadableGen(); - records.set( - draftKey('src'), - JSON.stringify({ - ...JSON.parse(records.get(draftKey('src')) ?? '{}'), - ...draft, - analysis: emptyAnalysis(), - analysisBooks: ['GEN', 'EXO'], - wipedBooks: 'all', - }), + await repository.replaceAll( + { ...draft, analysis: emptyAnalysis() }, + { discardUnreadable: true }, ); - const { unreadableBooks } = await new DraftRepository( - storage, - 'src', - silentLogger(), - ).exportForCopy(); - - expect(unreadableBooks).toEqual([]); + expect(records.has(draftAnalysisKey('src', 'GEN'))).toBe(false); }); }); }); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index dd16e89f7..7eed87989 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -23,6 +23,7 @@ import { import { summarizeProject } from '../../services/projectSummaries'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { removeBookFromAnalysis } from '../../utils/analysis-book'; +import { UnreadableBooksError } from '../../types/analysis-repository'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; import { createTestActivationContext, @@ -1364,7 +1365,7 @@ describe('projectStorage', () => { segmentation: SEGMENTATION, updatedAt: SAVE_TIME, }; - expect(result).toEqual({ project: expected, unreadableBooks: [] }); + expect(result).toEqual(expected); expect(storedProject(stored, 'proj-id')).toEqual(expected); }); @@ -1435,96 +1436,67 @@ describe('projectStorage', () => { const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); await write; - expect(result?.project.analysis).toEqual(draft.analysis); + expect(result?.analysis).toEqual(draft.analysis); }); - it("keeps the project's analysis and boundaries for a book the draft cannot read", async () => { + /** Storage holding project `proj-id` with GEN and JHN, and a draft whose JHN is damaged. */ + async function damagedJhn() { const stored = backStorageWithMap(); - const savedEarlier = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); - const jhnBoundary = { tokenRef: 'JHN 1:1!4', surfaceText: 'word-JHN' }; - storeListedProject(stored, { - ...makeStubProject('proj-id'), - analysis: savedEarlier.analysis, - segmentation: { removedVerseStarts: ['JHN 1:2:0'], addedStarts: [jhnBoundary] }, - }); - const edited = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); - edited.analysis.tokenAnalyses[0].gloss = { en: 'edited-GEN' }; - await saveDraft(token, 'src-proj', { ...edited, segmentation: SEGMENTATION }); - stored.set('draft:src-proj:analysis:JHN', '{not json'); - - const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); - - expect(result?.unreadableBooks).toEqual(['JHN']); - const project = storedProject(stored, 'proj-id'); - expect(project).toMatchObject({ - analysis: { - tokenAnalyses: [ - expect.objectContaining({ gloss: { en: 'edited-GEN' } }), - expect.objectContaining({ id: 'analysis-JHN' }), - ], - }, - segmentation: { - removedVerseStarts: ['GEN 1:2:0', 'JHN 1:2:0'], - addedStarts: [jhnBoundary], - }, - }); - }); - - it('clears the boundaries when neither the draft nor the kept book has any', async () => { - const stored = backStorageWithMap(); - storeListedProject(stored, { + const project = { ...makeStubProject('proj-id'), - analysis: makeDraftSpanningBooks('src-proj', 'JHN').analysis, - segmentation: SEGMENTATION, - }); + analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, + }; + storeListedProject(stored, project); await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); stored.set('draft:src-proj:analysis:JHN', '{not json'); + const loaded = await getDraft(token, 'src-proj'); + return { stored, project, loaded }; + } - await saveDraftToProject(token, 'src-proj', 'proj-id'); + it('refuses the copy while a book of the draft cannot be read, leaving the project untouched', async () => { + const { stored, project } = await damagedJhn(); - expect(storedProject(stored, 'proj-id')).not.toHaveProperty('segmentation'); + const refusal = saveDraftToProject(token, 'src-proj', 'proj-id'); + + await expect(refusal).rejects.toThrow(UnreadableBooksError); + await expect(refusal).rejects.toMatchObject({ books: ['JHN'] }); + expect(storedProject(stored, 'proj-id')).toEqual(project); }); - it('saves after Wipe All although a wiped book could not be read', async () => { - const stored = backStorageWithMap(); - storeListedProject(stored, makeStubProject('proj-id')); - await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); - stored.set('draft:src-proj:analysis:JHN', '{not json'); - const loaded = await getDraft(token, 'src-proj'); + it('keeps refusing after Wipe All, since the damaged book is still stored', async () => { + const { stored, project, loaded } = await damagedJhn(); await saveDraft(token, 'src-proj', { ...loaded, analysis: emptyAnalysis() }); - const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + await expect(saveDraftToProject(token, 'src-proj', 'proj-id')).rejects.toThrow( + UnreadableBooksError, + ); + expect(storedProject(stored, 'proj-id')).toEqual(project); + }); + + it('refuses a draft write carrying analysis for the damaged book, so an edit cannot hide it', async () => { + const { stored, loaded } = await damagedJhn(); - expect(result?.unreadableBooks).toEqual(['JHN']); + await expect( + saveDraft(token, 'src-proj', { + ...loaded, + analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, + }), + ).rejects.toThrow(UnreadableBooksError); + expect(stored.get('draft:src-proj:analysis:JHN')).toBe('{not json'); }); - it('empties a book the draft could not read once the draft wipes it, and keeps it again once the wipe is undone', async () => { - const stored = backStorageWithMap(); - storeListedProject(stored, { - ...makeStubProject('proj-id'), - analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, - }); - await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); - stored.set('draft:src-proj:analysis:JHN', '{not json'); - const loaded = await getDraft(token, 'src-proj'); - const keepsJhn = async () => - (await getProject(token, 'proj-id'))?.analysis.tokenAnalysisLinks.some( - ({ id }) => id === 'link-JHN', - ); + it('saves again once the draft is started over from the project', async () => { + const { project } = await damagedJhn(); + await saveDraft( + token, + 'src-proj', + { ...emptyDraft('src-proj'), analysis: project.analysis }, + { discardUnreadable: true }, + ); - await saveDraft(token, 'src-proj', { ...loaded, wipedBooks: ['JHN'] }); - const wiped = await saveDraftToProject(token, 'src-proj', 'proj-id'); - expect(wiped?.unreadableBooks).toEqual([]); - expect(await keepsJhn()).toBe(false); + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); - storeListedProject(stored, { - ...makeStubProject('proj-id'), - analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, - }); - await saveDraft(token, 'src-proj', loaded); - const undone = await saveDraftToProject(token, 'src-proj', 'proj-id'); - expect(undone?.unreadableBooks).toEqual(['JHN']); - expect(await keepsJhn()).toBe(true); + expect(result?.analysis).toEqual(project.analysis); }); it('rejects, writing no project, when the stored draft itself is unreadable', async () => { @@ -1612,7 +1584,7 @@ describe('projectStorage', () => { const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); - expect(result?.project.id).toBe('proj-id'); + expect(result?.id).toBe('proj-id'); expect(__mockLogger.warn).toHaveBeenCalledWith( 'Interlinearizer: could not mark the draft of src-proj clean after saving it:', failure, @@ -1630,40 +1602,6 @@ describe('projectStorage', () => { }); }); - describe('updateAnalysis keeping books', () => { - it("keeps the project's own boundaries throughout when told to leave them", async () => { - const stored = backStorageWithMap(); - const boundaries = { - removedVerseStarts: ['GEN 1:2:0', 'JHN 1:2:0'], - addedStarts: [], - }; - storeListedProject(stored, { - ...makeStubProject('proj-id'), - analysis: makeDraftSpanningBooks('src-proj', 'JHN').analysis, - segmentation: boundaries, - }); - - await updateAnalysis( - token, - 'proj-id', - makeDraftSpanningBooks('src-proj', 'GEN').analysis, - undefined, - undefined, - ['JHN'], - ); - - expect(storedProject(stored, 'proj-id')).toMatchObject({ - analysis: { - tokenAnalysisLinks: [ - expect.objectContaining({ id: 'link-GEN' }), - expect.objectContaining({ id: 'link-JHN' }), - ], - }, - segmentation: boundaries, - }); - }); - }); - describe('saveDraftAsNewProject', () => { it("creates an indexed project holding the stored draft's analysis and boundaries", async () => { const stored = backStorageWithMap(); @@ -1693,7 +1631,7 @@ describe('projectStorage', () => { segmentation: SEGMENTATION, links: [], }; - expect(result).toEqual({ project: expected, unreadableBooks: [] }); + expect(result).toEqual({ project: expected, omittedBooks: [] }); expect(storedProject(stored, expected.id)).toEqual(expected); expect(JSON.parse(stored.get('projectSummaries') ?? '{}').summaries).toEqual([ summarizeProject(expected), @@ -1714,9 +1652,9 @@ describe('projectStorage', () => { await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); stored.set('draft:src-proj:analysis:JHN', '{not json'); - const { project, unreadableBooks } = await saveDraftAsNewProject(token, 'src-proj', ['en']); + const { project, omittedBooks } = await saveDraftAsNewProject(token, 'src-proj', ['en']); - expect(unreadableBooks).toEqual(['JHN']); + expect(omittedBooks).toEqual(['JHN']); expect(project.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['link-GEN']); }); @@ -2556,7 +2494,7 @@ describe('projectStorage', () => { ); }); - it("rewrites an unreadable book's shard once that book has analyses again", async () => { + it("rewrites an unreadable book's shard once a started-over draft has analyses for it", async () => { const draft = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); await saveDraft(token, 'src-proj', draft); __mockReadUserData.mockImplementation(async (_t: unknown, key: unknown) => { @@ -2569,12 +2507,12 @@ describe('projectStorage', () => { __mockWriteUserData.mockClear(); loaded.analysis = makeDraftSpanningBooks('src-proj', 'JHN').analysis; - await saveDraft(token, 'src-proj', loaded); + await saveDraft(token, 'src-proj', loaded, { discardUnreadable: true }); expect(writtenKeys()).toContain('draft:src-proj:analysis:JHN'); }); - it('wipes a book that an earlier save restored from an unreadable shard', async () => { + it('wipes a book that a started-over draft restored from an unreadable shard', async () => { const draft = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'); await saveDraft(token, 'src-proj', draft); __mockReadUserData.mockImplementation(async (_t: unknown, key: unknown) => { @@ -2585,7 +2523,7 @@ describe('projectStorage', () => { }); const loaded = await getDraft(token, 'src-proj'); loaded.analysis = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis; - await saveDraft(token, 'src-proj', loaded); + await saveDraft(token, 'src-proj', loaded, { discardUnreadable: true }); loaded.analysis = removeBookFromAnalysis(loaded.analysis, 'JHN'); await saveDraft(token, 'src-proj', loaded); @@ -2608,7 +2546,7 @@ describe('projectStorage', () => { __mockDeleteUserData.mockRejectedValue(new Error('permission denied')); loaded.analysis = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis; - await saveDraft(token, 'src-proj', loaded); + await saveDraft(token, 'src-proj', loaded, { discardUnreadable: true }); __mockDeleteUserData.mockResolvedValue(undefined); loaded.analysis = removeBookFromAnalysis(loaded.analysis, 'JHN'); diff --git a/src/__tests__/types/type-guards.test.ts b/src/__tests__/types/type-guards.test.ts index a4ee25a13..3e22e0d48 100644 --- a/src/__tests__/types/type-guards.test.ts +++ b/src/__tests__/types/type-guards.test.ts @@ -1,9 +1,8 @@ import type { AssignmentStatus, PhraseAnalysisLink, TextAnalysis } from 'interlinearizer'; import { FIXTURE_STAMPS } from '../test-helpers'; -import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; +import { emptyAnalysis } from '../../types/empty-factories'; import { hasLinkWithoutId, - isDraftProject, isPt9ImportProvenance, isStringArray, isTextAnalysis, @@ -536,18 +535,6 @@ describe('isPt9ImportProvenance', () => { }); }); -describe('isDraftProject', () => { - it('accepts a draft whose wiped books are book codes or all', () => { - expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: ['JHN'] })).toBe(true); - expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: 'all' })).toBe(true); - }); - - it('rejects a draft whose wiped books are neither book codes nor all', () => { - expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: 'JHN' })).toBe(false); - expect(isDraftProject({ ...emptyDraft('src'), wipedBooks: [1] })).toBe(false); - }); -}); - describe('isStringArray', () => { it('accepts an array of strings', () => { expect(isStringArray(['GEN', 'EXO'])).toBe(true); diff --git a/src/__tests__/utils/debounced-writer.test.ts b/src/__tests__/utils/debounced-writer.test.ts index 9c6f0c2f1..12bef7763 100644 --- a/src/__tests__/utils/debounced-writer.test.ts +++ b/src/__tests__/utils/debounced-writer.test.ts @@ -85,28 +85,6 @@ describe('DebouncedWriter', () => { expect(writes).toHaveLength(0); }); - it('counts the writes it has sent', () => { - const { writer } = heldWriter(); - writer.writeNow('a'); - writer.schedule('b'); - expect(writer.writesSent).toBe(1); - - jest.advanceTimersByTime(100); - - expect(writer.writesSent).toBe(2); - }); - - it('reports whether a write is waiting out its delay', () => { - const { writer } = heldWriter(); - expect(writer.isScheduled).toBe(false); - - writer.schedule('a'); - expect(writer.isScheduled).toBe(true); - - jest.advanceTimersByTime(100); - expect(writer.isScheduled).toBe(false); - }); - it('reports each failed write', async () => { const { writer, writes, failures } = heldWriter(); const failure = new Error('disk full'); diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index b90b50079..4b9c9670a 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -32,52 +32,33 @@ const AUTOSAVE_DEBOUNCE_MS = 300; export type DraftContent = Readonly<{ analysis: TextAnalysis; segmentation: SegmentationDelta | undefined; - /** The books wiped from the draft, as {@link DraftProject} records them. */ - wipedBooks?: DraftProject['wipedBooks']; }>; function contentOf(draft: DraftProject): DraftContent { - return { - analysis: draft.analysis, - segmentation: draft.segmentation, - ...(draft.wipedBooks !== undefined && { wipedBooks: draft.wipedBooks }), - }; + return { analysis: draft.analysis, segmentation: draft.segmentation }; } function sameContent(a: DraftContent, b: DraftContent): boolean { - return ( - a.analysis === b.analysis && a.segmentation === b.segmentation && a.wipedBooks === b.wipedBooks - ); + return a.analysis === b.analysis && a.segmentation === b.segmentation; } /** Returns `draft` holding `content`, marked `dirty` or not. */ function withContent( draft: DraftProject, - { analysis, segmentation, wipedBooks }: DraftContent, + { analysis, segmentation }: DraftContent, dirty: boolean, ): DraftProject { const next: DraftProject = { ...draft, analysis, dirty }; if (segmentation !== undefined) next.segmentation = segmentation; else delete next.segmentation; - if (wipedBooks !== undefined) next.wipedBooks = wipedBooks; - else delete next.wipedBooks; return next; } -/** `wipedBooks` with `bookCode` added. */ -function withWipedBook( - wipedBooks: DraftProject['wipedBooks'], - bookCode: string, -): NonNullable { - if (wipedBooks === 'all') return 'all'; - return wipedBooks?.includes(bookCode) ? wipedBooks : [...(wipedBooks ?? []), bookCode]; -} - const memoizedPasses = new WeakMap, BookPass>(); /** * Returns `pass` answering each analysis and boundary delta pair with one result, however often - * run, and the same function for the same `pass`. The wiped books pass through unchanged. + * run, and the same function for the same `pass`. */ function memoizePass(pass: BookPass): BookPass { const cached = memoizedPasses.get(pass); @@ -94,8 +75,7 @@ function memoizePass(pass: BookPass): BookPass { result = pass(content); bySegmentation.set(content.segmentation, result); } - const { wipedBooks } = content; - return wipedBooks === undefined ? result : { ...result, wipedBooks }; + return result; }; memoizedPasses.set(pass, memoized); return memoized; @@ -341,6 +321,11 @@ export default function useDraftProject( const platformLanguageRef = useRef(platformLanguage); platformLanguageRef.current = platformLanguage; + // Whether the draft has been started over (New, Open) since it loaded, so the host may discard + // stored analysis it could not read. Every later write says so too: discarding again is harmless, + // so a write that fails needs no retry of its own. + const startedOverRef = useRef(false); + // Every change to the draft ref goes through this writer, so settling it is what makes the // stored draft the one in memory. The backend notifies the user of a failed write; here it is // only logged, since the writer never throws into a render or event handler. The `dirty` flag @@ -352,7 +337,9 @@ export default function useDraftProject( const json = perfTime('autosave-serialize', () => JSON.stringify(draft)); return perfTrack( 'autosave', - papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json), + startedOverRef.current + ? papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json, true) + : papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json), { bytes: json.length }, ); }, @@ -369,6 +356,7 @@ export default function useDraftProject( useEffect(() => { let canceled = false; + startedOverRef.current = false; setIsDraftLoading(true); setHistory(emptyHistory()); @@ -511,6 +499,7 @@ export default function useDraftProject( const loadFromProject = useCallback( (project: OpenableProject) => { + startedOverRef.current = true; applyReplacement( { sourceProjectId, @@ -522,7 +511,6 @@ export default function useDraftProject( ...(project.segmentation !== undefined && { segmentation: project.segmentation }), analysis: project.analysis, dirty: false, - wipedBooks: 'all', }, emptyHistory(), ); @@ -532,6 +520,7 @@ export default function useDraftProject( const newDraft = useCallback( (config: NewDraftConfig) => { + startedOverRef.current = true; applyReplacement( { sourceProjectId, @@ -543,7 +532,6 @@ export default function useDraftProject( }), analysis: emptyAnalysis(), dirty: false, - wipedBooks: 'all', }, emptyHistory(), ); @@ -561,12 +549,10 @@ export default function useDraftProject( // state keyed by book, so keeping them would re-apply the wiped book's merges/splits on // reload. Clear the field when nothing remains for any other book. const segmentation = removeBookFromSegmentation(current.segmentation, bookCode); - // Recorded as wiped, so a Save empties the book even where the stored draft could not read it. const next: DraftProject = { ...current, analysis: removeBookFromAnalysis(current.analysis, bookCode), dirty: true, - wipedBooks: withWipedBook(current.wipedBooks, bookCode), }; if (segmentation !== undefined) next.segmentation = segmentation; else delete next.segmentation; @@ -589,12 +575,7 @@ export default function useDraftProject( // to save an empty draft. The active project is left untouched, so a subsequent Save still // targets it. Custom segment boundaries are working state, so a whole-draft wipe clears them too. // (Per-book wipe stays dirty, as it is a partial edit the user will usually want to save.) - const next: DraftProject = { - ...current, - analysis: emptyAnalysis(), - dirty: false, - wipedBooks: 'all', - }; + const next: DraftProject = { ...current, analysis: emptyAnalysis(), dirty: false }; delete next.segmentation; applyReplacement( next, @@ -605,30 +586,24 @@ export default function useDraftProject( /** * Marks the draft synced once `saved` has been copied into a project, unless an edit landed * meanwhile: then the draft stays dirty, so the indicator and the next Save reflect that edit. - * The copy marks the stored draft clean itself unless a draft write reached storage after it, so - * the clean record is written from here only when `wroteSince` says one may have. + * The copy marks the stored draft clean itself unless a draft write reached it after the copy + * read it. An edit made and undone during the save leaves the content the copy saved but such a + * write behind it, so the stored draft can still read unsaved until the next write: it reopens + * marked unsaved, and the next Save clears that. */ - const markSynced = useCallback( - (saved: DraftProject, wroteSince: boolean) => { - const { current } = draftRef; - /* v8 ignore next -- a save runs only once the draft has loaded */ - if (!current) return; + const markSynced = useCallback((saved: DraftProject) => { + const { current } = draftRef; + /* v8 ignore next -- a save runs only once the draft has loaded */ + if (!current) return; - // An edit during the save swaps a fresh analysis or boundary delta into the ref. Both are - // compared: a boundary edit carries `analysis` over by reference. - if (current.analysis !== saved.analysis || current.segmentation !== saved.segmentation) - return; + // An edit during the save swaps a fresh analysis or boundary delta into the ref. Both are + // compared: a boundary edit carries `analysis` over by reference. + if (current.analysis !== saved.analysis || current.segmentation !== saved.segmentation) return; - const next: DraftProject = { ...current, dirty: false }; - draftRef.current = next; - cleanStatesRef.current = new Set([historyRef.current.state]); - // An edit undone during the save matches the snapshot yet was written or scheduled, so the - // stored draft may hold it. Written at once, so no scheduled dirty write lands after it. - if (wroteSince) writer.writeNow(next); - setDirty(false); - }, - [writer], - ); + draftRef.current = { ...current, dirty: false }; + cleanStatesRef.current = new Set([historyRef.current.state]); + setDirty(false); + }, []); const commitDraft = useCallback( async (copy: (snapshot: DraftProject) => Promise): Promise => { @@ -648,9 +623,8 @@ export default function useDraftProject( ); return false; } - const writesBefore = writer.writesSent; const landed = await copy(snapshot); - if (landed) markSynced(snapshot, writer.writesSent !== writesBefore || writer.isScheduled); + if (landed) markSynced(snapshot); return landed; } finally { isSavingRef.current = false; diff --git a/src/main.ts b/src/main.ts index cbc88f430..d216e7e18 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import * as projectStorage from './services/projectStorage'; import * as pt9ImportService from './services/pt9ImportService'; import type { Pt9ImportResult } from './services/pt9ImportService'; import { toProjectSummary } from './types/interlinear-project-summary'; +import { UnreadableBooksError } from './types/analysis-repository'; import { isDraftProject } from './types/type-guards'; import { BOOKLESS_PARTITION } from './utils/analysis-book'; import { perfTime, perfTimeAsync } from './utils/perf-marks'; @@ -119,6 +120,12 @@ let executionToken: ExecutionToken; */ const openWebViewsByProject = new Map(); +/** + * The unreadable books each source's draft writes were last refused for, so the user is told once, + * not on every keystroke, until a write succeeds. + */ +const refusedDraftBooks = new Map(); + // #endregion // #region Command handlers @@ -404,11 +411,15 @@ async function reportSaveFailure( } /** - * Tells the user which books of the saved draft could not be read, sending `message` with them - * listed, and naming analyses linked to no book in words rather than by their storage name. Sends - * nothing when every book was read; a notification that fails is dropped. + * Tells the user which books of the draft could not be read, sending `message` at `severity` with + * them listed, and naming analyses linked to no book in words rather than by their storage name. + * Sends nothing when no book is named; a notification that fails is logged and dropped. */ -async function reportUnreadableBooks(books: string[], message: LocalizeKey): Promise { +async function reportUnreadableBooks( + books: readonly string[], + message: LocalizeKey, + severity: 'warning' | 'error', +): Promise { if (books.length === 0) return; try { const [template, unlinked] = await Promise.all([ @@ -420,18 +431,18 @@ async function reportUnreadableBooks(books: string[], message: LocalizeKey): Pro const named = books.map((book) => (book === BOOKLESS_PARTITION ? unlinked : book)); await papi.notifications.send({ message: formatReplacementString(template, { books: named.join(', ') }), - severity: 'warning', + severity, }); } catch (e) { - logger.warn('Interlinearizer: could not report the unreadable books of a saved draft', e); + logger.warn('Interlinearizer: could not report the unreadable books of a draft', e); } } /** * Copies the source project's stored draft into an existing interlinearizer project: Save, and Save * As onto an existing project. The WebView writes its pending autosave first, so the stored draft - * is the one it means to save. A book of the draft that cannot be read keeps the project's saved - * analysis, and the user is told which. + * is the one it means to save. A draft that cannot be read for some book is not copied, and the + * user is told which books and how to recover. * * @param sourceProjectId - Platform.Bible source project ID whose draft to copy. * @param interlinearProjectId - UUID of the interlinearizer project to save into. @@ -439,9 +450,9 @@ async function reportUnreadableBooks(books: string[], message: LocalizeKey): Pro * project's configuration unchanged. * @returns The saved project's `InterlinearProjectSummary` (carrying the refreshed `updatedAt`) as * a JSON string, or `undefined` if no project with the given ID exists. - * @throws If the project is a Paratext 9 import, which is read-only, if the stored draft is - * unreadable, or if storage fails. The error is logged and an error notification is sent before - * rethrowing. + * @throws If the draft cannot be read for some book, if the project is a Paratext 9 import, which + * is read-only, if the stored draft is unreadable, or if storage fails. The error is logged and + * an error notification is sent before rethrowing. */ async function saveDraftToProject( sourceProjectId: string, @@ -456,19 +467,18 @@ async function saveDraftToProject( interlinearProjectId, config, ); - if (!saved) return undefined; - await reportUnreadableBooks( - saved.unreadableBooks, - '%interlinearizer_warning_save_unreadableBooksKept%', - ); - return JSON.stringify(toProjectSummary(saved.project)); + return saved ? JSON.stringify(toProjectSummary(saved)) : undefined; }); } catch (e) { - await reportSaveFailure( - e, - 'save the draft to a project', - '%interlinearizer_error_save_analysis_failed%', - ); + if (e instanceof UnreadableBooksError) { + logger.error('Interlinearizer: failed to save the draft to a project', e); + await reportUnreadableBooks(e.books, '%interlinearizer_error_save_unreadableBooks%', 'error'); + } else + await reportSaveFailure( + e, + 'save the draft to a project', + '%interlinearizer_error_save_analysis_failed%', + ); throw e; } } @@ -506,8 +516,9 @@ async function saveDraftAsProject( description, ); await reportUnreadableBooks( - created.unreadableBooks, + created.omittedBooks, '%interlinearizer_warning_saveAs_unreadableBooksOmitted%', + 'warning', ); return JSON.stringify(toProjectSummary(created.project)); }); @@ -625,28 +636,49 @@ async function getSegments( * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. - * @throws If JSON parsing, validation, or storage fails, or if the stored draft was written by a - * newer build and so must not be overwritten. The error is logged and an error notification is - * sent before rethrowing so the frontend `catch` block can suppress it without a second - * notification. + * @param discardUnreadable - Whether the draft was started over (New, Open), so stored analysis + * that could not be read is discarded rather than kept. + * @throws If JSON parsing, validation, or storage fails, if the draft carries analysis for a book + * whose stored analysis could not be read, or if the stored draft was written by a newer build + * and so must not be overwritten. The error is logged and an error notification is sent before + * rethrowing so the frontend `catch` block can suppress it without a second notification; a write + * refused for unreadable books notifies once until a write succeeds. */ -async function saveInterlinearDraft(sourceProjectId: string, draftJson: string): Promise { +async function saveInterlinearDraft( + sourceProjectId: string, + draftJson: string, + discardUnreadable?: boolean, +): Promise { try { await perfTimeAsync('saveDraft', async () => { const draft = perfTime('saveDraft:parse', () => JSON.parse(draftJson)); if (!isDraftProject(draft)) { throw new TypeError('saveInterlinearDraft: draftJson does not conform to DraftProject'); } - await projectStorage.saveDraft(executionToken, sourceProjectId, draft); + await projectStorage.saveDraft(executionToken, sourceProjectId, draft, { + discardUnreadable: discardUnreadable === true, + }); }); + refusedDraftBooks.delete(sourceProjectId); } catch (e) { logger.error('Interlinearizer: failed to save draft', e); - await papi.notifications - .send({ - message: '%interlinearizer_error_save_draft_failed%', - severity: 'error', - }) - .catch(() => {}); + if (e instanceof UnreadableBooksError) { + const books = e.books.join(', '); + if (refusedDraftBooks.get(sourceProjectId) !== books) { + refusedDraftBooks.set(sourceProjectId, books); + await reportUnreadableBooks( + e.books, + '%interlinearizer_error_saveDraft_unreadableBooks%', + 'error', + ); + } + } else + await papi.notifications + .send({ + message: '%interlinearizer_error_save_draft_failed%', + severity: 'error', + }) + .catch(() => {}); throw e; } } @@ -693,6 +725,7 @@ export async function activate(context: ExecutionActivationContext): Promise { - const { draft, unreadableBooks } = await this.readDraft(true); - const { wipedBooks } = draft; - return { - draft, - unreadableBooks: - wipedBooks === 'all' - ? [] - : unreadableBooks.filter((bookCode) => !wipedBooks?.includes(bookCode)), - }; + return this.readDraft(true); } /** @@ -207,27 +201,35 @@ export class DraftRepository implements AnalysisRepository { * * Recovery survives a restart. The next load finds a shard the envelope never came to name, and a * later save retries a failed deletion. A book dropped from the analysis leaves no shard behind, - * whether or not this instance loaded the draft, unless that load could not use the book's - * shard. - * - * The draft's `wipedBooks` is stored as the books it names, or all of them, that are held for - * being unreadable, so it never outlives the shards it speaks for. + * whether or not this instance loaded the draft, unless that load could not use the book's shard. + * Such a book is kept rather than overwritten too: a draft carrying analysis for it is refused, + * since the analysis would stand in for what could not be read. Starting the draft over discards + * it instead, through `options`. * - * A save that fails leaves no storage call of its own in flight. A wiped book's shard that could - * not be deleted is no failure of the save: the draft has landed, and the shard stays journaled - * for a later save to retry. + * A save that fails leaves no storage call of its own in flight. Once the draft has landed, a + * wiped book's shard that could not be deleted, or a shard journal that could not be updated, is + * no failure of the save: each is logged, and a later save retries it. * + * @throws {UnreadableBooksError} If the draft carries analysis for a book whose shard this + * instance's load could not use, unless `options` discards such books; nothing is written. * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read while checking the stored draft; nothing is written. * @throws If storage rejects a write. */ - async replaceAll(draft: DraftProject): Promise { + async replaceAll(draft: DraftProject, options?: ReplaceAllOptions): Promise { const { sourceProjectId, storage, shardContents: saved, unreadableShards: unreadable } = this; const { analysis } = draft; const storedBooks = await perfTimeAsync('saveDraft:read', () => this.assertStoredDraftIsWritable(), ); const byBook = perfTime('saveDraft:compare', () => splitAnalysisByBook(analysis)); + if (options?.discardUnreadable) unreadable.clear(); + const overwritten = [...unreadable].filter((bookCode) => byBook.has(bookCode)); + if (overwritten.length > 0) + throw new UnreadableBooksError( + `Interlinearizer: the draft of ${sourceProjectId} carries analysis for ${overwritten.join(', ')}, whose stored analysis could not be read; nothing was written`, + overwritten, + ); // Books this save no longer carries, less those held back because the load could not read them. // Without a load here, only the stored manifest and the journal name earlier shards. A @@ -268,15 +270,11 @@ export class DraftRepository implements AnalysisRepository { ), ); - const { wipedBooks, ...rest } = draft; - const wipedHeld = - wipedBooks === 'all' ? held : held.filter((bookCode) => wipedBooks?.includes(bookCode)); const envelope: StoredDraft = { - ...rest, + ...draft, modelVersion: CURRENT_MODEL_VERSION, analysis: emptyAnalysis(), analysisBooks: [...byBook.keys(), ...held], - ...(wipedHeld.length > 0 && { wipedBooks: wipedHeld }), }; await perfTimeAsync('saveDraft:write', () => storage.write(draftKey(sourceProjectId), JSON.stringify(envelope)), diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 8056e6bf5..782a9cf1b 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -7,19 +7,15 @@ import type { SegmentationDelta, TextAnalysis, } from 'interlinearizer'; -import type { DraftExport, KeyValueStorage } from '../types/analysis-repository'; +import type { DraftExport, KeyValueStorage, ReplaceAllOptions } from '../types/analysis-repository'; +import { UnreadableBooksError } from '../types/analysis-repository'; import { emptyAnalysis } from '../types/empty-factories'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import { assertSupportedModelVersion, CURRENT_MODEL_VERSION } from '../types/model-version'; import { hasLinkWithoutId, isStringArray } from '../types/type-guards'; -import { - mergeAnalyses, - removeBookFromSegmentation, - splitAnalysisByBook, -} from '../utils/analysis-book'; +import { removeBookFromSegmentation } from '../utils/analysis-book'; import { reportAnalysisViolations } from '../utils/analysis-violations'; import { perfTime, perfTimeAsync } from '../utils/perf-marks'; -import { segmentationForBook } from '../utils/segmentation'; import { DraftRepository } from './draftRepository'; import { ProjectSummaries, summarizeProject } from './projectSummaries'; import { draftKey, projectKey } from './storageKeys'; @@ -72,14 +68,11 @@ const draftRepositories = new Map(); */ const draftWriteCounts = new Map(); -/** A project that received a copy of the stored draft, and the books the copy could not read. */ -export type SavedDraftCopy = { +/** A project created from a copy of the stored draft, and the books the copy left out. */ +export type NewProjectFromDraft = { project: InterlinearProject; - /** - * Book codes whose stored draft analysis could not be read. Save keeps the project's own analysis - * and boundaries for these; Save As leaves them out of the new project. - */ - unreadableBooks: string[]; + /** Book codes whose stored draft analysis could not be read, so the new project lacks them. */ + omittedBooks: string[]; }; /** Extension user data as {@link KeyValueStorage}, reading a never-written key as absent. */ @@ -452,7 +445,8 @@ export async function createProject( * * The caller must first have written the draft it means to save; whatever the draft holds when its * queued read runs is what the project receives. A book whose stored draft analysis cannot be read - * is left out with its boundaries rather than copied as empty, and named in the result. Once the + * is left out with its boundaries rather than copied as empty, and named in the result: a new + * project has no analysis of its own to lose, so the rest of the draft can still be saved. Once the * project is created, the stored draft is marked clean unless a draft write was sent after it was * read. * @@ -478,7 +472,7 @@ export async function saveDraftAsNewProject( targetProjectId?: string, name?: string, description?: string, -): Promise { +): Promise { const { draft, unreadableBooks, writesBefore } = await readDraftForCopy(token, sourceProjectId); const { analysis } = draft; const segmentation = unreadableBooks.reduce( @@ -497,7 +491,7 @@ export async function saveDraftAsNewProject( ); await persistNewProject(token, project); await markCopiedDraftClean(token, sourceProjectId, writesBefore); - return { project, unreadableBooks }; + return { project, omittedBooks: unreadableBooks }; } /** Builds a new project record, with a fresh id and both times set to now. */ @@ -785,8 +779,6 @@ export function getProjectSummariesForSource( * any stored boundaries, or `undefined` to leave the project's existing boundaries unchanged. * @param config - The new analysis languages and target to persist, a missing target clearing the * binding, or `undefined` to leave the project's configuration unchanged. - * @param keepBooks - Book codes whose stored analysis and boundaries the project keeps, in place of - * whatever `analysis` and `segmentation` hold for them. Empty when omitted. * @returns The updated project record, or `undefined` if no project with the given ID exists or it * is unlisted. * @throws {SyntaxError} If the project's storage value contains invalid JSON. @@ -802,34 +794,24 @@ export async function updateAnalysis( analysis: TextAnalysis, segmentation?: SegmentationDelta | null, config?: ProjectConfig, - keepBooks: readonly string[] = [], ): Promise { return enqueueProjectOp(id, async () => { const raw = await perfTimeAsync('updateAnalysis:read', () => readRecord(token, projectKey(id))); if (raw === undefined) return undefined; const project = perfTime('updateAnalysis:parse', () => parseProject(raw, id)); if (project.pt9Import) throw pt9ImportReadOnlyError(id); - const kept = keepBooks.length === 0 ? undefined : keepStoredBooks(project, keepBooks); - const savedAnalysis = kept ? mergeAnalyses([analysis, kept.analysis]) : analysis; perfTime('updateAnalysis:parse', () => - reportAnalysisViolations(logger, savedAnalysis, `project ${id} on save`), + reportAnalysisViolations(logger, analysis, `project ${id} on save`), ); const updated: InterlinearProject = { ...project, modelVersion: CURRENT_MODEL_VERSION, - analysis: savedAnalysis, + analysis, updatedAt: new Date().toISOString(), }; - const savedSegmentation = kept - ? withBoundariesOf( - // eslint-disable-next-line no-null/no-null -- null is the explicit "clear stored boundaries" sentinel - segmentation === undefined ? project.segmentation : (segmentation ?? undefined), - kept, - ) - : segmentation; // eslint-disable-next-line no-null/no-null -- null is the explicit "clear stored boundaries" sentinel - if (savedSegmentation === null) delete updated.segmentation; - else if (savedSegmentation !== undefined) updated.segmentation = savedSegmentation; + if (segmentation === null) delete updated.segmentation; + else if (segmentation !== undefined) updated.segmentation = segmentation; if (config) { updated.analysisLanguages = config.analysisLanguages; if (config.targetProjectId === undefined) delete updated.targetProjectId; @@ -843,53 +825,6 @@ export async function updateAnalysis( }); } -/** A project's stored analysis and boundaries for some of its books. */ -type StoredBooks = { - books: readonly string[]; - analysis: TextAnalysis; - segmentation: SegmentationDelta | undefined; -}; - -/** What `project` stores for `books`: their analysis, and the boundaries anchored in them. */ -function keepStoredBooks(project: InterlinearProject, books: readonly string[]): StoredBooks { - const byBook = splitAnalysisByBook(project.analysis); - const boundaries = books.flatMap((book) => segmentationForBook(project.segmentation, book) ?? []); - return { - books, - analysis: mergeAnalyses(books.flatMap((book) => byBook.get(book) ?? [])), - segmentation: - boundaries.length === 0 - ? undefined - : { - removedVerseStarts: boundaries.flatMap((delta) => delta.removedVerseStarts), - addedStarts: boundaries.flatMap((delta) => delta.addedStarts), - }, - }; -} - -/** - * `delta` with the boundaries of the kept books replaced by those stored for them, or `null` when - * no boundary remains. - */ -function withBoundariesOf( - delta: SegmentationDelta | undefined, - kept: StoredBooks, -): SegmentationDelta | null { - const others = kept.books.reduce( - (remaining, book) => removeBookFromSegmentation(remaining, book), - delta, - ); - const removedVerseStarts = [ - ...(others?.removedVerseStarts ?? []), - ...(kept.segmentation?.removedVerseStarts ?? []), - ]; - const addedStarts = [...(others?.addedStarts ?? []), ...(kept.segmentation?.addedStarts ?? [])]; - return removedVerseStarts.length === 0 && addedStarts.length === 0 - ? // eslint-disable-next-line no-null/no-null -- null clears the project's stored boundaries - null - : { removedVerseStarts, addedStarts }; -} - /** * Copies the source project's stored draft into an existing interlinearizer project: the draft's * analysis replaces the project's, and the draft's custom segment boundaries replace the project's, @@ -897,11 +832,10 @@ function withBoundariesOf( * and description are untouched; its configuration changes only when `config` is given. * * The caller must first have written the draft it means to save; whatever the draft holds when its - * queued read runs is what the project receives. A book whose stored draft analysis cannot be read, - * and which the draft has not wiped, keeps the project's stored analysis and boundaries, and is - * named in the result: a copy never discards project analysis the draft cannot vouch for, whichever - * project it overwrites. Once the project is written, the stored draft is marked clean unless a - * draft write was sent after it was read. + * queued read runs is what the project receives. The copy is refused while any book's stored draft + * analysis cannot be read, since the draft would replace the project's analysis for that book with + * nothing. Once the project is written, the stored draft is marked clean unless a draft write was + * sent after it was read. * * @param token - The execution token for storage access. * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. @@ -910,6 +844,8 @@ function withBoundariesOf( * {@link updateAnalysis}. * @returns The saved project, or `undefined` if no project with the given ID exists or it is * unlisted. + * @throws {UnreadableBooksError} If any book's stored draft analysis cannot be read; nothing is + * written. * @throws {SyntaxError} If the draft's or the project's storage value contains invalid JSON. * @throws {Error} If the stored draft or project was written by a newer build; nothing is written. * @throws {Error} If the stored draft itself is unreadable; nothing is written. @@ -921,8 +857,13 @@ export async function saveDraftToProject( sourceProjectId: string, id: string, config?: ProjectConfig, -): Promise { +): Promise { const { draft, unreadableBooks, writesBefore } = await readDraftForCopy(token, sourceProjectId); + if (unreadableBooks.length > 0) + throw new UnreadableBooksError( + `Interlinearizer: the draft of ${sourceProjectId} could not be read for ${unreadableBooks.join(', ')}, so it was not saved to project ${id}`, + unreadableBooks, + ); const project = await updateAnalysis( token, id, @@ -930,11 +871,9 @@ export async function saveDraftToProject( // eslint-disable-next-line no-null/no-null -- null clears the project's stored boundaries draft.segmentation ?? null, config, - unreadableBooks, ); - if (!project) return undefined; - await markCopiedDraftClean(token, sourceProjectId, writesBefore); - return { project, unreadableBooks }; + if (project) await markCopiedDraftClean(token, sourceProjectId, writesBefore); + return project; } /** @@ -1084,8 +1023,8 @@ export async function getDraft( /** * Reads the stored draft for copying into a project, in turn with the source's draft writes. * - * @returns The draft and the unwiped books it could not read, with the number of draft writes sent - * for the source before this read was queued. + * @returns The export, with the number of draft writes sent for the source before this read was + * queued. * @throws As {@link DraftRepository.exportForCopy} does. */ function readDraftForCopy( @@ -1143,25 +1082,29 @@ async function markCopiedDraftClean( * A save that fails leaves no write of its own in flight, so the next one never overlaps it. * * A book whose stored analysis could not be read is kept while the draft carries none for it, so a - * draft that loaded it empty does not wipe it by being saved. Wiping such a book from the draft - * deletes nothing: the draft records the wipe in its `wipedBooks`, which undoing the wipe reverses, - * and a copy into a project reads it. + * draft that loaded it empty does not wipe it by being saved, and a draft carrying analysis for it + * is refused rather than let it stand in for what was lost. Starting the draft over discards such + * books instead, through `options`. * + * @throws {UnreadableBooksError} If the draft carries analysis for a book whose stored analysis + * could not be read, unless `options` discards such books; nothing is written. * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason while checking the * stored draft; nothing is written. - * @throws If `papi.storage.writeUserData` rejects. A wiped book's shard that could not be deleted - * is no failure of the save: it stays journaled for a later save to retry. + * @throws If `papi.storage.writeUserData` rejects. Once the draft has landed, a wiped book's shard + * that could not be deleted, or a shard journal that could not be updated, is no failure of the + * save: each is logged, and a later save retries it. */ export async function saveDraft( token: ExecutionToken, sourceProjectId: string, draft: DraftProject, + options?: ReplaceAllOptions, ): Promise { draftWriteCounts.set(sourceProjectId, (draftWriteCounts.get(sourceProjectId) ?? 0) + 1); // Invariant violations go unreported here: this runs on every edit, so one would log per keystroke. await enqueueSerialized(draftQueues, sourceProjectId, async () => { - await draftRepositoryFor(token, sourceProjectId).replaceAll(draft); + await draftRepositoryFor(token, sourceProjectId).replaceAll(draft, options); }); } diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 1fa8366c6..579445c53 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -14,13 +14,38 @@ export interface WarningLogger { warn(message: string, ...details: unknown[]): void; } +/** + * Thrown where going ahead would hide or overwrite stored draft analysis that could not be read, so + * no project and no draft ever loses analysis to a book the draft cannot vouch for. + */ +export class UnreadableBooksError extends Error { + /** Book codes whose stored analysis could not be read. */ + readonly books: readonly string[]; + + constructor(message: string, books: readonly string[]) { + super(message); + this.name = 'UnreadableBooksError'; + this.books = books; + } +} + +/** How a draft save treats stored analysis it could not read. */ +export type ReplaceAllOptions = { + /** + * Discard it, as starting a draft over (New, Open) does, so the save neither keeps nor refuses + * the books it belongs to. Absent, such a book is kept, and a save carrying analysis for it is + * refused. + */ + discardUnreadable?: boolean; +}; + /** A stored draft as read for copying elsewhere, with the books it could not vouch for. */ export type DraftExport = { /** The draft, holding no analysis for the books in `unreadableBooks`. */ draft: DraftProject; /** - * Book codes whose stored analysis could not be read and which the draft has not wiped, so the - * draft carries none for them. A copy must not take that absence for the book having been wiped. + * Book codes whose stored analysis could not be read, so the draft carries none for them. A copy + * must not take that absence for the book having been wiped. */ unreadableBooks: string[]; }; @@ -54,13 +79,14 @@ export interface AnalysisRepository { /** * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when * cleanup after it fails. A book whose stored analysis could not be read is kept while the draft - * carries none for it, so a draft that loaded it empty never wipes it, whatever the draft's - * `wipedBooks` says. + * carries none for it, so a draft that loaded it empty never wipes it. * + * @throws {UnreadableBooksError} If the draft carries analysis for a book whose stored analysis + * could not be read, unless `options` discards it; nothing is written. * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read or a write. */ - replaceAll(draft: DraftProject): Promise; + replaceAll(draft: DraftProject, options?: ReplaceAllOptions): Promise; /** * Records the stored draft as saved to its project, leaving its content as it stands. A draft diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 49b68c715..177da9930 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -310,11 +310,17 @@ declare module 'papi-shared-types' { * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. - * @throws If JSON parsing, validation, or storage fails. The error is logged and an error - * notification is sent before rethrowing so callers do not need to send a second - * notification. + * @param discardUnreadable - Whether the draft was started over (New, Open), so stored analysis + * that could not be read is discarded rather than kept. + * @throws If JSON parsing, validation, or storage fails, or if the draft carries analysis for a + * book whose stored analysis could not be read. The error is logged and an error notification + * is sent before rethrowing so callers do not need to send a second notification. */ - 'interlinearizer.saveDraft': (sourceProjectId: string, draftJson: string) => Promise; + 'interlinearizer.saveDraft': ( + sourceProjectId: string, + draftJson: string, + discardUnreadable?: boolean, + ) => Promise; /** * Turns the extension host's timing entries on or off, discarding any it holds. Off until @@ -1684,14 +1690,6 @@ declare module 'interlinearizer' { * project on Save. See {@link SegmentationDelta}. */ segmentation?: SegmentationDelta; - - /** - * Books the user has wiped from the draft, or `'all'`, so a Save treats them as emptied even - * where the stored draft could not read their analysis. Absent, a book the stored draft could - * not read keeps the project's saved analysis. Part of the undoable content, so undoing a wipe - * restores it. - */ - wipedBooks?: string[] | 'all'; } // --------------------------------------------------------------------------- diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts index cfb1b954d..becb38acc 100644 --- a/src/types/type-guards.ts +++ b/src/types/type-guards.ts @@ -323,7 +323,6 @@ export function isDraftProject(value: unknown): value is DraftProject { (!('suggestedName' in value) || typeof value.suggestedName === 'string') && (!('suggestedDescription' in value) || typeof value.suggestedDescription === 'string') && (!('segmentation' in value) || isSegmentationDelta(value.segmentation)) && - (!('wipedBooks' in value) || value.wipedBooks === 'all' || isStringArray(value.wipedBooks)) && 'analysis' in value && isTextAnalysis(value.analysis) ); diff --git a/src/utils/debounced-writer.ts b/src/utils/debounced-writer.ts index c5105ab81..a447cc952 100644 --- a/src/utils/debounced-writer.ts +++ b/src/utils/debounced-writer.ts @@ -29,22 +29,10 @@ export class DebouncedWriter { private failure: unknown; - private sent = 0; - constructor(options: DebouncedWriterOptions) { this.options = options; } - /** How many writes have been sent so far, so a caller can tell whether any went out since. */ - get writesSent(): number { - return this.sent; - } - - /** Whether a write is waiting out its delay. */ - get isScheduled(): boolean { - return this.timer !== undefined; - } - /** Writes `value` once the delay passes with no later call; a later call takes its place. */ schedule(value: T): void { this.cancel(); @@ -67,7 +55,6 @@ export class DebouncedWriter { writeNow(value: T): void { this.cancel(); const write = this.options.write(value); - this.sent += 1; this.latest = write; this.lastValue = value; this.failed = false; From e7922198f879330ff7e71363657008a4f27b0751 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Wed, 21 Oct 2026 13:36:04 -0600 Subject: [PATCH 06/10] Save the rest of a draft past its unreadable books (#433) --- src/__tests__/main.test.ts | 65 ++++++++++++++--- .../services/draftRepository.test.ts | 34 ++++++--- src/__tests__/services/projectStorage.test.ts | 50 +++++++++++--- src/__tests__/utils/debounced-writer.test.ts | 21 ++++++ src/hooks/useDraftProject.ts | 17 ++--- src/main.ts | 69 ++++++++++--------- src/services/draftRepository.ts | 31 ++++----- src/services/projectStorage.ts | 25 ++++--- src/types/analysis-repository.ts | 21 +++--- src/types/interlinearizer.d.ts | 9 +-- src/utils/debounced-writer.ts | 6 +- 11 files changed, 231 insertions(+), 117 deletions(-) diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 10bc45458..6bc65ff02 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -1614,7 +1614,7 @@ describe('main', () => { }); it('parses the JSON, validates it, and delegates to projectStorage.saveDraft', async () => { - mockSaveDraft.mockResolvedValue(undefined); + mockSaveDraft.mockResolvedValue([]); const handler = await getSaveDraftHandler(); await handler('src-project', JSON.stringify(stubDraft)); @@ -1625,7 +1625,7 @@ describe('main', () => { }); it('discards unreadable analysis only for a draft started over', async () => { - mockSaveDraft.mockResolvedValue(undefined); + mockSaveDraft.mockResolvedValue([]); const handler = await getSaveDraftHandler(); await handler('src-project', JSON.stringify(stubDraft), true); @@ -1635,23 +1635,68 @@ describe('main', () => { }); }); - it('tells the user once which books a refused write was for, until a write succeeds', async () => { + it('sends nothing for a write that left no book out', async () => { + mockSaveDraft.mockResolvedValue([]); + const handler = await getSaveDraftHandler(); + + await handler('src-project', JSON.stringify(stubDraft)); + + expect(__mockNotificationsSend).not.toHaveBeenCalled(); + }); + + it('tells the user which books a write left out, and resolves', async () => { __mockGetLocalizedString.mockResolvedValue('Changes lost: {books}'); + mockSaveDraft.mockResolvedValue(['JHN']); const handler = await getSaveDraftHandler(); - const refusal = new UnreadableBooksError('unreadable', ['JHN']); - mockSaveDraft.mockRejectedValueOnce(refusal).mockRejectedValueOnce(refusal); - await expect(handler('src-project', JSON.stringify(stubDraft))).rejects.toBe(refusal); - await expect(handler('src-project', JSON.stringify(stubDraft))).rejects.toBe(refusal); - expect(__mockNotificationsSend).toHaveBeenCalledTimes(1); + await expect(handler('src-project', JSON.stringify(stubDraft))).resolves.toBeUndefined(); + + expect(__mockGetLocalizedString).toHaveBeenCalledWith({ + localizeKey: '%interlinearizer_error_saveDraft_unreadableBooks%', + }); expect(__mockNotificationsSend).toHaveBeenCalledWith({ message: 'Changes lost: JHN', severity: 'error', }); + }); + + it('tells the user once while later writes leave out the same books', async () => { + __mockGetLocalizedString.mockResolvedValue('Changes lost: {books}'); + mockSaveDraft.mockResolvedValue(['JHN']); + const handler = await getSaveDraftHandler(); + + await handler('src-project', JSON.stringify(stubDraft)); + await handler('src-project', JSON.stringify(stubDraft)); + + expect(__mockNotificationsSend).toHaveBeenCalledTimes(1); + }); - mockSaveDraft.mockResolvedValueOnce(undefined).mockRejectedValueOnce(refusal); + it('tells the user again once a write has left no book out', async () => { + __mockGetLocalizedString.mockResolvedValue('Changes lost: {books}'); + mockSaveDraft + .mockResolvedValueOnce(['JHN']) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(['JHN']); + const handler = await getSaveDraftHandler(); + + await handler('src-project', JSON.stringify(stubDraft)); await handler('src-project', JSON.stringify(stubDraft)); - await expect(handler('src-project', JSON.stringify(stubDraft))).rejects.toBe(refusal); + await handler('src-project', JSON.stringify(stubDraft)); + + expect(__mockNotificationsSend).toHaveBeenCalledTimes(2); + }); + + it('tells the user again once the draft is loaded again', async () => { + __mockGetLocalizedString.mockResolvedValue('Changes lost: {books}'); + mockSaveDraft.mockResolvedValue(['JHN']); + jest.mocked(projectStorage.getDraft).mockResolvedValue(stubDraft); + const saveDraft = await getSaveDraftHandler(); + const getDraft = findRegisteredHandler('interlinearizer.getDraft'); + + await saveDraft('src-project', JSON.stringify(stubDraft)); + await getDraft?.('src-project'); + await saveDraft('src-project', JSON.stringify(stubDraft)); + expect(__mockNotificationsSend).toHaveBeenCalledTimes(2); }); diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts index 247345859..d61a1aed7 100644 --- a/src/__tests__/services/draftRepository.test.ts +++ b/src/__tests__/services/draftRepository.test.ts @@ -4,7 +4,6 @@ import type { DraftProject, TextAnalysis } from 'interlinearizer'; import { DraftRepository } from '../../services/draftRepository'; import { draftAnalysisKey, draftKey } from '../../services/storageKeys'; import type { KeyValueStorage, WarningLogger } from '../../types/analysis-repository'; -import { UnreadableBooksError } from '../../types/analysis-repository'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; import { FIXTURE_STAMPS, makePhraseLink } from '../test-helpers'; @@ -303,18 +302,37 @@ describe('DraftRepository', () => { expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); }); - it('refuses a draft carrying analysis for an unreadable book, writing nothing', async () => { - const { repository, records, writes } = await loadedWithUnreadableGen(); - writes.length = 0; + it('leaves out no book when the draft carries nothing for an unreadable one', async () => { + const { repository, draft } = await loadedWithUnreadableGen(); + + await expect(repository.replaceAll({ ...draft, analysis: emptyAnalysis() })).resolves.toEqual( + [], + ); + }); - const refusal = repository.replaceAll(twoBookDraft()); + it('leaves out the analysis a draft carries for an unreadable book, keeping its shard', async () => { + const { repository, records } = await loadedWithUnreadableGen(); + + await expect(repository.replaceAll(twoBookDraft())).resolves.toEqual(['GEN']); - await expect(refusal).rejects.toThrow(UnreadableBooksError); - await expect(refusal).rejects.toMatchObject({ books: ['GEN'] }); - expect(writes).toEqual([]); expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); }); + it('saves the rest of a draft carrying analysis for an unreadable book', async () => { + const { repository, storage } = await loadedWithUnreadableGen(); + const draft = twoBookDraft(); + + await repository.replaceAll({ + ...draft, + analysis: { ...draft.analysis, segmentAnalyses: [], segmentAnalysisLinks: [] }, + }); + + const reloaded = await new DraftRepository(storage, 'src', silentLogger()).exportForCopy(); + expect(reloaded.unreadableBooks).toEqual(['GEN']); + expect(reloaded.draft.analysis.segmentAnalysisLinks).toEqual([]); + expect(reloaded.draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']); + }); + it('writes over an unreadable book once the draft is started over', async () => { const { repository, records } = await loadedWithUnreadableGen(); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 7eed87989..1dda7ef6f 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -1473,7 +1473,7 @@ describe('projectStorage', () => { expect(storedProject(stored, 'proj-id')).toEqual(project); }); - it('refuses a draft write carrying analysis for the damaged book, so an edit cannot hide it', async () => { + it('leaves the damaged book out of a draft write carrying analysis for it', async () => { const { stored, loaded } = await damagedJhn(); await expect( @@ -1481,10 +1481,23 @@ describe('projectStorage', () => { ...loaded, analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, }), - ).rejects.toThrow(UnreadableBooksError); + ).resolves.toEqual(['JHN']); expect(stored.get('draft:src-proj:analysis:JHN')).toBe('{not json'); }); + it('keeps refusing the copy after an edit in the damaged book, so the edit cannot hide it', async () => { + const { stored, project, loaded } = await damagedJhn(); + await saveDraft(token, 'src-proj', { + ...loaded, + analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, + }); + + await expect(saveDraftToProject(token, 'src-proj', 'proj-id')).rejects.toThrow( + UnreadableBooksError, + ); + expect(storedProject(stored, 'proj-id')).toEqual(project); + }); + it('saves again once the draft is started over from the project', async () => { const { project } = await damagedJhn(); await saveDraft( @@ -1658,6 +1671,25 @@ describe('projectStorage', () => { expect(project.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['link-GEN']); }); + it('keeps the edits elsewhere of a draft that also carries edits for a book it cannot read', async () => { + const stored = backStorageWithMap(); + await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + const loaded = await getDraft(token, 'src-proj'); + await saveDraft(token, 'src-proj', { + ...loaded, + analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN', 'MRK').analysis, + }); + + const { project, omittedBooks } = await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect(omittedBooks).toEqual(['JHN']); + expect(project.analysis.tokenAnalysisLinks.map(({ id }) => id).sort()).toEqual([ + 'link-GEN', + 'link-MRK', + ]); + }); + it('leaves out the boundaries of a book the draft cannot read', async () => { const stored = backStorageWithMap(); await saveDraft(token, 'src-proj', { @@ -2141,7 +2173,7 @@ describe('projectStorage', () => { draft.analysis = removeBookFromAnalysis(draft.analysis, 'JHN'); - await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toEqual([]); }); it('saves past a shard delete failure that is not a missing key, logging it', async () => { @@ -2154,7 +2186,7 @@ describe('projectStorage', () => { // The draft landed, so the save is no failure: a caller taking it for one would resend the // same draft for nothing. - await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toEqual([]); expect(__mockLogger.warn).toHaveBeenCalledWith( 'Interlinearizer: could not delete the orphaned analysis shards JHN of src-proj; a later save retries:', failure, @@ -2383,7 +2415,7 @@ describe('projectStorage', () => { draft.analysis = removeBookFromAnalysis(draft.analysis, 'JHN'); - await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toEqual([]); expect(__mockLogger.warn).toHaveBeenCalledWith( 'Interlinearizer: could not delete the orphaned analysis shards JHN of src-proj; a later save retries:', expect.any(Error), @@ -2400,7 +2432,7 @@ describe('projectStorage', () => { const failure = new Error('journal delete failed'); __mockDeleteUserData.mockRejectedValue(failure); - await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toEqual([]); expect(__mockWriteUserData).toHaveBeenCalledWith( token, 'draft:src-proj', @@ -2429,7 +2461,7 @@ describe('projectStorage', () => { throw enoentError(); }); - await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toEqual([]); expect(__mockLogger.warn).toHaveBeenCalledWith( 'Interlinearizer: shard journal for src-proj is not valid JSON; treating as empty', ); @@ -2442,7 +2474,7 @@ describe('projectStorage', () => { throw enoentError(); }); - await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toEqual([]); expect(__mockLogger.warn).toHaveBeenCalledWith( 'Interlinearizer: shard journal for src-proj is malformed; treating as empty', ); @@ -2455,7 +2487,7 @@ describe('projectStorage', () => { throw enoentError(); }); - await expect(saveDraft(token, 'src-proj', draft)).resolves.toBeUndefined(); + await expect(saveDraft(token, 'src-proj', draft)).resolves.toEqual([]); expect(__mockLogger.warn).toHaveBeenCalledWith( 'Interlinearizer: shard journal for src-proj is malformed; treating as empty', ); diff --git a/src/__tests__/utils/debounced-writer.test.ts b/src/__tests__/utils/debounced-writer.test.ts index 12bef7763..2a41679ef 100644 --- a/src/__tests__/utils/debounced-writer.test.ts +++ b/src/__tests__/utils/debounced-writer.test.ts @@ -189,6 +189,27 @@ describe('DebouncedWriter', () => { await expect(settle).rejects.toBe(second); }); + it('sends a later value again when it fails after an earlier one was sent again', async () => { + const { writer, writes } = heldWriter(); + writer.writeNow('a'); + writes[0].reject(new Error('first')); + await flushMicrotasks(); + + const outcome = writer.settle().then( + () => 'landed', + () => 'failed', + ); + writer.schedule('b'); + writes[1].resolve(); + await flushMicrotasks(); + writes[2].reject(new Error('second')); + await flushMicrotasks(); + + expect(writes.map((w) => w.value)).toEqual(['a', 'a', 'b', 'b']); + writes[3].resolve(); + await expect(outcome).resolves.toBe('landed'); + }); + it('does not count a failure of a write that a later write superseded', async () => { const { writer, writes } = heldWriter(); writer.writeNow('a'); diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index 4b9c9670a..23e7083e4 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -185,11 +185,10 @@ export type UseDraftProjectResult = { isSaving: boolean; /** * Saves the draft to a project: once the stored draft holds the snapshot passed to `copy`, runs - * `copy`, which has the host copy the stored draft into a project and mark the stored draft - * clean, and resolves whether the copy landed. When it did, the draft is marked synced unless an - * edit landed meanwhile. Resolves what `copy` resolved, or `false` without copying when no draft - * has loaded, when another save is in flight, or when the draft was replaced while its writes - * settled. + * `copy`, which copies the stored draft into a project and resolves whether the copy landed. When + * it did, the draft is marked synced unless an edit landed meanwhile. Resolves what `copy` + * resolved, or `false` without copying when no draft has loaded, when another save is in flight, + * or when the draft was replaced while its writes settled. * * @throws If a draft write fails, so the stored draft lags the one in memory; the draft stays * dirty and nothing is copied. @@ -585,11 +584,9 @@ export default function useDraftProject( /** * Marks the draft synced once `saved` has been copied into a project, unless an edit landed - * meanwhile: then the draft stays dirty, so the indicator and the next Save reflect that edit. - * The copy marks the stored draft clean itself unless a draft write reached it after the copy - * read it. An edit made and undone during the save leaves the content the copy saved but such a - * write behind it, so the stored draft can still read unsaved until the next write: it reopens - * marked unsaved, and the next Save clears that. + * meanwhile: then the draft stays dirty, so the indicator and the next Save reflect that edit. An + * edit made and undone during the save leaves the draft synced in memory, though the stored draft + * may still reopen marked unsaved. */ const markSynced = useCallback((saved: DraftProject) => { const { current } = draftRef; diff --git a/src/main.ts b/src/main.ts index d216e7e18..404bedbdf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -121,10 +121,10 @@ let executionToken: ExecutionToken; const openWebViewsByProject = new Map(); /** - * The unreadable books each source's draft writes were last refused for, so the user is told once, - * not on every keystroke, until a write succeeds. + * The unreadable books each source's last draft write left out, so the user is told once, not on + * every write. */ -const refusedDraftBooks = new Map(); +const leftOutDraftBooks = new Map(); // #endregion @@ -534,7 +534,8 @@ async function saveDraftAsProject( /** * Returns the draft working buffer for a source project as a JSON string, creating a fresh empty - * draft when none has been written. The WebView loads this on mount to seed the editor. + * draft when none has been written. The WebView loads this on mount to seed the editor. Loading it + * also lets the next draft write that leaves books out tell the user again. * * @param sourceProjectId - Platform.Bible source project ID whose draft to fetch. * @throws {SyntaxError} If the draft's storage value contains invalid JSON. @@ -543,6 +544,7 @@ async function saveDraftAsProject( * before rethrowing. */ async function getInterlinearDraft(sourceProjectId: string): Promise { + leftOutDraftBooks.delete(sourceProjectId); try { return await perfTimeAsync('getDraft', async () => { const draft = await projectStorage.getDraft(executionToken, sourceProjectId); @@ -632,55 +634,60 @@ async function getSegments( /** * Persists the draft working buffer for a source project. Called from the WebView after every edit - * (auto-save) and whenever the draft is reset (New), opened from a project, or wiped. + * (auto-save) and whenever the draft is reset (New), opened from a project, or wiped. Analysis for + * a book whose stored analysis could not be read is left out of the write. The user is told which + * books once, and again only after the draft is reloaded or a write has left none out. * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. * @param discardUnreadable - Whether the draft was started over (New, Open), so stored analysis * that could not be read is discarded rather than kept. - * @throws If JSON parsing, validation, or storage fails, if the draft carries analysis for a book - * whose stored analysis could not be read, or if the stored draft was written by a newer build - * and so must not be overwritten. The error is logged and an error notification is sent before - * rethrowing so the frontend `catch` block can suppress it without a second notification; a write - * refused for unreadable books notifies once until a write succeeds. + * @throws If JSON parsing, validation, or storage fails, or if the stored draft was written by a + * newer build and so must not be overwritten. The error is logged and an error notification is + * sent before rethrowing so the frontend `catch` block can suppress it without a second + * notification. */ async function saveInterlinearDraft( sourceProjectId: string, draftJson: string, discardUnreadable?: boolean, ): Promise { + let leftOut: string[]; try { - await perfTimeAsync('saveDraft', async () => { + leftOut = await perfTimeAsync('saveDraft', async () => { const draft = perfTime('saveDraft:parse', () => JSON.parse(draftJson)); if (!isDraftProject(draft)) { throw new TypeError('saveInterlinearDraft: draftJson does not conform to DraftProject'); } - await projectStorage.saveDraft(executionToken, sourceProjectId, draft, { + return projectStorage.saveDraft(executionToken, sourceProjectId, draft, { discardUnreadable: discardUnreadable === true, }); }); - refusedDraftBooks.delete(sourceProjectId); } catch (e) { logger.error('Interlinearizer: failed to save draft', e); - if (e instanceof UnreadableBooksError) { - const books = e.books.join(', '); - if (refusedDraftBooks.get(sourceProjectId) !== books) { - refusedDraftBooks.set(sourceProjectId, books); - await reportUnreadableBooks( - e.books, - '%interlinearizer_error_saveDraft_unreadableBooks%', - 'error', - ); - } - } else - await papi.notifications - .send({ - message: '%interlinearizer_error_save_draft_failed%', - severity: 'error', - }) - .catch(() => {}); + await papi.notifications + .send({ + message: '%interlinearizer_error_save_draft_failed%', + severity: 'error', + }) + .catch(() => {}); throw e; } + if (leftOut.length === 0) { + leftOutDraftBooks.delete(sourceProjectId); + return; + } + const books = leftOut.join(', '); + if (leftOutDraftBooks.get(sourceProjectId) === books) return; + leftOutDraftBooks.set(sourceProjectId, books); + logger.warn( + `Interlinearizer: saved the draft of ${sourceProjectId} without its analysis for ${books}, whose stored analysis could not be read`, + ); + await reportUnreadableBooks( + leftOut, + '%interlinearizer_error_saveDraft_unreadableBooks%', + 'error', + ); } /** @@ -725,7 +732,7 @@ export async function activate(context: ExecutionActivationContext): Promise(); @@ -134,7 +133,7 @@ export class DraftRepository implements AnalysisRepository { * finds it. {@link replaceAll} refuses to overwrite it too. * * A book whose shard is missing or unusable loads as empty. {@link replaceAll} then keeps that - * shard and its manifest entry until a save carries analyses for the book again. + * shard and its manifest entry until a save starts the draft over. * * A shard holding a link with no id is unusable. A draft holding such a link inline loads as a * fresh empty draft. Both are logged. @@ -202,21 +201,21 @@ export class DraftRepository implements AnalysisRepository { * Recovery survives a restart. The next load finds a shard the envelope never came to name, and a * later save retries a failed deletion. A book dropped from the analysis leaves no shard behind, * whether or not this instance loaded the draft, unless that load could not use the book's shard. - * Such a book is kept rather than overwritten too: a draft carrying analysis for it is refused, - * since the analysis would stand in for what could not be read. Starting the draft over discards - * it instead, through `options`. + * Such a book's shard is kept as it stands, and any analysis the draft carries for the book is + * left out rather than written over what could not be read. Starting the draft over, through + * `options`, lets the save write or wipe the book like any other. * * A save that fails leaves no storage call of its own in flight. Once the draft has landed, a * wiped book's shard that could not be deleted, or a shard journal that could not be updated, is * no failure of the save: each is logged, and a later save retries it. * - * @throws {UnreadableBooksError} If the draft carries analysis for a book whose shard this - * instance's load could not use, unless `options` discards such books; nothing is written. + * @returns The books whose analysis in the draft was left out because this instance's load could + * not use their shard; empty when the draft was saved whole. * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read while checking the stored draft; nothing is written. * @throws If storage rejects a write. */ - async replaceAll(draft: DraftProject, options?: ReplaceAllOptions): Promise { + async replaceAll(draft: DraftProject, options?: ReplaceAllOptions): Promise { const { sourceProjectId, storage, shardContents: saved, unreadableShards: unreadable } = this; const { analysis } = draft; const storedBooks = await perfTimeAsync('saveDraft:read', () => @@ -224,12 +223,8 @@ export class DraftRepository implements AnalysisRepository { ); const byBook = perfTime('saveDraft:compare', () => splitAnalysisByBook(analysis)); if (options?.discardUnreadable) unreadable.clear(); - const overwritten = [...unreadable].filter((bookCode) => byBook.has(bookCode)); - if (overwritten.length > 0) - throw new UnreadableBooksError( - `Interlinearizer: the draft of ${sourceProjectId} carries analysis for ${overwritten.join(', ')}, whose stored analysis could not be read; nothing was written`, - overwritten, - ); + const leftOut = [...unreadable].filter((bookCode) => byBook.has(bookCode)); + leftOut.forEach((bookCode) => byBook.delete(bookCode)); // Books this save no longer carries, less those held back because the load could not read them. // Without a load here, only the stored manifest and the journal name earlier shards. A @@ -244,7 +239,6 @@ export class DraftRepository implements AnalysisRepository { const wiped = [...knownShards].filter( (bookCode) => !byBook.has(bookCode) && !unreadable.has(bookCode), ); - const held = [...unreadable].filter((bookCode) => !byBook.has(bookCode)); // Journal every shard this save could strand before writing any. A book the stored manifest // already names needs no entry: the manifest alone keeps it reachable. @@ -264,8 +258,6 @@ export class DraftRepository implements AnalysisRepository { shards.map(async ({ bookCode, json, changed }) => { if (changed) await storage.write(draftAnalysisKey(sourceProjectId, bookCode), json); saved.set(bookCode, json); - // This save restored the book, so a later wipe may delete its shard. - unreadable.delete(bookCode); }), ), ); @@ -274,7 +266,7 @@ export class DraftRepository implements AnalysisRepository { ...draft, modelVersion: CURRENT_MODEL_VERSION, analysis: emptyAnalysis(), - analysisBooks: [...byBook.keys(), ...held], + analysisBooks: [...byBook.keys(), ...unreadable], }; await perfTimeAsync('saveDraft:write', () => storage.write(draftKey(sourceProjectId), JSON.stringify(envelope)), @@ -321,6 +313,7 @@ export class DraftRepository implements AnalysisRepository { e, ); } + return leftOut; } /** diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 782a9cf1b..44e87ddb0 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -1000,9 +1000,9 @@ export async function hasDraft(token: ExecutionToken, sourceProjectId: string): * mixture of the two. * * A book whose shard is missing or unusable loads as empty, and {@link saveDraft} then leaves that - * shard and its manifest entry alone rather than treating the book as wiped, until a save carries - * analyses for that book again. A shard holding a link with no id is unusable, and a draft storing - * its analysis inline with such a link loads as a fresh empty draft; either is logged. + * shard and its manifest entry alone rather than treating the book as wiped, until a save starts + * the draft over. A shard holding a link with no id is unusable, and a draft storing its analysis + * inline with such a link loads as a fresh empty draft; either is logged. * * A save interrupted before its envelope landed leaves shards the stored manifest does not name. A * book first analyzed in that save still reopens with its work rather than empty. @@ -1081,13 +1081,12 @@ async function markCopiedDraftClean( * * A save that fails leaves no write of its own in flight, so the next one never overlaps it. * - * A book whose stored analysis could not be read is kept while the draft carries none for it, so a - * draft that loaded it empty does not wipe it by being saved, and a draft carrying analysis for it - * is refused rather than let it stand in for what was lost. Starting the draft over discards such - * books instead, through `options`. + * A book whose stored analysis could not be read is kept as stored: a draft that loaded it empty + * does not wipe it, and analysis the draft carries for it is left out rather than written over what + * was lost. Starting the draft over, through `options`, writes such books like any other. * - * @throws {UnreadableBooksError} If the draft carries analysis for a book whose stored analysis - * could not be read, unless `options` discards such books; nothing is written. + * @returns The books whose analysis in the draft was left out for that reason; empty when the draft + * was saved whole. * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason while checking the * stored draft; nothing is written. @@ -1100,12 +1099,12 @@ export async function saveDraft( sourceProjectId: string, draft: DraftProject, options?: ReplaceAllOptions, -): Promise { +): Promise { draftWriteCounts.set(sourceProjectId, (draftWriteCounts.get(sourceProjectId) ?? 0) + 1); // Invariant violations go unreported here: this runs on every edit, so one would log per keystroke. - await enqueueSerialized(draftQueues, sourceProjectId, async () => { - await draftRepositoryFor(token, sourceProjectId).replaceAll(draft, options); - }); + return enqueueSerialized(draftQueues, sourceProjectId, async () => + draftRepositoryFor(token, sourceProjectId).replaceAll(draft, options), + ); } /** diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 579445c53..428e9481e 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -15,8 +15,8 @@ export interface WarningLogger { } /** - * Thrown where going ahead would hide or overwrite stored draft analysis that could not be read, so - * no project and no draft ever loses analysis to a book the draft cannot vouch for. + * Thrown where a draft that could not be read for some books would overwrite stored analysis with + * only what it could read. */ export class UnreadableBooksError extends Error { /** Book codes whose stored analysis could not be read. */ @@ -32,9 +32,9 @@ export class UnreadableBooksError extends Error { /** How a draft save treats stored analysis it could not read. */ export type ReplaceAllOptions = { /** - * Discard it, as starting a draft over (New, Open) does, so the save neither keeps nor refuses - * the books it belongs to. Absent, such a book is kept, and a save carrying analysis for it is - * refused. + * Discard it, as starting a draft over (New, Open) does, so the save writes the books it belongs + * to like any other. Absent, such a book's stored analysis is kept, whatever the draft carries + * for it. */ discardUnreadable?: boolean; }; @@ -78,15 +78,16 @@ export interface AnalysisRepository { /** * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when - * cleanup after it fails. A book whose stored analysis could not be read is kept while the draft - * carries none for it, so a draft that loaded it empty never wipes it. + * cleanup after it fails. A book whose stored analysis could not be read is kept as stored unless + * `options` discards it: a draft that loaded it empty never wipes it, and analysis the draft + * carries for it never stands in for what could not be read. * - * @throws {UnreadableBooksError} If the draft carries analysis for a book whose stored analysis - * could not be read, unless `options` discards it; nothing is written. + * @returns The books whose analysis in the draft was left out for that reason; empty when the + * draft was saved whole. * @throws {Error} If the stored draft was written by a newer build; nothing is written. * @throws If storage rejects a read or a write. */ - replaceAll(draft: DraftProject, options?: ReplaceAllOptions): Promise; + replaceAll(draft: DraftProject, options?: ReplaceAllOptions): Promise; /** * Records the stored draft as saved to its project, leaving its content as it stands. A draft diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 177da9930..e4cd40999 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -306,15 +306,16 @@ declare module 'papi-shared-types' { /** * Persists the draft working buffer for the given source project. Called from the WebView after * every edit (auto-save) and whenever the draft is reset (New), opened from a project, or - * wiped. + * wiped. Analysis for a book whose stored analysis could not be read is left out of the write, + * and the user is told which books. * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. * @param discardUnreadable - Whether the draft was started over (New, Open), so stored analysis * that could not be read is discarded rather than kept. - * @throws If JSON parsing, validation, or storage fails, or if the draft carries analysis for a - * book whose stored analysis could not be read. The error is logged and an error notification - * is sent before rethrowing so callers do not need to send a second notification. + * @throws If JSON parsing, validation, or storage fails. The error is logged and an error + * notification is sent before rethrowing so callers do not need to send a second + * notification. */ 'interlinearizer.saveDraft': ( sourceProjectId: string, diff --git a/src/utils/debounced-writer.ts b/src/utils/debounced-writer.ts index a447cc952..a6727f5f7 100644 --- a/src/utils/debounced-writer.ts +++ b/src/utils/debounced-writer.ts @@ -81,12 +81,12 @@ export class DebouncedWriter { * @throws The failure, when the write sent again fails as well. */ async settle(): Promise { - let resent = false; + let resent: T | undefined; for (;;) { this.flush(); if (this.failed && this.lastValue !== undefined) { - if (resent) throw this.failure; - resent = true; + if (resent === this.lastValue) throw this.failure; + resent = this.lastValue; this.writeNow(this.lastValue); } const { latest } = this; From fb4ec61ae2fbc254e6f470ab2027d2a7051fe3c8 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Wed, 21 Oct 2026 13:48:45 -0600 Subject: [PATCH 07/10] Count a settle's retry by write, not by value (#433) --- src/__tests__/utils/debounced-writer.test.ts | 23 ++++++++++++++++++++ src/utils/debounced-writer.ts | 11 ++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/__tests__/utils/debounced-writer.test.ts b/src/__tests__/utils/debounced-writer.test.ts index 2a41679ef..73257f9b8 100644 --- a/src/__tests__/utils/debounced-writer.test.ts +++ b/src/__tests__/utils/debounced-writer.test.ts @@ -210,6 +210,29 @@ describe('DebouncedWriter', () => { await expect(outcome).resolves.toBe('landed'); }); + it('sends a recurring value again when its later write fails', async () => { + const { writer, writes } = heldWriter(); + writer.writeNow('a'); + writes[0].reject(new Error('first')); + await flushMicrotasks(); + + const outcome = writer.settle().then( + () => 'landed', + () => 'failed', + ); + writer.writeNow('b'); + writer.writeNow('a'); + writes[1].resolve(); + writes[2].resolve(); + await flushMicrotasks(); + writes[3].reject(new Error('second')); + await flushMicrotasks(); + + expect(writes.map((w) => w.value)).toEqual(['a', 'a', 'b', 'a', 'a']); + writes[4].resolve(); + await expect(outcome).resolves.toBe('landed'); + }); + it('does not count a failure of a write that a later write superseded', async () => { const { writer, writes } = heldWriter(); writer.writeNow('a'); diff --git a/src/utils/debounced-writer.ts b/src/utils/debounced-writer.ts index a6727f5f7..8e027143c 100644 --- a/src/utils/debounced-writer.ts +++ b/src/utils/debounced-writer.ts @@ -1,6 +1,9 @@ /** What a {@link DebouncedWriter} is built from. */ export type DebouncedWriterOptions = { - /** Sends one value to storage; its rejection is what the writer counts as a failed write. */ + /** + * Sends one value to storage, where writes must land in the order they are sent; its rejection is + * what the writer counts as a failed write. + */ write: (value: T) => Promise; /** Milliseconds a scheduled write waits for a later value before it is sent. */ delayMs: number; @@ -81,13 +84,13 @@ export class DebouncedWriter { * @throws The failure, when the write sent again fails as well. */ async settle(): Promise { - let resent: T | undefined; + let resent: Promise | undefined; for (;;) { this.flush(); if (this.failed && this.lastValue !== undefined) { - if (resent === this.lastValue) throw this.failure; - resent = this.lastValue; + if (this.latest === resent) throw this.failure; this.writeNow(this.lastValue); + resent = this.latest; } const { latest } = this; // eslint-disable-next-line no-await-in-loop -- each round waits for the write it sent From 3a8265e109c9980329bd30f6eea5ba5eb4bbd14b Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Wed, 21 Oct 2026 14:52:19 -0600 Subject: [PATCH 08/10] Warn of unreadable draft books when the draft loads (#433) --- contributions/localizedStrings.json | 1 + perf/storage.ts | 2 +- src/__tests__/main.test.ts | 23 ++++++- .../services/draftRepository.test.ts | 38 ++++++++--- src/__tests__/services/projectStorage.test.ts | 66 +++++++++---------- src/main.ts | 15 ++++- src/services/draftRepository.ts | 14 ++-- src/services/projectStorage.ts | 11 ++-- src/types/analysis-repository.ts | 7 +- 9 files changed, 112 insertions(+), 65 deletions(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index fa87a06ed..e4982dd99 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -329,6 +329,7 @@ "%interlinearizer_status_saving%": "Saving…", "%interlinearizer_error_save_unreadableBooks%": "Your working draft could not be read for {books}, so it was not saved to the project. Use Save As to keep your other work in a new project, then open a project or start a new draft to recover.", "%interlinearizer_error_saveDraft_unreadableBooks%": "Your working draft could not be read for {books}, so changes there cannot be kept. Use Save As to keep your other work in a new project, then open a project or start a new draft to recover.", + "%interlinearizer_warning_loadDraft_unreadableBooks%": "Your working draft could not be read for {books}, so it opened without their analysis. Changes there are not kept, and Save is refused, until you open a project or start a new draft, which discards what could not be read. Save As still keeps your other work.", "%interlinearizer_warning_saveAs_unreadableBooksOmitted%": "Some books in your working draft could not be read and were left out of the new project: {books}", "%interlinearizer_unreadable_unlinkedAnalyses%": "analyses not linked to any book", diff --git a/perf/storage.ts b/perf/storage.ts index 074a4e9b6..7a3141616 100644 --- a/perf/storage.ts +++ b/perf/storage.ts @@ -43,7 +43,7 @@ export function fileStorage(dir: string): KeyValueStorage { * @throws If the stored draft was written by a newer build, or a stored record cannot be read. */ export async function readDraft(dir: string, sourceProjectId: string): Promise { - return new DraftRepository(fileStorage(dir), sourceProjectId, console).exportAll(); + return (await new DraftRepository(fileStorage(dir), sourceProjectId, console).exportAll()).draft; } /** diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 6bc65ff02..68ba11a97 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -1438,7 +1438,7 @@ describe('main', () => { }); it('delegates to projectStorage.getDraft and returns the JSON-serialized draft', async () => { - mockGetDraft.mockResolvedValue(stubDraft); + mockGetDraft.mockResolvedValue({ draft: stubDraft, unreadableBooks: [] }); const handler = await getGetDraftHandler(); const result = await handler('src-project'); @@ -1447,6 +1447,23 @@ describe('main', () => { expect(result).toBe(JSON.stringify(stubDraft)); }); + it('warns which books of the draft could not be read and still returns the draft', async () => { + mockGetDraft.mockResolvedValue({ draft: stubDraft, unreadableBooks: ['JHN'] }); + __mockGetLocalizedString.mockResolvedValue('Opened without: {books}'); + const handler = await getGetDraftHandler(); + + const result = await handler('src-project'); + + expect(__mockGetLocalizedString).toHaveBeenCalledWith({ + localizeKey: '%interlinearizer_warning_loadDraft_unreadableBooks%', + }); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: 'Opened without: JHN', + severity: 'warning', + }); + expect(result).toBe(JSON.stringify(stubDraft)); + }); + it('logs the error and rethrows when storage throws', async () => { mockGetDraft.mockRejectedValue(new Error('disk full')); const handler = await getGetDraftHandler(); @@ -1689,7 +1706,9 @@ describe('main', () => { it('tells the user again once the draft is loaded again', async () => { __mockGetLocalizedString.mockResolvedValue('Changes lost: {books}'); mockSaveDraft.mockResolvedValue(['JHN']); - jest.mocked(projectStorage.getDraft).mockResolvedValue(stubDraft); + jest + .mocked(projectStorage.getDraft) + .mockResolvedValue({ draft: stubDraft, unreadableBooks: [] }); const saveDraft = await getSaveDraftHandler(); const getDraft = findRegisteredHandler('interlinearizer.getDraft'); diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts index d61a1aed7..9915fd9d6 100644 --- a/src/__tests__/services/draftRepository.test.ts +++ b/src/__tests__/services/draftRepository.test.ts @@ -66,18 +66,20 @@ describe('DraftRepository', () => { it('loads an empty draft for its source project when none is stored', async () => { const { storage } = memoryStorage(); - await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual( - emptyDraft('src'), - ); + await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual({ + draft: emptyDraft('src'), + unreadableBooks: [], + }); }); it('loads in a new instance what another saved', async () => { const { storage } = memoryStorage(); await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); - await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual( - twoBookDraft(), - ); + await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual({ + draft: twoBookDraft(), + unreadableBooks: [], + }); }); it('loads a payload shared by several books once', async () => { @@ -86,7 +88,8 @@ describe('DraftRepository', () => { expect(records.get(draftAnalysisKey('src', 'EXO'))).toContain('ta-and'); expect(records.get(draftAnalysisKey('src', 'GEN'))).toContain('ta-and'); - const { analysis } = await new DraftRepository(storage, 'src', silentLogger()).exportAll(); + const { analysis } = (await new DraftRepository(storage, 'src', silentLogger()).exportAll()) + .draft; expect(analysis.tokenAnalyses).toHaveLength(1); }); @@ -95,7 +98,7 @@ describe('DraftRepository', () => { const { storage, writes } = memoryStorage(); await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); const repository = new DraftRepository(storage, 'src', silentLogger()); - const draft = await repository.exportAll(); + const { draft } = await repository.exportAll(); writes.length = 0; draft.analysis.segmentAnalyses[0].freeTranslation = { en: 'And then.' }; @@ -139,6 +142,21 @@ describe('DraftRepository', () => { ); }); + it('names a book whose shard is unusable, and loads the rest', async () => { + const { storage, records } = memoryStorage(); + await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); + records.set(draftAnalysisKey('src', 'GEN'), 'not json'); + + const { draft, unreadableBooks } = await new DraftRepository( + storage, + 'src', + silentLogger(), + ).exportAll(); + + expect(unreadableBooks).toEqual(['GEN']); + expect(draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']); + }); + describe('exportForCopy', () => { it('exports a readable draft whole, naming no book', async () => { const { storage } = memoryStorage(); @@ -229,7 +247,7 @@ describe('DraftRepository', () => { expect(writes).toEqual([draftKey('src')]); await expect( new DraftRepository(storage, 'src', silentLogger()).exportAll(), - ).resolves.toEqual({ ...twoBookDraft(), dirty: false }); + ).resolves.toEqual({ draft: { ...twoBookDraft(), dirty: false }, unreadableBooks: [] }); expect(JSON.parse(records.get(draftKey('src')) ?? '{}').analysisBooks).toEqual([ 'GEN', 'EXO', @@ -290,7 +308,7 @@ describe('DraftRepository', () => { await new DraftRepository(memory.storage, 'src', silentLogger()).replaceAll(twoBookDraft()); memory.records.set(draftAnalysisKey('src', 'GEN'), 'not json'); const repository = new DraftRepository(memory.storage, 'src', silentLogger()); - const draft = await repository.exportAll(); + const { draft } = await repository.exportAll(); return { ...memory, repository, draft }; } diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 1dda7ef6f..ca4ea61b8 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -1449,7 +1449,7 @@ describe('projectStorage', () => { storeListedProject(stored, project); await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); stored.set('draft:src-proj:analysis:JHN', '{not json'); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); return { stored, project, loaded }; } @@ -1534,7 +1534,7 @@ describe('projectStorage', () => { await saveDraftToProject(token, 'src-proj', 'proj-id'); - expect((await getDraft(token, 'src-proj')).dirty).toBe(false); + expect((await getDraft(token, 'src-proj')).draft.dirty).toBe(false); }); it('marks the stored draft clean once the project holds it', async () => { @@ -1547,7 +1547,7 @@ describe('projectStorage', () => { await saveDraftToProject(token, 'src-proj', 'proj-id'); - expect((await getDraft(token, 'src-proj')).dirty).toBe(false); + expect((await getDraft(token, 'src-proj')).draft.dirty).toBe(false); }); it('leaves the stored draft dirty when a draft write was sent after the copy read it', async () => { @@ -1564,7 +1564,7 @@ describe('projectStorage', () => { const write = saveDraft(token, 'src-proj', later); await Promise.all([save, write]); - expect(await getDraft(token, 'src-proj')).toMatchObject({ + expect((await getDraft(token, 'src-proj')).draft).toMatchObject({ dirty: true, analysis: later.analysis, }); @@ -1579,7 +1579,7 @@ describe('projectStorage', () => { await saveDraftToProject(token, 'src-proj', 'missing'); - expect((await getDraft(token, 'src-proj')).dirty).toBe(true); + expect((await getDraft(token, 'src-proj')).draft.dirty).toBe(true); }); it('logs a failure to mark the draft clean and still reports the save', async () => { @@ -1675,7 +1675,7 @@ describe('projectStorage', () => { const stored = backStorageWithMap(); await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN', 'JHN')); stored.set('draft:src-proj:analysis:JHN', '{not json'); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); await saveDraft(token, 'src-proj', { ...loaded, analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN', 'MRK').analysis, @@ -1715,7 +1715,7 @@ describe('projectStorage', () => { await saveDraftAsNewProject(token, 'src-proj', ['en']); - expect((await getDraft(token, 'src-proj')).dirty).toBe(false); + expect((await getDraft(token, 'src-proj')).draft.dirty).toBe(false); }); it('reports an invariant violation the stored draft carries', async () => { @@ -1739,7 +1739,7 @@ describe('projectStorage', () => { const stored = { ...emptyDraft('src-proj'), analysisLanguages: ['fr'], dirty: true }; __mockReadUserData.mockResolvedValue(JSON.stringify(stored)); - const result = await getDraft(token, 'src-proj'); + const { draft: result } = await getDraft(token, 'src-proj'); expect(result).toEqual(stored); expect(__mockReadUserData).toHaveBeenCalledWith(token, 'draft:src-proj'); @@ -1760,7 +1760,7 @@ describe('projectStorage', () => { const stored = { ...emptyDraft('src-proj'), analysis: analysisWithDanglingLink() }; __mockReadUserData.mockResolvedValue(JSON.stringify(stored)); - const result = await getDraft(token, 'src-proj'); + const { draft: result } = await getDraft(token, 'src-proj'); expect(result).toEqual(stored); }); @@ -1770,7 +1770,7 @@ describe('projectStorage', () => { delete unversioned.modelVersion; __mockReadUserData.mockResolvedValue(JSON.stringify(unversioned)); - const result = await getDraft(token, 'src-proj'); + const { draft: result } = await getDraft(token, 'src-proj'); expect(result).toEqual(emptyDraft('src-proj')); expect(__mockLogger.warn).toHaveBeenCalledWith( @@ -1782,7 +1782,7 @@ describe('projectStorage', () => { const stored = { ...emptyDraft('src-proj'), modelVersion: 'one' }; __mockReadUserData.mockResolvedValue(JSON.stringify(stored)); - const result = await getDraft(token, 'src-proj'); + const { draft: result } = await getDraft(token, 'src-proj'); expect(result).toEqual(emptyDraft('src-proj')); expect(__mockLogger.warn).toHaveBeenCalledWith( @@ -1830,7 +1830,7 @@ describe('projectStorage', () => { throw enoentError(); }); - expect(await getDraft(token, 'src-proj')).toEqual(legacy); + expect((await getDraft(token, 'src-proj')).draft).toEqual(legacy); }); it('keeps the other books when one shard is missing', async () => { @@ -1843,7 +1843,7 @@ describe('projectStorage', () => { return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); expect(loaded.analysis.tokenAnalysisLinks).toHaveLength(1); expect(loaded.analysis.tokenAnalysisLinks[0].token.tokenRef).toBe('GEN 1:1!0'); @@ -1859,7 +1859,7 @@ describe('projectStorage', () => { return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); expect(loaded.analysis.tokenAnalysisLinks).toHaveLength(1); expect(__mockLogger.warn).toHaveBeenCalledWith( @@ -1879,7 +1879,7 @@ describe('projectStorage', () => { return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); expect(loaded.analysis.tokenAnalysisLinks.map((l) => l.token.tokenRef)).toEqual([ 'GEN 1:1!0', @@ -1894,7 +1894,7 @@ describe('projectStorage', () => { const stored = { ...draft, dirty: true, analysis: withoutLinkIds(draft.analysis) }; __mockReadUserData.mockResolvedValue(JSON.stringify(stored)); - const result = await getDraft(token, 'src-proj'); + const { draft: result } = await getDraft(token, 'src-proj'); expect(result).toEqual(emptyDraft('src-proj')); expect(__mockLogger.warn).toHaveBeenCalledWith( @@ -1912,7 +1912,7 @@ describe('projectStorage', () => { return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); expect(loaded.analysis.tokenAnalysisLinks).toHaveLength(1); expect(__mockLogger.warn).toHaveBeenCalledWith( @@ -1942,7 +1942,7 @@ describe('projectStorage', () => { throw enoentError(); }); - expect(await getDraft(token, 'src-proj')).toEqual(legacy); + expect((await getDraft(token, 'src-proj')).draft).toEqual(legacy); }); it('does not write to storage when reading a stored draft', async () => { @@ -1958,7 +1958,7 @@ describe('projectStorage', () => { it('returns a fresh empty draft when no draft has been written (ENOENT)', async () => { __mockReadUserData.mockRejectedValue(enoentError()); - const result = await getDraft(token, 'src-proj'); + const { draft: result } = await getDraft(token, 'src-proj'); expect(result).toEqual(emptyDraft('src-proj')); }); @@ -2009,7 +2009,7 @@ describe('projectStorage', () => { it('returns an empty draft and warns when the stored value does not match DraftProject shape', async () => { __mockReadUserData.mockResolvedValue(JSON.stringify({ not: 'a draft' })); - const result = await getDraft(token, 'src-proj'); + const { draft: result } = await getDraft(token, 'src-proj'); expect(result).toEqual(emptyDraft('src-proj')); expect(__mockLogger.warn).toHaveBeenCalledWith( @@ -2039,7 +2039,7 @@ describe('projectStorage', () => { return written[2]; }); - expect(await getDraft(token, 'src-proj')).toEqual(draft); + expect((await getDraft(token, 'src-proj')).draft).toEqual(draft); }); it('keeps a stored phrase whose tokens lie within one book', async () => { @@ -2061,7 +2061,7 @@ describe('projectStorage', () => { return written[2]; }); - expect(await getDraft(token, 'src-proj')).toEqual(draft); + expect((await getDraft(token, 'src-proj')).draft).toEqual(draft); }); it('reunites an analysis shared across books into a single payload', async () => { @@ -2090,7 +2090,7 @@ describe('projectStorage', () => { return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); expect(loaded.analysis.tokenAnalyses).toHaveLength(1); expect(loaded.analysis.tokenAnalysisLinks).toHaveLength(2); }); @@ -2242,7 +2242,7 @@ describe('projectStorage', () => { await expect(saveDraft(token, 'src-proj', draft)).rejects.toThrow('envelope write failed'); // GEN's shard landed before the envelope write failed, leaving JHN's as the first save wrote it. - const reopened = await getDraft(token, 'src-proj'); + const { draft: reopened } = await getDraft(token, 'src-proj'); expect(reopened.analysis.tokenAnalyses).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'analysis-GEN', gloss: { en: 'regloss-GEN' } }), @@ -2276,7 +2276,7 @@ describe('projectStorage', () => { // Stands in for an extension-host restart, which keeps no shard tally across it. resetQueuesForTesting(); - const reopened = await getDraft(token, 'src-proj'); + const { draft: reopened } = await getDraft(token, 'src-proj'); expect(reopened.analysis.tokenAnalyses).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'analysis-JHN', gloss: { en: 'gloss-JHN' } }), @@ -2314,7 +2314,7 @@ describe('projectStorage', () => { // The reopened draft has lost JHN's unreadable content, so an ordinary save no longer carries // the book; only the manifest can keep its shard reachable. - await saveDraft(token, 'src-proj', await getDraft(token, 'src-proj')); + await saveDraft(token, 'src-proj', (await getDraft(token, 'src-proj')).draft); expect(JSON.parse(stored.get('draft:src-proj') ?? '{}').analysisBooks).toEqual( expect.arrayContaining(['GEN', 'JHN']), @@ -2503,7 +2503,7 @@ describe('projectStorage', () => { return written[2]; }); - await saveDraft(token, 'src-proj', await getDraft(token, 'src-proj')); + await saveDraft(token, 'src-proj', (await getDraft(token, 'src-proj')).draft); expect(__mockDeleteUserData).not.toHaveBeenCalledWith(token, 'draft:src-proj:analysis:JHN'); }); @@ -2518,7 +2518,7 @@ describe('projectStorage', () => { return written[2]; }); - await saveDraft(token, 'src-proj', await getDraft(token, 'src-proj')); + await saveDraft(token, 'src-proj', (await getDraft(token, 'src-proj')).draft); const [, , json] = __mockWriteUserData.mock.calls.findLast(([, k]) => k === 'draft:src-proj'); expect(typeof json === 'string' && JSON.parse(json).analysisBooks).toEqual( @@ -2535,7 +2535,7 @@ describe('projectStorage', () => { if (!written) throw enoentError(); return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); __mockWriteUserData.mockClear(); loaded.analysis = makeDraftSpanningBooks('src-proj', 'JHN').analysis; @@ -2553,7 +2553,7 @@ describe('projectStorage', () => { if (!written) throw enoentError(); return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); loaded.analysis = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis; await saveDraft(token, 'src-proj', loaded, { discardUnreadable: true }); @@ -2574,7 +2574,7 @@ describe('projectStorage', () => { if (!written) throw enoentError(); return written[2]; }); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); __mockDeleteUserData.mockRejectedValue(new Error('permission denied')); loaded.analysis = makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis; @@ -2788,7 +2788,7 @@ describe('projectStorage', () => { await saveDraft(token, 'src-proj', makeDraftWithBareWord()); readBackWrites(); - const loaded = await getDraft(token, 'src-proj'); + const { draft: loaded } = await getDraft(token, 'src-proj'); expect(loaded.analysis.tokenAnalyses.map((a) => a.id)).toEqual([ 'analysis-GEN', @@ -3201,7 +3201,7 @@ describe('projectStorage', () => { analysis: opened?.analysis ?? emptyAnalysis(), }); - expect((await getDraft(token, 'src-project')).analysis).toEqual(IMPORTED); + expect((await getDraft(token, 'src-project')).draft.analysis).toEqual(IMPORTED); }); }); }); diff --git a/src/main.ts b/src/main.ts index 404bedbdf..24b6d10f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -534,8 +534,9 @@ async function saveDraftAsProject( /** * Returns the draft working buffer for a source project as a JSON string, creating a fresh empty - * draft when none has been written. The WebView loads this on mount to seed the editor. Loading it - * also lets the next draft write that leaves books out tell the user again. + * draft when none has been written. The WebView loads this on mount to seed the editor. The user is + * warned of any book whose stored analysis could not be read, and the next draft write that leaves + * books out tells them again. * * @param sourceProjectId - Platform.Bible source project ID whose draft to fetch. * @throws {SyntaxError} If the draft's storage value contains invalid JSON. @@ -547,7 +548,15 @@ async function getInterlinearDraft(sourceProjectId: string): Promise { leftOutDraftBooks.delete(sourceProjectId); try { return await perfTimeAsync('getDraft', async () => { - const draft = await projectStorage.getDraft(executionToken, sourceProjectId); + const { draft, unreadableBooks } = await projectStorage.getDraft( + executionToken, + sourceProjectId, + ); + await reportUnreadableBooks( + unreadableBooks, + '%interlinearizer_warning_loadDraft_unreadableBooks%', + 'warning', + ); return perfTime('getDraft:serialize', () => JSON.stringify(draft)); }); } catch (e) { diff --git a/src/services/draftRepository.ts b/src/services/draftRepository.ts index 983cb5c86..35e1c68c4 100644 --- a/src/services/draftRepository.ts +++ b/src/services/draftRepository.ts @@ -132,8 +132,9 @@ export class DraftRepository implements AnalysisRepository { * A draft written by a newer build is refused, not discarded, so the build that can read it still * finds it. {@link replaceAll} refuses to overwrite it too. * - * A book whose shard is missing or unusable loads as empty. {@link replaceAll} then keeps that - * shard and its manifest entry until a save starts the draft over. + * A book whose shard is missing or unusable loads as empty and is named among the unreadable + * books. {@link replaceAll} then keeps that shard and its manifest entry until a save starts the + * draft over. * * A shard holding a link with no id is unusable. A draft holding such a link inline loads as a * fresh empty draft. Both are logged. @@ -145,14 +146,13 @@ export class DraftRepository implements AnalysisRepository { * @throws {Error} If the stored draft's `modelVersion` is higher than this build's. * @throws If storage rejects a read. */ - async exportAll(): Promise { - return (await this.readDraft(false)).draft; + async exportAll(): Promise { + return this.readDraft(false); } /** - * Loads the stored draft as {@link exportAll} does, naming each book whose shard is missing or - * unusable instead of leaving its absence to pass for a wipe. A draft never saved exports as a - * fresh empty one. + * Loads the stored draft as {@link exportAll} does, for copying into a project. A draft never + * saved exports as a fresh empty one. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft's `modelVersion` is higher than this build's. diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 44e87ddb0..31c5e5614 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -999,10 +999,11 @@ export async function hasDraft(token: ExecutionToken, sourceProjectId: string): * A read concurrent with an auto-save for the same source returns one or the other whole, never a * mixture of the two. * - * A book whose shard is missing or unusable loads as empty, and {@link saveDraft} then leaves that - * shard and its manifest entry alone rather than treating the book as wiped, until a save starts - * the draft over. A shard holding a link with no id is unusable, and a draft storing its analysis - * inline with such a link loads as a fresh empty draft; either is logged. + * A book whose shard is missing or unusable loads as empty and is named among the unreadable books. + * {@link saveDraft} then leaves that shard and its manifest entry alone rather than treating the + * book as wiped, until a save starts the draft over. A shard holding a link with no id is unusable, + * and a draft storing its analysis inline with such a link loads as a fresh empty draft; either is + * logged. * * A save interrupted before its envelope landed leaves shards the stored manifest does not name. A * book first analyzed in that save still reopens with its work rather than empty. @@ -1014,7 +1015,7 @@ export async function hasDraft(token: ExecutionToken, sourceProjectId: string): export async function getDraft( token: ExecutionToken, sourceProjectId: string, -): Promise { +): Promise { return enqueueSerialized(draftQueues, sourceProjectId, async () => { return draftRepositoryFor(token, sourceProjectId).exportAll(); }); diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index 428e9481e..c345bb68c 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -39,7 +39,7 @@ export type ReplaceAllOptions = { discardUnreadable?: boolean; }; -/** A stored draft as read for copying elsewhere, with the books it could not vouch for. */ +/** A stored draft as read, with the books it could not vouch for. */ export type DraftExport = { /** The draft, holding no analysis for the books in `unreadableBooks`. */ draft: DraftProject; @@ -63,11 +63,10 @@ export interface AnalysisRepository { * @throws {Error} If the stored draft was written by a newer build. * @throws If storage rejects a read. */ - exportAll(): Promise; + exportAll(): Promise; /** - * Loads the whole stored draft for copying into a project, naming the books whose analysis could - * not be read rather than passing them off as empty. + * Loads the whole stored draft for copying into a project. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft was written by a newer build. From 5800c4f5c19f012d2f17421bc6afdeb6278c0b22 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Wed, 21 Oct 2026 15:44:41 -0600 Subject: [PATCH 09/10] Keep the draft unsaved after a Save As that leaves books out (#433) --- .../components/InterlinearizerLoader.test.tsx | 4 +- .../components/modals/ProjectModals.test.tsx | 44 +++++++++++++------ src/__tests__/hooks/useDraftProject.test.ts | 15 +++++++ src/__tests__/main.test.ts | 14 +++++- src/__tests__/services/projectStorage.test.ts | 13 ++++++ src/__tests__/types/type-guards.test.ts | 27 ++++++++++++ src/components/modals/ProjectModals.tsx | 18 ++++---- src/hooks/useDraftProject.ts | 23 ++++++---- src/main.ts | 11 +++-- src/services/projectStorage.ts | 7 +-- src/types/interlinear-project-summary.ts | 7 +++ src/types/interlinearizer.d.ts | 6 +-- src/types/type-guards.ts | 14 +++++- 13 files changed, 160 insertions(+), 43 deletions(-) diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index a1780485b..aa7e7c8a2 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -37,7 +37,7 @@ import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting import useProjectBookIds from '../../hooks/useProjectBookIds'; import useStaleAnalysisDismissal from '../../hooks/useStaleAnalysisDismissal'; import type { StaleAnalysesReport } from '../../components/StaleAnalysesReporter'; -import type { OpenableProject } from '../../hooks/useDraftProject'; +import type { DraftCopyResult, OpenableProject } from '../../hooks/useDraftProject'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { PT9_MANIFEST_TIMEOUT_MS } from '../../utils/pt9-manifest'; import type { PhraseMode } from '../../types/phrase-mode'; @@ -426,7 +426,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({ hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; loadFromProject: (project: OpenableProject) => void; - commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; onImportPt9: () => void; onOpenImport: (project: MockProject) => void; openRequest?: { project: MockProject; requestId: number }; diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index d2939eef3..a491d2a91 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -7,6 +7,7 @@ import papi, { logger } from '@papi/frontend'; import type { DraftProject } from 'interlinearizer'; import type { ModalState } from '../../../components/modals/ProjectModals'; import ProjectModals from '../../../components/modals/ProjectModals'; +import type { DraftCopyResult } from '../../../hooks/useDraftProject'; import { emptyAnalysis } from '../../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../../types/model-version'; import type { InterlinearProjectSummary } from '../../../types/interlinear-project-summary'; @@ -285,7 +286,7 @@ type ModalsOverrides = Partial<{ defaultAnalysisLanguage: string; hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; - commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; loadFromProject: jest.Mock; newDraft: jest.Mock; modal: ModalState; @@ -311,7 +312,7 @@ function buildProps(overrides: ModalsOverrides = {}) { overrides.commitDraft ?? (async (copy) => { const snapshot = getDraftSnapshot(); - return snapshot ? copy(snapshot) : false; + return snapshot ? (await copy(snapshot)) !== false : false; }), loadFromProject: overrides.loadFromProject ?? jest.fn(), newDraft: overrides.newDraft ?? jest.fn(), @@ -325,16 +326,21 @@ function buildProps(overrides: ModalsOverrides = {}) { }; } +/** The host's Save As reply for `project`, having left `omittedBooks` out. */ +function savedAs(project: InterlinearProjectSummary, omittedBooks: string[] = []): string { + return JSON.stringify({ project, omittedBooks }); +} + /** * A `commitDraft` that runs the copy on `snapshot` at once and records what each copy resolved, so * a test can tell a save the draft would count as landed from one it would not. */ function recordingCommitDraft(snapshot: DraftProject = MOCK_DRAFT) { - const outcomes: boolean[] = []; - const commitDraft = jest.fn(async (copy: (s: DraftProject) => Promise) => { - const landed = await copy(snapshot); - outcomes.push(landed); - return landed; + const outcomes: DraftCopyResult[] = []; + const commitDraft = jest.fn(async (copy: (s: DraftProject) => Promise) => { + const copied = await copy(snapshot); + outcomes.push(copied); + return copied !== false; }); return { commitDraft, outcomes }; } @@ -346,9 +352,9 @@ function recordingCommitDraft(snapshot: DraftProject = MOCK_DRAFT) { function heldCommitDraft() { let release!: () => void; const commitDraft = jest.fn( - (copy: (s: DraftProject) => Promise) => + (copy: (s: DraftProject) => Promise) => new Promise((resolve) => { - release = () => resolve(copy(MOCK_DRAFT)); + release = () => resolve(copy(MOCK_DRAFT).then((copied) => copied !== false)); }), ); return { commitDraft, release: () => release() }; @@ -839,7 +845,7 @@ describe('ProjectModals', () => { describe('save as flow', () => { it('has the host create a project from the stored draft through commitDraft, and closes', async () => { - jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(savedAs(MOCK_PROJECT)); const { commitDraft, outcomes } = recordingCommitDraft(); const setModal = jest.fn(); render(); @@ -861,9 +867,21 @@ describe('ProjectModals', () => { expect(outcomes).toEqual([true]); }); + it('reports a copy that left books out as partial, and closes', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(savedAs(MOCK_PROJECT, ['JHN'])); + const { commitDraft, outcomes } = recordingCommitDraft(); + const setModal = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('saveas-new')); + + await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + expect(outcomes).toEqual(['partial']); + }); + it('sends nothing until the draft lets the save run', async () => { const { commitDraft, release } = heldCommitDraft(); - jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(savedAs(MOCK_PROJECT)); render(); await userEvent.click(screen.getByTestId('saveas-new')); @@ -904,9 +922,7 @@ describe('ProjectModals', () => { it('caches only the summary of the created project on Save As New', async () => { const setActiveProject = jest.fn(); - jest - .mocked(papi.commands.sendCommand) - .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT)); + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(savedAs(MOCK_FULL_PROJECT)); render( { expect(result.current.dirty).toBe(true); }); + it('resolves that a partial copy landed, leaving the draft dirty', async () => { + const { result } = await renderLoaded(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('unsaved')); + }); + + let landed: boolean | undefined; + await act(async () => { + landed = await result.current.commitDraft(async () => 'partial'); + }); + + expect(landed).toBe(true); + expect(result.current.dirty).toBe(true); + }); + it('rejects and ends the save when the copy rejects', async () => { const { result } = await renderLoaded(); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 68ba11a97..a8212634e 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -1377,7 +1377,19 @@ describe('main', () => { 'Name', 'Desc', ); - expect(result).toBe(JSON.stringify(toProjectSummary(created))); + expect(result).toBe(JSON.stringify({ project: toProjectSummary(created), omittedBooks: [] })); + }); + + it('returns the books the new project left out', async () => { + mockSaveDraftAsNewProject.mockResolvedValue({ + project: makeStubProject('new-id'), + omittedBooks: ['JHN'], + }); + const handler = await getSaveDraftAsProjectHandler(); + + const result = await handler('src-project', ['en']); + + expect(JSON.parse(result)).toHaveProperty('omittedBooks', ['JHN']); }); it('warns which books of the draft could not be read and were left out', async () => { diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index ca4ea61b8..23da9d71d 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -1718,6 +1718,19 @@ describe('projectStorage', () => { expect((await getDraft(token, 'src-proj')).draft.dirty).toBe(false); }); + it('leaves the stored draft dirty when a book was left out', async () => { + const stored = backStorageWithMap(); + await saveDraft(token, 'src-proj', { + ...makeDraftSpanningBooks('src-proj', 'GEN', 'JHN'), + dirty: true, + }); + stored.set('draft:src-proj:analysis:JHN', '{not json'); + + await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect((await getDraft(token, 'src-proj')).draft.dirty).toBe(true); + }); + it('reports an invariant violation the stored draft carries', async () => { backStorageWithMap(); await saveDraft(token, 'src-proj', { diff --git a/src/__tests__/types/type-guards.test.ts b/src/__tests__/types/type-guards.test.ts index 3e22e0d48..a9ac63852 100644 --- a/src/__tests__/types/type-guards.test.ts +++ b/src/__tests__/types/type-guards.test.ts @@ -4,6 +4,7 @@ import { emptyAnalysis } from '../../types/empty-factories'; import { hasLinkWithoutId, isPt9ImportProvenance, + isSavedAsProject, isStringArray, isTextAnalysis, validateTextAnalysis, @@ -535,6 +536,32 @@ describe('isPt9ImportProvenance', () => { }); }); +describe('isSavedAsProject', () => { + const project = { + id: 'p-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + sourceProjectId: 'src', + analysisLanguages: ['en'], + }; + + it('accepts a project summary with the books left out', () => { + expect(isSavedAsProject({ project, omittedBooks: ['JHN'] })).toBe(true); + }); + + it('rejects a malformed project summary', () => { + expect(isSavedAsProject({ project: { id: 'p-1' }, omittedBooks: [] })).toBe(false); + }); + + it('rejects missing omitted books', () => { + expect(isSavedAsProject({ project })).toBe(false); + }); + + it('rejects a non-object', () => { + expect(isSavedAsProject('p-1')).toBe(false); + }); +}); + describe('isStringArray', () => { it('accepts an array of strings', () => { expect(isStringArray(['GEN', 'EXO'])).toBe(true); diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index 54df0460f..4f8c1a98e 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -2,12 +2,13 @@ import type { UseWebViewStateHook } from '@papi/core'; import papi, { logger } from '@papi/frontend'; import type { DraftProject } from 'interlinearizer'; import { useCallback, useEffect, useState } from 'react'; -import type { NewDraftConfig, OpenableProject } from '../../hooks/useDraftProject'; +import type { DraftCopyResult, NewDraftConfig, OpenableProject } from '../../hooks/useDraftProject'; import useSubmitGuard from '../../hooks/useSubmitGuard'; import { toProjectSummary } from '../../types/interlinear-project-summary'; import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary'; import { isInterlinearProjectSummary, + isSavedAsProject, isSegmentationDelta, isTextAnalysis, } from '../../types/type-guards'; @@ -50,7 +51,7 @@ type PendingReplace = * @param props.getDraftSnapshot - Returns the latest draft envelope (analysis + config). * @param props.commitDraft - Runs a Save As through the draft: once the stored draft is current, * runs the given copy with the draft snapshot and marks the draft synced when the copy reports it - * landed. Resolves what the copy resolved, or `false` when no save could run. + * landed whole. Resolves whether the copy landed, or `false` when no save could run. * @param props.loadFromProject - Loads a project's analysis + config into the draft (the "Open" * flow). * @param props.newDraft - Seeds the in-memory draft with empty analysis and the given config; @@ -92,7 +93,7 @@ export default function ProjectModals({ defaultAnalysisLanguage?: string; hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; - commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; loadFromProject: (project: OpenableProject) => void; newDraft: (config: NewDraftConfig) => void; modal: ModalState; @@ -408,8 +409,9 @@ export default function ProjectModals({ /** * Saves the current draft as a brand-new project: has the host create the project with the * draft's languages / target and copy the stored draft's analysis and segment boundaries into it, - * then makes it the active Save target and closes. Backend commands surface their own error - * notifications; here we only log. + * then makes it the active Save target and closes. When the project leaves some of the draft's + * books out, the draft stays unsaved. Backend commands surface their own error notifications; + * here we only log. * * @param name - Trimmed project name, or `undefined`. * @param description - Trimmed project description, or `undefined`. @@ -427,14 +429,14 @@ export default function ProjectModals({ description, ); const created: unknown = JSON.parse(createdJson); - if (!isInterlinearProjectSummary(created)) { + if (!isSavedAsProject(created)) { await papi.notifications .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) .catch(() => {}); return false; } - setActiveProject(toProjectSummary(created)); - return true; + setActiveProject(toProjectSummary(created.project)); + return created.omittedBooks.length > 0 ? 'partial' : true; }); if (saved) setModal('none'); } catch (e) { diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index 23e7083e4..a27d7388b 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -28,6 +28,12 @@ import { /** Milliseconds to wait after the last keystroke before flushing an autosave write. */ const AUTOSAVE_DEBOUNCE_MS = 300; +/** + * Whether a copy of the draft into a project landed, `'partial'` when it left part of the draft + * out. + */ +export type DraftCopyResult = boolean | 'partial'; + /** The part of a draft its undo history covers. */ export type DraftContent = Readonly<{ analysis: TextAnalysis; @@ -186,15 +192,16 @@ export type UseDraftProjectResult = { /** * Saves the draft to a project: once the stored draft holds the snapshot passed to `copy`, runs * `copy`, which copies the stored draft into a project and resolves whether the copy landed. When - * it did, the draft is marked synced unless an edit landed meanwhile. Resolves what `copy` - * resolved, or `false` without copying when no draft has loaded, when another save is in flight, - * or when the draft was replaced while its writes settled. + * it landed whole, the draft is marked synced unless an edit landed meanwhile; a partial copy + * leaves it unsaved. Resolves whether the copy landed, or `false` without copying when no draft + * has loaded, when another save is in flight, or when the draft was replaced while its writes + * settled. * * @throws If a draft write fails, so the stored draft lags the one in memory; the draft stays * dirty and nothing is copied. * @throws Whatever `copy` throws; the draft stays dirty. */ - commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; + commitDraft: (copy: (snapshot: DraftProject) => Promise) => Promise; /** * Persists an edited analysis into the draft and marks it dirty, recording `location` — the token * ref or segment id the edit was made at, if one place — for its undo step. Wire as the editor's @@ -603,7 +610,7 @@ export default function useDraftProject( }, []); const commitDraft = useCallback( - async (copy: (snapshot: DraftProject) => Promise): Promise => { + async (copy: (snapshot: DraftProject) => Promise): Promise => { /* v8 ignore next -- a save is only reachable once the draft has loaded */ if (!draftRef.current) return false; if (isSavingRef.current) return false; @@ -620,9 +627,9 @@ export default function useDraftProject( ); return false; } - const landed = await copy(snapshot); - if (landed) markSynced(snapshot); - return landed; + const copied = await copy(snapshot); + if (copied === true) markSynced(snapshot); + return copied !== false; } finally { isSavingRef.current = false; setIsSaving(false); diff --git a/src/main.ts b/src/main.ts index 24b6d10f7..ab29ea13b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,7 @@ import { readHostPerf, setHostPerfMarks } from './services/hostPerf'; import * as projectStorage from './services/projectStorage'; import * as pt9ImportService from './services/pt9ImportService'; import type { Pt9ImportResult } from './services/pt9ImportService'; -import { toProjectSummary } from './types/interlinear-project-summary'; +import { toProjectSummary, type SavedAsProject } from './types/interlinear-project-summary'; import { UnreadableBooksError } from './types/analysis-repository'; import { isDraftProject } from './types/type-guards'; import { BOOKLESS_PARTITION } from './utils/analysis-book'; @@ -494,7 +494,8 @@ async function saveDraftToProject( * @param targetProjectId - Optional Platform.Bible project ID of the target text. * @param name - Optional user-facing name for the project. * @param description - Optional user-facing description for the project. - * @returns The created project's `InterlinearProjectSummary` as a JSON string. + * @returns The created project's summary and the books it left out, as a JSON + * {@link SavedAsProject}. * @throws If the stored draft is unreadable or storage fails. The error is logged and an error * notification is sent before rethrowing. */ @@ -520,7 +521,11 @@ async function saveDraftAsProject( '%interlinearizer_warning_saveAs_unreadableBooksOmitted%', 'warning', ); - return JSON.stringify(toProjectSummary(created.project)); + const saved: SavedAsProject = { + project: toProjectSummary(created.project), + omittedBooks: created.omittedBooks, + }; + return JSON.stringify(saved); }); } catch (e) { await reportSaveFailure( diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 31c5e5614..6906c7ffe 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -447,8 +447,8 @@ export async function createProject( * queued read runs is what the project receives. A book whose stored draft analysis cannot be read * is left out with its boundaries rather than copied as empty, and named in the result: a new * project has no analysis of its own to lose, so the rest of the draft can still be saved. Once the - * project is created, the stored draft is marked clean unless a draft write was sent after it was - * read. + * project is created, the stored draft is marked clean unless a book was left out or a draft write + * was sent after it was read. * * @param token - The execution token for storage access. * @param sourceProjectId - The Platform.Bible project ID whose draft to copy. @@ -490,7 +490,8 @@ export async function saveDraftAsNewProject( segmentation, ); await persistNewProject(token, project); - await markCopiedDraftClean(token, sourceProjectId, writesBefore); + if (unreadableBooks.length === 0) + await markCopiedDraftClean(token, sourceProjectId, writesBefore); return { project, omittedBooks: unreadableBooks }; } diff --git a/src/types/interlinear-project-summary.ts b/src/types/interlinear-project-summary.ts index 47d9fe13a..04b4f699c 100644 --- a/src/types/interlinear-project-summary.ts +++ b/src/types/interlinear-project-summary.ts @@ -22,6 +22,13 @@ export type InterlinearProjectSummary = Pick< > & Partial; +/** A project created by Save As. */ +export type SavedAsProject = { + project: InterlinearProjectSummary; + /** Book codes whose stored draft analysis could not be read, so the project lacks them. */ + omittedBooks: string[]; +}; + /** * Copies out just the {@link InterlinearProjectSummary} fields, dropping any extra ones. * diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index e4cd40999..ffd27d32f 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -233,9 +233,9 @@ declare module 'papi-shared-types' { * @param targetProjectId - Optional Platform.Bible project ID of the target text. * @param name - Optional user-facing name for the project. * @param description - Optional user-facing description for the project. - * @returns The created project's `InterlinearProjectSummary` as a JSON string. - * @throws If the stored draft cannot be read whole, or if storage fails. Error is logged and an - * error notification is sent before rethrowing so callers do not need to send a second + * @returns The created project's summary and the books it left out, as a JSON `SavedAsProject`. + * @throws If the stored draft itself cannot be read, or if storage fails. Error is logged and + * an error notification is sent before rethrowing so callers do not need to send a second * notification. */ 'interlinearizer.saveDraftAsProject': ( diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts index becb38acc..355ff4040 100644 --- a/src/types/type-guards.ts +++ b/src/types/type-guards.ts @@ -8,7 +8,7 @@ import type { TextAnalysis, Token, } from 'interlinearizer'; -import type { InterlinearProjectSummary } from './interlinear-project-summary'; +import type { InterlinearProjectSummary, SavedAsProject } from './interlinear-project-summary'; /** Narrows a token to a word token. */ export function isWordToken(token: Token): token is Token & { type: 'word' } { @@ -40,6 +40,18 @@ export function isInterlinearProjectSummary(p: unknown): p is InterlinearProject ); } +/** Validates a {@link SavedAsProject} parsed from unknown JSON. */ +export function isSavedAsProject(p: unknown): p is SavedAsProject { + return ( + !!p && + typeof p === 'object' && + 'project' in p && + isInterlinearProjectSummary(p.project) && + 'omittedBooks' in p && + isStringArray(p.omittedBooks) + ); +} + /** Type guard for the `pt9Import` provenance an imported project carries. */ export function isPt9ImportProvenance( value: unknown, From 044ca19d04b34dad2a1ea7485f371edfff23ed69 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Thu, 22 Oct 2026 08:25:58 -0600 Subject: [PATCH 10/10] Correct Save As result and settle docs for left-out books (#433) --- src/hooks/useDraftProject.ts | 15 ++++++++------- src/main.ts | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index a27d7388b..e38579374 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -190,12 +190,12 @@ export type UseDraftProjectResult = { */ isSaving: boolean; /** - * Saves the draft to a project: once the stored draft holds the snapshot passed to `copy`, runs - * `copy`, which copies the stored draft into a project and resolves whether the copy landed. When - * it landed whole, the draft is marked synced unless an edit landed meanwhile; a partial copy - * leaves it unsaved. Resolves whether the copy landed, or `false` without copying when no draft - * has loaded, when another save is in flight, or when the draft was replaced while its writes - * settled. + * Saves the draft to a project: once the draft's writes have settled, passes the draft as it then + * stands to `copy`, which copies the stored draft into a project and resolves whether the copy + * landed. When it landed whole, the draft is marked synced unless an edit landed meanwhile; a + * partial copy leaves it unsaved. Resolves whether the copy landed, or `false` without copying + * when no draft has loaded, when another save is in flight, or when the draft was replaced while + * its writes settled. * * @throws If a draft write fails, so the stored draft lags the one in memory; the draft stays * dirty and nothing is copied. @@ -619,7 +619,8 @@ export default function useDraftProject( try { const replacements = replacementCountRef.current; await writer.settle(); - // Settled, so storage holds the draft the ref holds now, edits during the wait included. + // Settled, so storage holds the draft the ref holds now, edits during the wait included, + // less any analysis the host left out for a book it could not read. const snapshot = draftRef.current; if (replacementCountRef.current !== replacements) { logger.warn( diff --git a/src/main.ts b/src/main.ts index ab29ea13b..c0965b3b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -997,7 +997,7 @@ export async function activate(context: ExecutionActivationContext): Promise