Name NLB uploads, delete statements, and stop importing on upload

Four changes to statement handling in the web app, made together and
touching the same upload and statements-list code.

Name NLB uploads by statement date. parser.Namer is an optional
interface, like Warner, through which a parser names its statements;
nlb reads the "Datum izpiska" from the izpisek header and names it
izpisek_YYYY_MM_DD, lowercase, extension included -- ported from the
rename_izpiski.py it replaces. Uploads are staged as dotfiles, invisible
to import, so the parser can read them; two downloads of one statement
then meet under one name and the second is recognised as already there,
while a different statement of the same date is numbered _2 as the
script did. Only uploads are named: source_files records statements by
path, so renaming a file already in a folder would orphan its rows.

Delete a statement from the statements list. The file is removed from
disk for good -- the page says so before it asks -- and
store.ForgetSourceFile drops its transactions and their transfer rows.
A row two overlapping statements share is stored once, under the file
imported first, so it goes too; the account's other statements forget
their checksums and show as changed until the next Import re-reads them
and restores it. A file already gone from disk can be forgotten.

Upload and delete no longer import. Importing stays the user's call,
made with the Import button, so a batch can be put together and looked
over first. Delete still re-pairs transfers, which reads no statement.

Show rows and new rows per statement. The list read "0" for a file
whose rows an earlier, overlapping statement already held, which looked
like a file that failed to parse. source_files now records how many
transactions each statement holds, and the list reads "3 rows · 0 new".

This adds a column the code reads, so an index built by an earlier
version fails with "no such column: s.rows": delete index.db and import
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 21:48:29 +02:00
co-authored by Claude Opus 5.5
parent 9ad6c05f9a
commit a5f7541980
12 changed files with 652 additions and 146 deletions
+43 -20
View File
@@ -143,14 +143,14 @@ Anything new that reports a rule must report its file position too.
settles ties: a saved rule cannot displace an equally specific one written by
hand, while a narrower one is meant to take precedence and does.
**The report's period narrows the report and nothing else.** The screen opens
on last month, and `←`/`→` step along `report.Periods` — the named windows,
then the months the index holds. The report and the transaction list share a
scope (account, search, untagged), which `web.filterFrom` reads and
`state.filter` holds in the page; the period is read by the report handler
alone and kept in `state.report`, so opening on last month cannot hide the
rest of the index from the list. Nothing may put the period into that shared
scope, which is exactly what would make it leak. Covered by `TestReportPeriodLeavesTransactionsAlone`.
**The report's period narrows the report and nothing else.** The screen opens on
last month, and `←`/`→` step along `report.Periods` — the named windows, then
the months the index holds. The report and the transaction list share a scope
(account, search, untagged), which `web.filterFrom` reads and `state.filter`
holds in the page; the period is read by the report handler alone and kept in
`state.report`, so opening on last month cannot hide the rest of the index from
the list. Nothing may put the period into that shared scope, which is exactly
what would make it leak. Covered by `TestReportPeriodLeavesTransactionsAlone`.
The axis is built from `store.Months` over the *whole* index, not from the rows
in view, or it would grow and shrink as the account or search filter changed and
@@ -241,13 +241,14 @@ That is why `/api/upload` takes files base64 inside JSON rather than as
multipart: multipart is precisely what a cross-site form can send.
**An upload only puts a file where `money import` looks.** It writes into an
existing account folder and then runs the same import as `/api/import`, so the
statements stay the source of truth and nothing reaches the index any other
way. It never overwrites a statement (identical contents are a no-op, different
ones a 409), refuses any name import would not read back — not a plain file
name, a dotfile, `account.toml`, outside the account's `include` — and checks a
whole batch before writing any of it. Covered by
`TestUploadNeverReplacesAStatement` and `TestUploadRefusesBadNames`.
existing account folder and stops there: importing stays the user's call, made
with the Import button, so the statements stay the source of truth and nothing
reaches the index any other way. It never overwrites a statement (identical
contents are a no-op, different ones a 409), refuses any name import would not
read back — not a plain file name, a dotfile, `account.toml`, outside the
account's `include` — and checks a whole batch before writing any of it. Covered
by `TestUploadSavesWithoutImporting`, `TestUploadNeverReplacesAStatement` and
`TestUploadRefusesBadNames`.
**The statements list is read from the folders, not the index.** `/api/files`
walks `importer.StatementFiles` — the same list import reads — and only then
@@ -255,11 +256,23 @@ looks each file up in `store.SourceFiles`, comparing `importer.Checksum`, so a
file is listed exactly when import would read it and a file the index remembers
but the disk lost shows as `missing` instead of vanishing.
`/api/files/{account}/{name}` serves a file only by finding it in that list,
never by joining the name onto a path. A statement is served from the app's origin, where a script could drive
the API, so nothing is ever rendered as a page: text is `text/plain` under
`CSP: sandbox`, anything not text or PDF is a sandboxed download, and PDFs —
whose viewers refuse a sandbox — open in the browser's own isolated viewer.
Covered by `TestServeFileServesOnlyStatements`.
never by joining the name onto a path. A statement is served from the app's
origin, where a script could drive the API, so nothing is ever rendered as a
page: text is `text/plain` under `CSP: sandbox`, anything not text or PDF is a
sandboxed download, and PDFs — whose viewers refuse a sandbox — open in the
browser's own isolated viewer. Covered by `TestServeFileServesOnlyStatements`.
**Deleting a statement marks its neighbours to be re-read.** `/api/files/delete`
finds the file the same way `serveFile` does and deletes it — permanently, by
the user's choice; the page says so before it asks. `store.ForgetSourceFile`
then drops its transactions and their transfer rows, and `Link` re-pairs, which
reads no statement. It does not import, any more than an upload does. A
transaction two overlapping statements share is stored once, under the file
imported first, so it goes too; `ForgetSourceFile` therefore blanks the
checksums of the account's other statements, which show as `changed` until the
next Import re-reads them and restores what they hold. Without that the checksum
skip would leave those rows gone for good. Covered by
`TestRemoveFileKeepsWhatAnotherStatementHolds`.
## Adding a bank parser
@@ -300,6 +313,16 @@ description — at the end, and not in the position it held on the page, so an
IBAN wrapped across continuation lines stays contiguous for a glob to match.
A new parser must do the same rather than reintroduce a structured field.
**A parser may name its statements, and only uploads use it.** `parser.Namer`
is optional, like `parser.Warner`: `nlb` names an izpisek `izpisek_YYYY_MM_DD`
after its *Datum izpiska*, ported from the `rename_izpiski.py` it replaced.
`/api/upload` stages each file as a dotfile — invisible to import — so the
parser can read it, then numbers a chosen name that is taken by different
contents (`_2`, as the script did) where a name the user gave is refused with
409. Nothing renames a file already in a folder: `source_files` records
statements by path, so a rename would orphan its rows as `missing` until the
index is rebuilt. Covered by `TestUploadNamesStatementsTheParserCanName`.
## Verifying
```