calendar: add recurring events, event management, and safety rules
Add --rrule flag to send with DTSTART/BYDAY validation (RFC 5545). Add event list (via khal) and event delete (safe single-event removal). Document safety rules from 2026-03-25 incident: always dry-run recurring events, never rm .ics files, space out SMTP sends.
This commit is contained in:
@@ -6,8 +6,10 @@ Uses the icalendar library for proper RFC 5545 ICS generation and parsing.
|
||||
Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer.
|
||||
|
||||
Subcommands:
|
||||
python calendar.py send [options] # create and send an invite
|
||||
python calendar.py send [options] # create and send an invite (supports --rrule)
|
||||
python calendar.py reply [options] # accept/decline/tentative
|
||||
python calendar.py event list [options] # list/search calendar events
|
||||
python calendar.py event delete [options] # delete an event by UID or summary
|
||||
python calendar.py todo add [options] # create a VTODO task
|
||||
python calendar.py todo list [options] # list pending tasks
|
||||
python calendar.py todo edit [options] # edit a task's fields
|
||||
@@ -27,7 +29,7 @@ from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText
|
||||
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
@@ -111,6 +113,54 @@ def _parse_date(date_str):
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
# iCalendar day abbreviation -> Python weekday (0=Mon, 6=Sun)
|
||||
_ICAL_DAY_TO_WEEKDAY = {
|
||||
"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6,
|
||||
}
|
||||
|
||||
|
||||
def _validate_rrule_dtstart(rrule_dict, dtstart):
|
||||
"""Validate that DTSTART day-of-week matches BYDAY when FREQ=WEEKLY.
|
||||
|
||||
RFC 5545: 'The DTSTART property value SHOULD be synchronized with the
|
||||
recurrence rule. The recurrence set generated with a DTSTART property
|
||||
value not synchronized with the recurrence rule is undefined.'
|
||||
"""
|
||||
freq = rrule_dict.get("FREQ")
|
||||
if not freq:
|
||||
return
|
||||
# Normalize: icalendar may return freq as list or string
|
||||
if isinstance(freq, list):
|
||||
freq = freq[0]
|
||||
if str(freq).upper() != "WEEKLY":
|
||||
return
|
||||
|
||||
byday = rrule_dict.get("BYDAY")
|
||||
if not byday:
|
||||
return
|
||||
if not isinstance(byday, list):
|
||||
byday = [byday]
|
||||
|
||||
# Only validate alignment when there's a single BYDAY value
|
||||
if len(byday) != 1:
|
||||
return
|
||||
|
||||
day_str = str(byday[0]).upper()
|
||||
expected_weekday = _ICAL_DAY_TO_WEEKDAY.get(day_str)
|
||||
if expected_weekday is None:
|
||||
return
|
||||
|
||||
if dtstart.weekday() != expected_weekday:
|
||||
actual_day = [k for k, v in _ICAL_DAY_TO_WEEKDAY.items() if v == dtstart.weekday()][0]
|
||||
print(
|
||||
f"Error: DTSTART falls on {actual_day} but RRULE says BYDAY={day_str}. "
|
||||
f"RFC 5545 requires these to match for FREQ=WEEKLY. "
|
||||
f"Change --start to a date that falls on {day_str}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Send invite
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -154,6 +204,12 @@ def cmd_send(args):
|
||||
"RSVP": "TRUE",
|
||||
})
|
||||
|
||||
# Recurrence rule
|
||||
if args.rrule:
|
||||
rrule = vRecur.from_ical(args.rrule)
|
||||
_validate_rrule_dtstart(rrule, start)
|
||||
event.add("rrule", rrule)
|
||||
|
||||
# 1-day reminder
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
@@ -683,6 +739,81 @@ def cmd_todo_check(args):
|
||||
print(output)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_event_list(args):
|
||||
"""List calendar events via khal."""
|
||||
if args.search:
|
||||
cmd = ["khal", "search"]
|
||||
if args.format:
|
||||
cmd += ["--format", args.format]
|
||||
cmd.append(args.search)
|
||||
else:
|
||||
cmd = ["khal", "list"]
|
||||
if args.format:
|
||||
cmd += ["--format", args.format]
|
||||
cmd += [args.range_start, args.range_end]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
print(result.stdout.rstrip())
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error: khal failed: {e.stderr.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except FileNotFoundError:
|
||||
print("Error: khal is not installed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_event_delete(args):
|
||||
"""Delete a calendar event by UID or summary match."""
|
||||
if not args.uid and not args.match:
|
||||
print("Error: --uid or --match is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not CALENDAR_DIR.is_dir():
|
||||
print(f"Error: Calendar directory not found: {CALENDAR_DIR}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
ics_files = list(CALENDAR_DIR.glob("*.ics"))
|
||||
if not ics_files:
|
||||
print("No events found in calendar.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find matching event(s)
|
||||
matches = []
|
||||
for ics_path in ics_files:
|
||||
try:
|
||||
cal = Calendar.from_ical(ics_path.read_bytes())
|
||||
except Exception:
|
||||
continue
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
uid = str(component.get("uid", ""))
|
||||
summary = str(component.get("summary", ""))
|
||||
if args.uid and args.uid in uid:
|
||||
matches.append((ics_path, uid, summary))
|
||||
elif args.match and args.match in summary:
|
||||
matches.append((ics_path, uid, summary))
|
||||
|
||||
if not matches:
|
||||
target = args.uid or args.match
|
||||
print(f"Error: No event matching '{target}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if len(matches) > 1:
|
||||
print(f"Error: Multiple events match:", file=sys.stderr)
|
||||
for _, uid, summary in matches:
|
||||
print(f" - {summary} (uid: {uid})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
ics_path, uid, summary = matches[0]
|
||||
ics_path.unlink()
|
||||
print(f"Deleted event: {summary} (uid: {uid})")
|
||||
_sync_calendar()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -703,6 +834,7 @@ def main():
|
||||
send_p.add_argument("--location", default="", help="Event location")
|
||||
send_p.add_argument("--description", default="", help="Event description")
|
||||
send_p.add_argument("--organizer", default="", help="Organizer display name")
|
||||
send_p.add_argument("--rrule", default="", help="RRULE string (e.g. FREQ=WEEKLY;COUNT=13;BYDAY=TU)")
|
||||
send_p.add_argument("--uid", default="", help="Custom event UID")
|
||||
send_p.add_argument("--account", default="", help="Himalaya account")
|
||||
send_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
||||
@@ -718,6 +850,22 @@ def main():
|
||||
reply_p.add_argument("--comment", default="", help="Message to include in reply")
|
||||
reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
||||
|
||||
# --- event ---
|
||||
event_p = subparsers.add_parser("event", help="Manage calendar events")
|
||||
event_sub = event_p.add_subparsers(dest="event_command", required=True)
|
||||
|
||||
# event list
|
||||
elist_p = event_sub.add_parser("list", help="List events (via khal)")
|
||||
elist_p.add_argument("--search", default="", help="Search events by text")
|
||||
elist_p.add_argument("--range-start", default="today", help="Start of range (default: today)")
|
||||
elist_p.add_argument("--range-end", default="90d", help="End of range (default: 90d)")
|
||||
elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')")
|
||||
|
||||
# event delete
|
||||
edel_p = event_sub.add_parser("delete", help="Delete an event")
|
||||
edel_p.add_argument("--uid", default="", help="Event UID")
|
||||
edel_p.add_argument("--match", default="", help="Match on summary text")
|
||||
|
||||
# --- todo ---
|
||||
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
||||
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
|
||||
@@ -761,6 +909,11 @@ def main():
|
||||
cmd_send(args)
|
||||
elif args.command == "reply":
|
||||
cmd_reply(args)
|
||||
elif args.command == "event":
|
||||
if args.event_command == "list":
|
||||
cmd_event_list(args)
|
||||
elif args.event_command == "delete":
|
||||
cmd_event_delete(args)
|
||||
elif args.command == "todo":
|
||||
if args.todo_command == "add":
|
||||
cmd_todo_add(args)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
# calendar — wrapper script for the calendar and todo tool.
|
||||
#
|
||||
# Usage:
|
||||
# ./calendar.sh send [options] # send a calendar invite
|
||||
# ./calendar.sh send [options] # send a calendar invite (supports --rrule)
|
||||
# ./calendar.sh reply [options] # accept/decline/tentative
|
||||
# ./calendar.sh event list [options] # list/search calendar events
|
||||
# ./calendar.sh event delete [options] # delete an event by UID or summary
|
||||
# ./calendar.sh todo add [options] # create a todo
|
||||
# ./calendar.sh todo list [options] # list pending todos
|
||||
# ./calendar.sh todo edit [options] # edit a todo's fields
|
||||
|
||||
Reference in New Issue
Block a user