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:
+227
-68
@@ -27,6 +27,7 @@ import (
|
||||
"git.petrovv.com/nikola/money/internal/glob"
|
||||
"git.petrovv.com/nikola/money/internal/importer"
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
"git.petrovv.com/nikola/money/internal/parser"
|
||||
"git.petrovv.com/nikola/money/internal/report"
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
@@ -91,6 +92,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/upload", s.write(s.upload))
|
||||
mux.HandleFunc("GET /api/files", s.read(s.fileList))
|
||||
mux.HandleFunc("GET /api/files/{account}/{name}", s.serveFile)
|
||||
mux.HandleFunc("POST /api/files/delete", s.write(s.removeFile))
|
||||
mux.HandleFunc("POST /api/retag", s.write(s.retag))
|
||||
return mux
|
||||
}
|
||||
@@ -1211,26 +1213,22 @@ func (s *Server) runImport(r *http.Request) (any, error) {
|
||||
if err := decode(r, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.importAll(req.Force)
|
||||
}
|
||||
|
||||
func (s *Server) importAll(force bool) (importJSON, error) {
|
||||
accounts, err := config.LoadAccounts(s.root)
|
||||
if err != nil {
|
||||
return importJSON{}, err
|
||||
return nil, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return importJSON{}, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
||||
return nil, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
||||
s.root, config.AccountFile)
|
||||
}
|
||||
if err := s.reloadRules(); err != nil {
|
||||
return importJSON{}, err
|
||||
return nil, err
|
||||
}
|
||||
s.accounts = accounts
|
||||
|
||||
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: force})
|
||||
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: req.Force})
|
||||
if err != nil {
|
||||
return importJSON{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, added, skipped := res.Total()
|
||||
@@ -1269,10 +1267,11 @@ type uploadReq struct {
|
||||
Files []uploadFile `json:"files"`
|
||||
}
|
||||
|
||||
// upload saves statements into an account folder and imports them. The files
|
||||
// on disk are the source of truth and the index is derived from them, so an
|
||||
// upload is nothing more than putting a file where `money import` looks; the
|
||||
// import that follows is the one the Import button runs.
|
||||
// upload saves statements into an account folder. The files on disk are the
|
||||
// source of truth and the index is derived from them, so an upload is nothing
|
||||
// more than putting a file where `money import` looks. It does not import:
|
||||
// that stays the user's call, made with the Import button, so a batch can be
|
||||
// put together and checked on the statements list before it is read.
|
||||
//
|
||||
// Files arrive base64 inside JSON rather than as multipart: a multipart body
|
||||
// is exactly what a cross-site form can send, and the JSON-only rule is the
|
||||
@@ -1300,60 +1299,133 @@ func (s *Server) upload(r *http.Request) (any, error) {
|
||||
req.Account, s.root, config.AccountFile)
|
||||
}
|
||||
|
||||
// Everything is checked before anything is written, so a bad file in a
|
||||
// batch leaves the folder as it was rather than half uploaded.
|
||||
var write []uploadFile
|
||||
var same []string
|
||||
seen := map[string]bool{}
|
||||
for _, f := range req.Files {
|
||||
if err := checkStatementName(acc, f.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return nil, badRequest("%s is in the upload twice", f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
existing, err := os.ReadFile(filepath.Join(acc.Dir, f.Name))
|
||||
switch {
|
||||
case err == nil && bytes.Equal(existing, f.Data):
|
||||
same = append(same, f.Name)
|
||||
case err == nil:
|
||||
// A statement is the source of truth for what it already
|
||||
// imported; replacing it under the same name is not an upload's
|
||||
// decision to make.
|
||||
return nil, &apiError{http.StatusConflict, fmt.Sprintf(
|
||||
"%s/%s already exists with different contents; rename the file or remove the old one first",
|
||||
acc.Slug, f.Name)}
|
||||
case !errors.Is(err, fs.ErrNotExist):
|
||||
return nil, err
|
||||
default:
|
||||
write = append(write, f)
|
||||
}
|
||||
p, err := parser.For(acc)
|
||||
if err != nil {
|
||||
return nil, badRequest("%v", err)
|
||||
}
|
||||
for _, f := range write {
|
||||
if err := writeStatement(acc.Dir, f); err != nil {
|
||||
namer, _ := p.(parser.Namer)
|
||||
|
||||
// Each file is staged as a dotfile — invisible to import — so a parser
|
||||
// that names its statements can read it. Everything is then checked
|
||||
// before anything is moved into place, so a bad file in a batch leaves
|
||||
// the folder as it was rather than half uploaded.
|
||||
type staged struct {
|
||||
orig, name, tmp string
|
||||
data []byte
|
||||
named bool // the parser chose name, from the statement
|
||||
}
|
||||
var batch []*staged
|
||||
defer func() {
|
||||
for _, f := range batch {
|
||||
if f.tmp != "" {
|
||||
os.Remove(f.tmp)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, f := range req.Files {
|
||||
if err := checkPlainName(f.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmp, err := stageStatement(acc.Dir, f.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := &staged{orig: f.Name, name: f.Name, tmp: tmp, data: f.Data}
|
||||
batch = append(batch, st)
|
||||
if namer != nil {
|
||||
stem, err := namer.StatementName(tmp)
|
||||
if err != nil {
|
||||
return nil, badRequest("%s: %v", f.Name, err)
|
||||
}
|
||||
if stem != "" {
|
||||
st.name, st.named = stem+strings.ToLower(filepath.Ext(f.Name)), true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out, err := s.importAll(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var write []*staged
|
||||
var same, renamed []string
|
||||
claimed := map[string][]byte{}
|
||||
for _, f := range batch {
|
||||
base, n := f.name, 2
|
||||
resolve:
|
||||
for {
|
||||
if err := checkStatementName(acc, f.name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, inBatch := claimed[f.name]
|
||||
var err error
|
||||
if !inBatch {
|
||||
existing, err = os.ReadFile(filepath.Join(acc.Dir, f.name))
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !inBatch && err != nil: // free
|
||||
claimed[f.name] = f.data
|
||||
write = append(write, f)
|
||||
break resolve
|
||||
case bytes.Equal(existing, f.data):
|
||||
same = append(same, f.orig)
|
||||
break resolve
|
||||
case f.named:
|
||||
// A name the parser chose can be taken by a different
|
||||
// statement of the same date — a reissue — so it is
|
||||
// numbered, as rename_izpiski.py did, rather than refused.
|
||||
f.name = fmt.Sprintf("%s_%d%s", strings.TrimSuffix(base, filepath.Ext(base)), n, filepath.Ext(base))
|
||||
n++
|
||||
case inBatch:
|
||||
return nil, badRequest("%s is in the upload twice", f.orig)
|
||||
default:
|
||||
// A statement is the source of truth for what it already
|
||||
// imported; replacing it under the same name is not an
|
||||
// upload's decision to make.
|
||||
return nil, &apiError{http.StatusConflict, fmt.Sprintf(
|
||||
"%s/%s already exists with different contents; rename the file or remove the old one first",
|
||||
acc.Slug, f.name)}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range write {
|
||||
path := filepath.Join(acc.Dir, f.name)
|
||||
if err := os.Rename(f.tmp, path); err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
f.tmp = ""
|
||||
if f.name != f.orig {
|
||||
renamed = append(renamed, f.orig+" → "+f.name)
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("uploaded %d file(s) to %s", len(write), acc.Slug)
|
||||
if len(same) > 0 {
|
||||
msg += fmt.Sprintf(" (%d already there)", len(same))
|
||||
}
|
||||
out.Status = msg + " · " + out.Status
|
||||
return out, nil
|
||||
if len(renamed) > 0 {
|
||||
msg += ", named by statement date: " + strings.Join(renamed, ", ")
|
||||
}
|
||||
if len(write) > 0 {
|
||||
msg += " · press Import to read them"
|
||||
}
|
||||
return status{msg}, nil
|
||||
}
|
||||
|
||||
// checkPlainName refuses anything that is not a plain file name, before the
|
||||
// file is so much as staged.
|
||||
func checkPlainName(name string) error {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\\x00") ||
|
||||
name == "." || name == ".." {
|
||||
return badRequest("%q is not a plain file name", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkStatementName refuses any name the importer would not read back as a
|
||||
// statement of this account, and anything that is not a plain file name.
|
||||
func checkStatementName(acc *config.Account, name string) error {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\\x00") ||
|
||||
name == "." || name == ".." {
|
||||
return badRequest("%q is not a plain file name", name)
|
||||
if err := checkPlainName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(name, ".") || name == config.AccountFile {
|
||||
return badRequest("%s would be ignored by import (dotfiles and %s are not statements)",
|
||||
@@ -1366,29 +1438,28 @@ func checkStatementName(acc *config.Account, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStatement writes through a dotfile and renames it into place, so a
|
||||
// stageStatement writes an upload to a dotfile in the account folder. Import
|
||||
// skips dotfiles, so it is invisible until renamed into place — and a
|
||||
// concurrent `money import` never reads half a statement.
|
||||
func writeStatement(dir string, f uploadFile) error {
|
||||
path := filepath.Join(dir, f.Name)
|
||||
func stageStatement(dir string, data []byte) (string, error) {
|
||||
tmp, err := os.CreateTemp(dir, ".upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
return "", fmt.Errorf("write to %s: %w", dir, err)
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // a no-op once renamed
|
||||
if _, err := tmp.Write(f.Data); err != nil {
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
os.Remove(tmp.Name())
|
||||
return "", fmt.Errorf("write %s: %w", tmp.Name(), err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
os.Remove(tmp.Name())
|
||||
return "", fmt.Errorf("write %s: %w", tmp.Name(), err)
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
||||
return err
|
||||
os.Remove(tmp.Name())
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
return tmp.Name(), nil
|
||||
}
|
||||
|
||||
type fileRow struct {
|
||||
@@ -1404,7 +1475,11 @@ type fileRow struct {
|
||||
// is rebuilt).
|
||||
Status string `json:"status"`
|
||||
ImportedAt string `json:"importedAt"`
|
||||
Added int `json:"added"`
|
||||
// Rows is how many transactions the statement holds; Added how many of
|
||||
// them it was the first to bring in, which overlap with another statement
|
||||
// can make fewer.
|
||||
Rows int `json:"rows"`
|
||||
Added int `json:"added"`
|
||||
}
|
||||
|
||||
// fileList is every statement on disk next to what the index recorded about
|
||||
@@ -1443,7 +1518,7 @@ func (s *Server) fileList(*http.Request) (any, error) {
|
||||
row.Size, row.Modified = info.Size(), info.ModTime().Format("2006-01-02 15:04")
|
||||
}
|
||||
if rec, ok := byPath[rel]; ok {
|
||||
row.ImportedAt, row.Added, row.Status = rec.ImportedAt, rec.Added, "imported"
|
||||
row.ImportedAt, row.Rows, row.Added, row.Status = rec.ImportedAt, rec.Rows, rec.Added, "imported"
|
||||
if sum, err := importer.Checksum(path); err != nil {
|
||||
return nil, err
|
||||
} else if sum != rec.SHA256 {
|
||||
@@ -1457,7 +1532,7 @@ func (s *Server) fileList(*http.Request) (any, error) {
|
||||
if !onDisk[rec.Path] {
|
||||
out = append(out, fileRow{
|
||||
Account: rec.AccountSlug, Name: filepath.Base(rec.Path), Status: "missing",
|
||||
ImportedAt: rec.ImportedAt, Added: rec.Added,
|
||||
ImportedAt: rec.ImportedAt, Rows: rec.Rows, Added: rec.Added,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1532,6 +1607,90 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
type removeFileReq struct {
|
||||
Account string `json:"account"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// removeFile takes a statement out of the data: the file is deleted and its
|
||||
// transactions leave the index. A file already gone from disk is only
|
||||
// forgotten by the index. The deletion is permanent — the page says so before
|
||||
// asking — so nothing here keeps a copy.
|
||||
//
|
||||
// It does not import. Rows the file shared with an overlapping statement went
|
||||
// with it, and ForgetSourceFile marks the account's other statements so the
|
||||
// next Import re-reads them and restores those rows; until then they show as
|
||||
// changed. Pairing is re-derived here, though: it reads no statement, and a
|
||||
// leg whose partner just went should not stay counted as a transfer.
|
||||
func (s *Server) removeFile(r *http.Request) (any, error) {
|
||||
var req removeFileReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPlainName(req.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := filepath.Join(req.Account, req.Name)
|
||||
|
||||
// The file is found in the account's statement list, never by joining the
|
||||
// request onto a path, exactly as serveFile does.
|
||||
var path string
|
||||
accounts, err := config.LoadAccounts(s.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, acc := range accounts {
|
||||
if acc.Slug != req.Account {
|
||||
continue
|
||||
}
|
||||
paths, err := importer.StatementFiles(acc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range paths {
|
||||
if filepath.Base(p) == req.Name {
|
||||
path = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, fmt.Errorf("remove %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
dropped, err := s.db.ForgetSourceFile(req.Account, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" && dropped == 0 {
|
||||
known, err := s.db.SourceFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.ContainsFunc(known, func(f store.SourceFileInfo) bool { return f.Path == rel }) {
|
||||
return nil, &apiError{http.StatusNotFound, fmt.Sprintf("no statement %s", rel)}
|
||||
}
|
||||
}
|
||||
|
||||
_, unpaired, err := s.links.Link(s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verb := "deleted"
|
||||
if path == "" {
|
||||
verb = "forgot"
|
||||
}
|
||||
msg := fmt.Sprintf("%s %s, %d transaction(s) dropped", verb, rel, dropped)
|
||||
if unpaired > 0 {
|
||||
msg += fmt.Sprintf(" · %d transfer leg(s) unpaired", unpaired)
|
||||
}
|
||||
if dropped > 0 {
|
||||
msg += " · press Import to restore any of them another statement also holds"
|
||||
}
|
||||
return status{msg}, nil
|
||||
}
|
||||
|
||||
// retag is `money retag`: both halves of what rules.toml decides, re-derived
|
||||
// from the file as it is on disk now.
|
||||
func (s *Server) retag(*http.Request) (any, error) {
|
||||
|
||||
Reference in New Issue
Block a user