Budget history by month, shared tab navigation, file imported statements into Statements/year/month and stop re-reading them
build-container / build (push) Successful in 40s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 15:52:05 -04:00
1 parent d7808a3925
commit 20317b14c9
7 files changed
+147 -12

No files matched your search

+76 -2
View File
@@ -217,12 +217,32 @@ def parse_capone_csv(path):
return st
_CACHE = {}
_DISK = DATA / ".statement_cache.pkl" # parsed statements survive restarts (re-reading ~30 PDFs takes several seconds)
_CACHE_VERSION = 1
_loaded = False
def _load_cache():
global _loaded
if _loaded: return
_loaded = True
try:
import pickle
v, data = pickle.loads(_DISK.read_bytes())
if v == _CACHE_VERSION: _CACHE.update(data)
except Exception: pass
def _save_cache():
try:
import pickle
tmp = _DISK.with_suffix(".tmp"); tmp.write_bytes(pickle.dumps((_CACHE_VERSION, _CACHE))); tmp.replace(_DISK)
except Exception: pass
def load_statements(folder=DATA):
"""Parse every statement file in folder. Returns (statements, skipped, duplicates)."""
sts, skipped, dups, seen = [], [], [], {}
_load_cache(); parsed_new = False
for p in sorted(Path(folder).glob("*")):
if p.suffix.lower() not in (".pdf", ".csv"): continue
ck = (str(p), p.stat().st_mtime_ns, p.stat().st_size)
ck = (p.name, p.stat().st_mtime_ns, p.stat().st_size)
if ck in _CACHE:
st, err = _CACHE[ck]
if err: skipped.append((p.name, err))
@@ -244,12 +264,16 @@ def load_statements(folder=DATA):
st = parse_capone_csv(p)
except Exception as e:
err = f"could not read: {e}"
_CACHE[ck] = (st, err)
_CACHE[ck] = (st, err); parsed_new = True
if err: skipped.append((p.name, err)); continue
if st is None: continue
key = (st.account, st.end, st.kind)
if key in seen and st.kind == "pdf": dups.append((st.file, seen[key])); continue
seen[key] = st.file; sts.append(st)
if parsed_new:
live = {(p.name, p.stat().st_mtime_ns, p.stat().st_size) for p in Path(folder).glob("*")}
for k in [k for k in _CACHE if k not in live]: del _CACHE[k] # forget deleted / replaced files
_save_cache()
return sts, skipped, dups
# ---------------------------------------------------------------- classification
@@ -603,6 +627,56 @@ def summarize(rows):
return dict(log=sum(r.kind == "log" for r in rows), excluded=sum(r.kind == "excluded" for r in rows),
new_payees=sorted({r.payee for r in rows if r.is_new_payee}))
# ---------------------------------------------------------------- filing statements away
ARCHIVE = DATA / "Statements" # Statements/YYYY/MM/<file>, by statement end date
INDEX = DATA / "statements_index.json" # what each filed statement covered, so coverage needs no re-reading
def _index():
try: return json.loads(INDEX.read_text())
except Exception: return []
def archived_statements():
"""Light Statement objects (no transactions) for everything already filed away."""
out = []
for i in _index():
try: out.append(Statement(i["file"], i["account"], dt.date.fromisoformat(i["start"]) if i.get("start") else None,
dt.date.fromisoformat(i["end"]), kind=i["kind"]))
except Exception: pass
return out
def archive_statements():
"""Once everything read is in the workbook, move the statement files into Statements/YYYY/MM and remember what they covered."""
res = run_preview(DATA)
if res["new"]: raise ValueError("Add the new transactions to the workbook first.")
idx, batch, moved = _index(), f"{dt.datetime.now():%Y%m%d%H%M%S}", 0
for st in res["statements"]:
if st.file in res["bad"]: continue
src = DATA / st.file
if not src.exists(): continue
dest = ARCHIVE / f"{st.end.year}" / f"{st.end.month:02d}"; dest.mkdir(parents=True, exist_ok=True)
target, n = dest / src.name, 1
while target.exists(): target = dest / f"{src.stem}_{n}{src.suffix}"; n += 1
shutil.move(str(src), str(target))
if st.kind == "pdf": idx = [i for i in idx if (i["account"], i["end"], i["kind"]) != (st.account, st.end.isoformat(), st.kind)]
idx.append(dict(file=str(target.relative_to(DATA)), account=st.account, start=st.start.isoformat() if st.start else None,
end=st.end.isoformat(), kind=st.kind, batch=batch)); moved += 1
INDEX.write_text(json.dumps(idx, indent=1))
if moved and res["dups"]: moved += archive_statements()["moved"] # identical copies read as duplicates until the original is filed
return dict(moved=moved)
def unarchive_last():
"""Put the most recently filed batch of statements back in the data folder (used by Undo)."""
idx = _index()
if not idx or not any(i.get("batch") for i in idx): return 0
last = max(i.get("batch", "") for i in idx); n = 0
for i in [i for i in idx if i.get("batch") == last]:
src = DATA / i["file"]
if src.exists():
target = DATA / src.name
if not target.exists(): shutil.move(str(src), str(target)); n += 1
INDEX.write_text(json.dumps([i for i in idx if i.get("batch") != last], indent=1))
return n
def run_preview(folder=DATA):
from openpyxl import load_workbook
sts, skipped, dups = load_statements(folder)