diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-27 19:51:09 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-27 19:51:09 +0200 |
| commit | 1abb3002de93f9af64fa419109d2ef9114b99e76 (patch) | |
| tree | 7857f668c794bbfb28c5b2813bb57259896fde4c | |
| parent | 2dd08a66fe9e443564fe9dc3ee8cedda1c8a7425 (diff) | |
| download | cal-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-- | .gitignore | 1 | ||||
| -rwxr-xr-x | cal-notif | 103 | ||||
| -rw-r--r-- | test_cal_notif.py | 60 |
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) |
