diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-27 20:15:28 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-27 20:15:28 +0200 |
| commit | ca56303bde2dd11eb259f51c33b5d1d96495502e (patch) | |
| tree | aa34909e29d2d9c4d641a429d345eb14dd25fb63 | |
| parent | d6a988f86d84ae955140a42f91b811c48c3ea1c5 (diff) | |
| download | cal-notif-master.tar.gz cal-notif-master.zip | |
- daemon(): key the rescan on signature(cfg) and the current hour, not
just signature, so a quiet calendar keeps advancing its notification
window instead of going stale after ~1 day.
- load_overrides(): validate each value is a list of parseable durations,
raising ValueError(f"override {uid!r}: ...") otherwise; the daemon's
existing reload try/except keeps the previous config on failure.
- load_config(): validate the say/say_now voice templates format cleanly
against a stub summary/in, catching bad placeholders at load time.
- notify(): pass "--" to notify-send so a summary starting with "-" is
not parsed as an option.
- daemon(): rebuild voice_ready() on config reload so voice changes
(enabled, model path) apply without a restart.
- parse_choice(): lowercase input so "Reset"/"NONE" work from rofi -i.
- notify(): note the overlapping-say-children limitation with a
ponytail comment naming the upgrade path.
- README/spec: document the hourly rebuild and its effect on in-place
.ics edits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 4 | ||||
| -rwxr-xr-x | cal-notif | 29 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-09-27-cal-notif-design.md | 8 | ||||
| -rw-r--r-- | test_cal_notif.py | 25 |
4 files changed, 58 insertions, 8 deletions
@@ -26,7 +26,9 @@ it out loud with a local Kokoro voice. Nothing leaves the machine. Config: `~/.config/cal-notif/config.toml`, see `config.example.toml`. Per-event alarms from `pick` go to `~/.config/cal-notif/overrides.toml` (`"<event UID>" = ["1h", "10m"]`), which can also be edited by hand. The -daemon picks up changes to both files, and to the calendars, within 30 s. +daemon picks up changes to both files, and to the calendars, within 30 s, +except an `.ics` file edited in place (not replaced), only seen at the next +hourly rebuild. In the picker, type durations like `1d 2h` (one alarm) or `1d, 1h` (two alarms), `none` to silence the event, `reset` to go back to its own alarms. @@ -116,6 +116,11 @@ def load_config(path=None): for v in cfg["defaults"].values(): for o in (v.get("offsets", []) if isinstance(v, dict) else v): parse_duration(o) + for t in ("say", "say_now"): # fail early on a bad template + try: + cfg["voice"][t].format(summary="", **{"in": ""}) + except (KeyError, IndexError, ValueError) as e: + raise ValueError(f"voice.{t}: bad template {cfg['voice'][t]!r}: {e}") from None return cfg @@ -124,7 +129,16 @@ def load_overrides(path=None): if not path.exists(): return {} with open(path, "rb") as f: - return tomllib.load(f) + ov = tomllib.load(f) + for uid, offs in ov.items(): + try: + if not isinstance(offs, list): + raise ValueError("want a list of durations") + for o in offs: + parse_duration(o) + except ValueError as e: + raise ValueError(f"override {uid!r}: {e}") from None + return ov def save_overrides(ov, path=None): @@ -342,10 +356,12 @@ def notify(occ, now, cfg, voice): body = "\n".join(x for x in (when, occ.location) if x) urgency = "critical" if now >= occ.start - timedelta(minutes=1) else "normal" kids = [subprocess.Popen(["notify-send", "--wait", "--app-name=cal-notif", "-u", urgency, - "--action=snooze=Snooze", "--action=dismiss=Dismiss", + "--action=snooze=Snooze", "--action=dismiss=Dismiss", "--", occ.summary or "(senza titolo)", body], stdout=subprocess.PIPE, text=True)] if voice and cal_entry(cfg, occ.cal)[1]: + # ponytail: two alarms in the same tick spawn overlapping `say` children; + # upgrade path: one say per tick joining texts. kids.append(subprocess.Popen([sys.executable, __file__, "say", announce(occ, now, cfg["voice"])])) return [(k, occ) for k in kids] @@ -369,10 +385,15 @@ def daemon(): sig, entries, snoozed, kids = None, [], [], [] while True: now = datetime.now().astimezone() - if (s := signature(cfg)) != sig: + # Also key on the hour: without this, a quiet calendar (unchanged + # signature) never rebuilds and the [now, now+horizon] window stops + # advancing, so alarms dry up after about a day. Hourly rebuild keeps + # the window moving. + if (s := (signature(cfg), now.strftime("%Y%m%d%H"))) != sig: if sig is not None: try: cfg, ov = load_config(), load_overrides() + voice = voice_ready(cfg["voice"]) except Exception as e: # keep the previous config print(f"cal-notif: config not reloaded: {e}", file=sys.stderr) occs = load_events(cfg["calendars_dir"], now - timedelta(days=1), now + horizon(cfg, ov)) @@ -406,7 +427,7 @@ def rofi(lines, prompt, theme, mesg=None, index=False): def parse_choice(s): """Returns a list of offsets, or None for 'reset'. Raises ValueError.""" - s = s.strip() + s = s.strip().lower() if s == "reset": return None if s == "none": diff --git a/docs/superpowers/specs/2026-09-27-cal-notif-design.md b/docs/superpowers/specs/2026-09-27-cal-notif-design.md index 4d02f90..f2bfea1 100644 --- a/docs/superpowers/specs/2026-09-27-cal-notif-design.md +++ b/docs/superpowers/specs/2026-09-27-cal-notif-design.md @@ -92,8 +92,9 @@ For each event occurrence, the offsets are the first of: Every 30 s: 1. **Rescan** the `.ics` files when any calendar directory's mtime changed - (vdirsyncer rewrites files there), and when the config or overrides file's - mtime changed. + (vdirsyncer rewrites files there), when the config or overrides file's + mtime changed, and every hour regardless, so the window keeps moving even + when nothing changed. 2. **Expand** events into concrete occurrences within a window `[now, now + largest offset in use + 1 day]`. The window follows the largest offset, so a "1 week before" alarm is never dropped. @@ -104,7 +105,8 @@ Every 30 s: from there. - Floating times are local time. The result is a sorted list of `(fire_time, occurrence, offset)`, - recomputed only on rescan. + recomputed on rescan, which also runs every hour so the window keeps + moving. 3. **Fire** every entry with `last_tick < fire_time <= now`, skipping those older than `now - late` and those whose occurrence already started (except offset 0, the "now" alarm). diff --git a/test_cal_notif.py b/test_cal_notif.py index 0bb647c..89ff1ee 100644 --- a/test_cal_notif.py +++ b/test_cal_notif.py @@ -79,6 +79,29 @@ def test_config(): raise AssertionError except ValueError: pass + p.write_text('[voice]\nsay = "Fra {minuti}: {summary}"\n') + try: + cn.load_config(p) + raise AssertionError + except ValueError: + pass + + +def test_overrides_validate(): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "overrides.toml" + p.write_text('"x" = ["1hour"]\n') + try: + cn.load_overrides(p) + raise AssertionError + except ValueError: + pass + p.write_text('"x" = "1h"\n') + try: + cn.load_overrides(p) + raise AssertionError + except ValueError: + pass def test_expand(): @@ -198,6 +221,8 @@ def test_choice(): assert cn.parse_choice("90m") == ["1h 30m"] assert cn.parse_choice("none") == [] assert cn.parse_choice("reset") is None + assert cn.parse_choice("Reset") is None + assert cn.parse_choice("NONE") == [] try: cn.parse_choice("soon") raise AssertionError |
