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
+22
View File
@@ -43,6 +43,10 @@ var (
nlbAccount = regexp.MustCompile(`^SI\d{2}(?:\s?\d{4}){3}\s?\d{3}$`)
)
// nlbStatementDate is the issue date in an izpisek's header. It names the
// statement: NLB's downloads are not named for what they hold.
var nlbStatementDate = regexp.MustCompile(`Datum izpiska\s+(\d{2})\.(\d{2})\.(\d{4})`)
// nlbMinContinuationIndent is the shallowest indent a wrapped description line
// may have. The real threshold is the description column of the transaction
// the line belongs to, measured per line rather than hardcoded: pdftotext
@@ -61,6 +65,24 @@ func (p *nlbParser) Parse(path string, acc *config.Account) ([]RawTxn, error) {
return parseNLBText(text, p.digits)
}
// StatementName names an izpisek after its statement date, izpisek_YYYY_MM_DD,
// so the folder sorts by date and two downloads of one statement collide.
func (p *nlbParser) StatementName(path string) (string, error) {
text, err := pdfToText(path)
if err != nil {
return "", err
}
return nlbStatementName(text), nil
}
func nlbStatementName(text string) string {
m := nlbStatementDate.FindStringSubmatch(text)
if m == nil {
return ""
}
return fmt.Sprintf("izpisek_%s_%s_%s", m[3], m[2], m[1])
}
// parseNLBText holds the whole parser, separated from PDF extraction so it can
// be tested against captured pdftotext output.
func parseNLBText(text string, digits int) ([]RawTxn, error) {
+12
View File
@@ -112,3 +112,15 @@ func TestParseNLBRejectsMalformedLine(t *testing.T) {
t.Error("expected an error for a transaction line with no amount")
}
}
// An izpisek is named after its statement date, as rename_izpiski.py did.
func TestNLBStatementName(t *testing.T) {
header := " IZPISEK 001/2026\n" +
" Datum izpiska 31.01.2026 Stran 1\n"
if got := nlbStatementName(header + nlbPage); got != "izpisek_2026_01_31" {
t.Errorf("name = %q, want izpisek_2026_01_31", got)
}
if got := nlbStatementName(nlbPage); got != "" {
t.Errorf("name without a statement date = %q, want none", got)
}
}
+11
View File
@@ -43,6 +43,17 @@ type Warner interface {
Warnings() []string
}
// Namer is an optional interface for parsers whose bank names its downloads
// unhelpfully. StatementName reads the statement at path and returns the name,
// without extension, it should be stored under — the upload's extension is
// kept, lowercased — or "" when the statement does not say, in which case it
// keeps the name it came with. Only an upload uses
// it; files already in a folder are never renamed, since the index records
// them by path.
type Namer interface {
StatementName(path string) (string, error)
}
// Factory builds a parser from an account's config, validating it up front so
// a bad account.toml fails before any file is read.
type Factory func(acc *config.Account) (Parser, error)