diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-27 20:00:15 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-27 20:00:15 +0200 |
| commit | 223292883da497b46caa0e01be0f4a1a3f134ea8 (patch) | |
| tree | 2aba248109001d87be75447882e66b4e87c55023 | |
| parent | 16f92566380d46132e7026e22c3c79aed2ddfbbb (diff) | |
| download | cal-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-x | cal-notif | 28 | ||||
| -rw-r--r-- | test_cal_notif.py | 22 |
2 files changed, 43 insertions, 7 deletions
@@ -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" |
