Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment thread
imnasnainaec marked this conversation as resolved.
"%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.",
Expand Down
6 changes: 1 addition & 5 deletions e2e-tests/tests/perf/app-perf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 3 additions & 3 deletions e2e-tests/tests/perf/view-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HostCall>;

Expand Down
2 changes: 1 addition & 1 deletion perf/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DraftProject> {
return new DraftRepository(fileStorage(dir), sourceProjectId, console).exportAll();
return (await new DraftRepository(fileStorage(dir), sourceProjectId, console).exportAll()).draft;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion perf/summary/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const METRIC_LABELS: Record<string, string> = {
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',
Expand Down
189 changes: 155 additions & 34 deletions src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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<DraftCopyResult>) => Promise<boolean>;
onImportPt9: () => void;
onOpenImport: (project: MockProject) => void;
openRequest?: { project: MockProject; requestId: number };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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,
Expand All @@ -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<typeof renderLoader> | 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)),
);
Expand All @@ -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());
Expand All @@ -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)),
);
Expand All @@ -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)),
);
Expand All @@ -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<void>> {
let finishSave!: (summary: string) => void;
mockSendCommand.mockImplementation((...args) =>
args[0] === 'interlinearizer.saveDraftToProject'
? new Promise<string>((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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 }) }),
);
Expand All @@ -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);
});

Expand Down
Loading
Loading