aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-27 19:52:28 +0200
committerDanilo M. <danix@danix.xyz>2026-09-27 19:52:28 +0200
commitdb9a7a2dc1e35991c6410a1cef1ddad1c0b3c155 (patch)
tree81be08fbf139b3ac6c400f953d94798f8442b01b
parent1abb3002de93f9af64fa419109d2ef9114b99e76 (diff)
downloadcal-notif-db9a7a2dc1e35991c6410a1cef1ddad1c0b3c155.tar.gz
cal-notif-db9a7a2dc1e35991c6410a1cef1ddad1c0b3c155.zip
Load config with defaults, read and write per-event overrides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rwxr-xr-xcal-notif51
-rw-r--r--test_cal_notif.py26
2 files changed, 77 insertions, 0 deletions
diff --git a/cal-notif b/cal-notif
index e7bd244..3b4556d 100755
--- a/cal-notif
+++ b/cal-notif
@@ -32,6 +32,24 @@ from pathlib import Path
from dateutil.rrule import rrulestr
from icalendar import Calendar, vRecur
+CONF_DIR = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "cal-notif"
+DEFAULTS = {
+ "calendars_dir": "~/.local/share/calendars",
+ "snooze": "5m",
+ "late": "10m",
+ "rofi_theme": "~/.config/rofi/udt/list.rasi",
+ "defaults": {"*": []},
+ "voice": {"enabled": True,
+ "model": "/data/voice-models/kokoro/kokoro-v1.0.onnx",
+ "voices": "/data/voice-models/kokoro/voices-v1.0.bin",
+ "lang": "it",
+ "blend": {"if_sara": 0.8, "af_bella": 0.2},
+ "lead_silence": 0.3,
+ "say": "Tra {in}: {summary}",
+ "say_now": "Adesso: {summary}"},
+}
+PICK_DAYS = 30
+
# ---- durations ----
DUR = re.compile(r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?")
@@ -84,6 +102,39 @@ def spoken_it(td):
# ---- config ----
+def load_config(path=None):
+ path = path or CONF_DIR / "config.toml"
+ cfg = {k: dict(v) if isinstance(v, dict) else v for k, v in DEFAULTS.items()}
+ if path.exists():
+ with open(path, "rb") as f:
+ user = tomllib.load(f)
+ for k, v in user.items():
+ cfg[k] = {**cfg[k], **v} if k == "voice" else v
+ for k in ("snooze", "late"): # fail early on bad durations
+ parse_duration(cfg[k])
+ for v in cfg["defaults"].values():
+ for o in (v.get("offsets", []) if isinstance(v, dict) else v):
+ parse_duration(o)
+ return cfg
+
+
+def load_overrides(path=None):
+ path = path or CONF_DIR / "overrides.toml"
+ if not path.exists():
+ return {}
+ with open(path, "rb") as f:
+ return tomllib.load(f)
+
+
+def save_overrides(ov, path=None):
+ path = path or CONF_DIR / "overrides.toml"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ # JSON strings and string arrays are valid TOML, so no TOML writer is needed.
+ tmp.write_text("".join(f"{json.dumps(uid)} = {json.dumps(offs)}\n"
+ for uid, offs in sorted(ov.items())))
+ os.replace(tmp, path)
+
# ---- calendars ----
# ---- alarms ----
diff --git a/test_cal_notif.py b/test_cal_notif.py
index f025ff4..27f60ba 100644
--- a/test_cal_notif.py
+++ b/test_cal_notif.py
@@ -53,6 +53,32 @@ def test_durations():
assert cn.number_it(28) == "ventotto" and cn.number_it(45) == "quarantacinque"
+def test_overrides_roundtrip():
+ with tempfile.TemporaryDirectory() as d:
+ p = Path(d) / "overrides.toml"
+ ov = {"a@example.org": ["1h", "10m"], 'q"uote@example.org': []}
+ cn.save_overrides(ov, p)
+ assert cn.load_overrides(p) == ov
+ assert cn.load_overrides(Path(d) / "missing.toml") == {}
+
+
+def test_config():
+ with tempfile.TemporaryDirectory() as d:
+ p = Path(d) / "config.toml"
+ assert cn.load_config(p)["voice"]["lang"] == "it" # no file: defaults
+ p.write_text('snooze = "15m"\n[voice]\nlang = "en-us"\n'
+ '[defaults]\nbd = { offsets = ["1d"], voice = false }\n')
+ c = cn.load_config(p)
+ assert c["snooze"] == "15m" and c["late"] == "10m"
+ assert c["voice"]["lang"] == "en-us" and c["voice"]["lead_silence"] == 0.3 # merged
+ p.write_text('[defaults]\n"*" = ["soon"]\n')
+ try:
+ cn.load_config(p)
+ raise AssertionError
+ except ValueError:
+ pass
+
+
if __name__ == "__main__":
for name, fn in list(globals().items()):
if name.startswith("test_"):