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