timeglance 1.0.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.
timeglance/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """timeglance: printable weekly routine sheets and a year-at-a-glance planner."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1 @@
1
+ """Shared pieces: the control-panel web server and the Plasma wallpaper backend."""
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env python3
2
+ """timeglance control panel: one server driving both tools.
3
+
4
+ A single-page UI (common/web) switches between the weekly sheet and the year planner,
5
+ and between a live preview and a raw-YAML config editor. It renders previews, exports
6
+ (PNG/PDF), sets the desktop wallpaper, and runs a daily re-render scheduler.
7
+
8
+ Run: python -m common.serve then open http://localhost:8753
9
+ The YAML is read on every request, so external edits show up on reload.
10
+ """
11
+
12
+ import argparse
13
+ import datetime as dt
14
+ import json
15
+ import threading
16
+ import traceback
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+ from urllib.parse import parse_qs, urlparse
20
+
21
+ import cairosvg
22
+ import yaml
23
+
24
+ from timeglance import paths
25
+ from timeglance.common import wallpaper
26
+ from timeglance.weekly import build as wbuild
27
+ from timeglance.year import build as ybuild
28
+ from timeglance.year import render as yrender
29
+
30
+ WEB = Path(__file__).parent / "web"
31
+ STATE = paths.state_dir()
32
+ NO_CACHE = {"Cache-Control": "no-store"}
33
+
34
+
35
+ class YearTool:
36
+ """Control-panel adapter for the year planner: config, preview, export and wallpaper."""
37
+
38
+ name = "year"
39
+ template = ybuild.TEMPLATE
40
+
41
+ @property
42
+ def data(self):
43
+ """Path to the working ``year/config.yaml``."""
44
+ return paths.tool_config("year")
45
+
46
+ @property
47
+ def out(self):
48
+ """Output directory for rendered year planners."""
49
+ return paths.tool_out("year")
50
+
51
+ def _raw(self):
52
+ """Load the raw year config dict."""
53
+ return ybuild.load(self.data)
54
+
55
+ def build_cfg(self, p):
56
+ """Build a resolved render config, applying the request's UI overrides onto the YAML."""
57
+ raw = self._raw()
58
+ for key in ("theme", "layout", "week_start", "size"):
59
+ if p.get(key):
60
+ raw[key] = p[key]
61
+ if "week_align" in p:
62
+ raw["week_align"] = p["week_align"] == "true"
63
+ if p.get("start") or p.get("end"):
64
+ r = raw.get("range") or {}
65
+ raw["range"] = {
66
+ "start": p.get("start") or r.get("start", "year-start"),
67
+ "end": p.get("end") or r.get("end", "year-end"),
68
+ }
69
+ ybuild.assemble(raw)
70
+ ybuild.resolve_size(raw)
71
+ if "hide" in p:
72
+ hide = set(filter(None, p["hide"].split(",")))
73
+ for lyr in raw["layers"]:
74
+ lyr["visible"] = lyr["id"] not in hide
75
+ return yrender.parse(raw)
76
+
77
+ def meta(self):
78
+ """UI metadata: layers, themes, layouts, sizes, range, source summary and wallpaper info."""
79
+ raw = self._raw()
80
+ summary = ybuild.assemble(raw)
81
+ backend = wallpaper.detect()
82
+ wp = raw.get("wallpaper") or {}
83
+ return {
84
+ "layers": [
85
+ {"id": lyr["id"], "label": lyr["label"], "color": lyr["color"], "visible": lyr.get("visible", True)}
86
+ for lyr in raw["layers"]
87
+ ],
88
+ "themes": list(yrender.THEMES),
89
+ "layouts": ybuild.ALL_LAYOUTS,
90
+ "sizes": list(raw.get("sizes") or {}),
91
+ "range": raw.get("range") or {"start": "year-start", "end": "year-end"},
92
+ "week_start": raw.get("week_start", "Mon"),
93
+ "theme": raw.get("theme") or "light",
94
+ "layout": raw.get("layout") or "vertical",
95
+ "week_align": raw.get("week_align", True),
96
+ "size": raw.get("size", ""),
97
+ "sources": summary,
98
+ "wallpaper": _wp_meta(backend, wp),
99
+ }
100
+
101
+ def preview(self, p):
102
+ """Return the planner as an SVG preview for the request params."""
103
+ return "image/svg+xml", yrender.render_svg(self.build_cfg(p)).encode()
104
+
105
+ def export(self, p):
106
+ """Render the planner to a PNG for download."""
107
+ cfg = self.build_cfg(p)
108
+ png = cairosvg.svg2png(
109
+ bytestring=yrender.render_svg(cfg).encode(), output_width=cfg.width, output_height=cfg.height
110
+ )
111
+ return "image/png", "year.png", png
112
+
113
+ def set_wallpaper(self, p):
114
+ """Render per-screen and set the planner as the desktop wallpaper."""
115
+ backend = wallpaper.detect()
116
+ if not backend:
117
+ return {"ok": False, "error": "no supported wallpaper backend"}
118
+ cfg = self.build_cfg(p)
119
+ wp = self._raw().get("wallpaper") or {}
120
+ screens = wallpaper.resolve_screens(backend, p.get("screens") or wp.get("screens", "all"))
121
+ fill = p.get("fill") or wp.get("fill", "preserveAspectCrop")
122
+ _, info = ybuild.apply_wallpaper(cfg, backend, screens, fill, self.out)
123
+ return {"ok": True, **info}
124
+
125
+ def warnings(self):
126
+ """Return any event-source warnings from assembling the config."""
127
+ raw = self._raw()
128
+ ybuild.assemble(raw)
129
+ return raw.get("_warnings", [])
130
+
131
+
132
+ class WeeklyTool:
133
+ """Control-panel adapter for the weekly sheet: config, preview, export and wallpaper."""
134
+
135
+ name = "weekly"
136
+ template = wbuild.TEMPLATE
137
+ papers = ["A4", "A3"]
138
+
139
+ @property
140
+ def data(self):
141
+ """Path to the working ``weekly/config.yaml``."""
142
+ return paths.tool_config("weekly")
143
+
144
+ @property
145
+ def out(self):
146
+ """Output directory for rendered weekly sheets."""
147
+ return paths.tool_out("weekly")
148
+
149
+ def _raw(self):
150
+ """Load the raw weekly config dict."""
151
+ return wbuild.load(self.data)
152
+
153
+ def _hide(self, p):
154
+ """Hidden-activity set from the request's ``hide`` param, or None to use YAML defaults."""
155
+ return set(filter(None, p["hide"].split(","))) if "hide" in p else None
156
+
157
+ def meta(self):
158
+ """UI metadata: paper sizes, activity layers and wallpaper info."""
159
+ raw = self._raw()
160
+ backend = wallpaper.detect()
161
+ wp = raw.get("wallpaper") or {}
162
+ return {
163
+ "papers": self.papers,
164
+ "paper": "A4",
165
+ "layers": [
166
+ {"id": k, "label": a["label"], "color": a["color"], "visible": a.get("visible", True)}
167
+ for k, a in raw.get("activities", {}).items()
168
+ ],
169
+ "wallpaper": _wp_meta(backend, wp),
170
+ }
171
+
172
+ def preview(self, p):
173
+ """Return the sheet as an HTML preview for the request params."""
174
+ html = wbuild.render(self._raw(), self._hide(p), p.get("paper", "A4"))
175
+ return "text/html; charset=utf-8", html.encode()
176
+
177
+ def export(self, p):
178
+ """Render the sheet to a PDF for download."""
179
+ pdf = wbuild.render_pdf(self._raw(), self._hide(p), p.get("paper", "A4"))
180
+ return "application/pdf", "weekly.pdf", pdf
181
+
182
+ def set_wallpaper(self, p):
183
+ """Composite the sheet onto a themed canvas and set it as the desktop wallpaper."""
184
+ backend = wallpaper.detect()
185
+ if not backend:
186
+ return {"ok": False, "error": "no supported wallpaper backend"}
187
+ raw = self._raw()
188
+ wp = raw.get("wallpaper") or {}
189
+ screens = wallpaper.resolve_screens(backend, p.get("screens") or wp.get("screens", "all"))
190
+ fill = p.get("fill") or wp.get("fill", "preserveAspectCrop")
191
+ pdf = wbuild.render_pdf(raw, self._hide(p), p.get("paper", "A4"))
192
+ render_png = wbuild.make_wallpaper_renderer(
193
+ pdf, wp.get("background", "#2b2f36"), wp.get("margin", 0.05), wbuild.resolve_shadow(wp)
194
+ )
195
+ _, info = wallpaper.apply(backend, screens, self.out, render_png, fill, prefix="weekly-wallpaper")
196
+ return {"ok": True, **info}
197
+
198
+ def warnings(self):
199
+ """The weekly sheet has no external event sources, so never any warnings."""
200
+ return []
201
+
202
+
203
+ def _screen_list(backend):
204
+ """Serialise the backend's screens for the UI (empty when there's no backend)."""
205
+ return [
206
+ {"name": s.name, "w": s.pw, "h": s.ph, "primary": s.primary, "model": s.model}
207
+ for s in (backend.list_screens() if backend else [])
208
+ ]
209
+
210
+
211
+ def _human_size(n):
212
+ """Format a byte count as a human-readable KB/MB string."""
213
+ kb = n / 1024
214
+ return f"{kb:.0f} KB" if kb < 1024 else f"{kb / 1024:.1f} MB"
215
+
216
+
217
+ def _wp_meta(backend, wp):
218
+ """Build the wallpaper metadata block (availability, backend, screens, selection) for the UI."""
219
+ return {
220
+ "available": backend is not None,
221
+ "backend": backend.name if backend else None,
222
+ "screens": _screen_list(backend),
223
+ "selected": wp.get("screens", "all"),
224
+ "fill": wp.get("fill", "preserveAspectCrop"),
225
+ }
226
+
227
+
228
+ TOOLS = {"year": YearTool(), "weekly": WeeklyTool()}
229
+
230
+
231
+ def read_config(tool):
232
+ """Read the tool's working config, falling back to its packaged template when absent."""
233
+ path = tool.data if tool.data.exists() else tool.template
234
+ return {"yaml": path.read_text(), "from_template": not tool.data.exists(), "path": tool.data.name}
235
+
236
+
237
+ def write_config(tool, text):
238
+ """Validate ``text`` as YAML and write it to the tool's working config."""
239
+ yaml.safe_load(text) # validate; raises on bad YAML
240
+ tool.data.write_text(text)
241
+
242
+
243
+ def reset_config(tool):
244
+ """Overwrite the working config with the template, backing up the current file first.
245
+ Returns the backup filename (or None if there was nothing to back up)."""
246
+ backup = None
247
+ if tool.data.exists():
248
+ stamp = dt.datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
249
+ backup = tool.data.with_name(f"{tool.data.name}.{stamp}.bak")
250
+ backup.write_text(tool.data.read_text())
251
+ tool.data.write_text(tool.template.read_text())
252
+ return backup.name if backup else None
253
+
254
+
255
+ class EventLog:
256
+ """Append-only activity log shared by the scheduler and the manual export/wallpaper
257
+ actions, so every durable output (not live previews) leaves a trace under state/.
258
+ A lock keeps concurrent writes from the threading HTTP server interleaved cleanly."""
259
+
260
+ def __init__(self):
261
+ """Open (creating as needed) the activity log under ``state/``."""
262
+ STATE.mkdir(parents=True, exist_ok=True)
263
+ self.path = STATE / "activity.log"
264
+ self._lock = threading.Lock()
265
+
266
+ def log(self, source, msg):
267
+ """Append a timestamped ``source: msg`` line (thread-safe)."""
268
+ line = f"{dt.datetime.now():%Y-%m-%d %H:%M:%S} {source}: {msg}"
269
+ with self._lock, self.path.open("a") as f:
270
+ f.write(line + "\n")
271
+
272
+ def lines(self, n=200):
273
+ """Return the last ``n`` log lines (empty if the log doesn't exist yet)."""
274
+ if not self.path.exists():
275
+ return []
276
+ return self.path.read_text().splitlines()[-n:]
277
+
278
+ def clear(self):
279
+ """Empty the activity log."""
280
+ self.path.write_text("")
281
+
282
+
283
+ LOG = EventLog()
284
+
285
+
286
+ class Scheduler:
287
+ """Daily re-render loop. Sleeps until the configured HH:MM, runs the selected tools
288
+ (writing outputs and, where a backend exists, setting the wallpaper). Runs feed the
289
+ shared activity log; config persists under state/ so a container restart keeps it."""
290
+
291
+ def __init__(self):
292
+ """Load persisted schedule config from ``state/`` and set up the (stopped) loop."""
293
+ STATE.mkdir(parents=True, exist_ok=True)
294
+ self.cfg_path = STATE / "schedule.json"
295
+ self.cfg = self._load_cfg()
296
+ self._thread = None
297
+ self._stop = threading.Event()
298
+ self.last_run = None
299
+ self.next_run = None
300
+
301
+ def _load_cfg(self):
302
+ """Load the persisted schedule config, or defaults on first run."""
303
+ if self.cfg_path.exists():
304
+ return json.loads(self.cfg_path.read_text())
305
+ return {"time": "06:00", "tools": ["year"], "wallpaper": True}
306
+
307
+ def save_cfg(self, cfg):
308
+ """Merge updates into the schedule config and persist it."""
309
+ self.cfg = {**self.cfg, **cfg}
310
+ self.cfg_path.write_text(json.dumps(self.cfg, indent=2))
311
+
312
+ def run_once(self):
313
+ """Render every selected tool, isolating failures so one bad tool/feed can't
314
+ sink the rest; each outcome (including tracebacks) lands in the log."""
315
+ self.last_run = dt.datetime.now().isoformat(timespec="seconds")
316
+ for name in self.cfg.get("tools", []):
317
+ tool = TOOLS[name]
318
+ try:
319
+ ctype, fname, data = tool.export({})
320
+ (tool.out).mkdir(parents=True, exist_ok=True)
321
+ (tool.out / fname).write_bytes(data)
322
+ LOG.log(name, f"wrote {fname} ({_human_size(len(data))})")
323
+ for w in tool.warnings():
324
+ LOG.log(name, f"WARNING {w}")
325
+ if not self.cfg.get("wallpaper"):
326
+ continue
327
+ if not wallpaper.detect():
328
+ LOG.log(name, "wallpaper skipped (no backend)")
329
+ continue
330
+ scr = (self.cfg.get("screens") or {}).get(name)
331
+ r = tool.set_wallpaper({"screens": scr} if scr else {})
332
+ detail = "set " + ",".join(r["applied"]) if r["ok"] else "FAILED " + r["error"]
333
+ LOG.log(name, f"wallpaper {detail}")
334
+ except Exception:
335
+ LOG.log(name, f"ERROR\n{traceback.format_exc().strip()}")
336
+
337
+ def _loop(self):
338
+ """Background loop: sleep until the configured time, run once, repeat until stopped."""
339
+ LOG.log("scheduler", "started")
340
+ while not self._stop.is_set():
341
+ hh, mm = (int(x) for x in self.cfg["time"].split(":"))
342
+ now = dt.datetime.now()
343
+ nxt = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
344
+ if nxt <= now:
345
+ nxt += dt.timedelta(days=1)
346
+ self.next_run = nxt.isoformat(timespec="seconds")
347
+ if self._stop.wait((nxt - now).total_seconds()):
348
+ break
349
+ self.run_once()
350
+ self.next_run = None
351
+ LOG.log("scheduler", "stopped")
352
+
353
+ @property
354
+ def running(self):
355
+ """True while the background loop thread is alive."""
356
+ return self._thread is not None and self._thread.is_alive()
357
+
358
+ def start(self):
359
+ """Start the background scheduler loop (no-op if already running)."""
360
+ if self.running:
361
+ return
362
+ self._stop.clear()
363
+ self._thread = threading.Thread(target=self._loop, daemon=True)
364
+ self._thread.start()
365
+
366
+ def stop(self):
367
+ """Signal the background loop to stop."""
368
+ self._stop.set()
369
+
370
+ def status(self):
371
+ """Return the scheduler's current state and config for the UI."""
372
+ return {
373
+ "running": self.running,
374
+ "time": self.cfg["time"],
375
+ "tools": self.cfg["tools"],
376
+ "wallpaper": self.cfg.get("wallpaper", True),
377
+ "screens": self.cfg.get("screens", {}),
378
+ "wp_screens": _screen_list(wallpaper.detect()),
379
+ "last_run": self.last_run,
380
+ "next_run": self.next_run,
381
+ }
382
+
383
+
384
+ SCHED = Scheduler()
385
+
386
+
387
+ class Handler(BaseHTTPRequestHandler):
388
+ """HTTP handler serving the SPA assets and the ``/api/*`` endpoints."""
389
+
390
+ def log_message(self, *a):
391
+ """Silence the default per-request stderr logging."""
392
+ pass
393
+
394
+ def _send(self, code, ctype, body, extra=None):
395
+ """Send a response with the given status, content type, body and extra headers."""
396
+ if isinstance(body, str):
397
+ body = body.encode()
398
+ self.send_response(code)
399
+ self.send_header("Content-Type", ctype)
400
+ self.send_header("Content-Length", str(len(body)))
401
+ for k, v in (extra or {}).items():
402
+ self.send_header(k, v)
403
+ self.end_headers()
404
+ self.wfile.write(body)
405
+
406
+ def _json(self, obj, code=200):
407
+ """Send ``obj`` as a JSON response."""
408
+ self._send(code, "application/json", json.dumps(obj).encode())
409
+
410
+ def _tool(self, params):
411
+ """Resolve the target tool from request params (defaults to the year planner)."""
412
+ return TOOLS[params.get("tool", "year")]
413
+
414
+ def _body(self):
415
+ """Parse and return the JSON request body (empty dict if none)."""
416
+ length = int(self.headers.get("Content-Length", 0))
417
+ return json.loads(self.rfile.read(length) or b"{}")
418
+
419
+ def do_GET(self):
420
+ """Route GET requests: static assets, config, preview, export, schedule and log."""
421
+ u = urlparse(self.path)
422
+ p = {k: v[0] for k, v in parse_qs(u.query).items()}
423
+ try:
424
+ if u.path == "/":
425
+ self._send(200, "text/html; charset=utf-8", (WEB / "index.html").read_bytes(), NO_CACHE)
426
+ elif u.path == "/app.js":
427
+ self._send(200, "text/javascript", (WEB / "app.js").read_bytes(), NO_CACHE)
428
+ elif u.path == "/favicon.svg":
429
+ self._send(200, "image/svg+xml", (WEB / "favicon.svg").read_bytes())
430
+ elif u.path == "/favicon.ico":
431
+ self._send(200, "image/x-icon", (WEB / "favicon.ico").read_bytes())
432
+ elif u.path == "/icon.png":
433
+ self._send(200, "image/png", (WEB / "icon.png").read_bytes())
434
+ elif u.path == "/api/config":
435
+ self._json({"meta": self._tool(p).meta(), **read_config(self._tool(p))})
436
+ elif u.path == "/api/preview":
437
+ ctype, body = self._tool(p).preview(p)
438
+ self._send(200, ctype, body)
439
+ elif u.path == "/api/export":
440
+ tool = self._tool(p)
441
+ ctype, fname, body = tool.export(p)
442
+ fmt = fname.rsplit(".", 1)[-1].upper()
443
+ LOG.log(tool.name, f"exported {fname} ({fmt}, {_human_size(len(body))})")
444
+ self._send(200, ctype, body, {"Content-Disposition": f'inline; filename="{fname}"'})
445
+ elif u.path == "/api/schedule":
446
+ self._json(SCHED.status())
447
+ elif u.path == "/api/log":
448
+ self._json({"log": LOG.lines()})
449
+ else:
450
+ self._send(404, "text/plain", "not found")
451
+ except ValueError as e:
452
+ self._json({"error": str(e)}, 400)
453
+ except Exception:
454
+ self._json({"error": traceback.format_exc().strip()}, 500)
455
+
456
+ def do_POST(self):
457
+ """Route POST requests: config save/reset, wallpaper, log clear and schedule control."""
458
+ u = urlparse(self.path)
459
+ p = {k: v[0] for k, v in parse_qs(u.query).items()}
460
+ try:
461
+ if u.path == "/api/config":
462
+ write_config(self._tool(p), self._body()["yaml"])
463
+ self._json({"ok": True})
464
+ elif u.path == "/api/config/reset":
465
+ backup = reset_config(self._tool(p))
466
+ self._json({"meta": self._tool(p).meta(), "backup": backup, **read_config(self._tool(p))})
467
+ elif u.path == "/api/wallpaper":
468
+ tool = self._tool(p)
469
+ r = tool.set_wallpaper(self._body())
470
+ if r["ok"]:
471
+ LOG.log(tool.name, f"wallpaper set {','.join(r['applied']) or 'nothing matched'} (manual)")
472
+ else:
473
+ LOG.log(tool.name, f"wallpaper FAILED {r['error']} (manual)")
474
+ self._json(r)
475
+ elif u.path == "/api/log/clear":
476
+ LOG.clear()
477
+ self._json({"ok": True})
478
+ elif u.path == "/api/schedule":
479
+ body = self._body()
480
+ SCHED.save_cfg({k: body[k] for k in ("time", "tools", "wallpaper", "screens") if k in body})
481
+ if body.get("action") == "start":
482
+ SCHED.start()
483
+ elif body.get("action") == "stop":
484
+ SCHED.stop()
485
+ elif body.get("action") == "run":
486
+ threading.Thread(target=SCHED.run_once, daemon=True).start()
487
+ self._json(SCHED.status())
488
+ else:
489
+ self._send(404, "text/plain", "not found")
490
+ except Exception:
491
+ self._json({"error": traceback.format_exc().strip()}, 500)
492
+
493
+
494
+ def main():
495
+ """CLI entry point: start the scheduler (if autostart) and serve the control panel."""
496
+ ap = argparse.ArgumentParser(description=__doc__)
497
+ ap.add_argument("--host", default="127.0.0.1", help="bind address (use 0.0.0.0 in a container)")
498
+ ap.add_argument("--port", type=int, default=8753)
499
+ args = ap.parse_args()
500
+ if SCHED.cfg.get("autostart"):
501
+ SCHED.start()
502
+ print(f"timeglance -> http://{args.host}:{args.port} (Ctrl-C to stop)")
503
+ ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
504
+
505
+
506
+ if __name__ == "__main__":
507
+ main()