aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-27 20:00:15 +0200
committerDanilo M. <danix@danix.xyz>2026-09-27 20:00:15 +0200
commit223292883da497b46caa0e01be0f4a1a3f134ea8 (patch)
tree2aba248109001d87be75447882e66b4e87c55023
parent16f92566380d46132e7026e22c3c79aed2ddfbbb (diff)
downloadcal-notif-223292883da497b46caa0e01be0f4a1a3f134ea8.tar.gz
cal-notif-223292883da497b46caa0e01be0f4a1a3f134ea8.zip
Fix review findings: ACTION:NONE alarms, negative offsets, non-ASCII UIDs
- valarms()/lead() now skip RFC 9074 ACTION:NONE placeholder VALARMs, so those events fall through to the calendar default instead of going silent. - fmt_duration() renders negative timedeltas as "-Xd Yh Zm" instead of a wrapped-around wall-clock artifact. - save_overrides() writes JSON with ensure_ascii=False and utf-8 encoding, so non-BMP UIDs (e.g. emoji) round-trip through tomllib instead of being written as surrogate-pair escapes tomllib rejects. Marked as known limits with ponytail comments (no behaviour change): - valarms()/alarms_for(): offsets add in wall-clock time, off by the jump across a DST transition. - lead(): absolute DATE-TIME triggers don't widen the expansion window. - expand(): RDATE is ignored. - rule_for(): a VEVENT with more than one RRULE line fails and is skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rwxr-xr-xcal-notif28
-rw-r--r--test_cal_notif.py22
2 files changed, 43 insertions, 7 deletions
diff --git a/cal-notif b/cal-notif
index 3d9dcb3..c8ed33e 100755
--- a/cal-notif
+++ b/cal-notif
@@ -65,9 +65,10 @@ def parse_duration(s):
def fmt_duration(td):
m = round(td.total_seconds() / 60)
- d, m = divmod(m, 1440)
+ sign = "-" if m < 0 else ""
+ d, m = divmod(abs(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"
+ return sign + (" ".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",
@@ -131,8 +132,8 @@ def save_overrides(ov, path=None):
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())))
+ tmp.write_text("".join(f"{json.dumps(uid, ensure_ascii=False)} = {json.dumps(offs, ensure_ascii=False)}\n"
+ for uid, offs in sorted(ov.items())), encoding="utf-8")
os.replace(tmp, path)
# ---- calendars ----
@@ -151,6 +152,8 @@ def local(dt):
def rule_for(comp, start):
# dateutil wants UNTIL to match DTSTART: naive for floating/all-day,
# UTC for zoned. Clients do not always agree, so normalise it.
+ # ponytail: a VEVENT with more than one RRULE line fails here and
+ # load_events skips the whole file; upgrade path: merge into one rruleset.
rec = vRecur(comp["RRULE"])
if "UNTIL" in rec:
u = rec["UNTIL"][0]
@@ -164,12 +167,20 @@ def rule_for(comp, start):
return rrulestr(rec.to_ical().decode(), dtstart=start)
+def _live_valarms(comp):
+ # RFC 9074 ACTION:NONE is Apple/iCloud's "no alarm" placeholder, not a
+ # real VALARM: skip it or it silences the calendar default.
+ return [a for a in comp.walk("VALARM") if str(a.get("ACTION", "")).upper() != "NONE"]
+
+
def valarms(comp, start, end):
out = []
- for a in comp.walk("VALARM"):
+ for a in _live_valarms(comp):
t = a.get("TRIGGER")
if t is None:
continue
+ # ponytail: offsets add in wall-clock time, so an alarm spanning a
+ # DST jump is off by the jump; upgrade path: add the sub-day part in UTC.
if isinstance(t.dt, timedelta):
out.append((end if t.params.get("RELATED") == "END" else start) + t.dt)
else:
@@ -179,7 +190,9 @@ def valarms(comp, start, end):
def lead(comp):
# The longest relative VALARM lead, so expansion reaches far enough.
- trig = [a["TRIGGER"].dt for a in comp.walk("VALARM") if "TRIGGER" in a]
+ # ponytail: an absolute DATE-TIME trigger does not widen the window, so
+ # an absolute alarm for an event beyond the horizon is dropped.
+ trig = [a["TRIGGER"].dt for a in _live_valarms(comp) if "TRIGGER" in a]
return max([-t for t in trig if isinstance(t, timedelta)] + [timedelta()])
@@ -218,6 +231,7 @@ def expand(ics, cal, lo, hi):
if lo <= first.start <= top:
out.append(first)
continue
+ # ponytail: RDATE is ignored; upgrade path: dateutil rruleset.rdate.
# Expand in the event's own clock: naive for all-day/floating.
start = s0 if isinstance(s0, datetime) else datetime.combine(s0, dtime())
conv = (lambda d: d) if start.tzinfo else (lambda d: d.astimezone().replace(tzinfo=None))
@@ -262,6 +276,8 @@ def alarms_for(occ, cfg, overrides):
return list(occ.valarms)
else:
offs = cal_entry(cfg, occ.cal)[0]
+ # ponytail: offset subtracted in wall-clock time, so an alarm spanning a
+ # DST jump is off by the jump; upgrade path: apply the sub-day part in UTC.
return [occ.start - parse_duration(o) for o in offs]
diff --git a/test_cal_notif.py b/test_cal_notif.py
index ea5aba5..f22fbc3 100644
--- a/test_cal_notif.py
+++ b/test_cal_notif.py
@@ -46,6 +46,7 @@ def test_durations():
pass
assert cn.fmt_duration(D + 2 * H) == "1d 2h"
assert cn.fmt_duration(timedelta()) == "0m"
+ assert cn.fmt_duration(timedelta(minutes=-45)) == "-45m"
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"
@@ -56,7 +57,8 @@ def test_durations():
def test_overrides_roundtrip():
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "overrides.toml"
- ov = {"a@example.org": ["1h", "10m"], 'q"uote@example.org': []}
+ ov = {"a@example.org": ["1h", "10m"], 'q"uote@example.org': [],
+ "café\U0001F600@example.org": ["30m"]}
cn.save_overrides(ov, p)
assert cn.load_overrides(p) == ov
assert cn.load_overrides(Path(d) / "missing.toml") == {}
@@ -135,6 +137,24 @@ END:VALARM"""
assert o.valarms == (at(2026, 1, 10, 10, 45),)
+def test_alarm_action_none():
+ lo, hi = at(2026, 1, 1), at(2026, 2, 1)
+ # RFC 9074 ACTION:NONE placeholder (Apple/iCloud "no alarm") must not
+ # count as a VALARM, so alarms_for falls back to the calendar default.
+ none_alarm = """UID:n@example.org
+DTSTART;TZID=Europe/Rome:20260115T090000
+SUMMARY:silent
+BEGIN:VALARM
+ACTION:NONE
+TRIGGER;VALUE=DATE-TIME:19760401T005545Z
+END:VALARM"""
+ (o,) = cn.expand(ics(none_alarm), "c", lo, hi)
+ assert o.valarms == ()
+ cfg = cn.load_config(Path("/nonexistent"))
+ cfg["defaults"] = {"c": ["15m"]}
+ assert cn.alarms_for(o, cfg, {}) == [o.start - 15 * M]
+
+
def test_load_events():
with tempfile.TemporaryDirectory() as d:
cal = Path(d) / "123"