aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-27 19:57:56 +0200
committerDanilo M. <danix@danix.xyz>2026-09-27 19:57:56 +0200
commit16f92566380d46132e7026e22c3c79aed2ddfbbb (patch)
treea581f088020e4e9f82d38805ad80618d99ea0438
parentff6736d6ae7a1274587394fba34da34bb2cb1d8a (diff)
downloadcal-notif-16f92566380d46132e7026e22c3c79aed2ddfbbb.tar.gz
cal-notif-16f92566380d46132e7026e22c3c79aed2ddfbbb.zip
Add the daemon loop: rescan on change, notify, speak, snooze
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rwxr-xr-xcal-notif58
1 files changed, 57 insertions, 1 deletions
diff --git a/cal-notif b/cal-notif
index d577d13..3d9dcb3 100755
--- a/cal-notif
+++ b/cal-notif
@@ -321,12 +321,68 @@ def voice_ready(vcfg):
# ---- daemon ----
+def notify(occ, now, cfg, voice):
+ when = f"{occ.start:%d.%m}" if occ.allday else f"{occ.start:%d.%m %H:%M}"
+ 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",
+ occ.summary or "(senza titolo)", body],
+ stdout=subprocess.PIPE, text=True)]
+ if voice and cal_entry(cfg, occ.cal)[1]:
+ kids.append(subprocess.Popen([sys.executable, __file__, "say",
+ announce(occ, now, cfg["voice"])]))
+ return [(k, occ) for k in kids]
+
+
+def signature(cfg):
+ paths = [CONF_DIR / "config.toml", CONF_DIR / "overrides.toml"]
+ paths += [d for d, _ in calendars(cfg["calendars_dir"])]
+ # ponytail: directory mtimes catch vdirsyncer's rename-into-place writes;
+ # an in-place edit of an .ics would go unnoticed until the next change.
+ return tuple(p.stat().st_mtime if p.exists() else 0 for p in paths)
+
+
+def daemon():
+ if not shutil.which("notify-send"):
+ sys.exit("cal-notif: notify-send not found")
+ cfg, ov = load_config(), load_overrides() # a bad config at startup exits
+ voice = voice_ready(cfg["voice"])
+ now = datetime.now().astimezone()
+ last = now - parse_duration(cfg["late"])
+ sig, entries, snoozed, kids = None, [], [], []
+ while True:
+ now = datetime.now().astimezone()
+ if (s := signature(cfg)) != sig:
+ if sig is not None:
+ try:
+ cfg, ov = load_config(), load_overrides()
+ 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))
+ entries, sig = schedule(occs, cfg, ov), s
+ # ponytail: fired alarms live in memory; a restart within `late`
+ # repeats those popups once.
+ fire = due(entries, last, now, parse_duration(cfg["late"]))
+ fire += [e for e in snoozed if e[0] <= now]
+ snoozed = [e for e in snoozed if e[0] > now]
+ for _, o in fire:
+ kids += notify(o, now, cfg, voice)
+ for k, o in kids:
+ if k.poll() is not None and k.stdout and k.stdout.read().strip() == "snooze":
+ snoozed.append((now + parse_duration(cfg["snooze"]), o))
+ kids = [(k, o) for k, o in kids if k.returncode is None]
+ last = now
+ time.sleep(30)
+
# ---- picker ----
def main():
cmd = sys.argv[1:2]
- if cmd == ["say"] and len(sys.argv) > 2:
+ if cmd == ["daemon"]:
+ daemon()
+ elif cmd == ["say"] and len(sys.argv) > 2:
say(" ".join(sys.argv[2:]), load_config()["voice"])
else:
sys.exit(__doc__)