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:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user