aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island/calendar/calctl
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/island/calendar/calctl')
-rwxr-xr-xdesktop/island/calendar/calctl96
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)