worklog-for-claude-code 0.1.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.
- worklog/__init__.py +3 -0
- worklog/cli.py +249 -0
- worklog/collector.py +197 -0
- worklog/config.py +294 -0
- worklog/demo.py +214 -0
- worklog/hooks.py +78 -0
- worklog/identity.py +238 -0
- worklog/parser.py +180 -0
- worklog/redact.py +39 -0
- worklog/report.py +229 -0
- worklog/services.py +139 -0
- worklog/store.py +135 -0
- worklog/web.py +776 -0
- worklog_for_claude_code-0.1.0.dist-info/METADATA +112 -0
- worklog_for_claude_code-0.1.0.dist-info/RECORD +19 -0
- worklog_for_claude_code-0.1.0.dist-info/WHEEL +5 -0
- worklog_for_claude_code-0.1.0.dist-info/entry_points.txt +2 -0
- worklog_for_claude_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- worklog_for_claude_code-0.1.0.dist-info/top_level.txt +1 -0
worklog/web.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"""Local web UI: daily dashboard + settings. Standard library only.
|
|
2
|
+
|
|
3
|
+
Security model (it can edit config and run git/claude, so it is locked down):
|
|
4
|
+
* binds to loopback only (127.0.0.1 and ::1), never to the network
|
|
5
|
+
* Host header must be a known loopback name -> blocks DNS-rebinding attacks
|
|
6
|
+
* every POST needs a per-process CSRF token and a same-origin Origin header
|
|
7
|
+
* all output is HTML-escaped; strict Content-Security-Policy; no external assets
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import shutil
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import threading
|
|
21
|
+
from dataclasses import replace
|
|
22
|
+
from datetime import date, datetime, timedelta
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from urllib.parse import parse_qs, urlsplit
|
|
26
|
+
|
|
27
|
+
from . import services
|
|
28
|
+
from .collector import collect, sanitize_remote
|
|
29
|
+
from .config import (
|
|
30
|
+
_NAME_RE,
|
|
31
|
+
CONFIG_PATH,
|
|
32
|
+
Account,
|
|
33
|
+
Config,
|
|
34
|
+
ConfigError,
|
|
35
|
+
_expand,
|
|
36
|
+
example_config,
|
|
37
|
+
home_relative,
|
|
38
|
+
load_config,
|
|
39
|
+
save_config,
|
|
40
|
+
under_home,
|
|
41
|
+
)
|
|
42
|
+
from .hooks import hook_installed, install_hook
|
|
43
|
+
from .identity import detected_logins, mask_email
|
|
44
|
+
from .report import aggregate, build_report, load_saved_summaries, plain_summary
|
|
45
|
+
from .store import GitStore
|
|
46
|
+
|
|
47
|
+
log = logging.getLogger(__name__)
|
|
48
|
+
DEFAULT_PORT = 8765
|
|
49
|
+
HOST_NAME = "worklog.localhost" # *.localhost resolves to loopback in browsers and systemd-resolved
|
|
50
|
+
MAX_BODY = 64 * 1024
|
|
51
|
+
COLORS = ["#2F5BEA", "#0F9D8A", "#C98A12", "#8A4FBF", "#C4456A", "#3E8E3E"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def e(v: object) -> str:
|
|
55
|
+
return html.escape(str(v), quote=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
OK_MESSAGES = {
|
|
59
|
+
"setup": "Setup saved. Next: install the hook for each account below.",
|
|
60
|
+
"account_added": "Account added. Log in to it once in a terminal (command below), then install its hook.",
|
|
61
|
+
"account_removed": "Account removed from this device. Its records already synced are kept.",
|
|
62
|
+
"saved": "Settings saved.",
|
|
63
|
+
"named": "Login named. Its sessions are counted under that account from the next sync.",
|
|
64
|
+
"job": "Started. This page updates when it finishes.",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ================================================================== jobs
|
|
69
|
+
class Jobs:
|
|
70
|
+
"""One background job at a time (sync / summaries), so the UI never blocks."""
|
|
71
|
+
|
|
72
|
+
def __init__(self):
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
self._state = {"running": None, "finished": None, "message": "", "ok": True}
|
|
75
|
+
|
|
76
|
+
def start(self, name: str, fn) -> bool:
|
|
77
|
+
with self._lock:
|
|
78
|
+
if self._state["running"]:
|
|
79
|
+
return False
|
|
80
|
+
self._state["running"] = name
|
|
81
|
+
|
|
82
|
+
def run():
|
|
83
|
+
try:
|
|
84
|
+
msg, ok = fn(), True
|
|
85
|
+
except Exception as exc: # noqa: BLE001 - report any failure to the UI
|
|
86
|
+
log.exception("%s failed", name)
|
|
87
|
+
msg, ok = f"{name} failed: {exc}", False
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._state.update(running=None, finished=datetime.now().strftime("%H:%M"), message=msg, ok=ok)
|
|
90
|
+
|
|
91
|
+
threading.Thread(target=run, daemon=True).start()
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
def snapshot(self) -> dict:
|
|
95
|
+
with self._lock:
|
|
96
|
+
return dict(self._state)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ================================================================== helpers
|
|
100
|
+
def _try_config() -> Config | None:
|
|
101
|
+
try:
|
|
102
|
+
return load_config()
|
|
103
|
+
except ConfigError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _store(cfg: Config) -> GitStore | None:
|
|
108
|
+
try:
|
|
109
|
+
return GitStore(cfg.store_repo)
|
|
110
|
+
except ConfigError:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _n(v: int) -> str:
|
|
115
|
+
return f"{v:,}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _short(v: int) -> str:
|
|
119
|
+
for div, suf in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "k")):
|
|
120
|
+
if v >= div:
|
|
121
|
+
return f"{v / div:.1f}".rstrip("0").rstrip(".") + suf
|
|
122
|
+
return str(v)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _dur(m: int) -> str:
|
|
126
|
+
h, m = divmod(m, 60)
|
|
127
|
+
return f"{h}h {m:02d}m" if h else f"{m}m"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _join(items) -> str:
|
|
131
|
+
items = [e(i) for i in items]
|
|
132
|
+
return items[0] if len(items) == 1 else ", ".join(items[:-1]) + " and " + items[-1] if items else ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _transcript_count(a: Account, cap: int = 5000) -> int:
|
|
136
|
+
base = a.config_dir / "projects"
|
|
137
|
+
n = 0
|
|
138
|
+
if base.is_dir():
|
|
139
|
+
for _ in base.rglob("*.jsonl"):
|
|
140
|
+
n += 1
|
|
141
|
+
if n >= cap:
|
|
142
|
+
break
|
|
143
|
+
return n
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _git_out(repo: Path, *args: str) -> str:
|
|
147
|
+
try:
|
|
148
|
+
r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=10)
|
|
149
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
150
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ================================================================== layout
|
|
155
|
+
CSS = """
|
|
156
|
+
:root{--paper:#EEF1F4;--sheet:#fff;--ink:#1B2A41;--muted:#5B6B82;--rule:#D3DAE3;--accent:#2F5BEA;--ok:#0F7B5F;--bad:#B3261E;
|
|
157
|
+
font-family:"Ubuntu","Noto Sans","DejaVu Sans",system-ui,sans-serif;color:var(--ink);background:var(--paper);
|
|
158
|
+
font-variant-numeric:tabular-nums;line-height:1.5}
|
|
159
|
+
@media (prefers-color-scheme:dark){:root{--paper:#121822;--sheet:#1A2230;--ink:#DCE3ED;--muted:#93A1B5;--rule:#2A3444;--accent:#7B9BFF;--ok:#4CC9A0;--bad:#FF8A80}}
|
|
160
|
+
*{box-sizing:border-box}body{margin:0}a{color:var(--accent)}
|
|
161
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
162
|
+
.wrap{max-width:1080px;margin:0 auto;padding:0 20px 64px}
|
|
163
|
+
header{display:flex;align-items:center;gap:20px;padding:18px 0;border-bottom:1px solid var(--rule);margin-bottom:28px}
|
|
164
|
+
.brand{font-weight:700;font-size:1.05rem;color:var(--ink);text-decoration:none;margin-right:auto}
|
|
165
|
+
nav{display:flex;gap:22px}nav a{color:var(--muted);text-decoration:none;padding:4px 2px}nav a[aria-current]{color:var(--ink);border-bottom:2px solid var(--accent)}
|
|
166
|
+
h1{font-size:clamp(2rem,5vw,3.1rem);line-height:1.05;margin:0;font-weight:700;letter-spacing:-.02em}
|
|
167
|
+
h1 small{display:block;font-size:.4em;font-weight:400;color:var(--muted);letter-spacing:0;margin-top:6px}
|
|
168
|
+
h2{font-size:1.15rem;margin:0 0 12px}h3{font-size:1.1rem;margin:0}
|
|
169
|
+
.daynav{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}.daynav .arrows{display:flex;gap:6px;margin-left:auto}
|
|
170
|
+
.lede{font-size:1.15rem;max-width:62ch;margin:14px 0 22px;color:var(--ink)}
|
|
171
|
+
.strip{background:var(--sheet);border:1px solid var(--rule);border-radius:10px;padding:14px 14px 6px;margin-bottom:30px}
|
|
172
|
+
.strip svg{width:100%;height:auto;display:block}.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:.85rem;color:var(--muted);margin:4px 2px 6px}
|
|
173
|
+
.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;vertical-align:-1px}
|
|
174
|
+
.cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:36px;align-items:start}
|
|
175
|
+
@media (max-width:860px){.cols{grid-template-columns:1fr}}
|
|
176
|
+
.entry{padding:20px 0;border-top:1px solid var(--rule)}.entry:first-of-type{border-top:0;padding-top:4px}
|
|
177
|
+
.entry .where{color:var(--muted);font-size:.9rem;margin:2px 0 10px}.entry ul{margin:8px 0 12px;padding-left:1.1em;max-width:70ch}
|
|
178
|
+
.entry li{margin:3px 0}.src{font-size:.8rem;color:var(--muted)}
|
|
179
|
+
dl.facts{display:flex;flex-wrap:wrap;gap:4px 22px;margin:8px 0 0;font-size:.88rem}dl.facts div{display:flex;gap:6px}
|
|
180
|
+
dl.facts dt{color:var(--muted)}dl.facts dd{margin:0}
|
|
181
|
+
details{margin-top:8px;font-size:.9rem}summary{cursor:pointer;color:var(--muted)}details ul{margin:6px 0}
|
|
182
|
+
aside .panel{background:var(--sheet);border:1px solid var(--rule);border-radius:10px;padding:16px;margin-bottom:18px}
|
|
183
|
+
table{width:100%;border-collapse:collapse;font-size:.88rem}th,td{padding:6px 4px;text-align:right;border-bottom:1px solid var(--rule)}
|
|
184
|
+
td{white-space:nowrap}th{vertical-align:bottom;line-height:1.2}th:first-child,td:first-child{text-align:left}th{color:var(--muted);font-weight:500}tr:last-child td{border-bottom:0}
|
|
185
|
+
.tablewrap{overflow-x:auto}
|
|
186
|
+
button,.btn{font:inherit;font-size:.9rem;border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:7px;padding:7px 14px;cursor:pointer;text-decoration:none;display:inline-block}
|
|
187
|
+
button.quiet,.btn.quiet{background:transparent;color:var(--accent)}button.danger{background:transparent;border-color:var(--bad);color:var(--bad)}
|
|
188
|
+
button:disabled{opacity:.55;cursor:progress}
|
|
189
|
+
.banner{border-radius:8px;padding:10px 14px;margin-bottom:22px;border:1px solid var(--rule);background:var(--sheet)}
|
|
190
|
+
.banner.ok{border-color:var(--ok)}.banner.bad{border-color:var(--bad);color:var(--bad)}
|
|
191
|
+
section.block{background:var(--sheet);border:1px solid var(--rule);border-radius:10px;padding:20px;margin-bottom:22px}
|
|
192
|
+
section.block p.help{color:var(--muted);margin:0 0 14px;max-width:70ch}
|
|
193
|
+
label{display:block;font-size:.9rem;margin:12px 0 4px}input[type=text],input[type=date],select{font:inherit;width:100%;max-width:420px;padding:7px 9px;border:1px solid var(--rule);border-radius:6px;background:var(--paper);color:var(--ink)}
|
|
194
|
+
.check{display:flex;gap:8px;align-items:center;margin:12px 0}.check label{margin:0}
|
|
195
|
+
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:14px}
|
|
196
|
+
code,.cmd{font-family:"Ubuntu Mono","DejaVu Sans Mono",monospace;font-size:.9em}
|
|
197
|
+
.cmd{display:block;background:var(--paper);border:1px solid var(--rule);border-radius:6px;padding:8px 10px;margin:6px 0;overflow-x:auto;white-space:pre}
|
|
198
|
+
.state-ok{color:var(--ok)}.state-bad{color:var(--bad)}.muted{color:var(--muted)}
|
|
199
|
+
.empty{padding:28px;border:1px dashed var(--rule);border-radius:10px;text-align:center;color:var(--muted)}
|
|
200
|
+
@media (prefers-reduced-motion:no-preference){.bar{transition:opacity .15s}a:hover .bar{opacity:.8}}
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
SCRIPT = """
|
|
204
|
+
(function(){var b=document.getElementById('jobstate');if(!b||b.dataset.running!=='1')return;
|
|
205
|
+
function poll(){fetch('/api/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
|
|
206
|
+
if(!s.running){location.reload()}else{setTimeout(poll,2000)}}).catch(function(){setTimeout(poll,4000)})}
|
|
207
|
+
setTimeout(poll,2000)})();
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def layout(title: str, body: str, active: str, nonce: str, job: dict, ok: str | None = None, error: str | None = None) -> str:
|
|
212
|
+
nav = "".join(f'<a href="{h}"{" aria-current=page" if k == active else ""}>{t}</a>'
|
|
213
|
+
for k, h, t in (("day", "/", "Today"), ("settings", "/settings", "Settings")))
|
|
214
|
+
banners = ""
|
|
215
|
+
if job.get("running"):
|
|
216
|
+
banners += f'<div class="banner" id="jobstate" data-running="1" role="status">{e(job["running"])}…</div>'
|
|
217
|
+
elif job.get("message") and job.get("finished"):
|
|
218
|
+
cls = "ok" if job.get("ok") else "bad"
|
|
219
|
+
banners += f'<div class="banner {cls}" role="status">{e(job["message"])} <span class="muted">({e(job["finished"])})</span></div>'
|
|
220
|
+
if ok in OK_MESSAGES:
|
|
221
|
+
banners += f'<div class="banner ok" role="status">{e(OK_MESSAGES[ok])}</div>'
|
|
222
|
+
if error:
|
|
223
|
+
banners += f'<div class="banner bad" role="alert">{e(error)}</div>'
|
|
224
|
+
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
225
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{e(title)} – Worklog</title>
|
|
226
|
+
<style>{CSS}</style></head><body><div class="wrap">
|
|
227
|
+
<header><a class="brand" href="/">Worklog</a><nav>{nav}</nav></header>{banners}{body}</div>
|
|
228
|
+
<script nonce="{nonce}">{SCRIPT}</script></body></html>"""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ================================================================== pages
|
|
232
|
+
def _chart(history: list[tuple[date, dict[str, int]]], accounts: list[str], selected: date) -> str:
|
|
233
|
+
w, h, top, bottom = 700, 130, 8, 22
|
|
234
|
+
peak = max((sum(v.values()) for _, v in history), default=0) or 1
|
|
235
|
+
slot = w / len(history)
|
|
236
|
+
bw = slot * 0.62
|
|
237
|
+
parts = []
|
|
238
|
+
for i, (d, per) in enumerate(history):
|
|
239
|
+
x = i * slot + (slot - bw) / 2
|
|
240
|
+
y = h - bottom
|
|
241
|
+
total = sum(per.values())
|
|
242
|
+
rects = []
|
|
243
|
+
for name in accounts:
|
|
244
|
+
v = per.get(name, 0)
|
|
245
|
+
if not v:
|
|
246
|
+
continue
|
|
247
|
+
bh = (h - top - bottom) * v / peak
|
|
248
|
+
y -= bh
|
|
249
|
+
rects.append(f'<rect class="bar" x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{bh:.1f}" rx="2" '
|
|
250
|
+
f'fill="{COLORS[accounts.index(name) % len(COLORS)]}"/>')
|
|
251
|
+
if not rects:
|
|
252
|
+
rects.append(f'<rect x="{x:.1f}" y="{h - bottom - 2}" width="{bw:.1f}" height="2" fill="var(--rule)"/>')
|
|
253
|
+
is_sel = d == selected
|
|
254
|
+
label_weight = "700" if is_sel else "400"
|
|
255
|
+
label_fill = "var(--ink)" if is_sel else "var(--muted)"
|
|
256
|
+
mark = (f'<rect x="{x - 3:.1f}" y="{top - 4}" width="{bw + 6:.1f}" height="{h - bottom - top + 6}" rx="4" '
|
|
257
|
+
f'fill="none" stroke="var(--accent)" stroke-width="1.5"/>') if is_sel else ""
|
|
258
|
+
tip = f"{d:%a %d %b}: {_n(total)} tokens" + "".join(f", {k} {_n(v)}" for k, v in per.items() if v)
|
|
259
|
+
parts.append(f'<a href="/?d={d.isoformat()}"><title>{e(tip)}</title>{mark}{"".join(rects)}'
|
|
260
|
+
f'<rect x="{i * slot:.1f}" y="0" width="{slot:.1f}" height="{h}" fill="transparent"/>'
|
|
261
|
+
f'<text x="{x + bw / 2:.1f}" y="{h - 6}" text-anchor="middle" font-size="11" '
|
|
262
|
+
f'font-weight="{label_weight}" fill="{label_fill}">{d.day}</text></a>')
|
|
263
|
+
legend = "".join(f'<span><i style="background:{COLORS[i % len(COLORS)]}"></i>{e(n)}</span>'
|
|
264
|
+
for i, n in enumerate(accounts))
|
|
265
|
+
return (f'<div class="strip"><div class="legend">{legend or "<span>No activity yet</span>"}'
|
|
266
|
+
f'<span style="margin-left:auto">Last {len(history)} days, peak {_short(peak) if peak > 1 else 0} tokens</span></div>'
|
|
267
|
+
f'<svg viewBox="0 0 {w} {h}" role="img" aria-label="Tokens per day for the last {len(history)} days">'
|
|
268
|
+
f'{"".join(parts)}</svg></div>')
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def page_day(app: App, cfg: Config, day: date) -> tuple[str, str | None]:
|
|
272
|
+
store = _store(cfg)
|
|
273
|
+
if store is None:
|
|
274
|
+
return (f'<div class="empty">No data repo found at <code>{e(home_relative(cfg.store_repo))}</code>. '
|
|
275
|
+
f'<a href="/settings">Set it up in Settings</a>.</div>'), None
|
|
276
|
+
records = list(store.iter_json(f"records/{day.isoformat()}"))
|
|
277
|
+
accounts, projects = aggregate(records)
|
|
278
|
+
saved = load_saved_summaries(store, day).get("summaries", {})
|
|
279
|
+
|
|
280
|
+
history_days = [day - timedelta(days=i) for i in reversed(range(14))]
|
|
281
|
+
history, names = [], [a.name for a in cfg.accounts]
|
|
282
|
+
for d in history_days:
|
|
283
|
+
per: dict[str, int] = {}
|
|
284
|
+
for r in store.iter_json(f"records/{d.isoformat()}"):
|
|
285
|
+
name = r.get("account", "?")
|
|
286
|
+
per[name] = per.get(name, 0) + sum(v for v in (r.get("tokens") or {}).values() if isinstance(v, int))
|
|
287
|
+
if name not in names:
|
|
288
|
+
names.append(name) # account configured on another device only
|
|
289
|
+
history.append((d, per))
|
|
290
|
+
|
|
291
|
+
prev_d, next_d = day - timedelta(days=1), day + timedelta(days=1)
|
|
292
|
+
today = date.today()
|
|
293
|
+
arrows = (f'<a class="btn quiet" href="/?d={prev_d.isoformat()}" aria-label="Previous day">‹</a>'
|
|
294
|
+
+ (f'<a class="btn quiet" href="/?d={next_d.isoformat()}" aria-label="Next day">›</a>' if day < today else "")
|
|
295
|
+
+ ('<a class="btn quiet" href="/">Today</a>' if day != today else ""))
|
|
296
|
+
jump = (f'<form method="get" action="/" class="row" style="margin:0"><label for="d" class="muted" style="margin:0">Go to</label>'
|
|
297
|
+
f'<input type="date" id="d" name="d" value="{day.isoformat()}" max="{today.isoformat()}" style="width:auto">'
|
|
298
|
+
f'<button class="quiet">Show</button></form>')
|
|
299
|
+
head = (f'<div class="daynav"><h1>{day:%A, %d %B}<small>{day.year}'
|
|
300
|
+
f'{", today" if day == today else ""}</small></h1><div class="arrows">{arrows}</div></div>')
|
|
301
|
+
|
|
302
|
+
busy = " disabled" if app.jobs.snapshot().get("running") else ""
|
|
303
|
+
actions = (f'<div class="row"><form method="post" action="/action/collect"><input type="hidden" name="csrf" value="{app.csrf}">'
|
|
304
|
+
f'<button{busy}>Sync now</button></form>'
|
|
305
|
+
f'<form method="post" action="/action/report"><input type="hidden" name="csrf" value="{app.csrf}">'
|
|
306
|
+
f'<input type="hidden" name="d" value="{day.isoformat()}">'
|
|
307
|
+
f'<button class="quiet"{busy}>{"Rewrite summaries" if saved else "Write summaries"}</button></form>{jump}</div>')
|
|
308
|
+
|
|
309
|
+
if not records:
|
|
310
|
+
body = (head + '<p class="lede">Nothing recorded for this day yet.</p>' + _chart(history, names, day)
|
|
311
|
+
+ '<div class="empty">Sessions appear here after a Claude Code session ends, or when you press '
|
|
312
|
+
'<b>Sync now</b>. Other devices show up once they have synced.</div>' + actions)
|
|
313
|
+
return body, None
|
|
314
|
+
|
|
315
|
+
total = sum(sum(a["tokens"].values()) for a in accounts.values())
|
|
316
|
+
sessions = sum(a["sessions"] for a in accounts.values())
|
|
317
|
+
devices = sorted({d for a in accounts.values() for d in a["devices"]})
|
|
318
|
+
lede = (f'<p class="lede">{_n(total)} tokens across {sessions} session{"s" * (sessions != 1)} in '
|
|
319
|
+
f'{len(projects)} project{"s" * (len(projects) != 1)}, on {_join(devices)}.</p>')
|
|
320
|
+
|
|
321
|
+
entries = []
|
|
322
|
+
for p in sorted(projects.values(), key=lambda p: (p.kind == "misc", -sum(p.tokens.values()))):
|
|
323
|
+
s = saved.get(p.key)
|
|
324
|
+
bullets = s["bullets"] if s else plain_summary(p)
|
|
325
|
+
src = ("Summary by Claude" if s and s.get("source") == "ai" else
|
|
326
|
+
"Your prompts. Press “Write summaries” for a written summary." if not s else "Your prompts")
|
|
327
|
+
items = "".join(f"<li>{e(b[2:] if b.startswith('- ') else b)}</li>" for b in bullets)
|
|
328
|
+
where = (f"{e(p.remote['host'])}/{e(p.remote['slug'])}" if p.remote
|
|
329
|
+
else ("Folders: " + _join(sorted(p.folders)) if p.folders else "Not in a git repo"))
|
|
330
|
+
facts = [("Accounts", _join(sorted(p.accounts))), ("Devices", _join(sorted(p.devices))),
|
|
331
|
+
("Sessions", str(p.sessions)), ("Time", _dur(p.minutes)),
|
|
332
|
+
("Tokens", _n(sum(p.tokens.values()))), ("Files edited", str(len(p.files)))]
|
|
333
|
+
dl = "".join(f"<div><dt>{k}</dt><dd>{v}</dd></div>" for k, v in facts)
|
|
334
|
+
extra = ""
|
|
335
|
+
if p.commits:
|
|
336
|
+
extra += (f'<details><summary>{len(p.commits)} commit{"s" * (len(p.commits) != 1)}</summary><ul>'
|
|
337
|
+
+ "".join(f"<li><code>{e(h)}</code> {e(sub)}</li>" for h, sub in p.commits.items()) + "</ul></details>")
|
|
338
|
+
if p.files:
|
|
339
|
+
extra += ('<details><summary>Files edited</summary><ul>'
|
|
340
|
+
+ "".join(f"<li><code>{e(f)}</code></li>" for f in sorted(p.files)[:60]) + "</ul></details>")
|
|
341
|
+
entries.append(f'<article class="entry"><h3>{e(p.name)}</h3><div class="where">{where}</div>'
|
|
342
|
+
f'<ul>{items}</ul><div class="src">{e(src)}</div><dl class="facts">{dl}</dl>{extra}</article>')
|
|
343
|
+
|
|
344
|
+
acct_rows = "".join(
|
|
345
|
+
f'<tr><td><i style="display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px;'
|
|
346
|
+
f'background:{COLORS[names.index(n) % len(COLORS)] if n in names else "var(--muted)"}"></i>{e(n)}</td>'
|
|
347
|
+
f'<td>{_short(a["tokens"]["input"])}</td><td>{_short(a["tokens"]["output"])}</td>'
|
|
348
|
+
f'<td>{_short(a["tokens"]["cache_creation"])}</td><td>{_short(a["tokens"]["cache_read"])}</td>'
|
|
349
|
+
f'<td><b>{_short(sum(a["tokens"].values()))}</b></td></tr>' for n, a in sorted(accounts.items()))
|
|
350
|
+
model_rows = "".join(
|
|
351
|
+
f'<tr><td>{e(n)}</td><td style="text-align:left">{e(m)}</td><td>{_short(sum(t.values()))}</td></tr>'
|
|
352
|
+
for n, a in sorted(accounts.items()) for m, t in sorted(a["models"].items()))
|
|
353
|
+
aside = (f'<aside><div class="panel"><h2>Tokens by account</h2><div class="tablewrap"><table>'
|
|
354
|
+
f'<tr><th>Account</th><th>In</th><th>Out</th><th>Cache<br>write</th><th>Cache<br>read</th><th>Total</th></tr>'
|
|
355
|
+
f'{acct_rows}</table></div></div><div class="panel"><h2>By model</h2><div class="tablewrap"><table>'
|
|
356
|
+
f'<tr><th>Account</th><th style="text-align:left">Model</th><th>Total</th></tr>{model_rows}</table></div>'
|
|
357
|
+
f'<p class="muted" style="font-size:.8rem;margin:10px 0 0">Usage as logged by Claude Code, not a bill.</p></div></aside>')
|
|
358
|
+
body = (head + lede + _chart(history, names, day) + actions
|
|
359
|
+
+ f'<div class="cols" style="margin-top:26px"><main><h2>What I worked on</h2>{"".join(entries)}</main>{aside}</div>')
|
|
360
|
+
return body, None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def page_setup(app: App, form: dict | None = None) -> str:
|
|
364
|
+
f = form or {}
|
|
365
|
+
default_device = re.search(r'device_name = "([^"]+)"', example_config()).group(1)
|
|
366
|
+
|
|
367
|
+
def v(k: str, d: str = "") -> str:
|
|
368
|
+
return e(f.get(k, d))
|
|
369
|
+
|
|
370
|
+
return f"""<h1>Set up this device<small>One time only. You can change everything later in Settings.</small></h1>
|
|
371
|
+
<form method="post" action="/setup" style="margin-top:24px"><input type="hidden" name="csrf" value="{app.csrf}">
|
|
372
|
+
<section class="block"><h2>This device</h2><p class="help">A short name so the report can tell your machines apart. Use a different name on each device.</p>
|
|
373
|
+
<label for="device">Device name</label><input type="text" id="device" name="device" value="{v('device', default_device)}" required pattern="[A-Za-z0-9_-]{{1,40}}"></section>
|
|
374
|
+
<section class="block"><h2>Data repo</h2><p class="help">A private git repo (GitHub, GitLab or Bitbucket) that every device syncs to. Create it empty on the website first.
|
|
375
|
+
If this device already has a clone, just enter its folder. Otherwise also paste the clone URL and it will be cloned for you. The URL must work without a password prompt (SSH deploy key, see README).</p>
|
|
376
|
+
<label for="store">Folder</label><input type="text" id="store" name="store" value="{v('store', '~/claude-worklog-data')}" required>
|
|
377
|
+
<label for="clone">Clone URL (only if the folder doesn't exist yet)</label><input type="text" id="clone" name="clone" value="{v('clone')}" placeholder="git@github.com:you/claude-worklog-data.git"></section>
|
|
378
|
+
<section class="block"><h2>First Claude account</h2><p class="help">The account you use with plain <code>claude</code> lives in <code>~/.claude</code>. You can add more accounts after setup.</p>
|
|
379
|
+
<label for="aname">Account name</label><input type="text" id="aname" name="aname" value="{v('aname', 'personal')}" required pattern="[A-Za-z0-9_-]{{1,40}}">
|
|
380
|
+
<label for="adir">Claude config folder</label><input type="text" id="adir" name="adir" value="{v('adir', '~/.claude')}" required></section>
|
|
381
|
+
<button>Save setup</button></form>"""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _detected_section(cfg: Config, csrf_field: str) -> str:
|
|
385
|
+
"""Logins seen by the SessionStart hook. E-mails are masked; forms carry an opaque ref, never the id."""
|
|
386
|
+
by_dir = cfg.accounts_by_dir()
|
|
387
|
+
rows = []
|
|
388
|
+
for login in detected_logins():
|
|
389
|
+
for d in login["dirs"]:
|
|
390
|
+
if d not in by_dir:
|
|
391
|
+
continue # folder not configured on this device
|
|
392
|
+
current = cfg.resolve_account(d, login["account_id"], login["email"])
|
|
393
|
+
linked = current.match_account_id == login["account_id"] or (
|
|
394
|
+
current.match_email and login["email"] and current.match_email.casefold() == login["email"].casefold())
|
|
395
|
+
status = (f'Counted as <b>{e(current.name)}</b>' if linked
|
|
396
|
+
else f'Counted as <b>{e(current.name)}</b> <span class="muted">(folder default)</span>')
|
|
397
|
+
names = "".join(f'<option value="{e(a.name)}">' for a in by_dir[d])
|
|
398
|
+
list_id = f"names-{login['ref']}-{len(rows)}"
|
|
399
|
+
seen = login["last_seen"][:16].replace("T", " ")
|
|
400
|
+
rows.append(
|
|
401
|
+
f'<div class="entry"><h3>{e(mask_email(login["email"]))}</h3>'
|
|
402
|
+
f'<div class="where"><code>{e(home_relative(d))}</code> · last seen {e(seen)} · {status}</div>'
|
|
403
|
+
f'<form method="post" action="/accounts/name" class="row" style="margin-top:4px">{csrf_field}'
|
|
404
|
+
f'<input type="hidden" name="ref" value="{e(login["ref"])}"><input type="hidden" name="dir" value="{e(home_relative(d))}">'
|
|
405
|
+
f'<label for="{list_id}-in" class="muted" style="margin:0">Account name</label>'
|
|
406
|
+
f'<input type="text" id="{list_id}-in" name="name" list="{list_id}" value="{e(current.name) if linked else ""}" '
|
|
407
|
+
f'required pattern="[A-Za-z0-9_-]{{1,40}}" style="width:auto"><datalist id="{list_id}">{names}</datalist>'
|
|
408
|
+
f'<button class="quiet">{"Rename" if linked else "Name this account"}</button></form></div>')
|
|
409
|
+
body = "".join(rows) or ('<div class="empty">No logins recorded yet. Install the hook, then start a Claude Code '
|
|
410
|
+
'session. Each account you log in with appears here.</div>')
|
|
411
|
+
return (f'<section class="block"><h2>Detected accounts</h2><p class="help">Accounts seen logged in when a session started. '
|
|
412
|
+
f'If you switch accounts in one folder with <code>/logout</code> and <code>/login</code>, give each one a name so '
|
|
413
|
+
f'its sessions are counted separately. Sessions from a login without a name go to the folder's default account. '
|
|
414
|
+
f'Only the name is written to your data repo; the e-mail and account id stay on this device.</p>{body}</section>')
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def page_settings(app: App, cfg: Config) -> str:
|
|
418
|
+
c = f'<input type="hidden" name="csrf" value="{app.csrf}">'
|
|
419
|
+
store = _store(cfg)
|
|
420
|
+
if store:
|
|
421
|
+
remotes = [sanitize_remote(u) for u in _git_out(cfg.store_repo, "remote", "get-url", "origin").splitlines()]
|
|
422
|
+
remote = next((f"{r['host']}/{r['slug']}" for r in remotes if r), "none (local only)")
|
|
423
|
+
last = _git_out(cfg.store_repo, "log", "-1", "--format=%cr") or "no commits yet"
|
|
424
|
+
store_state = f'<span class="state-ok">Connected</span>. Remote: {e(remote)}. Last change {e(last)}.'
|
|
425
|
+
else:
|
|
426
|
+
store_state = '<span class="state-bad">Not a git clone.</span> Clone your private data repo into this folder.'
|
|
427
|
+
|
|
428
|
+
rows = []
|
|
429
|
+
for a in cfg.accounts:
|
|
430
|
+
exists = a.config_dir.is_dir()
|
|
431
|
+
n = _transcript_count(a) if exists else 0
|
|
432
|
+
hooked = hook_installed(a)
|
|
433
|
+
env = "" if a.config_dir == Path.home() / ".claude" else f"CLAUDE_CONFIG_DIR={home_relative(a.config_dir)} "
|
|
434
|
+
login = f'<span class="cmd">{e(env)}claude\n# then type /login and sign in with this account</span>'
|
|
435
|
+
state = (f'<span class="state-ok">{n} session file{"s" * (n != 1)}</span>' if exists and n
|
|
436
|
+
else '<span class="state-bad">Not logged in yet</span>' if not exists else '<span class="muted">No sessions yet</span>')
|
|
437
|
+
hook_btn = ('<span class="state-ok">Hook installed</span>' if hooked else
|
|
438
|
+
f'<form method="post" action="/accounts/hook">{c}<input type="hidden" name="name" value="{e(a.name)}">'
|
|
439
|
+
f'<button class="quiet">Install hook</button></form>')
|
|
440
|
+
remove = (f'<form method="post" action="/accounts/remove">{c}<input type="hidden" name="name" value="{e(a.name)}">'
|
|
441
|
+
f'<button class="danger">Remove</button></form>') if len(cfg.accounts) > 1 else ""
|
|
442
|
+
rows.append(f'<div class="entry"><h3>{e(a.name)}</h3><div class="where"><code>{e(home_relative(a.config_dir))}</code></div>'
|
|
443
|
+
f'<div class="row" style="margin-top:4px">{state}{hook_btn}{remove}</div>'
|
|
444
|
+
f'{"" if exists and n else "<details open><summary>How to log in</summary>" + login + "</details>"}</div>')
|
|
445
|
+
|
|
446
|
+
detected = _detected_section(cfg, c)
|
|
447
|
+
acct_opts = "".join(f'<option{" selected" if a.name == cfg.summarizer_account else ""}>{e(a.name)}</option>' for a in cfg.accounts)
|
|
448
|
+
found = shutil.which(cfg.claude_bin)
|
|
449
|
+
claude_state = (f'<span class="state-ok">Found at {e(home_relative(found))}</span>' if found
|
|
450
|
+
else '<span class="state-bad">Not found. Summaries will fall back to your prompts.</span>')
|
|
451
|
+
svc = services.status()
|
|
452
|
+
svc_rows = "".join(f"<tr><td><code>{e(u)}</code></td><td>{e(s)}</td></tr>" for u, s in svc.items())
|
|
453
|
+
report_on = services.report_timer_enabled()
|
|
454
|
+
return f"""<h1>Settings<small>Device {e(cfg.device)}</small></h1>
|
|
455
|
+
<section class="block" style="margin-top:24px"><h2>Claude accounts on this device</h2>
|
|
456
|
+
<p class="help">Each account keeps its sessions in its own folder. Installing the hook makes every finished session sync automatically.</p>
|
|
457
|
+
{"".join(rows)}
|
|
458
|
+
<form method="post" action="/accounts/add" style="border-top:1px solid var(--rule);padding-top:14px;margin-top:6px">{c}
|
|
459
|
+
<h3>Add an account</h3><label for="name">Name</label><input type="text" id="name" name="name" placeholder="work" required pattern="[A-Za-z0-9_-]{{1,40}}">
|
|
460
|
+
<label for="dir">Claude config folder (leave empty for <code>~/.claude-<name></code>)</label><input type="text" id="dir" name="dir" placeholder="~/.claude-work">
|
|
461
|
+
<div class="row"><button>Add account</button></div></form></section>
|
|
462
|
+
{detected}
|
|
463
|
+
|
|
464
|
+
<section class="block"><h2>Summaries and daily report</h2>
|
|
465
|
+
<form method="post" action="/settings/report">{c}
|
|
466
|
+
<div class="check"><input type="checkbox" id="use_ai" name="use_ai" value="1"{" checked" if cfg.use_ai else ""}><label for="use_ai">Write summaries with Claude (uses a small amount of tokens)</label></div>
|
|
467
|
+
<label for="summ">Account that writes the summaries</label><select id="summ" name="summarizer">{acct_opts}</select>
|
|
468
|
+
<label for="bin">Claude command</label><input type="text" id="bin" name="claude_bin" value="{e(cfg.claude_bin)}"><div class="muted" style="font-size:.85rem;margin-top:4px">{claude_state}</div>
|
|
469
|
+
<div class="row"><button>Save changes</button></div></form>
|
|
470
|
+
<form method="post" action="/settings/report-timer" style="border-top:1px solid var(--rule);padding-top:14px;margin-top:16px">{c}
|
|
471
|
+
<p class="help">Build the daily report automatically at 23:30 on this device. Turn this on for one device only.</p>
|
|
472
|
+
<input type="hidden" name="enabled" value="{"0" if report_on else "1"}">
|
|
473
|
+
<button class="quiet"{"" if services.available() else " disabled"}>{"Turn off daily report here" if report_on else "Turn on daily report here"}</button></form></section>
|
|
474
|
+
|
|
475
|
+
<section class="block"><h2>This device</h2>
|
|
476
|
+
<form method="post" action="/settings/device">{c}
|
|
477
|
+
<label for="device">Device name</label><input type="text" id="device" name="device" value="{e(cfg.device)}" required pattern="[A-Za-z0-9_-]{{1,40}}">
|
|
478
|
+
<p class="muted" style="font-size:.85rem">Renaming files new sessions under the new name. Earlier days keep the old one.</p>
|
|
479
|
+
<label for="store">Data repo folder</label><input type="text" id="store" name="store" value="{e(home_relative(cfg.store_repo))}" required>
|
|
480
|
+
<p style="font-size:.9rem">{store_state}</p><div class="row"><button>Save changes</button></div></form></section>
|
|
481
|
+
|
|
482
|
+
<section class="block"><h2>Background services</h2><p class="help">These keep running after you close the browser and start again when you log in.</p>
|
|
483
|
+
<div class="tablewrap"><table><tr><th>Service</th><th>State</th></tr>{svc_rows}</table></div>
|
|
484
|
+
<p class="muted" style="font-size:.85rem">Config file: <code>{e(home_relative(cfg.path))}</code></p></section>"""
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ================================================================== app / routing
|
|
488
|
+
class App:
|
|
489
|
+
def __init__(self, port: int):
|
|
490
|
+
self.port = port
|
|
491
|
+
self.csrf = secrets.token_urlsafe(32)
|
|
492
|
+
self.jobs = Jobs()
|
|
493
|
+
self.hosts = {f"{h}:{port}" for h in ("127.0.0.1", "localhost", HOST_NAME, "[::1]")}
|
|
494
|
+
|
|
495
|
+
# ---- POST handlers: return (redirect_location | None, error | None, form)
|
|
496
|
+
def post(self, path: str, form: dict):
|
|
497
|
+
cfg = _try_config()
|
|
498
|
+
if path == "/setup":
|
|
499
|
+
return self._setup(form)
|
|
500
|
+
if cfg is None:
|
|
501
|
+
return "/setup", None, form
|
|
502
|
+
if path == "/action/collect":
|
|
503
|
+
today = date.today()
|
|
504
|
+
started = self.jobs.start("Syncing", lambda: f"Synced: {collect(cfg, [today - timedelta(days=1), today])} new or updated session record(s).")
|
|
505
|
+
return ("/?ok=job" if started else "/"), None, form
|
|
506
|
+
if path == "/action/report":
|
|
507
|
+
day = _parse_date(form.get("d")) or date.today()
|
|
508
|
+
|
|
509
|
+
def job():
|
|
510
|
+
collect(cfg, [day])
|
|
511
|
+
build_report(cfg, day, use_ai=cfg.use_ai, write=True)
|
|
512
|
+
return f"Summaries written for {day:%d %b}."
|
|
513
|
+
started = self.jobs.start("Writing summaries", job)
|
|
514
|
+
return (f"/?d={day.isoformat()}&ok=job" if started else f"/?d={day.isoformat()}"), None, form
|
|
515
|
+
if path == "/accounts/add":
|
|
516
|
+
return self._account_add(cfg, form)
|
|
517
|
+
if path == "/accounts/remove":
|
|
518
|
+
name = form.get("name", "")
|
|
519
|
+
keep = [a for a in cfg.accounts if a.name != name]
|
|
520
|
+
if not keep or len(keep) == len(cfg.accounts):
|
|
521
|
+
return None, "That account can't be removed.", form
|
|
522
|
+
cfg.accounts = keep
|
|
523
|
+
if cfg.summarizer_account == name:
|
|
524
|
+
cfg.summarizer_account = keep[0].name
|
|
525
|
+
return self._save(cfg, "account_removed")
|
|
526
|
+
if path == "/accounts/name":
|
|
527
|
+
return self._account_name(cfg, form)
|
|
528
|
+
if path == "/accounts/hook":
|
|
529
|
+
acct = cfg.account(form.get("name", ""))
|
|
530
|
+
if acct is None:
|
|
531
|
+
return None, "Unknown account.", form
|
|
532
|
+
ok, msg = install_hook(acct)
|
|
533
|
+
return ("/settings?ok=saved" if ok else None), (None if ok else msg), form
|
|
534
|
+
if path == "/settings/report":
|
|
535
|
+
summ = form.get("summarizer", "")
|
|
536
|
+
binv = form.get("claude_bin", "").strip()
|
|
537
|
+
if cfg.account(summ) is None:
|
|
538
|
+
return None, "Pick one of your accounts to write summaries.", form
|
|
539
|
+
if not binv or len(binv) > 300 or any(ch in binv for ch in "\n\r\0"):
|
|
540
|
+
return None, "Enter the claude command or its full path.", form
|
|
541
|
+
cfg.use_ai, cfg.summarizer_account, cfg.claude_bin = form.get("use_ai") == "1", summ, binv
|
|
542
|
+
return self._save(cfg, "saved")
|
|
543
|
+
if path == "/settings/report-timer":
|
|
544
|
+
try:
|
|
545
|
+
services.set_report_timer(form.get("enabled") == "1")
|
|
546
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
547
|
+
return None, str(exc), form
|
|
548
|
+
return "/settings?ok=saved", None, form
|
|
549
|
+
if path == "/settings/device":
|
|
550
|
+
device = form.get("device", "")
|
|
551
|
+
store = _expand(form.get("store", "") or "~/claude-worklog-data")
|
|
552
|
+
if not _NAME_RE.match(device):
|
|
553
|
+
return None, "Device name: use 1-40 letters, digits, '_' or '-'.", form
|
|
554
|
+
if not under_home(store):
|
|
555
|
+
return None, "The data repo folder must be inside your home folder.", form
|
|
556
|
+
cfg.device, cfg.store_repo = device, store
|
|
557
|
+
return self._save(cfg, "saved")
|
|
558
|
+
return None, "Unknown action.", form
|
|
559
|
+
|
|
560
|
+
def _save(self, cfg: Config, ok: str):
|
|
561
|
+
try:
|
|
562
|
+
save_config(cfg)
|
|
563
|
+
except (ConfigError, OSError) as exc:
|
|
564
|
+
return None, f"Couldn't save settings: {exc}", {}
|
|
565
|
+
return f"/settings?ok={ok}", None, {}
|
|
566
|
+
|
|
567
|
+
def _account_add(self, cfg: Config, form: dict):
|
|
568
|
+
name = form.get("name", "").strip()
|
|
569
|
+
if not _NAME_RE.match(name):
|
|
570
|
+
return None, "Account name: use 1-40 letters, digits, '_' or '-'.", form
|
|
571
|
+
if cfg.account(name):
|
|
572
|
+
return None, f"There is already an account called {name}.", form
|
|
573
|
+
cdir = _expand(form.get("dir", "").strip() or f"~/.claude-{name}")
|
|
574
|
+
if not under_home(cdir):
|
|
575
|
+
return None, "The Claude config folder must be inside your home folder.", form
|
|
576
|
+
if any(a.config_dir == cdir for a in cfg.accounts):
|
|
577
|
+
return None, ("Another account already uses that folder. To count a second login in the same folder, "
|
|
578
|
+
"start a session with it, then name it under Detected accounts."), form
|
|
579
|
+
cfg.accounts.append(Account(name, cdir))
|
|
580
|
+
return self._save(cfg, "account_added")
|
|
581
|
+
|
|
582
|
+
def _account_name(self, cfg: Config, form: dict):
|
|
583
|
+
"""Give a detected login a name: link it to an existing account in its folder, or add one."""
|
|
584
|
+
name, ref_ = form.get("name", "").strip(), form.get("ref", "")
|
|
585
|
+
login = next((x for x in detected_logins() if secrets.compare_digest(x["ref"], ref_)), None)
|
|
586
|
+
if login is None:
|
|
587
|
+
return None, "That login is no longer recorded on this device.", form
|
|
588
|
+
cdir = _expand(form.get("dir", ""))
|
|
589
|
+
if cdir not in login["dirs"] or cdir not in cfg.accounts_by_dir():
|
|
590
|
+
return None, "That folder isn't configured for this login.", form
|
|
591
|
+
if not _NAME_RE.match(name):
|
|
592
|
+
return None, "Account name: use 1-40 letters, digits, '_' or '-'.", form
|
|
593
|
+
uid = login["account_id"]
|
|
594
|
+
target = cfg.account(name)
|
|
595
|
+
if target is not None and target.config_dir != cdir:
|
|
596
|
+
return None, f"{name} is an account for another folder. Pick a different name.", form
|
|
597
|
+
if target is not None and target.match_account_id not in (None, uid):
|
|
598
|
+
return None, f"{name} is already linked to another login.", form
|
|
599
|
+
linked = next((a for a in cfg.accounts_by_dir()[cdir] if a.match_account_id == uid), None)
|
|
600
|
+
if target is None and linked is not None: # new name for an already-named login: rename it
|
|
601
|
+
cfg.accounts = [replace(a, name=name) if a is linked else a for a in cfg.accounts]
|
|
602
|
+
if cfg.summarizer_account == linked.name:
|
|
603
|
+
cfg.summarizer_account = name
|
|
604
|
+
elif target is None:
|
|
605
|
+
cfg.accounts.append(Account(name, cdir, match_account_id=uid))
|
|
606
|
+
elif linked is not None and linked is not target:
|
|
607
|
+
return None, f"This login is already named {linked.name}. Rename that one instead.", form
|
|
608
|
+
else: # link an existing account (for example the folder's default) to this login
|
|
609
|
+
cfg.accounts = [replace(a, match_account_id=uid) if a is target else a for a in cfg.accounts]
|
|
610
|
+
return self._save(cfg, "named")
|
|
611
|
+
|
|
612
|
+
def _setup(self, form: dict):
|
|
613
|
+
device, aname = form.get("device", "").strip(), form.get("aname", "").strip()
|
|
614
|
+
store, adir = _expand(form.get("store", "").strip()), _expand(form.get("adir", "").strip())
|
|
615
|
+
clone = form.get("clone", "").strip()
|
|
616
|
+
if not _NAME_RE.match(device) or not _NAME_RE.match(aname):
|
|
617
|
+
return None, "Names can use 1-40 letters, digits, '_' or '-'.", form
|
|
618
|
+
if not under_home(store) or not under_home(adir):
|
|
619
|
+
return None, "Folders must be inside your home folder.", form
|
|
620
|
+
if not (store / ".git").exists():
|
|
621
|
+
if not clone:
|
|
622
|
+
return None, f"{home_relative(store)} is not a git clone yet. Paste the clone URL of your private data repo.", form
|
|
623
|
+
if clone.startswith("-") or not re.match(r"^(https://|ssh://|[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+:|[A-Za-z0-9_.-]+:)\S+$", clone):
|
|
624
|
+
return None, "That doesn't look like a git clone URL (use git@host:owner/repo.git or https://…).", form
|
|
625
|
+
if store.exists() and any(store.iterdir()):
|
|
626
|
+
return None, f"{home_relative(store)} already exists and isn't empty.", form
|
|
627
|
+
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
|
628
|
+
env.setdefault("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ConnectTimeout=15")
|
|
629
|
+
try:
|
|
630
|
+
r = subprocess.run(["git", "clone", "--", clone, str(store)], capture_output=True, text=True, timeout=120, env=env)
|
|
631
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
632
|
+
return None, f"git clone failed: {exc}", form
|
|
633
|
+
if r.returncode != 0:
|
|
634
|
+
return None, f"git clone failed: {r.stderr.strip()[-300:]}", form
|
|
635
|
+
cfg = Config(device=device, store_repo=store, accounts=[Account(aname, adir)], summarizer_account=aname,
|
|
636
|
+
claude_bin=shutil.which("claude") or "claude")
|
|
637
|
+
try:
|
|
638
|
+
save_config(cfg, CONFIG_PATH)
|
|
639
|
+
except (ConfigError, OSError) as exc:
|
|
640
|
+
return None, f"Couldn't save settings: {exc}", form
|
|
641
|
+
return "/settings?ok=setup", None, form
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _parse_date(v: str | None) -> date | None:
|
|
645
|
+
try:
|
|
646
|
+
d = date.fromisoformat(v or "")
|
|
647
|
+
except ValueError:
|
|
648
|
+
return None
|
|
649
|
+
return min(d, date.today())
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def make_handler(app: App):
|
|
653
|
+
class Handler(BaseHTTPRequestHandler):
|
|
654
|
+
server_version = "claude-worklog"
|
|
655
|
+
sys_version = ""
|
|
656
|
+
|
|
657
|
+
def log_message(self, fmt, *args): # route access logs to our logger, not stderr
|
|
658
|
+
log.debug("%s %s", self.address_string(), fmt % args)
|
|
659
|
+
|
|
660
|
+
# ---------------------------------------------------------- guards
|
|
661
|
+
def _host_ok(self) -> bool:
|
|
662
|
+
return (self.headers.get("Host") or "").lower() in app.hosts
|
|
663
|
+
|
|
664
|
+
def _send(self, status: int, body: str, ctype="text/html; charset=utf-8", nonce: str = ""):
|
|
665
|
+
data = body.encode("utf-8")
|
|
666
|
+
self.send_response(status)
|
|
667
|
+
self.send_header("Content-Type", ctype)
|
|
668
|
+
self.send_header("Content-Length", str(len(data)))
|
|
669
|
+
self.send_header("Cache-Control", "no-store")
|
|
670
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
671
|
+
self.send_header("Referrer-Policy", "same-origin") # "no-referrer" makes browsers send Origin: null on POST
|
|
672
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
673
|
+
self.send_header("Content-Security-Policy",
|
|
674
|
+
f"default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-{nonce}'; "
|
|
675
|
+
"img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
|
676
|
+
self.end_headers()
|
|
677
|
+
self.wfile.write(data)
|
|
678
|
+
|
|
679
|
+
def _redirect(self, location: str):
|
|
680
|
+
self.send_response(303)
|
|
681
|
+
self.send_header("Location", location)
|
|
682
|
+
self.send_header("Content-Length", "0")
|
|
683
|
+
self.end_headers()
|
|
684
|
+
|
|
685
|
+
def _page(self, title, body, active, ok=None, error=None, status=200):
|
|
686
|
+
nonce = secrets.token_urlsafe(16)
|
|
687
|
+
self._send(status, layout(title, body, active, nonce, app.jobs.snapshot(), ok, error), nonce=nonce)
|
|
688
|
+
|
|
689
|
+
# ---------------------------------------------------------- GET
|
|
690
|
+
def do_GET(self):
|
|
691
|
+
if not self._host_ok():
|
|
692
|
+
return self._send(403, "Forbidden host", "text/plain")
|
|
693
|
+
try:
|
|
694
|
+
url = urlsplit(self.path)
|
|
695
|
+
q = {k: v[0] for k, v in parse_qs(url.query).items()}
|
|
696
|
+
if url.path == "/api/status":
|
|
697
|
+
return self._send(200, json.dumps(app.jobs.snapshot()), "application/json")
|
|
698
|
+
cfg = _try_config()
|
|
699
|
+
if url.path == "/setup" or (cfg is None and url.path in ("/", "/settings")):
|
|
700
|
+
if cfg is not None:
|
|
701
|
+
return self._redirect("/settings")
|
|
702
|
+
return self._page("Set up", page_setup(app), "settings")
|
|
703
|
+
if url.path == "/":
|
|
704
|
+
day = _parse_date(q.get("d")) or date.today()
|
|
705
|
+
body, err = page_day(app, cfg, day)
|
|
706
|
+
return self._page(f"{day:%d %b %Y}", body, "day", q.get("ok"), err)
|
|
707
|
+
if url.path == "/settings":
|
|
708
|
+
return self._page("Settings", page_settings(app, cfg), "settings", q.get("ok"))
|
|
709
|
+
return self._send(404, "Not found", "text/plain")
|
|
710
|
+
except Exception: # noqa: BLE001
|
|
711
|
+
log.exception("GET %s failed", self.path)
|
|
712
|
+
return self._send(500, "Something went wrong. See ~/.local/state/claude-worklog/worklog.log", "text/plain")
|
|
713
|
+
|
|
714
|
+
# ---------------------------------------------------------- POST
|
|
715
|
+
def do_POST(self):
|
|
716
|
+
if not self._host_ok():
|
|
717
|
+
return self._send(403, "Forbidden host", "text/plain")
|
|
718
|
+
origin = self.headers.get("Origin")
|
|
719
|
+
if origin and urlsplit(origin).netloc.lower() not in app.hosts:
|
|
720
|
+
return self._send(403, "Cross-origin request blocked", "text/plain")
|
|
721
|
+
try:
|
|
722
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
723
|
+
except ValueError:
|
|
724
|
+
length = -1
|
|
725
|
+
if not 0 <= length <= MAX_BODY:
|
|
726
|
+
return self._send(413, "Request too large", "text/plain")
|
|
727
|
+
form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode("utf-8", "replace"),
|
|
728
|
+
keep_blank_values=True).items()}
|
|
729
|
+
if not secrets.compare_digest(form.get("csrf", ""), app.csrf):
|
|
730
|
+
return self._send(403, "Form expired. Go back and reload the page.", "text/plain")
|
|
731
|
+
path = urlsplit(self.path).path
|
|
732
|
+
try:
|
|
733
|
+
location, error, kept = app.post(path, form)
|
|
734
|
+
except Exception as exc: # noqa: BLE001
|
|
735
|
+
log.exception("POST %s failed", path)
|
|
736
|
+
location, error, kept = None, f"Something went wrong: {exc}", form
|
|
737
|
+
if location:
|
|
738
|
+
return self._redirect(location)
|
|
739
|
+
cfg = _try_config()
|
|
740
|
+
if cfg is None or path == "/setup":
|
|
741
|
+
return self._page("Set up", page_setup(app, kept), "settings", error=error, status=400)
|
|
742
|
+
return self._page("Settings", page_settings(app, cfg), "settings", error=error, status=400)
|
|
743
|
+
|
|
744
|
+
return Handler
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class _Server(ThreadingHTTPServer):
|
|
748
|
+
daemon_threads = True
|
|
749
|
+
allow_reuse_address = True
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
class _Server6(_Server):
|
|
753
|
+
address_family = socket.AF_INET6
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def serve(port: int = DEFAULT_PORT) -> None:
|
|
757
|
+
app = App(port)
|
|
758
|
+
handler = make_handler(app)
|
|
759
|
+
try:
|
|
760
|
+
servers = [_Server(("127.0.0.1", port), handler)]
|
|
761
|
+
except OSError as exc:
|
|
762
|
+
raise SystemExit(f"Port {port} is busy ({exc}). Is the service already running? Try another --port.") from exc
|
|
763
|
+
try:
|
|
764
|
+
servers.append(_Server6(("::1", port), handler)) # browsers may try ::1 first for *.localhost
|
|
765
|
+
except OSError:
|
|
766
|
+
log.info("IPv6 loopback unavailable; serving on 127.0.0.1 only")
|
|
767
|
+
for s in servers[1:]:
|
|
768
|
+
threading.Thread(target=s.serve_forever, daemon=True).start()
|
|
769
|
+
print(f"Worklog UI: http://{HOST_NAME}:{port} (also http://localhost:{port})", flush=True)
|
|
770
|
+
try:
|
|
771
|
+
servers[0].serve_forever()
|
|
772
|
+
except KeyboardInterrupt:
|
|
773
|
+
pass
|
|
774
|
+
finally:
|
|
775
|
+
for s in servers:
|
|
776
|
+
s.server_close()
|