diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx
index ff3bb598f..c80081630 100644
--- a/__mocks__/platform-bible-react.tsx
+++ b/__mocks__/platform-bible-react.tsx
@@ -107,10 +107,10 @@ export const MOCK_SAVE_AS_MENU_ITEM: MenuItemContainingCommand = {
localizeNotes: '',
};
-/** Sentinel menu item passed by the mock toolbar when the wipe button is clicked. */
-export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = {
- label: '%interlinearizer_wipe%',
- command: 'interlinearizer.wipe',
+/** Sentinel menu item passed by the mock toolbar when the discard-changes button is clicked. */
+export const MOCK_DISCARD_CHANGES_MENU_ITEM: MenuItemContainingCommand = {
+ label: '%interlinearizer_discardChanges%',
+ command: 'interlinearizer.openDiscardChangesModal',
group: 'interlinearizer.draftActions',
order: 1,
localizeNotes: '',
@@ -259,10 +259,10 @@ export function TabToolbar({
{onSelectProjectMenuItem && (
)}
{onSelectProjectMenuItem && (
diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json
index fed9517e5..69ef81f93 100644
--- a/contributions/localizedStrings.json
+++ b/contributions/localizedStrings.json
@@ -13,7 +13,7 @@
"%interlinearizer_openProjectInfoModal%": "View Project Info…",
"%interlinearizer_save%": "Save",
"%interlinearizer_saveAs%": "Save As…",
- "%interlinearizer_wipe%": "Wipe…",
+ "%interlinearizer_discardChanges%": "Discard Unsaved Changes…",
"%interlinearizer_openLexiconChooser%": "Connect a Lexicon…",
"%interlinearizer_error_openLexiconChooser_failed%": "The lexicon chooser could not be opened.",
@@ -32,10 +32,10 @@
"%interlinearizer_redone_catalogDeleteChecked%": "Redid deleting {count} analyses ({usageCount} uses).",
"%interlinearizer_undone_catalogClearGlossChecked%": "Undid clearing the gloss on {count} analyses.",
"%interlinearizer_redone_catalogClearGlossChecked%": "Redid clearing the gloss on {count} analyses.",
- "%interlinearizer_undone_wipeBook%": "Undid wiping {book}.",
- "%interlinearizer_redone_wipeBook%": "Redid wiping {book}.",
- "%interlinearizer_undone_wipeAll%": "Undid wiping the whole draft.",
- "%interlinearizer_redone_wipeAll%": "Redid wiping the whole draft.",
+ "%interlinearizer_undone_discardBook%": "Undid discarding unsaved changes to {book}.",
+ "%interlinearizer_redone_discardBook%": "Redid discarding unsaved changes to {book}.",
+ "%interlinearizer_undone_discardAll%": "Undid discarding all unsaved changes.",
+ "%interlinearizer_redone_discardAll%": "Redid discarding all unsaved changes.",
"%interlinearizer_menu_column_view%": "View",
"%interlinearizer_openAnalysisCatalog%": "Analysis Catalog",
@@ -299,14 +299,14 @@
"%interlinearizer_modal_saveAs_save_active_clean%": "No changes to save.",
"%interlinearizer_modal_saveAs_cancel%": "Cancel",
- "%interlinearizer_wipe_modal_title%": "Wipe draft analysis",
- "%interlinearizer_wipe_modal_prompt%": "Choose how much of the draft's analysis to remove. Save afterward to persist the change to a project.",
- "%interlinearizer_wipe_modal_scope_book%": "Current book",
- "%interlinearizer_wipe_modal_scope_book_description%": "Removes all analysis for the current book from the draft.",
- "%interlinearizer_wipe_modal_scope_all%": "Entire draft",
- "%interlinearizer_wipe_modal_scope_all_description%": "Removes all analysis from the draft.",
- "%interlinearizer_wipe_modal_confirm%": "Wipe",
- "%interlinearizer_wipe_modal_cancel%": "Cancel",
+ "%interlinearizer_discardChanges_modal_title%": "Discard unsaved changes",
+ "%interlinearizer_discardChanges_modal_prompt%": "Choose how much of the unsaved analyses to discard.",
+ "%interlinearizer_discardChanges_modal_scope_book%": "Current book",
+ "%interlinearizer_discardChanges_modal_scope_book_description%": "Discard all unsaved analyses for the current book.",
+ "%interlinearizer_discardChanges_modal_scope_all%": "Entire draft",
+ "%interlinearizer_discardChanges_modal_scope_all_description%": "Discard all unsaved analyses.",
+ "%interlinearizer_discardChanges_modal_confirm%": "Discard",
+ "%interlinearizer_discardChanges_modal_cancel%": "Cancel",
"%interlinearizer_bookNotInProject_title%": "Book not in this project",
"%interlinearizer_bookNotInProject_description%": "This book is not part of this project yet. You can add it with {buttonLabel}.",
@@ -324,6 +324,7 @@
"%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_discardChanges_failed%": "The saved project could not be read, so no changes were discarded.",
"%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…",
diff --git a/contributions/menus.json b/contributions/menus.json
index 7ddc48ba1..a83fe679f 100644
--- a/contributions/menus.json
+++ b/contributions/menus.json
@@ -96,11 +96,11 @@
"command": "interlinearizer.openSaveAsModal"
},
{
- "label": "%interlinearizer_wipe%",
- "localizeNotes": "Interlinearizer top menu > Open the wipe dialog to remove the current book's or the whole draft's analysis",
+ "label": "%interlinearizer_discardChanges%",
+ "localizeNotes": "Interlinearizer top menu > Open the dialog that discards the unsaved analysis of the current book or the whole draft",
"group": "interlinearizer.draftActions",
"order": 1,
- "command": "interlinearizer.wipe"
+ "command": "interlinearizer.openDiscardChangesModal"
},
{
"label": "%interlinearizer_openLexiconChooser%",
diff --git a/e2e-tests/README.md b/e2e-tests/README.md
index c32218321..a3b70ab86 100644
--- a/e2e-tests/README.md
+++ b/e2e-tests/README.md
@@ -42,7 +42,7 @@ Feature tests run with `npm run test:e2e:cdp`. That command launches a fresh, is
- **Import from `cdp.fixture`, never `app.fixture`.** The CDP config already serializes execution (`workers: 1`), so tests never race each other on the shared instance.
- **The instance is only ever used with the WEB project.** This is an operating assumption, not something tests verify: `ensureInterlinearizerOpenOnWeb()` trusts an existing Interlinearizer tab and only picks WEB when opening fresh. Don't point `start:cdp` at other projects.
- **Mutating tests operate on the dedicated "E2E Test Project", never on a developer's own projects.** `ensureE2eProjectActive()` opens it (creating it on first use) at the start of each mutating test. Because the draft is the single per-source working buffer, replacing it could destroy unsaved developer work — so when the draft is dirty and the active project is _not_ the e2e project, the helper first saves the draft into a new `e2e-rescued-work-` project. Rescue projects are backups, not junk: delete them manually once recovered. Dirty state left while the e2e project is active is treated as leftover test data and discarded.
-- **Self-establish every precondition.** A mutating test's setup composes these steps as needed: `ensureInterlinearizerOpenOnWeb()`, `ensureE2eProjectActive()`, `navigateToScriptureRef()` (the scroll-group reference could be anywhere), and `wipeDraft()`. They stay as separate calls rather than one wrapper on purpose — tests pick the subset they need (e.g. the draft-persistence reopen deliberately runs only open + navigate, skipping the project reset and wipe that would destroy the state it is verifying).
+- **Self-establish every precondition.** A mutating test's setup composes these steps as needed: `ensureInterlinearizerOpenOnWeb()`, `ensureE2eProjectActive()`, `navigateToScriptureRef()` (the scroll-group reference could be anywhere), and `discardAllChanges()`. They stay as separate calls rather than one wrapper on purpose — tests pick the subset they need (e.g. the draft-persistence reopen deliberately runs only open + navigate, skipping the project reset and discard that would destroy the state it is verifying).
- **Reset at the start, tidy at the end.** Correctness rests on the start-of-test sequence, which self-heals whatever a failed run left behind (leftover modals, stray project pickers, another test's dirty draft); see the JSDocs on `ensureInterlinearizerOpenOnWeb()` and `ensureE2eProjectActive()` for the mechanism. This self-healing is also what lets the CDP config safely `retries` in CI. Mutating tests additionally end with `ensureE2eProjectActive(page, { rescueDirtyDraft: false })` to discard their own leftovers — a courtesy to the next run, not something correctness depends on.
- **Use unique per-run values** (e.g. `` `e2e-gloss-${Date.now()}` ``) for anything written into the draft, so a stale leftover can never satisfy an assertion.
- **Drive only the visible UI.** No JSON-RPC/WebSocket calls to set up or assert state (the rpc.discover readiness polls in the shared helpers are the one sanctioned exception).
diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts
index 06dc7d101..6cea92727 100644
--- a/e2e-tests/fixtures/helpers.ts
+++ b/e2e-tests/fixtures/helpers.ts
@@ -1695,28 +1695,32 @@ export async function ensureE2eProjectActive(
}
/**
- * Wipe the entire draft's analysis via the visible UI (Project menu → Wipe… → "Entire draft" →
- * Wipe). Standard reset step for mutating feature tests on the shared CDP instance: run it at the
- * START of a test (never at the end) so a previously failed run self-heals instead of poisoning the
- * next one.
+ * Discard the entire draft's unsaved changes via the visible UI (Project menu → Discard Unsaved
+ * Changes… → "Entire draft" → Discard). With the e2e project active, the discard empties the draft,
+ * because no test saves to that project. Standard reset step for mutating feature tests on the
+ * shared CDP instance: run it at the START of a test (never at the end) so a previously failed run
+ * self-heals instead of poisoning the next one.
*
- * @throws If the wipe dialog does not open or does not close after confirming.
+ * @throws If the discard dialog does not open or does not close after confirming.
*/
-export async function wipeDraft(page: Page): Promise {
+export async function discardAllChanges(page: Page): Promise {
const frame = await openInterlinearizerProjectMenu(page);
- await frame.getByRole('menuitem', { name: /Wipe/i }).first().click();
+ await frame
+ .getByRole('menuitem', { name: /Discard Unsaved Changes/i })
+ .first()
+ .click();
- const wipeDialogTitle = frame.getByTestId('wipe-modal-title');
- await expect(wipeDialogTitle).toBeVisible({ timeout: 5_000 });
- const scopeAll = frame.getByTestId('wipe-scope-all');
+ const dialogTitle = frame.getByTestId('discard-changes-modal-title');
+ await expect(dialogTitle).toBeVisible({ timeout: 5_000 });
+ const scopeAll = frame.getByTestId('discard-changes-scope-all');
// `force`: on a slow/software-rendered CI display the just-opened modal overlay hasn't won the
// hit-test yet, so a normal click is intercepted by the iframe's `#root` for a few frames and
// times out. We've asserted the modal is open and target an element inside it by a unique test id,
// so skipping the hit-test check is safe.
await expect(scopeAll).toBeEnabled({ timeout: 5_000 });
await scopeAll.check({ force: true });
- await frame.getByTestId('wipe-confirm').click({ force: true });
- await expect(wipeDialogTitle).not.toBeVisible({ timeout: 10_000 });
+ await frame.getByTestId('discard-changes-confirm').click({ force: true });
+ await expect(dialogTitle).not.toBeVisible({ timeout: 10_000 });
}
/**
diff --git a/e2e-tests/global-setup-cdp.ts b/e2e-tests/global-setup-cdp.ts
index f05435ee8..bbf9d83a0 100644
--- a/e2e-tests/global-setup-cdp.ts
+++ b/e2e-tests/global-setup-cdp.ts
@@ -75,8 +75,8 @@ const RENDERER_SETTLE_TIMEOUT = process.env.CI ? 180_000 : 120_000;
* 4. Records the PID and user-data dir for {@link globalTeardownCdp}.
*
* The isolated user-data dir means the run never touches a developer's real profile; the feature
- * tests self-establish every precondition (they create the E2E project, navigate, and wipe the
- * draft at the start of each test), so a fresh profile is sufficient.
+ * tests self-establish every precondition (they create the E2E project, navigate, and discard the
+ * draft's changes at the start of each test), so a fresh profile is sufficient.
*
* If the CDP port is already in use, a warm instance is assumed (a developer's own `npm run
* start:cdp`) and setup is a no-op: no app is launched and nothing is recorded for teardown, so the
diff --git a/e2e-tests/tests/features/draft-persistence.spec.ts b/e2e-tests/tests/features/draft-persistence.spec.ts
index 8e2b8c1db..fca4e5fc2 100644
--- a/e2e-tests/tests/features/draft-persistence.spec.ts
+++ b/e2e-tests/tests/features/draft-persistence.spec.ts
@@ -6,7 +6,7 @@ import {
getInterlinearizerFrame,
navigateToScriptureRef,
waitForAppAndInterlinearizerReady,
- wipeDraft,
+ discardAllChanges,
} from '../../fixtures/helpers';
test.describe('Draft persistence', () => {
@@ -21,7 +21,7 @@ test.describe('Draft persistence', () => {
await ensureInterlinearizerOpenOnWeb(mainPage);
await ensureE2eProjectActive(mainPage);
await navigateToScriptureRef(mainPage, 'GEN 1:1');
- await wipeDraft(mainPage);
+ await discardAllChanges(mainPage);
const frame = getInterlinearizerFrame(mainPage);
const glossInput = frame.getByLabel('Gloss for beginning', { exact: true }).first();
diff --git a/e2e-tests/tests/features/gloss-roundtrip.spec.ts b/e2e-tests/tests/features/gloss-roundtrip.spec.ts
index d866289c8..e018220e5 100644
--- a/e2e-tests/tests/features/gloss-roundtrip.spec.ts
+++ b/e2e-tests/tests/features/gloss-roundtrip.spec.ts
@@ -5,7 +5,7 @@ import {
getInterlinearizerFrame,
navigateToScriptureRef,
waitForAppAndInterlinearizerReady,
- wipeDraft,
+ discardAllChanges,
} from '../../fixtures/helpers';
test.describe('Gloss round-trip', () => {
@@ -19,14 +19,14 @@ test.describe('Gloss round-trip', () => {
await ensureInterlinearizerOpenOnWeb(mainPage);
await ensureE2eProjectActive(mainPage);
await navigateToScriptureRef(mainPage, 'GEN 1:1');
- await wipeDraft(mainPage);
+ await discardAllChanges(mainPage);
const frame = getInterlinearizerFrame(mainPage);
// WEB Genesis 1:1: "In the beginning, God created the heavens and the earth."
const glossInput = frame.getByLabel('Gloss for beginning', { exact: true }).first();
await expect(glossInput).toBeVisible({ timeout: 30_000 });
- // The wipe just cleared all analysis, so the field must start empty.
+ // The discard just emptied the draft, so the field must start empty.
await expect(glossInput).toHaveValue('');
// Unique per run so a leftover value from a previous run can never false-pass.
diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx
index 392af0afb..60a512958 100644
--- a/src/__tests__/components/InterlinearizerLoader.test.tsx
+++ b/src/__tests__/components/InterlinearizerLoader.test.tsx
@@ -179,29 +179,43 @@ jest.mock('../../components/controls/ScriptureNavControls', () => ({
),
}));
-jest.mock('../../components/modals/WipeModal', () => ({
+jest.mock('../../components/modals/DiscardChangesModal', () => ({
__esModule: true,
/**
- * Minimal WipeModal stand-in exposing per-scope confirm buttons and cancel so tests can drive the
- * loader's wipe handlers without the real dialog's localization or scope-picker UI.
+ * Minimal DiscardChangesModal stand-in exposing per-scope confirm buttons and cancel so tests can
+ * drive the loader's discard handlers without the real dialog's localization or scope-picker UI.
*/
- WipeModal: ({
+ DiscardChangesModal: ({
hasActiveBook,
+ isSubmitting,
onConfirm,
onCancel,
}: {
hasActiveBook: boolean;
+ isSubmitting: boolean;
onConfirm: (scope: 'book' | 'all') => void;
onCancel: () => void;
}) => (
-
-
diff --git a/src/components/modals/DiscardChangesModal.tsx b/src/components/modals/DiscardChangesModal.tsx
new file mode 100644
index 000000000..4b97852bd
--- /dev/null
+++ b/src/components/modals/DiscardChangesModal.tsx
@@ -0,0 +1,117 @@
+import { useLocalizedStrings } from '@papi/frontend/react';
+import { Button, Label, RadioGroup, RadioGroupItem } from 'platform-bible-react';
+import { useState } from 'react';
+import { ModalShell } from './ModalShell';
+
+/** The portion of the draft a discard covers: the current book only, or the entire draft. */
+export type DiscardScope = 'book' | 'all';
+
+/** Localized string keys requested for this modal's rendered text. */
+const DISCARD_CHANGES_MODAL_STRING_KEYS: `%${string}%`[] = [
+ '%interlinearizer_discardChanges_modal_title%',
+ '%interlinearizer_discardChanges_modal_prompt%',
+ '%interlinearizer_discardChanges_modal_scope_book%',
+ '%interlinearizer_discardChanges_modal_scope_book_description%',
+ '%interlinearizer_discardChanges_modal_scope_all%',
+ '%interlinearizer_discardChanges_modal_scope_all_description%',
+ '%interlinearizer_discardChanges_modal_confirm%',
+ '%interlinearizer_discardChanges_modal_cancel%',
+];
+
+/**
+ * Dialog that asks how much of the draft's unsaved analysis to discard: the current book's or the
+ * entire draft's. It only collects the scope; the caller does the discarding.
+ *
+ * @param props.hasActiveBook - Whether a book is currently loaded. When `false`, the "current book"
+ * option is disabled and the selection defaults to the whole draft.
+ * @param props.isSubmitting - Whether the caller is still carrying out the confirmed discard. When
+ * `true`, the buttons are disabled and the dialog can't be dismissed.
+ * @param props.onConfirm - Called with the chosen {@link DiscardScope} when the user confirms.
+ * @param props.onCancel - Called when the user backs out, leaving the draft untouched.
+ * @returns The dialog, or nothing while localized strings are loading.
+ */
+export function DiscardChangesModal({
+ hasActiveBook,
+ isSubmitting = false,
+ onConfirm,
+ onCancel,
+}: Readonly<{
+ hasActiveBook: boolean;
+ isSubmitting?: boolean;
+ onConfirm: (scope: DiscardScope) => void;
+ onCancel: () => void;
+}>) {
+ const [localizedStrings, stringsLoading] = useLocalizedStrings(DISCARD_CHANGES_MODAL_STRING_KEYS);
+
+ // Default to the narrower per-book discard when a book is loaded; otherwise the whole draft is
+ // the only actionable choice.
+ const [scope, setScope] = useState(hasActiveBook ? 'book' : 'all');
+
+ /* v8 ignore next */ if (stringsLoading) return undefined;
+
+ return (
+
+
-
- );
-}
diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts
index e38579374..2c06f4420 100644
--- a/src/hooks/useDraftProject.ts
+++ b/src/hooks/useDraftProject.ts
@@ -8,7 +8,7 @@ import type {
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 { restoreBookInAnalysis, restoreBookInSegmentation } from '../utils/analysis-book';
import { DebouncedWriter } from '../utils/debounced-writer';
import { perfTime, perfTrack } from '../utils/perf-marks';
import { isEmptyDelta } from '../utils/segmentation';
@@ -116,8 +116,8 @@ export type StepSummary = Readonly<
/** How many analyses had their gloss cleared. */
count: number;
}
- | { kind: 'wipeBook'; book: string }
- | { kind: 'wipeAll' }
+ | { kind: 'discardBook'; book: string }
+ | { kind: 'discardAll' }
>;
/** An undo step, as recorded. */
@@ -159,9 +159,9 @@ export type UseDraftProjectResult = {
*/
draft: DraftProject | undefined;
/**
- * Monotonic counter bumped on every wholesale analysis replacement (New / Open / Wipe). Include
- * it in the editor's React `key` so the analysis store reseeds from the new draft; per-edit
- * auto-saves deliberately do not bump it, so editing never remounts the editor.
+ * Monotonic counter bumped on every wholesale analysis replacement (New / Open / Discard).
+ * Include it in the editor's React `key` so the analysis store reseeds from the new draft;
+ * per-edit auto-saves deliberately do not bump it, so editing never remounts the editor.
*/
draftVersion: number;
/**
@@ -186,7 +186,7 @@ export type UseDraftProjectResult = {
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.
+ * Discard) must be held off while it is, so the draft being saved stays the one the save copies.
*/
isSaving: boolean;
/**
@@ -226,13 +226,18 @@ export type UseDraftProjectResult = {
* first edit. The caller is responsible for immediately persisting the project to the backend.
*/
newDraft: (config: NewDraftConfig) => void;
- /** Removes one book's analysis from the draft, by 3-letter book code, and marks it dirty. */
- wipeBook: (bookCode: string) => void;
/**
- * Clears the draft's analysis entirely and marks it **not** dirty — a wiped draft is treated as a
- * clean baseline, so the unsaved-changes indicator clears. The active project is left untouched.
+ * Discards one book's unsaved changes, by 3-letter book code: resets its analysis and boundaries
+ * to those in `saved`, or removes them when `saved` is absent. With `saved`, the draft stays as
+ * dirty as it was. Without `saved`, the draft is marked dirty.
*/
- wipeAll: () => void;
+ discardBook: (bookCode: string, saved?: DraftContent) => void;
+ /**
+ * Discards the draft's unsaved changes: resets its analysis and boundaries to those in `saved`,
+ * or empties them when `saved` is absent. Marks the draft clean. Keeps its languages and
+ * alignment target.
+ */
+ discardAll: (saved?: DraftContent) => void;
canUndo: boolean;
canRedo: boolean;
/**
@@ -280,7 +285,7 @@ export type UseDraftProjectResult = {
/**
* Owns the always-present, auto-saved draft for one source project. Loads the draft on mount, seeds
* a gloss language when none is stored, and exposes callbacks to auto-save edits, to undo and redo
- * them, and to replace the draft wholesale (New / Open / Wipe).
+ * them, and to replace the draft wholesale (New / Open / Discard).
*
* The full draft lives in a ref — the synchronous source of truth for persistence and Save — while
* a small amount of state (`isDraftLoading`, `draftVersion`, `dirty`) drives re-renders, so
@@ -302,7 +307,7 @@ export default function useDraftProject(
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
// The history states that read clean: the one the draft last started from or was saved at, and
- // any whole-draft wipe since.
+ // any discard since that left it clean.
const cleanStatesRef = useRef(new Set());
const replacementListenersRef = useRef(new Set<(analysis: TextAnalysis) => void>());
const historyRevisionRef = useRef(0);
@@ -545,49 +550,49 @@ export default function useDraftProject(
[applyReplacement, sourceProjectId],
);
- const wipeBook = useCallback(
- (bookCode: string) => {
+ const discardBook = useCallback(
+ (bookCode: string, saved?: DraftContent) => {
const { current } = draftRef;
- /* v8 ignore next -- wipe is only reachable from the mounted editor */
+ /* v8 ignore next -- discarding is only reachable from the mounted editor */
if (!current) return;
- // Drop the book's custom segment boundaries alongside its analysis: the anchors are working
- // state keyed by book, so keeping them would re-apply the wiped book's merges/splits on
- // reload. Clear the field when nothing remains for any other book.
- const segmentation = removeBookFromSegmentation(current.segmentation, bookCode);
- const next: DraftProject = {
- ...current,
- analysis: removeBookFromAnalysis(current.analysis, bookCode),
- dirty: true,
+ const restored: DraftContent = {
+ analysis: restoreBookInAnalysis(current.analysis, saved?.analysis, bookCode),
+ segmentation: restoreBookInSegmentation(
+ current.segmentation,
+ saved?.segmentation,
+ bookCode,
+ ),
};
- if (segmentation !== undefined) next.segmentation = segmentation;
- else delete next.segmentation;
+ // Other books may still differ from the saved project.
+ // Without one, nothing they hold is saved.
applyReplacement(
- next,
+ withContent(current, restored, saved ? current.dirty : true),
recordStep(historyRef.current, contentOf(current), {
- summary: { kind: 'wipeBook', book: bookCode },
+ summary: { kind: 'discardBook', book: bookCode },
}),
);
},
[applyReplacement],
);
- const wipeAll = useCallback(() => {
- const { current } = draftRef;
- /* v8 ignore next -- wipe is only reachable from the mounted editor */
- if (!current) return;
+ const discardAll = useCallback(
+ (saved?: DraftContent) => {
+ const { current } = draftRef;
+ /* v8 ignore next -- discarding is only reachable from the mounted editor */
+ if (!current) return;
- // Wiping the whole draft is treated as a clean baseline (dirty: false) so the user is not nagged
- // to save an empty draft. The active project is left untouched, so a subsequent Save still
- // targets it. Custom segment boundaries are working state, so a whole-draft wipe clears them too.
- // (Per-book wipe stays dirty, as it is a partial edit the user will usually want to save.)
- const next: DraftProject = { ...current, analysis: emptyAnalysis(), dirty: false };
- delete next.segmentation;
- applyReplacement(
- next,
- recordStep(historyRef.current, contentOf(current), { summary: { kind: 'wipeAll' } }),
- );
- }, [applyReplacement]);
+ applyReplacement(
+ withContent(
+ current,
+ saved ?? { analysis: emptyAnalysis(), segmentation: undefined },
+ false,
+ ),
+ recordStep(historyRef.current, contentOf(current), { summary: { kind: 'discardAll' } }),
+ );
+ },
+ [applyReplacement],
+ );
/**
* Marks the draft synced once `saved` has been copied into a project, unless an edit landed
@@ -737,8 +742,8 @@ export default function useDraftProject(
autosaveSegmentation,
loadFromProject,
newDraft,
- wipeBook,
- wipeAll,
+ discardBook,
+ discardAll,
canUndo,
canRedo,
getHistoryRevision,
diff --git a/src/hooks/useLostBoundaryDismissal.ts b/src/hooks/useLostBoundaryDismissal.ts
index f217b3177..d08339acd 100644
--- a/src/hooks/useLostBoundaryDismissal.ts
+++ b/src/hooks/useLostBoundaryDismissal.ts
@@ -11,7 +11,7 @@ type LostBoundaryDismissalOptions = {
segmentation: SegmentationDelta | undefined;
/** Bumped by every boundary edit. */
segmentationVersion: number;
- /** Bumped by every wholesale draft replacement (New / Open / Wipe). */
+ /** Bumped by every wholesale draft replacement (New / Open / Discard). */
draftVersion: number;
/** Whether the initial draft load is still outstanding, which bumps neither counter. */
isDraftLoading: boolean;
diff --git a/src/hooks/useRecordDismissal.ts b/src/hooks/useRecordDismissal.ts
index f71872deb..97d4d74d1 100644
--- a/src/hooks/useRecordDismissal.ts
+++ b/src/hooks/useRecordDismissal.ts
@@ -6,7 +6,7 @@ type RecordDismissalOptions = {
records: readonly string[];
/** The book `records` were found in, `undefined` while no book's records are observable. */
observedBookRef: string | undefined;
- /** Bumped by every wholesale draft replacement (New / Open / Wipe). */
+ /** Bumped by every wholesale draft replacement (New / Open / Discard). */
draftVersion: number;
/** The WebView-state key the dismissal is stored under, one per banner. */
stateKey: string;
diff --git a/src/hooks/useStaleAnalysisDismissal.ts b/src/hooks/useStaleAnalysisDismissal.ts
index 405f4a0f8..00a6a503c 100644
--- a/src/hooks/useStaleAnalysisDismissal.ts
+++ b/src/hooks/useStaleAnalysisDismissal.ts
@@ -6,7 +6,7 @@ import useRecordDismissal from './useRecordDismissal';
type StaleAnalysisDismissalOptions = {
/** The loaded book's stale analyses, `undefined` while no book's are known. */
report: StaleAnalysesReport | undefined;
- /** Bumped by every wholesale draft replacement (New / Open / Wipe). */
+ /** Bumped by every wholesale draft replacement (New / Open / Discard). */
draftVersion: number;
/** Scopes the dismissal to one tab. */
useWebViewState: UseWebViewStateHook;
diff --git a/src/main.ts b/src/main.ts
index 0db89f7aa..ce60c9efc 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -683,9 +683,10 @@ async function getTextReadStatus(sourceProjectId: string): Promise {
/**
* 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. 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.
+ * (auto-save) and whenever the draft is reset (New), opened from a project, or has its changes
+ * discarded. 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.
@@ -1927,14 +1928,14 @@ export async function activate(context: ExecutionActivationContext): Promise {},
{
method: {
summary:
- "Open the wipe dialog to remove the current book's or the whole draft's analysis (handled in the WebView)",
+ 'Open the dialog that discards the unsaved analysis of the current book or the whole draft (handled in the WebView)',
params: [],
result: { name: 'return value', summary: 'void', schema: { type: 'null' } },
},
@@ -1986,7 +1987,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise;
/**
- * Opens the wipe dialog in the Interlinearizer WebView, where the user chooses to remove the
- * currently viewed book's analysis or the entire draft's analysis and confirms. The backend
+ * Opens the discard-changes dialog in the Interlinearizer WebView, where the user chooses to
+ * discard the unsaved analysis of the currently viewed book or of the entire draft. The backend
* registers this command to make it visible to the platform menu system; all logic executes in
* the WebView.
*/
- 'interlinearizer.wipe': () => Promise;
+ 'interlinearizer.openDiscardChangesModal': () => Promise;
/**
* Opens the analysis catalog panel in the Interlinearizer WebView, listing every analysis the
@@ -315,9 +315,9 @@ 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. Analysis for a book whose stored analysis could not be read is left out of the write,
- * and the user is told which books.
+ * every edit (auto-save) and whenever the draft is reset (New), opened from a project, or has
+ * its changes discarded. 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.
diff --git a/src/utils/analysis-book.ts b/src/utils/analysis-book.ts
index 1d69678be..6c9af024d 100644
--- a/src/utils/analysis-book.ts
+++ b/src/utils/analysis-book.ts
@@ -124,8 +124,8 @@ function collectPayloads(
*
* A record is dropped when the token or segment it is attached to belongs to the book, and a phrase
* when its token run does — a run lies within one book, so its first token decides for the whole. A
- * payload the wipe leaves unreferenced is dropped with it, so no orphans remain; one that no link
- * referenced beforehand belongs to no book and survives.
+ * payload that the removal leaves unreferenced is dropped with it, so no orphans remain; one that
+ * no link referenced beforehand belongs to no book and survives.
*/
export function removeBookFromAnalysis(analysis: TextAnalysis, bookCode: string): TextAnalysis {
const tokenAnalysisLinks = analysis.tokenAnalysisLinks.filter(
@@ -164,10 +164,24 @@ export function removeBookFromAnalysis(analysis: TextAnalysis, bookCode: string)
}
/**
- * Returns a copy of the delta with every boundary anchor belonging to the book removed, so wiping a
- * book also drops its custom segment boundaries. The result is `undefined` when the delta is absent
- * or the removal empties it, so segmentation collapses back to the default rather than persisting
- * empty arrays.
+ * Returns a copy of the analysis, unmutated, with the book's records replaced by those that
+ * `source` holds for it, if any. A payload that the book shares with another book keeps the
+ * analysis's version.
+ */
+export function restoreBookInAnalysis(
+ analysis: TextAnalysis,
+ source: TextAnalysis | undefined,
+ bookCode: string,
+): TextAnalysis {
+ const remaining = removeBookFromAnalysis(analysis, bookCode);
+ const restored = source && splitAnalysisByBook(source).get(bookCode);
+ return restored ? mergeAnalyses([remaining, restored]) : remaining;
+}
+
+/**
+ * Returns a copy of the delta with every boundary anchor belonging to the book removed. The result
+ * is `undefined` when the delta is absent or the removal empties it, so segmentation collapses back
+ * to the default rather than persisting empty arrays.
*/
export function removeBookFromSegmentation(
delta: SegmentationDelta | undefined,
@@ -179,3 +193,25 @@ export function removeBookFromSegmentation(
if (removedVerseStarts.length === 0 && addedStarts.length === 0) return undefined;
return { removedVerseStarts, addedStarts };
}
+
+/**
+ * Returns a copy of the delta with the book's boundary anchors replaced by those that `source`
+ * holds for it, if any, or `undefined` when no anchor remains.
+ */
+export function restoreBookInSegmentation(
+ delta: SegmentationDelta | undefined,
+ source: SegmentationDelta | undefined,
+ bookCode: string,
+): SegmentationDelta | undefined {
+ const kept = removeBookFromSegmentation(delta, bookCode);
+ const removedVerseStarts = [
+ ...(kept?.removedVerseStarts ?? []),
+ ...(source?.removedVerseStarts.filter((ref) => bookOfRef(ref) === bookCode) ?? []),
+ ];
+ const addedStarts = [
+ ...(kept?.addedStarts ?? []),
+ ...(source?.addedStarts.filter((start) => bookOfRef(start.tokenRef) === bookCode) ?? []),
+ ];
+ if (removedVerseStarts.length === 0 && addedStarts.length === 0) return undefined;
+ return { removedVerseStarts, addedStarts };
+}
diff --git a/src/utils/read-openable-project.ts b/src/utils/read-openable-project.ts
new file mode 100644
index 000000000..0dc11ca02
--- /dev/null
+++ b/src/utils/read-openable-project.ts
@@ -0,0 +1,30 @@
+import papi from '@papi/frontend';
+import type { OpenableProject } from '../hooks/useDraftProject';
+import {
+ isInterlinearProjectSummary,
+ isSegmentationDelta,
+ isTextAnalysis,
+} from '../types/type-guards';
+
+/**
+ * Reads the stored interlinear project that `projectId` names, with its analysis and any stored
+ * segment boundaries.
+ *
+ * @returns The project, or `undefined` when it is missing or malformed.
+ * @throws If the `interlinearizer.getProject` command rejects, or its reply is not JSON.
+ */
+export async function readOpenableProject(projectId: string): Promise {
+ const json = await papi.commands.sendCommand('interlinearizer.getProject', projectId);
+ const parsed: unknown = json ? JSON.parse(json) : undefined;
+ if (!isInterlinearProjectSummary(parsed)) return undefined;
+ const analysis = 'analysis' in parsed ? parsed.analysis : undefined;
+ const segmentation = 'segmentation' in parsed ? parsed.segmentation : undefined;
+ if (!isTextAnalysis(analysis)) return undefined;
+ if (segmentation !== undefined && !isSegmentationDelta(segmentation)) return undefined;
+ return {
+ analysisLanguages: parsed.analysisLanguages,
+ ...(parsed.targetProjectId !== undefined && { targetProjectId: parsed.targetProjectId }),
+ ...(segmentation !== undefined && { segmentation }),
+ analysis,
+ };
+}
diff --git a/user-questions.md b/user-questions.md
index 4827f4caf..51070c73e 100644
--- a/user-questions.md
+++ b/user-questions.md
@@ -12,16 +12,17 @@ Explicit questions and/or clear list of options. Use multiple questions as neede
List of relevant GitHub issues/prs/commits.
-## Draft project, Save / Save As, and Wipe
+## Draft project, Save / Save As, and Discard Unsaved Changes
The Interlinearizer now keeps an always-present **draft** per source project that auto-saves every
edit (so work is never lost), decoupled from the user's saved projects. Editing no longer writes to a
project automatically; instead the user explicitly **Save**s (writes the draft to the active project)
or **Save As**es (new project, or overwrite an existing one). **New** starts an empty draft (a project
is only created on Save As), and **Open** loads a project into the draft as a working copy. Users can
-**Wipe** the draft via a single **Wipe…** dialog where they choose the scope — the whole draft or just
-the current book. The tab title shows a `●` marker while the draft has unsaved changes (Platform.Bible
-exposes no native "unsaved" tab indicator).
+discard the draft's unsaved changes via a single **Discard Unsaved Changes…** dialog where they choose
+the scope — the whole draft or just the current book. Discarding returns that scope to the active
+project's saved analysis, or empties it when no project is active. The tab title shows a `●` marker
+while the draft has unsaved changes (Platform.Bible exposes no native "unsaved" tab indicator).
Decisions made during development that we'd like reviewed:
@@ -33,9 +34,10 @@ Decisions made during development that we'd like reviewed:
changes shows a two-button confirm (Discard / Cancel). Should it instead offer a three-way choice
("Save As first" / Discard / Cancel) so the in-progress draft can be kept?
-3. **"Wipe book" scope.** The Wipe dialog's "Current book" option targets the book currently in view.
- A future option — a picker of the books that have draft analysis — is the likely direction but is
- not yet confirmed; "current book only" is a deliberate interim choice to keep this PR scoped.
+3. **"Current book" discard scope.** The Discard Unsaved Changes dialog's "Current book" option
+ targets the book currently in view. A future option — a picker of the books that have draft
+ analysis — is the likely direction but is not yet confirmed; "current book only" is a deliberate
+ interim choice to keep this PR scoped.
4. **Unsaved indicator + Save feedback.** The unsaved state is shown as a `●` appended to the tab
title. Options: a different glyph, swapping the tab icon to a "modified" badge, and/or whether a
@@ -57,13 +59,7 @@ Decisions made during development that we'd like reviewed:
blur — only the indicator is eager). Is reflecting in-progress typing as "unsaved" the right
behavior, or should the marker wait until an edit is actually committed?
-8. **Wipe and the unsaved indicator.** Wiping the **entire draft** is treated as a clean baseline: it
- clears the `●` marker (the empty draft is not flagged as unsaved) while keeping the active project
- as the Save target, so a subsequent Save still writes the (now empty) draft to it. Wiping the
- **current book** stays flagged as unsaved, since it is a partial edit the user will usually want to
- save. Is this split right, or should both wipes behave the same?
-
-9. **Save As → Overwrite and the target's metadata.** "Save As → Overwrite an existing project"
+8. **Save As → Overwrite and the target's metadata.** "Save As → Overwrite an existing project"
replaces that project's analysis with the draft's. The draft's _config_ (analysis languages and
alignment target) can differ from the chosen project's — e.g. you Open project A (languages
`[en]`), then Save As → Overwrite project B (languages `[fr]`). We currently push the draft's
@@ -80,14 +76,13 @@ Decisions made during development that we'd like reviewed:
- Should the Overwrite confirmation surface when the draft's languages differ from the target's,
so the user is aware their language tags are about to change?
-10. **Single "Wipe…" menu item with a scope picker.** The two former menu items ("Wipe Current Book…"
- and "Wipe Draft…") were collapsed into one **Wipe…** item that opens a dialog where the user picks
- the scope — current book or entire draft — then confirms. The dialog defaults to "Current book"
- (the less destructive option) and disables that option when no book is loaded. Alternative: keep
- two separate menu items (each a single click, no scope step). Current choice: one menu item plus a
- scope-picker dialog.
+9. **Single "Discard Unsaved Changes…" menu item with a scope picker.** One **Discard Unsaved
+ Changes…** item opens a dialog where the user picks the scope — current book or entire draft —
+ then confirms. The dialog defaults to "Current book" (the less destructive option) and disables
+ that option when no book is loaded. Alternative: two separate menu items (each a single click, no
+ scope step). Current choice: one menu item plus a scope-picker dialog.
-11. **In-progress typing guards a project swap.** Switching projects (New / Open) shows the discard
+10. **In-progress typing guards a project swap.** Switching projects (New / Open) shows the discard
confirm (item 2) whenever the draft has unsaved changes. This now also fires when a gloss field
holds **uncommitted** text — the same eager signal that lights the `●` marker (item 7) — not only
after that text has committed on blur. Previously the guard checked committed changes only, so