diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index fa4556def..e4982dd99 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -324,6 +324,14 @@ "%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_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", "%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..a1feb6218 100644 --- a/e2e-tests/tests/perf/app-perf.spec.ts +++ b/e2e-tests/tests/perf/app-perf.spec.ts @@ -397,17 +397,13 @@ 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); // 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/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/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..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'; @@ -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,74 @@ describe('InterlinearizerLoader', () => { renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), ); + mockSendCommand.mockClear(); await userEvent.click(screen.getByTestId('tab-toolbar-save')); - expect(mockSendCommand).toHaveBeenCalledWith( - 'interlinearizer.saveAnalysis', - 'proj-1', - JSON.stringify(draftAnalysis), - // The draft has no custom boundaries, so Save sends "null" to clear any stored ones. - 'null', + // Nothing was pending, so the stored draft is already current: Save's first command is the + // 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'], + ]); + }); + + 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('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 saveAnalysis response', async () => { - // saveAnalysis returns the persisted project carrying the storage-refreshed updatedAt; every + 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)), ); @@ -3937,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()); @@ -3952,12 +4014,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 +4039,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 +4069,63 @@ 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', + }); + }); + + /** + * 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' + ? new Promise((resolve) => { + finishSave = resolve; + }) + : Promise.resolve(JSON.stringify(emptyDraft(testProjectId))), + ); + await act(async () => + renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), + ); + 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. + 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); + }); + + it('shows no save running, and lets the held commands through, once the save ends', async () => { + const finishSave = await startHeldSave(); + + 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 () => { @@ -4027,7 +4146,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 +4213,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 +4239,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..a491d2a91 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -3,10 +3,11 @@ 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'; +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'; @@ -69,12 +70,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 +286,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 +302,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 ? (await copy(snapshot)) !== false : 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 +326,40 @@ 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: DraftCopyResult[] = []; + const commitDraft = jest.fn(async (copy: (s: DraftProject) => Promise) => { + const copied = await copy(snapshot); + outcomes.push(copied); + return copied !== false; + }); + 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).then((copied) => copied !== false)); + }), + ); + 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,108 +844,85 @@ describe('ProjectModals', () => { }); describe('save as flow', () => { - it('creates a new project, writes the analysis, marks synced, and closes', async () => { - jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); - const markSynced = jest.fn(); + it('has the host create a project from the stored draft through commitDraft, and closes', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(savedAs(MOCK_PROJECT)); + 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 () => { - jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); - const markSynced = jest.fn(); - render( - MOCK_DRAFT_WITH_SEGMENTATION, - })} - />, - ); + 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(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 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(savedAs(MOCK_PROJECT)); + render(); + + await userEvent.click(screen.getByTestId('saveas-new')); + expect(commitDraft).toHaveBeenCalledTimes(1); + expect(papi.commands.sendCommand).not.toHaveBeenCalled(); + + 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)); + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(savedAs(MOCK_FULL_PROJECT)); render( { 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 +1067,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 +1096,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 +1124,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..ef70e8f75 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,18 @@ 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); + }); +} + +/** Counts the draft writes sent so far. */ +function saveDraftCount(): number { + return mockSendCommand.mock.calls.filter((c) => c[0] === 'interlinearizer.saveDraft').length; +} + describe('useDraftProject', () => { beforeEach(() => { jest.mocked(logger.error).mockImplementation(() => {}); @@ -192,6 +208,340 @@ describe('useDraftProject', () => { }); }); + describe('commitDraft', () => { + /** + * 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'); + expect(saveDraftCount()).toBe(1); + 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('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(); + + 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,89 +888,118 @@ describe('useDraftProject', () => { }); }); - describe('markSynced', () => { - it('clears dirty and persists the synced draft without bumping the version', async () => { + describe('marking the draft synced after a save', () => { + it('clears dirty 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('sends no draft write once the copy lands, since the host marks the stored draft clean', async () => { const { result } = await renderLoaded(); - // The analysis that a Save captured and persisted. - const savedSnapshot = analysisWithToken('tok-saved'); act(() => { - result.current.autosaveAnalysis(savedSnapshot); + result.current.autosaveAnalysis(analysisWithToken('tok-sync')); }); - // A newer edit lands during the save round-trip, replacing the ref's analysis. - act(() => { - result.current.autosaveAnalysis(analysisWithToken('tok-newer')); + await act(async () => { + await result.current.commitDraft(async () => false); }); - expect(result.current.dirty).toBe(true); + const writesBeforeSave = saveDraftCount(); + + await saveNow(result); - // Syncing against the now-stale snapshot must not clear the unsaved-changes flag. + expect(saveDraftCount()).toBe(writesBeforeSave); + }); + + 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.markSynced(savedSnapshot, undefined); + result.current.autosaveAnalysis(analysisWithToken('tok-saved')); }); + let writesDuringCopy = 0; - expect(result.current.dirty).toBe(true); + await act(async () => { + await result.current.commitDraft(async () => { + const before = saveDraftCount(); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-undone')); + }); + act(() => { + result.current.undo(); + }); + writesDuringCopy = saveDraftCount() - before; + return true; + }); + }); + + expect(result.current.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 a segmentation edit landed since the saved snapshot', async () => { + it('leaves the draft dirty when an 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); + result.current.autosaveAnalysis(analysisWithToken('tok-saved')); }); - // 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: [] }); + + 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); + }); - // 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. + it('leaves the draft dirty when a segmentation edit landed during the copy', async () => { + const { result } = await renderLoaded(); 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(); - expect(saved.dirty).toBe(false); - expect(saved.segmentation).toEqual(delta); + expect(lastSavedDraft().segmentation).toEqual(delta); }); }); @@ -639,6 +1018,65 @@ describe('useDraftProject', () => { }); }); + 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('says nothing of starting over with an edit or a wipe', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.wipeBook('JHN')); + act(() => result.current.wipeAll()); + + expect(startedOverPerWrite()).toEqual([undefined, undefined]); + }); + + it('says the draft was started over with New', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.newDraft({ analysisLanguages: ['en'] })); + + expect(startedOverPerWrite()).toEqual([true]); + }); + + it('says the draft was started over with Open', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.loadFromProject({ analysis: emptyAnalysis(), analysisLanguages: ['en'] }), + ); + + expect(startedOverPerWrite()).toEqual([true]); + }); + + it('keeps saying so with every later write', async () => { + const { result } = await renderLoaded(); + act(() => result.current.newDraft({ analysisLanguages: ['en'] })); + + act(() => result.current.wipeAll()); + + expect(startedOverPerWrite()).toEqual([true, true]); + }); + + 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'] })); + + view.rerender({ source: 'source-project-2' }); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + act(() => view.result.current.wipeAll()); + + expect(startedOverPerWrite().at(-1)).toBeUndefined(); + }); + }); + describe('undo history', () => { it('drops the history when the source changes', async () => { const view = renderHook(({ source }) => useDraftProject(source, PLATFORM_LANGUAGE), { @@ -934,7 +1372,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 +1601,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 +1629,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..a8212634e 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -4,11 +4,13 @@ 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 { UnreadableBooksError } from '../types/analysis-repository'; +import { emptyDraft } from '../types/empty-factories'; import { toProjectSummary } from '../types/interlinear-project-summary'; import { createTestActivationContext, makeStubProject } from './test-helpers'; @@ -179,11 +181,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 = () => @@ -191,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. @@ -368,7 +382,8 @@ describe('main', () => { 'interlinearizer.openForWebView', 'interlinearizer.createProject', 'interlinearizer.getProject', - 'interlinearizer.saveAnalysis', + 'interlinearizer.saveDraftToProject', + 'interlinearizer.saveDraftAsProject', 'interlinearizer.getProjectsForSource', 'interlinearizer.getDraft', 'interlinearizer.getTokenizedBook', @@ -1215,134 +1230,206 @@ 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))); + expect(__mockNotificationsSend).not.toHaveBeenCalled(); }); - 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(); + 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' + : 'Not saved: {books}', + ); + const handler = await getSaveDraftToProjectHandler(); - const result = await handler('proj-id', JSON.stringify(stubAnalysis)); + await expect(handler('src-project', 'proj-id')).rejects.toBe(refusal); - // 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)); + expect(__mockGetLocalizedString).toHaveBeenCalledWith({ + 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: 'Not saved: JHN, analyses not linked to any book', + severity: 'error', + }); }); - it('logs the error, sends an error notification, and rethrows when storage throws', async () => { - mockUpdateAnalysis.mockRejectedValue(new Error('disk full')); - const handler = await getSaveAnalysisHandler(); + 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('proj-id', JSON.stringify(stubAnalysis))).rejects.toThrow('disk full'); - expect(__mockLogger.error).toHaveBeenCalledWith( - 'Interlinearizer: failed to save analysis', + await expect(handler('src-project', 'proj-id')).rejects.toBe(refusal); + expect(__mockLogger.warn).toHaveBeenCalledWith( + 'Interlinearizer: could not report the unreadable books of a draft', expect.any(Error), ); - expect(__mockNotificationsSend).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }), - ); }); - it('logs the error, sends an error notification, and rethrows when analysisJson is not valid JSON', async () => { - const handler = await getSaveAnalysisHandler(); + it('forwards the config to set', async () => { + mockSaveDraftToProject.mockResolvedValue(makeStubProject('proj-id')); + 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' }), + await handler('src-project', 'proj-id', { + analysisLanguages: ['fr'], + targetProjectId: 'tgt', + }); + + expect(mockSaveDraftToProject).toHaveBeenCalledWith( + expect.anything(), + 'src-project', + 'proj-id', + { + analysisLanguages: ['fr'], + targetProjectId: 'tgt', + }, ); - expect(mockUpdateAnalysis).not.toHaveBeenCalled(); }); - it('logs the error, sends an error notification, and rethrows when analysisJson does not conform to TextAnalysis', 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', JSON.stringify({ segmentAnalyses: [] }))).rejects.toThrow( - TypeError, - ); + await expect(handler('src-project', 'proj-id')).resolves.toBeUndefined(); + }); + + 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('src-project', 'proj-id')).rejects.toThrow('disk full'); expect(__mockLogger.error).toHaveBeenCalledWith( - 'Interlinearizer: failed to save analysis', - expect.any(TypeError), + 'Interlinearizer: failed to save the draft to a project', + expect.any(Error), ); - expect(__mockNotificationsSend).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'error' }), + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: '%interlinearizer_error_save_analysis_failed%', + severity: 'error', + }); + }); + }); + + describe('interlinearizer.saveDraftAsProject command', () => { + const mockSaveDraftAsNewProject = jest.mocked(projectStorage.saveDraftAsNewProject); + + it('registers the interlinearizer.saveDraftAsProject command', async () => { + const context = createTestActivationContext(); + + await activate(context); + + expect(__mockRegisterCommand).toHaveBeenCalledWith( + 'interlinearizer.saveDraftAsProject', + expect.any(Function), + expect.any(Object), ); - expect(mockUpdateAnalysis).not.toHaveBeenCalled(); }); - 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: [] }; + 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, omittedBooks: [] }); + const handler = await getSaveDraftAsProjectHandler(); - await handler('proj-id', JSON.stringify(stubAnalysis), JSON.stringify(segmentation)); + const result = await handler('src-project', ['fr'], 'tgt', 'Name', 'Desc'); - expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect(mockSaveDraftAsNewProject).toHaveBeenCalledWith( expect.anything(), - 'proj-id', - stubAnalysis, - segmentation, + 'src-project', + ['fr'], + 'tgt', + 'Name', + 'Desc', ); + expect(result).toBe(JSON.stringify({ project: toProjectSummary(created), omittedBooks: [] })); }); - it('passes null through to updateAnalysis to clear boundaries when segmentationJson is "null"', async () => { - mockUpdateAnalysis.mockResolvedValue(undefined); - const handler = await getSaveAnalysisHandler(); + it('returns the books the new project left out', async () => { + mockSaveDraftAsNewProject.mockResolvedValue({ + project: makeStubProject('new-id'), + omittedBooks: ['JHN'], + }); + const handler = await getSaveDraftAsProjectHandler(); - await handler('proj-id', JSON.stringify(stubAnalysis), 'null'); + const result = await handler('src-project', ['en']); - expect(mockUpdateAnalysis).toHaveBeenCalledWith( - expect.anything(), - 'proj-id', - stubAnalysis, - // eslint-disable-next-line no-null/no-null -- asserting the clear-boundaries sentinel is forwarded - null, + expect(JSON.parse(result)).toHaveProperty('omittedBooks', ['JHN']); + }); + + it('warns which books of the draft could not be read and were left out', async () => { + mockSaveDraftAsNewProject.mockResolvedValue({ + project: makeStubProject('new-id'), + omittedBooks: ['JHN'], + }); + __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']); + + expect(__mockGetLocalizedString).toHaveBeenCalledWith({ + localizeKey: '%interlinearizer_warning_saveAs_unreadableBooksOmitted%', + }); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: 'Left out: JHN', + severity: 'warning', + }); }); - 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', + }); }); }); @@ -1363,7 +1450,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'); @@ -1372,6 +1459,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(); @@ -1539,12 +1643,92 @@ 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)); - 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([]); + const handler = await getSaveDraftHandler(); + + await handler('src-project', JSON.stringify(stubDraft), true); + + expect(mockSaveDraft).toHaveBeenCalledWith(expect.anything(), 'src-project', stubDraft, { + discardUnreadable: true, + }); + }); + + 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(); + + 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); + }); + + 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 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({ draft: stubDraft, unreadableBooks: [] }); + 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); }); 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 fbe1eaf35..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.' }; @@ -138,4 +141,233 @@ describe('DraftRepository', () => { `Interlinearizer: analysis shard ${draftAnalysisKey('src', 'GEN')} is not valid JSON; treating as empty`, ); }); + + 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(); + 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({ draft: { ...twoBookDraft(), dirty: false }, unreadableBooks: [] }); + 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([]); + }); + }); + + describe('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 draft carries nothing for', async () => { + const { repository, draft, records } = await loadedWithUnreadableGen(); + + await repository.replaceAll({ ...draft, analysis: emptyAnalysis() }); + + expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); + }); + + 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( + [], + ); + }); + + 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']); + + 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(); + + await repository.replaceAll(twoBookDraft(), { discardUnreadable: true }); + + expect(records.get(draftAnalysisKey('src', 'GEN'))).not.toBe('not json'); + }); + + it('wipes an unreadable book the draft carries nothing for once the draft is started over', async () => { + const { repository, draft, records } = await loadedWithUnreadableGen(); + + await repository.replaceAll( + { ...draft, analysis: emptyAnalysis() }, + { discardUnreadable: true }, + ); + + 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 3801a539a..23da9d71d 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -13,13 +13,17 @@ 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 { UnreadableBooksError } from '../../types/analysis-repository'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; import { createTestActivationContext, @@ -1301,12 +1305,454 @@ 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); + }); + + /** Storage holding project `proj-id` with GEN and JHN, and a draft whose JHN is damaged. */ + async function damagedJhn() { + const stored = backStorageWithMap(); + const project = { + ...makeStubProject('proj-id'), + 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 { draft: loaded } = await getDraft(token, 'src-proj'); + return { stored, project, loaded }; + } + + it('refuses the copy while a book of the draft cannot be read, leaving the project untouched', async () => { + const { stored, project } = await damagedJhn(); + + 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('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() }); + + await expect(saveDraftToProject(token, 'src-proj', 'proj-id')).rejects.toThrow( + UnreadableBooksError, + ); + expect(storedProject(stored, 'proj-id')).toEqual(project); + }); + + it('leaves the damaged book out of a draft write carrying analysis for it', async () => { + const { stored, loaded } = await damagedJhn(); + + await expect( + saveDraft(token, 'src-proj', { + ...loaded, + analysis: makeDraftSpanningBooks('src-proj', 'GEN', 'JHN').analysis, + }), + ).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( + token, + 'src-proj', + { ...emptyDraft('src-proj'), analysis: project.analysis }, + { discardUnreadable: true }, + ); + + const result = await saveDraftToProject(token, 'src-proj', 'proj-id'); + + expect(result?.analysis).toEqual(project.analysis); + }); + + 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')).draft.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')).draft.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')).draft).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')).draft.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?.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 () => { + 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({ project: expected, omittedBooks: [] }); + 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 { project } = await saveDraftAsNewProject(token, 'src-proj', ['en']); + + 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, omittedBooks } = await saveDraftAsNewProject(token, 'src-proj', ['en']); + + expect(omittedBooks).toEqual(['JHN']); + 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 { draft: 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', { + ...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', { + ...makeDraftSpanningBooks('src-proj', 'GEN'), + dirty: true, + }); + + await saveDraftAsNewProject(token, 'src-proj', ['en']); + + 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', { + ...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 }; __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'); @@ -1327,7 +1773,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); }); @@ -1337,7 +1783,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( @@ -1349,7 +1795,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( @@ -1397,7 +1843,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 () => { @@ -1410,7 +1856,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'); @@ -1426,7 +1872,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( @@ -1446,7 +1892,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', @@ -1461,7 +1907,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( @@ -1479,7 +1925,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( @@ -1509,7 +1955,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 () => { @@ -1525,7 +1971,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')); }); @@ -1576,7 +2022,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( @@ -1606,7 +2052,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 () => { @@ -1628,7 +2074,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 () => { @@ -1657,7 +2103,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); }); @@ -1740,17 +2186,24 @@ 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('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.toEqual([]); + 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 () => { @@ -1802,7 +2255,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' } }), @@ -1836,7 +2289,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' } }), @@ -1874,7 +2327,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']), @@ -1942,7 +2395,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 +2411,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,19 +2428,33 @@ 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.toEqual([]); + 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), ); }); - 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.toEqual([]); + 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 () => { @@ -2007,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 not valid JSON; treating as empty', ); @@ -2020,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', ); @@ -2033,7 +2500,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', ); @@ -2049,7 +2516,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'); }); @@ -2064,7 +2531,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( @@ -2072,7 +2539,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) => { @@ -2081,16 +2548,16 @@ 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; - 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) => { @@ -2099,9 +2566,9 @@ 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); + await saveDraft(token, 'src-proj', loaded, { discardUnreadable: true }); loaded.analysis = removeBookFromAnalysis(loaded.analysis, 'JHN'); await saveDraft(token, 'src-proj', loaded); @@ -2120,11 +2587,11 @@ 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; - await expect(saveDraft(token, 'src-proj', loaded)).rejects.toThrow('permission denied'); + await saveDraft(token, 'src-proj', loaded, { discardUnreadable: true }); __mockDeleteUserData.mockResolvedValue(undefined); loaded.analysis = removeBookFromAnalysis(loaded.analysis, 'JHN'); @@ -2141,7 +2608,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); @@ -2334,7 +2801,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', @@ -2747,7 +3214,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/__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/__tests__/utils/debounced-writer.test.ts b/src/__tests__/utils/debounced-writer.test.ts new file mode 100644 index 000000000..73257f9b8 --- /dev/null +++ b/src/__tests__/utils/debounced-writer.test.ts @@ -0,0 +1,250 @@ +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', () => { + const { writer, writes } = heldWriter(); + + writer.schedule('a'); + writer.flush(); + + 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('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('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('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'); + 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..4f8c1a98e 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -1,13 +1,14 @@ 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 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'; @@ -47,16 +48,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 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; * 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 +78,9 @@ export default function ProjectModals({ defaultAnalysisLanguage, hasUnsavedWork, getDraftSnapshot, + commitDraft, loadFromProject, newDraft, - markSynced, modal, onImportPt9, onOpenImport, @@ -93,12 +93,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,9 +407,10 @@ 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; + * 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. 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`. @@ -420,108 +418,76 @@ export default function ProjectModals({ */ 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 (!isSavedAsProject(created)) { + await papi.notifications + .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) + .catch(() => {}); + return false; + } + setActiveProject(toProjectSummary(created.project)); + return created.omittedBooks.length > 0 ? 'partial' : 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..e38579374 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 { @@ -27,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; @@ -177,6 +184,24 @@ 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 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. + * @throws Whatever `copy` throws; the draft stays dirty. + */ + 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 +233,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,34 +327,42 @@ 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)); - }, + // 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 + // 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', + startedOverRef.current + ? papi.commands.sendCommand('interlinearizer.saveDraft', sourceProjectId, json, true) + : 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; + startedOverRef.current = false; setIsDraftLoading(true); setHistory(emptyHistory()); @@ -376,14 +397,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 +419,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 +441,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], ); /** @@ -496,6 +505,7 @@ export default function useDraftProject( const loadFromProject = useCallback( (project: OpenableProject) => { + startedOverRef.current = true; applyReplacement( { sourceProjectId, @@ -516,6 +526,7 @@ export default function useDraftProject( const newDraft = useCallback( (config: NewDraftConfig) => { + startedOverRef.current = true; applyReplacement( { sourceProjectId, @@ -578,32 +589,54 @@ export default function useDraftProject( ); }, [applyReplacement]); - const markSynced = useCallback( - (savedAnalysis: TextAnalysis, savedSegmentation: SegmentationDelta | undefined) => { - const { current } = draftRef; - /* v8 ignore next -- save is only reachable from the mounted editor */ - if (!current) return; + /** + * 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. 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; + /* 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; + + draftRef.current = { ...current, dirty: false }; + cleanStatesRef.current = new Set([historyRef.current.state]); + setDirty(false); + }, []); - // 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; + 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, + // less any analysis the host left out for a book it could not read. + 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 copied = await copy(snapshot); + if (copied === true) markSynced(snapshot); + return copied !== false; + } finally { + isSavingRef.current = false; + setIsSaving(false); } - const next: DraftProject = { ...current, dirty: false }; - draftRef.current = next; - cleanStatesRef.current = new Set([historyRef.current.state]); - persist(next); - setDirty(false); }, - [persist], + [markSynced, writer], ); /** @@ -698,13 +731,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..c0965b3b0 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,10 @@ 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, 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'; import { perfTime, perfTimeAsync } from './utils/perf-marks'; // #region WebView provider @@ -117,6 +120,12 @@ let executionToken: ExecutionToken; */ const openWebViewsByProject = new Map(); +/** + * The unreadable books each source's last draft write left out, so the user is told once, not on + * every write. + */ +const leftOutDraftBooks = new Map(); + // #endregion // #region Command handlers @@ -389,75 +398,150 @@ async function getInterlinearProject(interlinearProjectId: string): Promise { + logger.error(`Interlinearizer: failed to ${description}`, e); + await papi.notifications.send({ message, severity: 'error' }).catch(() => {}); +} + +/** + * 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: readonly string[], + message: LocalizeKey, + severity: 'warning' | 'error', +): Promise { + if (books.length === 0) return; + try { + 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: named.join(', ') }), + severity, + }); + } catch (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 draft that cannot be read for some book is not copied, and the + * user is told which books and how to recover. * - * @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 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 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(() => {}); + 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; + } +} + +/** + * 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 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. + * @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 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. + */ +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, + ); + await reportUnreadableBooks( + created.omittedBooks, + '%interlinearizer_warning_saveAs_unreadableBooksOmitted%', + 'warning', + ); + const saved: SavedAsProject = { + project: toProjectSummary(created.project), + omittedBooks: created.omittedBooks, + }; + return JSON.stringify(saved); + }); + } catch (e) { + await reportSaveFailure( + e, + 'save the draft as a new project', + '%interlinearizer_error_create_project_failed%', + ); throw e; } } /** * 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. 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. @@ -466,9 +550,18 @@ async function saveInterlinearAnalysis( * before rethrowing. */ 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) { @@ -555,23 +648,34 @@ 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, 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, + 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, + }); }); } catch (e) { logger.error('Interlinearizer: failed to save draft', e); @@ -583,6 +687,21 @@ async function saveInterlinearDraft(sourceProjectId: string, draftJson: string): .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', + ); } /** @@ -627,6 +746,7 @@ export async function activate(context: ExecutionActivationContext): Promise(); @@ -130,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 carries analyses for the book again. + * 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. @@ -143,8 +146,48 @@ 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 this.readDraft(); + async exportAll(): Promise { + return this.readDraft(false); + } + + /** + * 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. + * @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 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 }), + ); } /** @@ -157,24 +200,31 @@ 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. + * whether or not this instance loaded the draft, unless that load could not use the book's shard. + * 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. + * 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. * + * @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. - * @throws If deleting a wiped book's shard rejects; the shard stays journaled for a later save to - * retry. */ - 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 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 @@ -189,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. @@ -209,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); }), ), ); @@ -219,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)), @@ -246,36 +293,52 @@ 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 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( + `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 }), ); } catch (e) { - if (!failed) throw e; this.logger.warn( `Interlinearizer: could not update the shard journal for ${sourceProjectId}:`, e, ); } - - if (failed?.status === 'rejected') throw failed.reason; + return leftOut; } /** - * Loads the stored draft as {@link exportAll} describes. Loading a sharded draft also resets the - * shard contents and unreadable books this instance keeps. + * 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 `strictEnvelope` and the draft itself is unreadable. * @throws If storage rejects a read. */ - private async readDraft(): Promise { + private async readDraft(strictEnvelope: boolean): Promise { const { sourceProjectId, storage } = this; + /** + * 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. @@ -286,15 +349,14 @@ export class DraftRepository implements AnalysisRepository { 'analysis' in parsed && hasLinkWithoutId(parsed.analysis) ) { - this.logger.warn( + return 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'); - 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. @@ -302,7 +364,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. @@ -335,6 +397,9 @@ export class DraftRepository implements AnalysisRepository { const loadedByBook = booksToRead .map((bookCode, i) => ({ bookCode, ...shards[i] })) .filter(({ bookCode, state }) => state !== 'absent' || !orphaned.includes(bookCode)); + const unreadableBooks = loadedByBook + .filter(({ state }) => state !== 'loaded') + .map(({ bookCode }) => bookCode); perfTime('getDraft:compare', () => { this.shardContents = new Map( loadedByBook @@ -342,15 +407,13 @@ 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(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 f090134cb..6906c7ffe 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -3,14 +3,17 @@ import type { ExecutionToken } from '@papi/core'; import type { DraftProject, InterlinearProject, + ProjectConfig, SegmentationDelta, TextAnalysis, } from 'interlinearizer'; -import type { 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 { removeBookFromSegmentation } from '../utils/analysis-book'; import { reportAnalysisViolations } from '../utils/analysis-violations'; import { perfTime, perfTimeAsync } from '../utils/perf-marks'; import { DraftRepository } from './draftRepository'; @@ -59,6 +62,19 @@ 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 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, so the new project lacks them. */ + omittedBooks: string[]; +}; + /** Extension user data as {@link KeyValueStorage}, reading a never-written key as absent. */ function userDataStorage(token: ExecutionToken): KeyValueStorage { return { @@ -409,10 +425,89 @@ 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. 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 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. + * @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 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( + token: ExecutionToken, + sourceProjectId: string, + analysisLanguages: string[], + targetProjectId?: string, + name?: string, + description?: string, +): Promise { + const { draft, unreadableBooks, writesBefore } = await readDraftForCopy(token, sourceProjectId); + 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, + analysisLanguages, + targetProjectId, + name, + description, + analysis, + segmentation, + ); + await persistNewProject(token, project); + if (unreadableBooks.length === 0) + await markCopiedDraftClean(token, sourceProjectId, writesBefore); + return { project, omittedBooks: unreadableBooks }; +} + +/** 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 +516,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 +770,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 +794,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 +813,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 +826,57 @@ 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. 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. + * @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 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. + * @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 { 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, + draft.analysis, + // eslint-disable-next-line no-null/no-null -- null clears the project's stored boundaries + draft.segmentation ?? null, + config, + ); + if (project) await markCopiedDraftClean(token, sourceProjectId, writesBefore); + return project; +} + /** * Updates the metadata of an existing interlinearizer project. Refreshes `updatedAt` to the current * time. @@ -847,10 +1000,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 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. + * 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. @@ -862,12 +1016,54 @@ 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(); }); } +/** + * Reads the stored draft for copying into a project, in turn with the source's draft writes. + * + * @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( + 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 @@ -887,22 +1083,30 @@ export async function getDraft( * * 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 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. + * + * @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. - * @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. 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, -): Promise { + 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); - }); + return enqueueSerialized(draftQueues, sourceProjectId, async () => + draftRepositoryFor(token, sourceProjectId).replaceAll(draft, options), + ); } /** @@ -916,4 +1120,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 85b5bf8d6..c345bb68c 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -14,25 +14,87 @@ export interface WarningLogger { warn(message: string, ...details: unknown[]): void; } +/** + * 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. */ + 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 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; +}; + +/** 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; + /** + * 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 * 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, 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; + exportAll(): Promise; /** - * Saves the whole draft, replacing the stored one. + * 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. + * @throws {Error} If the draft itself is unreadable, so no book in it can be vouched for. + * @throws If storage rejects a read. + */ + exportForCopy(): Promise; + + /** + * 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 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. + * + * @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): Promise; + replaceAll(draft: DraftProject, options?: ReplaceAllOptions): 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/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 eea1c47af..ffd27d32f 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 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': ( + 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 @@ -281,15 +306,22 @@ 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. 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 @@ -1590,6 +1622,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/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, diff --git a/src/utils/debounced-writer.ts b/src/utils/debounced-writer.ts new file mode 100644 index 000000000..8e027143c --- /dev/null +++ b/src/utils/debounced-writer.ts @@ -0,0 +1,101 @@ +/** What a {@link DebouncedWriter} is built from. */ +export type DebouncedWriterOptions = { + /** + * 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; + /** 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: Promise | undefined; + for (;;) { + this.flush(); + if (this.failed && this.lastValue !== undefined) { + 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 + await latest.catch(() => {}); + if (this.timer === undefined && this.latest === latest && !this.failed) return; + } + } +}