daylogs 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- daylogs/__init__.py +3 -0
- daylogs/__main__.py +160 -0
- daylogs/body.py +255 -0
- daylogs/categories.py +88 -0
- daylogs/claude.py +165 -0
- daylogs/complete.py +72 -0
- daylogs/config.py +151 -0
- daylogs/db.py +124 -0
- daylogs/estimate.py +101 -0
- daylogs/export.py +77 -0
- daylogs/fmt.py +25 -0
- daylogs/horizon.py +215 -0
- daylogs/log.py +34 -0
- daylogs/markup.py +31 -0
- daylogs/money.py +576 -0
- daylogs/moneyview.py +124 -0
- daylogs/parse.py +414 -0
- daylogs/photo.py +107 -0
- daylogs/sigil.py +113 -0
- daylogs/summary.py +260 -0
- daylogs/tui/__init__.py +2 -0
- daylogs/tui/app.py +381 -0
- daylogs/tui/app.tcss +144 -0
- daylogs/tui/body_tab.py +624 -0
- daylogs/tui/chart.py +126 -0
- daylogs/tui/common.py +41 -0
- daylogs/tui/footer.py +150 -0
- daylogs/tui/help.py +63 -0
- daylogs/tui/hints.py +119 -0
- daylogs/tui/keymap.py +149 -0
- daylogs/tui/money_tab.py +596 -0
- daylogs/tui/prompt.py +153 -0
- daylogs/tui/summary_tab.py +229 -0
- daylogs/tui/widgets.py +215 -0
- daylogs/undo.py +22 -0
- daylogs-0.2.0.dist-info/METADATA +446 -0
- daylogs-0.2.0.dist-info/RECORD +41 -0
- daylogs-0.2.0.dist-info/WHEEL +5 -0
- daylogs-0.2.0.dist-info/entry_points.txt +2 -0
- daylogs-0.2.0.dist-info/licenses/LICENSE +21 -0
- daylogs-0.2.0.dist-info/top_level.txt +1 -0
daylogs/__init__.py
ADDED
daylogs/__main__.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Entry point.
|
|
2
|
+
|
|
3
|
+
No arguments launches the TUI. The subcommands exist so the summary and a
|
|
4
|
+
backup can run headless from cron without any code change — and because a
|
|
5
|
+
single-file SQLite database that only exists in one place is one accident away
|
|
6
|
+
from being no database at all.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from daylogs import __version__, claude, export, summary
|
|
18
|
+
from daylogs.config import load_config
|
|
19
|
+
from daylogs.db import connect, ensure_schema
|
|
20
|
+
from daylogs.log import setup_logging
|
|
21
|
+
from daylogs.money import MoneyError, check_date
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="day", description="a personal daily log: weight, food, expenses"
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="store_true", help="print version and exit")
|
|
29
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
30
|
+
|
|
31
|
+
p_sum = sub.add_parser("summary", help="generate the daily summary and print it")
|
|
32
|
+
p_sum.add_argument("--date", help="YYYY-MM-DD; defaults to yesterday")
|
|
33
|
+
|
|
34
|
+
p_bak = sub.add_parser("backup", help="write a consistent copy of the database")
|
|
35
|
+
p_bak.add_argument("dest", help="destination directory")
|
|
36
|
+
|
|
37
|
+
p_exp = sub.add_parser("export", help="write one CSV per table, readable anywhere")
|
|
38
|
+
p_exp.add_argument("dest", help="destination directory")
|
|
39
|
+
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv: list[str] | None = None) -> int:
|
|
44
|
+
parser = build_parser()
|
|
45
|
+
try:
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
except SystemExit as e:
|
|
48
|
+
return int(e.code) if e.code else 0
|
|
49
|
+
|
|
50
|
+
if args.version:
|
|
51
|
+
print(f"daylogs {__version__}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
cfg = load_config()
|
|
55
|
+
if (stale := _legacy_root_to_move(cfg.root)) is not None:
|
|
56
|
+
print(
|
|
57
|
+
f"Your data is still under the old name: {stale}\n"
|
|
58
|
+
f"daylogs was renamed from daybook. Move it, then run again:\n"
|
|
59
|
+
f" mv {stale} {cfg.root}\n"
|
|
60
|
+
f" mv {cfg.root / 'daybook.db'} {cfg.db_path}",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
setup_logging()
|
|
66
|
+
conn = connect(cfg.db_path)
|
|
67
|
+
ensure_schema(conn)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
if args.cmd == "summary":
|
|
71
|
+
return _summary(conn, cfg, args.date)
|
|
72
|
+
if args.cmd == "backup":
|
|
73
|
+
return _backup(conn, Path(args.dest).expanduser())
|
|
74
|
+
if args.cmd == "export":
|
|
75
|
+
return _export(conn, Path(args.dest).expanduser())
|
|
76
|
+
return _tui(conn, cfg)
|
|
77
|
+
finally:
|
|
78
|
+
conn.close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _legacy_root_to_move(root: Path) -> Path | None:
|
|
82
|
+
"""The pre-rename data root, when it holds the only copy of the data.
|
|
83
|
+
|
|
84
|
+
Renaming the project moved the default root from ~/Documents/daybook to
|
|
85
|
+
~/Documents/daylogs. Without this check the first run after upgrading finds
|
|
86
|
+
no database, creates an empty one, and presents a working app with none of
|
|
87
|
+
your history in it — which reads as data loss even though nothing was
|
|
88
|
+
deleted. Refusing to start is the kinder failure, and it can name the exact
|
|
89
|
+
two commands that fix it.
|
|
90
|
+
|
|
91
|
+
Only fires when the new root is genuinely absent, so it costs nothing on a
|
|
92
|
+
fresh install and disappears as soon as the move happens. Deletable once no
|
|
93
|
+
installation predates the rename.
|
|
94
|
+
"""
|
|
95
|
+
legacy = root.parent / "daybook"
|
|
96
|
+
if root.exists() or not (legacy / "daybook.db").is_file():
|
|
97
|
+
return None
|
|
98
|
+
return legacy
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _summary(conn, cfg, date: str | None) -> int:
|
|
102
|
+
target = date or summary.target_date(dt.date.today().isoformat())
|
|
103
|
+
try:
|
|
104
|
+
check_date(target)
|
|
105
|
+
except MoneyError as e:
|
|
106
|
+
print(str(e), file=sys.stderr)
|
|
107
|
+
return 2
|
|
108
|
+
try:
|
|
109
|
+
content = asyncio.run(
|
|
110
|
+
summary.generate(conn, cfg, date=target, runner=claude.run_oneshot_text)
|
|
111
|
+
)
|
|
112
|
+
except Exception as e: # noqa: BLE001 - top-level CLI boundary
|
|
113
|
+
print(str(e), file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
print(content)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _backup(conn, dest: Path) -> int:
|
|
120
|
+
"""VACUUM INTO writes a consistent single-file copy without stopping the
|
|
121
|
+
app — exactly what a cron backup to a synced folder needs."""
|
|
122
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
out = dest / f"daylogs-{dt.date.today().isoformat()}.db"
|
|
124
|
+
out.unlink(missing_ok=True)
|
|
125
|
+
conn.execute("VACUUM INTO ?", (str(out),))
|
|
126
|
+
print(out)
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _export(conn, dest: Path) -> int:
|
|
131
|
+
"""One CSV per table, so the data is readable without daylogs.
|
|
132
|
+
|
|
133
|
+
stdout is *only* the directory, so `cd "$(day export ~/Drive)"` works; the
|
|
134
|
+
per-table counts go to stderr, where a human sees them and a script does not
|
|
135
|
+
have to parse around them.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
written = export.export_csv(conn, dest)
|
|
139
|
+
except OSError as e:
|
|
140
|
+
# A path you cannot write to is a typo, not a crash. `summary` already
|
|
141
|
+
# answers user error with a message and a non-zero exit; printing pathlib's
|
|
142
|
+
# traceback instead would just be the newest command being the rudest.
|
|
143
|
+
print(f"cannot export to {dest}: {e.strerror or e}", file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
counts = export.row_counts(conn)
|
|
146
|
+
for path in written:
|
|
147
|
+
print(f"{path.name:16} {counts[path.stem]:>7} rows", file=sys.stderr)
|
|
148
|
+
print(written[0].parent if written else dest)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _tui(conn, cfg) -> int:
|
|
153
|
+
from daylogs.tui.app import DaylogsApp
|
|
154
|
+
|
|
155
|
+
DaylogsApp(cfg, conn).run()
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
raise SystemExit(main())
|
daylogs/body.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Weight and food: reads, writes, trend windows, and BMR.
|
|
2
|
+
|
|
3
|
+
Every write validates before it touches SQLite, so a bad prompt never
|
|
4
|
+
produces a half-valid row. Deletes return the removed row so the caller can
|
|
5
|
+
push it onto the undo stack.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
15
|
+
_SOURCES = frozenset({"labeled", "estimated"})
|
|
16
|
+
_MAX_KG = 500.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BodyError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_date(date: str) -> str:
|
|
24
|
+
if not _DATE_RE.match(date):
|
|
25
|
+
raise BodyError(f"date {date!r} must be YYYY-MM-DD")
|
|
26
|
+
try:
|
|
27
|
+
dt.date.fromisoformat(date)
|
|
28
|
+
except ValueError as e:
|
|
29
|
+
raise BodyError(f"date {date!r} is not a real date") from e
|
|
30
|
+
return date
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _window_start(end_date: str, days: int) -> str:
|
|
34
|
+
return (dt.date.fromisoformat(end_date) - dt.timedelta(days=days - 1)).isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── weight ───────────────────────────────────────────────────────────────
|
|
38
|
+
def add_weight(conn, *, kg: float, date: str, at: int, note: str | None = None) -> int:
|
|
39
|
+
if not 0 < float(kg) <= _MAX_KG:
|
|
40
|
+
raise BodyError(f"{kg} kg is not a plausible weight")
|
|
41
|
+
cur = conn.execute(
|
|
42
|
+
"INSERT INTO weight (date, measured_at, kg, note) VALUES (?, ?, ?, ?)",
|
|
43
|
+
(_check_date(date), int(at), float(kg), note or None),
|
|
44
|
+
)
|
|
45
|
+
return int(cur.lastrowid)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_weight(conn, *, since: str | None = None, limit: int = 200) -> list[sqlite3.Row]:
|
|
49
|
+
sql = "SELECT * FROM weight"
|
|
50
|
+
args: list = []
|
|
51
|
+
if since:
|
|
52
|
+
sql += " WHERE date >= ?"
|
|
53
|
+
args.append(_check_date(since))
|
|
54
|
+
sql += " ORDER BY date DESC, measured_at DESC LIMIT ?"
|
|
55
|
+
args.append(int(limit))
|
|
56
|
+
return list(conn.execute(sql, args))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def latest_weight(conn, *, on_or_before: str | None = None) -> sqlite3.Row | None:
|
|
60
|
+
sql = "SELECT * FROM weight"
|
|
61
|
+
args: list = []
|
|
62
|
+
if on_or_before:
|
|
63
|
+
sql += " WHERE date <= ?"
|
|
64
|
+
args.append(_check_date(on_or_before))
|
|
65
|
+
sql += " ORDER BY date DESC, measured_at DESC LIMIT 1"
|
|
66
|
+
return conn.execute(sql, args).fetchone()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def weight_series(conn, *, end_date: str, days: int) -> list[tuple[str, float]]:
|
|
70
|
+
"""One point per day in the window, ascending. When a day has several
|
|
71
|
+
readings the latest wins — a morning weigh-in plus a curious evening
|
|
72
|
+
re-check should not become two points."""
|
|
73
|
+
rows = conn.execute(
|
|
74
|
+
"""
|
|
75
|
+
SELECT date, kg FROM weight w
|
|
76
|
+
WHERE date BETWEEN ? AND ?
|
|
77
|
+
AND measured_at = (
|
|
78
|
+
SELECT MAX(measured_at) FROM weight w2 WHERE w2.date = w.date
|
|
79
|
+
)
|
|
80
|
+
GROUP BY date
|
|
81
|
+
ORDER BY date ASC
|
|
82
|
+
""",
|
|
83
|
+
(_window_start(_check_date(end_date), days), end_date),
|
|
84
|
+
).fetchall()
|
|
85
|
+
return [(r["date"], r["kg"]) for r in rows]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def weight_series_between(
|
|
89
|
+
conn, *, start: str | None, end: str
|
|
90
|
+
) -> list[tuple[str, float]]:
|
|
91
|
+
"""One point per day between `start` and `end` inclusive, ascending.
|
|
92
|
+
`start=None` means unbounded. Same last-reading-wins rule as weight_series."""
|
|
93
|
+
sql = """
|
|
94
|
+
SELECT date, kg FROM weight w
|
|
95
|
+
WHERE date <= ?
|
|
96
|
+
AND measured_at = (
|
|
97
|
+
SELECT MAX(measured_at) FROM weight w2 WHERE w2.date = w.date
|
|
98
|
+
)
|
|
99
|
+
"""
|
|
100
|
+
args: list = [_check_date(end)]
|
|
101
|
+
if start is not None:
|
|
102
|
+
sql += " AND date >= ?"
|
|
103
|
+
args.append(_check_date(start))
|
|
104
|
+
sql += " GROUP BY date ORDER BY date ASC"
|
|
105
|
+
return [(r["date"], r["kg"]) for r in conn.execute(sql, args)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def kcal_series_between(conn, *, start: str | None, end: str) -> list[tuple[str, int]]:
|
|
109
|
+
"""Daily calorie totals between `start` and `end`, ascending. Days with no
|
|
110
|
+
entries are absent rather than zero — a logging gap is not a fast."""
|
|
111
|
+
sql = "SELECT date, SUM(kcal) AS total FROM food WHERE date <= ?"
|
|
112
|
+
args: list = [_check_date(end)]
|
|
113
|
+
if start is not None:
|
|
114
|
+
sql += " AND date >= ?"
|
|
115
|
+
args.append(_check_date(start))
|
|
116
|
+
sql += " GROUP BY date ORDER BY date ASC"
|
|
117
|
+
return [(r["date"], int(r["total"])) for r in conn.execute(sql, args)]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def kcal_average(conn, *, start: str | None, end: str) -> int | None:
|
|
121
|
+
"""Mean intake over the days that actually have entries."""
|
|
122
|
+
series = kcal_series_between(conn, start=start, end=end)
|
|
123
|
+
if not series:
|
|
124
|
+
return None
|
|
125
|
+
return round(sum(v for _, v in series) / len(series))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def weight_delta(conn, *, end_date: str, days: int) -> float | None:
|
|
129
|
+
series = weight_series(conn, end_date=end_date, days=days)
|
|
130
|
+
if len(series) < 2:
|
|
131
|
+
return None
|
|
132
|
+
return round(series[-1][1] - series[0][1], 2)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def update_weight(conn, id: int, **fields) -> bool:
|
|
136
|
+
return _update(conn, "weight", id, fields, allowed={"kg", "date", "measured_at", "note"})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def delete_weight(conn, id: int) -> dict | None:
|
|
140
|
+
return _delete(conn, "weight", id)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── food ─────────────────────────────────────────────────────────────────
|
|
144
|
+
def add_food(conn, *, description: str, kcal: int, source: str, date: str, at: int) -> int:
|
|
145
|
+
if source not in _SOURCES:
|
|
146
|
+
raise BodyError(f"source must be one of {sorted(_SOURCES)}")
|
|
147
|
+
if not description.strip():
|
|
148
|
+
raise BodyError("description must be non-empty")
|
|
149
|
+
if int(kcal) < 0:
|
|
150
|
+
raise BodyError("kcal must be >= 0")
|
|
151
|
+
cur = conn.execute(
|
|
152
|
+
"INSERT INTO food (date, ate_at, description, kcal, source) VALUES (?, ?, ?, ?, ?)",
|
|
153
|
+
(_check_date(date), int(at), description.strip(), int(kcal), source),
|
|
154
|
+
)
|
|
155
|
+
return int(cur.lastrowid)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def list_food(conn, *, date: str) -> list[sqlite3.Row]:
|
|
159
|
+
return list(
|
|
160
|
+
conn.execute(
|
|
161
|
+
"SELECT * FROM food WHERE date = ? ORDER BY ate_at ASC, id ASC",
|
|
162
|
+
(_check_date(date),),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def day_kcal(conn, *, date: str) -> int:
|
|
168
|
+
row = conn.execute(
|
|
169
|
+
"SELECT COALESCE(SUM(kcal), 0) AS total FROM food WHERE date = ?",
|
|
170
|
+
(_check_date(date),),
|
|
171
|
+
).fetchone()
|
|
172
|
+
return int(row["total"])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def update_food(conn, id: int, **fields) -> bool:
|
|
176
|
+
if "source" in fields and fields["source"] not in _SOURCES:
|
|
177
|
+
raise BodyError(f"source must be one of {sorted(_SOURCES)}")
|
|
178
|
+
return _update(
|
|
179
|
+
conn,
|
|
180
|
+
"food",
|
|
181
|
+
id,
|
|
182
|
+
fields,
|
|
183
|
+
allowed={"description", "kcal", "source", "date", "ate_at"},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def restamp(at: int, *, date: str, hhmm: str) -> int | None:
|
|
188
|
+
"""The new epoch-second timestamp for a row whose clock time was edited, or
|
|
189
|
+
`None` when the minute did not change.
|
|
190
|
+
|
|
191
|
+
Stored timestamps carry seconds; the grammar's only time token is `HH:MM`. So
|
|
192
|
+
re-deriving the timestamp on every edit would quietly shave the seconds off a
|
|
193
|
+
row whose time nobody touched — and for weight those seconds are the
|
|
194
|
+
tie-breaker `weight_series` uses to pick a day's reading. Returning `None`
|
|
195
|
+
means "leave the column alone", which is what the caller wants far more often
|
|
196
|
+
than a rewrite.
|
|
197
|
+
"""
|
|
198
|
+
current = dt.datetime.fromtimestamp(int(at))
|
|
199
|
+
if current.strftime("%H:%M") == hhmm:
|
|
200
|
+
return None
|
|
201
|
+
hh, mm = (int(part) for part in hhmm.split(":"))
|
|
202
|
+
return int(
|
|
203
|
+
dt.datetime.combine(dt.date.fromisoformat(date), dt.time(hh, mm)).timestamp()
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def delete_food(conn, id: int) -> dict | None:
|
|
208
|
+
return _delete(conn, "food", id)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── BMR ──────────────────────────────────────────────────────────────────
|
|
212
|
+
def age_from_birthday(birthday: str | None, today: str | None = None) -> int | None:
|
|
213
|
+
if not birthday:
|
|
214
|
+
return None
|
|
215
|
+
try:
|
|
216
|
+
b = dt.date.fromisoformat(birthday)
|
|
217
|
+
except ValueError:
|
|
218
|
+
return None
|
|
219
|
+
t = dt.date.fromisoformat(today) if today else dt.date.today()
|
|
220
|
+
return t.year - b.year - ((t.month, t.day) < (b.month, b.day))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def compute_bmr(cfg, kg: float | None, today: str | None = None) -> int | None:
|
|
224
|
+
"""Mifflin-St Jeor. None whenever an input is missing — a calorie total with
|
|
225
|
+
no maintenance baseline is better shown bare than shown against a guess."""
|
|
226
|
+
if kg is None or cfg.height_cm is None or not cfg.sex:
|
|
227
|
+
return None
|
|
228
|
+
age = age_from_birthday(cfg.birthday, today)
|
|
229
|
+
if age is None:
|
|
230
|
+
return None
|
|
231
|
+
base = 10 * float(kg) + 6.25 * float(cfg.height_cm) - 5 * age
|
|
232
|
+
return round(base + (5 if cfg.sex == "male" else -161))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ── shared row helpers ───────────────────────────────────────────────────
|
|
236
|
+
def _update(conn, table: str, id: int, fields: dict, *, allowed: set[str]) -> bool:
|
|
237
|
+
fields = {k: v for k, v in fields.items() if v is not None}
|
|
238
|
+
unknown = set(fields) - allowed
|
|
239
|
+
if unknown:
|
|
240
|
+
raise BodyError(f"cannot update {sorted(unknown)} on {table}")
|
|
241
|
+
if not fields:
|
|
242
|
+
return False
|
|
243
|
+
if "date" in fields:
|
|
244
|
+
_check_date(fields["date"])
|
|
245
|
+
sets = ", ".join(f"{k} = ?" for k in fields)
|
|
246
|
+
cur = conn.execute(f"UPDATE {table} SET {sets} WHERE id = ?", (*fields.values(), int(id)))
|
|
247
|
+
return cur.rowcount > 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _delete(conn, table: str, id: int) -> dict | None:
|
|
251
|
+
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (int(id),)).fetchone()
|
|
252
|
+
if row is None:
|
|
253
|
+
return None
|
|
254
|
+
conn.execute(f"DELETE FROM {table} WHERE id = ?", (int(id),))
|
|
255
|
+
return dict(row)
|
daylogs/categories.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Expense categories: a constant tuple, extensible from config.toml.
|
|
2
|
+
|
|
3
|
+
A table with CRUD endpoints, a colour column and a sort order is the obvious
|
|
4
|
+
design and the wrong one here: ten rows that change twice a year do not need a
|
|
5
|
+
table, three foreign keys and a service. There is deliberately no `employment`
|
|
6
|
+
slug — it would only ever classify income, which daylogs does not track.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
FALLBACK_SLUG = "other"
|
|
14
|
+
|
|
15
|
+
# Vivid warm-earth palette, also used for the good/bad signals in the UI.
|
|
16
|
+
# Each hue's HSV saturation multiplied by 1.55 and value by 1.04, clamped to 1.0.
|
|
17
|
+
PALETTE: tuple[str, ...] = (
|
|
18
|
+
"#5f7bbe",
|
|
19
|
+
"#9d81b8",
|
|
20
|
+
"#63af7b",
|
|
21
|
+
"#dc9142",
|
|
22
|
+
"#cc5131",
|
|
23
|
+
"#67acb9",
|
|
24
|
+
"#bf8772",
|
|
25
|
+
"#8d919f",
|
|
26
|
+
"#88ba68",
|
|
27
|
+
"#aaa095",
|
|
28
|
+
"#b7607d",
|
|
29
|
+
"#9ea64c",
|
|
30
|
+
"#cf8626",
|
|
31
|
+
"#78a7ae",
|
|
32
|
+
"#aa6941",
|
|
33
|
+
"#739b8e",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Category:
|
|
39
|
+
slug: str
|
|
40
|
+
display: str
|
|
41
|
+
color: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
BUILTIN: tuple[Category, ...] = (
|
|
45
|
+
Category("grocery", "Grocery", "#dc9142"),
|
|
46
|
+
Category("restaurant", "Restaurant", "#cc5131"),
|
|
47
|
+
Category("transport", "Transport", "#5f7bbe"),
|
|
48
|
+
Category("housing", "Housing", "#9d81b8"),
|
|
49
|
+
Category("utilities", "Utilities", "#8d919f"),
|
|
50
|
+
Category("subscriptions", "Subscriptions", "#67acb9"),
|
|
51
|
+
Category("entertainment", "Entertainment", "#bf8772"),
|
|
52
|
+
Category("education", "Education", "#63af7b"),
|
|
53
|
+
Category(FALLBACK_SLUG, "Other", "#aaa095"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def auto_color(slug: str) -> str:
|
|
58
|
+
"""FNV-1a 32-bit hash into PALETTE. The same slug always yields the same
|
|
59
|
+
colour, so a config-added category looks stable across machines."""
|
|
60
|
+
h = 2166136261
|
|
61
|
+
for ch in slug.encode("utf-8"):
|
|
62
|
+
h ^= ch
|
|
63
|
+
h = (h * 16777619) & 0xFFFFFFFF
|
|
64
|
+
return PALETTE[h % len(PALETTE)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def all_categories(cfg=None) -> tuple[Category, ...]:
|
|
68
|
+
"""Built-ins plus anything config.toml adds. Config cannot shadow a
|
|
69
|
+
built-in — a typo in config should never silently redefine `grocery`."""
|
|
70
|
+
extra = getattr(cfg, "extra_categories", ()) or ()
|
|
71
|
+
builtin_slugs = {c.slug for c in BUILTIN}
|
|
72
|
+
added = tuple(
|
|
73
|
+
Category(slug, display or slug, color or auto_color(slug))
|
|
74
|
+
for slug, display, color in extra
|
|
75
|
+
if slug not in builtin_slugs
|
|
76
|
+
)
|
|
77
|
+
return BUILTIN + added
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def slugs(cfg=None) -> frozenset[str]:
|
|
81
|
+
return frozenset(c.slug for c in all_categories(cfg))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get(slug: str, cfg=None) -> Category | None:
|
|
85
|
+
for c in all_categories(cfg):
|
|
86
|
+
if c.slug == slug:
|
|
87
|
+
return c
|
|
88
|
+
return None
|
daylogs/claude.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Subprocess wrappers for `claude -p`.
|
|
2
|
+
|
|
3
|
+
Three call shapes: plain text (the daily summary), structured JSON from text
|
|
4
|
+
(a calorie estimate from a description), and structured JSON from an image (a
|
|
5
|
+
calorie estimate from a photo). One error type for all of them, because a
|
|
6
|
+
caller forced to distinguish "binary missing" from "timed out" from "bad
|
|
7
|
+
JSON" is a caller that will forget one of them.
|
|
8
|
+
|
|
9
|
+
Every invocation passes --no-session-persistence, so daylogs never pollutes
|
|
10
|
+
~/.claude/projects/. Runs under a Max OAuth subscription — no per-token API
|
|
11
|
+
charge.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os.path
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
CLAUDE_BIN = "claude"
|
|
24
|
+
_STDERR_TAIL = 500
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClaudeError(RuntimeError):
|
|
28
|
+
"""Missing binary, timeout, non-zero exit, or unparseable output."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _kill(proc) -> None:
|
|
32
|
+
"""Stop the child and reap it. Safe to call on one that has already exited.
|
|
33
|
+
|
|
34
|
+
The `await proc.wait()` makes the reap synchronous rather than eventual. asyncio's
|
|
35
|
+
child watcher does collect the corpse on its own, but measured at roughly 50 ms
|
|
36
|
+
later — so without the wait, the process is still there the instant the call it
|
|
37
|
+
belonged to has finished unwinding, and a zombie still answers `os.kill(pid, 0)`.
|
|
38
|
+
A test asserts the pid is gone immediately, with no polling, because polling would
|
|
39
|
+
pass either way.
|
|
40
|
+
"""
|
|
41
|
+
if proc.returncode is None:
|
|
42
|
+
proc.kill()
|
|
43
|
+
await proc.wait()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run(argv: list[str], *, timeout_sec: float, label: str) -> bytes:
|
|
47
|
+
try:
|
|
48
|
+
proc = await asyncio.create_subprocess_exec(
|
|
49
|
+
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
50
|
+
)
|
|
51
|
+
except FileNotFoundError as e:
|
|
52
|
+
raise ClaudeError(f"{CLAUDE_BIN} not on PATH") from e
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
|
|
56
|
+
except TimeoutError as e:
|
|
57
|
+
await _kill(proc)
|
|
58
|
+
raise ClaudeError(f"{label} timed out after {timeout_sec}s") from e
|
|
59
|
+
except BaseException:
|
|
60
|
+
# Not `Exception`. The case this exists for is cancellation, and
|
|
61
|
+
# `CancelledError` is a BaseException, so the timeout branch above never sees
|
|
62
|
+
# it. Both estimate workers are @work(exclusive=True), so pressing `f` again
|
|
63
|
+
# cancels the first — and without this the model call it had spawned kept
|
|
64
|
+
# running for up to timeout_sec: unread, billed, and invisible. Re-raised
|
|
65
|
+
# unchanged, because swallowing it would break the cancellation itself.
|
|
66
|
+
await _kill(proc)
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
if proc.returncode != 0:
|
|
70
|
+
tail = stderr.decode(errors="replace")[-_STDERR_TAIL:]
|
|
71
|
+
raise ClaudeError(f"{label} exited {proc.returncode}: {tail}")
|
|
72
|
+
return stdout
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _structured(stdout: bytes, label: str) -> dict:
|
|
76
|
+
try:
|
|
77
|
+
envelope = json.loads(stdout.decode())
|
|
78
|
+
except json.JSONDecodeError as e:
|
|
79
|
+
tail = stdout.decode(errors="replace")[-_STDERR_TAIL:]
|
|
80
|
+
raise ClaudeError(f"{label} returned non-JSON stdout: {tail}") from e
|
|
81
|
+
inner = envelope.get("structured_output")
|
|
82
|
+
if inner is None:
|
|
83
|
+
raise ClaudeError(f"{label} envelope missing structured_output key")
|
|
84
|
+
return inner
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _model_args(model: str | None) -> list[str]:
|
|
88
|
+
return ["--model", model] if model else []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def run_oneshot_text(
|
|
92
|
+
system_prompt: str,
|
|
93
|
+
user_prompt: str,
|
|
94
|
+
*,
|
|
95
|
+
timeout_sec: float,
|
|
96
|
+
model: str | None = None,
|
|
97
|
+
) -> str:
|
|
98
|
+
argv = [
|
|
99
|
+
CLAUDE_BIN,
|
|
100
|
+
"-p",
|
|
101
|
+
"--system-prompt",
|
|
102
|
+
system_prompt,
|
|
103
|
+
*_model_args(model),
|
|
104
|
+
"--output-format",
|
|
105
|
+
"text",
|
|
106
|
+
"--no-session-persistence",
|
|
107
|
+
user_prompt,
|
|
108
|
+
]
|
|
109
|
+
stdout = await _run(argv, timeout_sec=timeout_sec, label="claude -p")
|
|
110
|
+
return stdout.decode().strip()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def run_oneshot_json(
|
|
114
|
+
*,
|
|
115
|
+
system_prompt: str,
|
|
116
|
+
user_prompt: str,
|
|
117
|
+
json_schema: dict,
|
|
118
|
+
timeout_sec: float,
|
|
119
|
+
model: str | None = None,
|
|
120
|
+
) -> dict:
|
|
121
|
+
argv = [
|
|
122
|
+
CLAUDE_BIN,
|
|
123
|
+
"-p",
|
|
124
|
+
"--system-prompt",
|
|
125
|
+
system_prompt,
|
|
126
|
+
*_model_args(model),
|
|
127
|
+
"--json-schema",
|
|
128
|
+
json.dumps(json_schema),
|
|
129
|
+
"--output-format",
|
|
130
|
+
"json",
|
|
131
|
+
"--no-session-persistence",
|
|
132
|
+
user_prompt,
|
|
133
|
+
]
|
|
134
|
+
stdout = await _run(argv, timeout_sec=timeout_sec, label="claude -p (json)")
|
|
135
|
+
return _structured(stdout, "claude -p (json)")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def run_with_image_json(
|
|
139
|
+
*,
|
|
140
|
+
image_path: str,
|
|
141
|
+
prompt: str,
|
|
142
|
+
json_schema: dict,
|
|
143
|
+
timeout_sec: float,
|
|
144
|
+
model: str | None = None,
|
|
145
|
+
) -> dict:
|
|
146
|
+
"""The prompt must reference the image by absolute path; claude reads it
|
|
147
|
+
with the Read tool, which --add-dir plus --tools Read grant and nothing
|
|
148
|
+
else does."""
|
|
149
|
+
argv = [
|
|
150
|
+
CLAUDE_BIN,
|
|
151
|
+
"-p",
|
|
152
|
+
"--add-dir",
|
|
153
|
+
os.path.dirname(image_path),
|
|
154
|
+
"--tools",
|
|
155
|
+
"Read",
|
|
156
|
+
*_model_args(model),
|
|
157
|
+
"--json-schema",
|
|
158
|
+
json.dumps(json_schema),
|
|
159
|
+
"--output-format",
|
|
160
|
+
"json",
|
|
161
|
+
"--no-session-persistence",
|
|
162
|
+
prompt,
|
|
163
|
+
]
|
|
164
|
+
stdout = await _run(argv, timeout_sec=timeout_sec, label="claude -p (image)")
|
|
165
|
+
return _structured(stdout, "claude -p (image)")
|