aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-27 19:51:09 +0200
committerDanilo M. <danix@danix.xyz>2026-09-27 19:51:09 +0200
commit1abb3002de93f9af64fa419109d2ef9114b99e76 (patch)
tree7857f668c794bbfb28c5b2813bb57259896fde4c
parent2dd08a66fe9e443564fe9dc3ee8cedda1c8a7425 (diff)
downloadcal-notif-1abb3002de93f9af64fa419109d2ef9114b99e76.tar.gz
cal-notif-1abb3002de93f9af64fa419109d2ef9114b99e76.zip
Add cal-notif skeleton with duration parsing and Italian spoken form
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--.gitignore1
-rwxr-xr-xcal-notif103
-rw-r--r--test_cal_notif.py60
3 files changed, 164 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c18dd8d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/cal-notif b/cal-notif
new file mode 100755
index 0000000..e7bd244
--- /dev/null
+++ b/cal-notif
@@ -0,0 +1,103 @@
+#!/usr/bin/python3
+# cal-notif: calendar notifications with local spoken announcements.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 2 as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+"""Usage: cal-notif daemon | pick | say TEXT
+
+daemon watch the calendars and notify (popup + voice) at each alarm
+pick rofi picker to set custom alarm offsets for one event
+say speak TEXT with the configured voice
+"""
+
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+import tomllib
+from collections import namedtuple
+from datetime import datetime, time as dtime, timedelta, timezone
+from pathlib import Path
+
+from dateutil.rrule import rrulestr
+from icalendar import Calendar, vRecur
+
+# ---- durations ----
+
+DUR = re.compile(r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?")
+
+
+def parse_duration(s):
+ m = DUR.fullmatch(s.replace(" ", ""))
+ if not s.strip() or not m:
+ raise ValueError(f"bad duration {s!r}, want e.g. '1d 2h 15m'")
+ d, h, mi = (int(x or 0) for x in m.groups())
+ return timedelta(days=d, hours=h, minutes=mi)
+
+
+def fmt_duration(td):
+ m = round(td.total_seconds() / 60)
+ d, m = divmod(m, 1440)
+ h, m = divmod(m, 60)
+ return " ".join(f"{n}{u}" for n, u in ((d, "d"), (h, "h"), (m, "m")) if n) or "0m"
+
+
+UNITS = ["", "uno", "due", "tre", "quattro", "cinque", "sei", "sette", "otto", "nove",
+ "dieci", "undici", "dodici", "tredici", "quattordici", "quindici", "sedici",
+ "diciassette", "diciotto", "diciannove"]
+TENS = ["", "", "venti", "trenta", "quaranta", "cinquanta", "sessanta", "settanta",
+ "ottanta", "novanta"]
+
+
+def number_it(n):
+ # 1..99; ventuno, ventotto: the tens drop their vowel before uno/otto.
+ if n < 20:
+ return UNITS[n]
+ t, u = divmod(n, 10)
+ return (TENS[t][:-1] if u in (1, 8) else TENS[t]) + UNITS[u]
+
+
+def spoken_it(td):
+ m = round(td.total_seconds() / 60)
+ d, m = divmod(m, 1440)
+ h, m = divmod(m, 60)
+ parts = []
+ for n, one, many in ((d, "un giorno", "giorni"), (h, "un'ora", "ore"), (m, "un minuto", "minuti")):
+ if n == 1:
+ parts.append(one)
+ elif n:
+ # ponytail: number_it covers 1-99; bigger counts are read as digits.
+ parts.append(f"{number_it(n) if n < 100 else n} {many}")
+ if not parts:
+ return ""
+ return parts[0] if len(parts) == 1 else ", ".join(parts[:-1]) + " e " + parts[-1]
+
+# ---- config ----
+
+# ---- calendars ----
+
+# ---- alarms ----
+
+# ---- voice ----
+
+# ---- daemon ----
+
+# ---- picker ----
+
+
+def main():
+ sys.exit(__doc__)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_cal_notif.py b/test_cal_notif.py
new file mode 100644
index 0000000..f025ff4
--- /dev/null
+++ b/test_cal_notif.py
@@ -0,0 +1,60 @@
+#!/usr/bin/python3
+# Tests for cal-notif. Run: /usr/bin/python3 test_cal_notif.py
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License version 2 only.
+
+import importlib.machinery
+import importlib.util
+import os
+import tempfile
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+os.environ["TZ"] = "Europe/Rome" # all-day and floating times are local
+time.tzset()
+
+HERE = Path(__file__).resolve().parent
+_loader = importlib.machinery.SourceFileLoader("cal_notif", str(HERE / "cal-notif"))
+_spec = importlib.util.spec_from_loader("cal_notif", _loader)
+cn = importlib.util.module_from_spec(_spec)
+_loader.exec_module(cn)
+
+ROME = ZoneInfo("Europe/Rome")
+H, M, D = timedelta(hours=1), timedelta(minutes=1), timedelta(days=1)
+
+
+def ics(*events):
+ body = "".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT\n" for e in events)
+ return f"BEGIN:VCALENDAR\nVERSION:2.0\n{body}END:VCALENDAR\n".encode()
+
+
+def at(y, mo, d, h=0, mi=0):
+ return datetime(y, mo, d, h, mi, tzinfo=ROME)
+
+
+def test_durations():
+ assert cn.parse_duration("1d 2h 15m") == D + 2 * H + 15 * M
+ assert cn.parse_duration("90m") == 90 * M
+ assert cn.parse_duration("0m") == timedelta()
+ for bad in ("", "5", "1x", "m", "2h1d"):
+ try:
+ cn.parse_duration(bad)
+ raise AssertionError(bad)
+ except ValueError:
+ pass
+ assert cn.fmt_duration(D + 2 * H) == "1d 2h"
+ assert cn.fmt_duration(timedelta()) == "0m"
+ assert cn.spoken_it(10 * M) == "dieci minuti"
+ assert cn.spoken_it(H) == "un'ora"
+ assert cn.spoken_it(D + 2 * H) == "un giorno e due ore"
+ assert cn.spoken_it(2 * D + H + 21 * M) == "due giorni, un'ora e ventuno minuti"
+ assert cn.number_it(28) == "ventotto" and cn.number_it(45) == "quarantacinque"
+
+
+if __name__ == "__main__":
+ for name, fn in list(globals().items()):
+ if name.startswith("test_"):
+ fn()
+ print("ok", name)