aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-27 20:15:28 +0200
committerDanilo M. <danix@danix.xyz>2026-09-27 20:15:28 +0200
commitca56303bde2dd11eb259f51c33b5d1d96495502e (patch)
treeaa34909e29d2d9c4d641a429d345eb14dd25fb63
parentd6a988f86d84ae955140a42f91b811c48c3ea1c5 (diff)
downloadcal-notif-ca56303bde2dd11eb259f51c33b5d1d96495502e.tar.gz
cal-notif-ca56303bde2dd11eb259f51c33b5d1d96495502e.zip
Fix final review: hourly rebuild, validate overrides and voice templatesHEADmaster
- 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.md4
-rwxr-xr-xcal-notif29
-rw-r--r--docs/superpowers/specs/2026-09-27-cal-notif-design.md8
-rw-r--r--test_cal_notif.py25
4 files changed, 58 insertions, 8 deletions
diff --git a/README.md b/README.md
index 09eb80a..38ce041 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/cal-notif b/cal-notif
index 792d754..21977e7 100755
--- a/cal-notif
+++ b/cal-notif
@@ -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