From d9954bd0924fd43ff92eb09e9b729cf75d018c4c Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Thu, 16 Jul 2026 14:29:37 -0700 Subject: [PATCH] csv import: style the file input, add cancel-draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style the native file input to match the app's fields and buttons via ::file-selector-button. Cancel discards an unfinished (draft) import and hard-deletes its archived bytes, from either the import list or the wizard. Restricted to drafts so it can never hard-delete a committed import's record — that stays soft, via undo. A draft owns no transactions, so the row deletes cleanly. Co-Authored-By: Claude Opus 4.8 --- src/lib/server/services/imports.test.ts | 35 +++++++++++++ src/lib/server/services/imports.ts | 17 +++++++ .../(app)/settings/import/+page.server.ts | 20 +++++++- src/routes/(app)/settings/import/+page.svelte | 49 +++++++++++++++++++ .../settings/import/[id]/+page.server.ts | 12 ++++- .../(app)/settings/import/[id]/+page.svelte | 42 +++++++++++++++- 6 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/lib/server/services/imports.test.ts b/src/lib/server/services/imports.test.ts index 020440f..d9945f8 100644 --- a/src/lib/server/services/imports.test.ts +++ b/src/lib/server/services/imports.test.ts @@ -3,6 +3,7 @@ import { openDatabase } from '../db.ts'; import { commitImport, createDraftImport, + deleteDraftImport, findPotentialDuplicates, previewImport, saveDecisions, @@ -463,6 +464,40 @@ Deno.test('undo then re-import with the same mapping revives rather than duplica db.close(); }); +Deno.test('cancelling a draft deletes it and its archived bytes', () => { + const db = testDb(); + const id = createDraftImport(db, 'chk', 'scratch.csv', 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + deleteDraftImport(db, id); + + const row = db.prepare('SELECT id FROM imports WHERE id = ?').get(id); + if (row) throw new Error('the draft row and its payload should be gone'); + db.close(); +}); + +Deno.test('cancel refuses a committed import so history is never hard-deleted', () => { + const db = testDb(); + const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); + commitImport(db, id); + + let threw = false; + try { + deleteDraftImport(db, id); + } catch { + threw = true; + } + if (!threw) throw new Error('a committed import must not be hard-deletable'); + // It survives, and its rows with it — undo is the only removal path. + const still = db.prepare('SELECT status FROM imports WHERE id = ?').get(id) as { status: string }; + if (still.status !== 'committed') throw new Error('the committed import must be untouched'); + if ( + (db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE import_id = ?").get(id) as { n: number }) + .n !== 1 + ) { + throw new Error('the committed import kept its transaction'); + } + db.close(); +}); + Deno.test('only a committed import can be undone', () => { const db = testDb(); const id = draft(db, 'Date,Description,Amount\n2026-06-01,Shell,-40.00\n'); diff --git a/src/lib/server/services/imports.ts b/src/lib/server/services/imports.ts index d5f9c54..458681c 100644 --- a/src/lib/server/services/imports.ts +++ b/src/lib/server/services/imports.ts @@ -390,3 +390,20 @@ export function undoImport(db: DatabaseSync, importId: number): number { throw err; } } + +/** + * Discard an unfinished import, archived bytes and all. A draft was never + * committed and owns no transactions, so unlike undo (which preserves a committed + * import as history) there is nothing to keep — this is the "cancel and delete the + * file" the user asked for. Restricted to drafts precisely so it can never become + * a path to hard-deleting a committed import's record; that stays soft, via undo. + */ +export function deleteDraftImport(db: DatabaseSync, importId: number): void { + const record = getImport(db, importId); + if (!record) throw new Error(`Unknown import: ${importId}`); + if (record.status !== 'draft') { + throw new Error(`Only an unfinished import can be cancelled; ${importId} is ${record.status}.`); + } + // A draft has no transactions referencing it, so the row deletes cleanly. + db.prepare('DELETE FROM imports WHERE id = ? AND status = ?').run(importId, 'draft'); +} diff --git a/src/routes/(app)/settings/import/+page.server.ts b/src/routes/(app)/settings/import/+page.server.ts index 1a04d11..ac580f8 100644 --- a/src/routes/(app)/settings/import/+page.server.ts +++ b/src/routes/(app)/settings/import/+page.server.ts @@ -1,9 +1,14 @@ import { fail, redirect } from '@sveltejs/kit'; import { getDb } from '$lib/server/db'; import { listAccounts } from '$lib/server/services/accounts'; -import { createDraftImport, listImports, undoImport } from '$lib/server/services/imports'; +import { + createDraftImport, + deleteDraftImport, + listImports, + saveMapping, + undoImport +} from '$lib/server/services/imports'; import { detectMapping, parseCsv } from '$lib/server/services/csv-import'; -import { saveMapping } from '$lib/server/services/imports'; import type { Actions, PageServerLoad } from './$types'; /** Guards against a stray upload of something enormous or binary. */ @@ -74,5 +79,16 @@ export const actions: Actions = { } catch (err) { return fail(400, { message: err instanceof Error ? err.message : 'Undo failed.' }); } + }, + + cancel: async ({ request }) => { + const form = await request.formData(); + const importId = Number(form.get('importId')); + try { + deleteDraftImport(getDb(), importId); + return { message: 'Discarded the unfinished import and its file.' }; + } catch (err) { + return fail(400, { message: err instanceof Error ? err.message : 'Could not cancel.' }); + } } }; diff --git a/src/routes/(app)/settings/import/+page.svelte b/src/routes/(app)/settings/import/+page.svelte index f86ea2f..e34f794 100644 --- a/src/routes/(app)/settings/import/+page.svelte +++ b/src/routes/(app)/settings/import/+page.svelte @@ -66,6 +66,20 @@
{#if record.status === 'draft'} Resume +
{ + if (!confirm('Discard this unfinished import and delete its uploaded file?')) { + e.preventDefault(); + } + }} + > + + +
{:else if record.status === 'committed'}
{ + try { + deleteDraftImport(getDb(), Number(params.id)); + } catch (err) { + return fail(400, { message: err instanceof Error ? err.message : 'Could not cancel.' }); + } + redirect(303, '/settings/import'); + }, + commit: async ({ request, params }) => { const form = await request.formData(); const db = getDb(); diff --git a/src/routes/(app)/settings/import/[id]/+page.svelte b/src/routes/(app)/settings/import/[id]/+page.svelte index 9338e1f..b8a5cb7 100644 --- a/src/routes/(app)/settings/import/[id]/+page.svelte +++ b/src/routes/(app)/settings/import/[id]/+page.svelte @@ -18,7 +18,24 @@

Import CSV

-

← All imports

+
+ ← All imports + {#if data.status === 'draft'} + { + if (!confirm('Discard this unfinished import and delete its uploaded file?')) { + e.preventDefault(); + } + }} + > + + + {/if} +
{#if data.status !== 'draft'}
@@ -257,6 +274,29 @@ .crumb { margin-top: var(--space-4); font-size: var(--text-xs); + display: flex; + align-items: baseline; + gap: var(--space-3); + } + + .inline { + display: inline; + } + + /* A destructive-but-safe action styled as a quiet link, not a button. */ + .linkish { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--text-muted); + cursor: pointer; + } + + .linkish:hover { + color: var(--text); + background: none; + text-decoration: underline; } .hint { -- 2.51.2