diff options
Diffstat (limited to 'desktop/island/calendar/calctl')
| -rwxr-xr-x | desktop/island/calendar/calctl | 96 |
1 files changed, 61 insertions, 35 deletions
diff --git a/desktop/island/calendar/calctl b/desktop/island/calendar/calctl index d79d8b9..e9cfb93 100755 --- a/desktop/island/calendar/calctl +++ b/desktop/island/calendar/calctl @@ -17,10 +17,10 @@ # # calctl calendars {default, calendars: [{name, color, path}]} # calctl list FROM TO occurrences in [FROM, TO), local time -# calctl get UID the series itself, for the editor +# calctl get UID CAL the series itself, for the editor # calctl new EVENT-JSON {uid} -# calctl edit UID EVENT-JSON {uid} -# calctl delete UID {} +# calctl edit UID CAL EVENT-JSON {uid}; CAL is where it is now +# calctl delete UID CAL {} # # Every command prints one object: {"ok": true, "data": ...} or # {"ok": false, "error": "..."} with exit 1. Environment, for the tests: @@ -61,7 +61,8 @@ COLORS = { "cyan": "teal", "dark cyan": "teal", "light cyan": "teal", } -USAGE = "usage: calctl calendars | list FROM TO | get UID | new JSON | edit UID JSON | delete UID" +USAGE = ("usage: calctl calendars | list FROM TO | get UID CAL | new JSON" + " | edit UID CAL JSON | delete UID CAL") class Fail(Exception): @@ -150,11 +151,13 @@ def events(): yield cal, path, ev -def find(uid): +def find(uid, cal_name): + """An event by UID within one calendar: the same invite synced into two + calendars has one UID and two files, and each row must act on its own.""" for cal, path, ev in events(): - if str(ev.get("UID", "")) == uid: + if cal["name"] == cal_name and str(ev.get("UID", "")) == uid: return cal, path - raise Fail(f"no event {uid}") + raise Fail(f"no event {uid} in {cal_name}") def as_local(value): @@ -256,7 +259,17 @@ def cmd_list(lo_s, hi_s): dur = e - s if "RRULE" in ev: # Naive local time keeps the wall-clock time across DST. - rule = rrulestr(ev["RRULE"].to_ical().decode(), dtstart=s, ignoretz=True) + # ponytail: right for local and same-rule zones (all the live + # data); a series in a zone with other DST dates shows an hour + # off in the weeks they differ. Expand in the event's own zone + # if such events arrive. + rule = ev["RRULE"] + if "UNTIL" in rule: + # UNTIL is UTC on disk; read it as local time like the + # start, or a 22:30 series loses its last occurrence. + rule = icalendar.vRecur(rule) + rule["UNTIL"] = [as_local(u) for u in rule["UNTIL"]] + rule = rrulestr(rule.to_ical().decode(), dtstart=s, ignoretz=True) starts = rule.between(lo - dur, hi, inc=True) else: starts = [s] @@ -273,8 +286,8 @@ def cmd_list(lo_s, hi_s): return out -def cmd_get(uid): - cal, path = find(uid) +def cmd_get(uid, cal_name): + cal, path = find(uid, cal_name) ev = master(icalendar.Calendar.from_ical(path.read_bytes())) start, end, all_day = span(ev) return to_json(cal, ev, start, end, all_day, overrides()) @@ -320,23 +333,26 @@ def parse_payload(text, cals): def apply(ev, p): - """Write the form's fields into a VEVENT. Everything else on it is left - alone; a `custom` repeat or alarm keeps what is there.""" - for key in ("DTSTART", "DTEND", "DURATION", "SUMMARY", "LOCATION", "DESCRIPTION", - "DTSTAMP", "LAST-MODIFIED"): - ev.pop(key, None) - if p["allDay"]: - ev.add("DTSTART", p["start"]) - ev.add("DTEND", p["end"]) - else: - ev.add("DTSTART", p["start"].replace(tzinfo=LOCAL)) - ev.add("DTEND", p["end"].replace(tzinfo=LOCAL)) - ev.add("SUMMARY", p["title"]) - if p["location"]: - ev.add("LOCATION", p["location"]) - if p["notes"]: - ev.add("DESCRIPTION", p["notes"]) - if p["repeat"] != "custom": + """Write the form's fields into a VEVENT, touching only what changed: an + unchanged time keeps its zone and DURATION, an unchanged alarm keeps its + ACTION and extras, an unchanged title keeps its parameters. Everything + else on it is left alone; a `custom` repeat or alarm keeps what is there.""" + if "DTSTART" not in ev or span(ev) != (p["start"], p["end"], p["allDay"]): + for key in ("DTSTART", "DTEND", "DURATION"): + ev.pop(key, None) + if p["allDay"]: + ev.add("DTSTART", p["start"]) + ev.add("DTEND", p["end"]) + else: + ev.add("DTSTART", p["start"].replace(tzinfo=LOCAL)) + ev.add("DTEND", p["end"].replace(tzinfo=LOCAL)) + for key, prop in (("title", "SUMMARY"), ("location", "LOCATION"), ("notes", "DESCRIPTION")): + if str(ev.get(prop, "")) != p[key]: + ev.pop(prop, None) + if p[key]: + ev.add(prop, p[key]) + until = p["until"].isoformat() if p["until"] and p["repeat"] != "none" else "" + if p["repeat"] != "custom" and repeat_of(ev) != (p["repeat"], until): ev.pop("RRULE", None) if p["repeat"] != "none": rule = {"FREQ": PRESET_REPEAT[p["repeat"]]} @@ -346,7 +362,7 @@ def apply(ev, p): datetime.combine(p["until"], time(23, 59, 59), LOCAL) .astimezone(timezone.utc)) ev.add("RRULE", rule) - if p["alarm"] != "custom": + if p["alarm"] != "custom" and alarm_of(ev) != p["alarm"]: ev.subcomponents = [c for c in ev.subcomponents if c.name != "VALARM"] if p["alarm"] != "none": alarm = icalendar.Alarm() @@ -355,6 +371,8 @@ def apply(ev, p): alarm.add("TRIGGER", -PRESET_ALARM[p["alarm"]]) ev.add_component(alarm) now = datetime.now(timezone.utc).replace(microsecond=0) + ev.pop("DTSTAMP", None) + ev.pop("LAST-MODIFIED", None) ev.add("DTSTAMP", now) ev.add("LAST-MODIFIED", now) @@ -365,6 +383,8 @@ def write(path, ics): temporary name does not end in .ics, so nothing lists it.""" fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".calctl-", suffix=".tmp") try: + # mkstemp makes 0600; vdirsyncer's own files are 0644. + os.fchmod(fd, 0o644) with os.fdopen(fd, "wb") as f: f.write(ics.to_ical()) os.replace(tmp, path) @@ -405,12 +425,18 @@ def cmd_new(text): return {"uid": uid} -def cmd_edit(uid, text): +def cmd_edit(uid, cal_name, text): cals, _ = calendars() p = parse_payload(text, cals) - _, path = find(uid) + _, path = find(uid, cal_name) ics = icalendar.Calendar.from_ical(path.read_bytes()) ev = master(ics) + if p["repeat"] == "custom": + # A rule like BYDAY=MO is tied to its day; moving the start alone + # would leave the series on the old day. + day = lambda v: v.date() if isinstance(v, datetime) else v + if day(span(ev)[0]) != day(p["start"]): + raise Fail("this event repeats by a rule the panel cannot edit, so its date cannot move here") apply(ev, p) sequence = int(ev.pop("SEQUENCE", 0)) ev.add("SEQUENCE", sequence + 1) @@ -425,8 +451,8 @@ def cmd_edit(uid, text): return {"uid": uid} -def cmd_delete(uid): - _, path = find(uid) +def cmd_delete(uid, cal_name): + _, path = find(uid, cal_name) path.unlink() sync() return {} @@ -442,13 +468,13 @@ def main(argv): for c in cals]} elif cmd == "list" and len(args) == 2: data = cmd_list(*args) - elif cmd == "get" and len(args) == 1: + elif cmd == "get" and len(args) == 2: data = cmd_get(*args) elif cmd == "new" and len(args) == 1: data = cmd_new(*args) - elif cmd == "edit" and len(args) == 2: + elif cmd == "edit" and len(args) == 3: data = cmd_edit(*args) - elif cmd == "delete" and len(args) == 1: + elif cmd == "delete" and len(args) == 2: data = cmd_delete(*args) else: raise Fail(USAGE) |
