Calendar plugin (webmail_plugin_calendar, non-removable core plugin): - Local calendars/events CRUD with RLS isolation; recurrence expanded at query time (DST-correct), EXDATE + detached-override merge. - Calendar app bundle: month/week/day/3-day views, mini-month, event form (color, show-as, timezone combobox, repeat, reminders, guests w/ recipient autocomplete, Jitsi video call), event popover with RSVP badges, resizable/ collapsible left panel + theme toggle, overlap layout. - iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML + text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token) records accepted/declined/tentative per attendee. - "This and following" edit + scoped delete (this / this-and-following), with a delete confirmation dialog for recurring events. Core: - Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations section; date order follows time format (en-GB vs en-US) across Mail. - Plugin SDK: mail (send from a user's account), vault (envelope encrypt), multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed PluginPage. Mail HTML rendering: - Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like, strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so already-indexed mail re-sanitizes on view. - Attachments: a Content-Disposition: attachment part is a real attachment even when it also carries a Content-ID (fixes hidden invite.ics). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
3.4 KiB
Python
94 lines
3.4 KiB
Python
"""Range expansion + override merge for the events list (SPEC §A.3).
|
|
|
|
Given the base events (``recurrence_id IS NULL``) and detached override rows
|
|
(``recurrence_id`` set) that overlap a window, produce the flat list of instances
|
|
the UI renders: expand each base event's RRULE (DST-correct, EXDATE-aware), drop the
|
|
occurrence an override replaces, and emit the override at its own moved time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from webmail_plugin_calendar.recurrence import expand_occurrences
|
|
|
|
Row = Mapping[str, Any]
|
|
|
|
|
|
def _duration(dtstart: datetime, dtend: datetime | None, all_day: bool) -> timedelta:
|
|
if dtend is not None:
|
|
return dtend - dtstart
|
|
return timedelta(days=1) if all_day else timedelta(0)
|
|
|
|
|
|
def _instance(
|
|
row: Row, start: datetime, end: datetime, recurrence_id: datetime | None
|
|
) -> dict[str, Any]:
|
|
origin = row.get("origin_message_id")
|
|
return {
|
|
"id": str(row["id"]),
|
|
"calendar_id": str(row["calendar_id"]),
|
|
"ical_uid": row["ical_uid"],
|
|
"summary": row["summary"],
|
|
"description": row.get("description") or "",
|
|
"location": row["location"],
|
|
"tz": row.get("tz"),
|
|
"rrule": row.get("rrule"),
|
|
"reminders": list(row.get("reminders") or []),
|
|
"start": start.isoformat(),
|
|
"end": end.isoformat(),
|
|
"all_day": row["all_day"],
|
|
"status": row["status"],
|
|
"recurring": row["rrule"] is not None,
|
|
"recurrence_id": recurrence_id.isoformat() if recurrence_id else None,
|
|
"color": row["color"],
|
|
"show_as": row.get("show_as", "busy"),
|
|
"conference_url": row.get("conference_url", ""),
|
|
"attendees": row.get("attendees") or [],
|
|
"organizer": row.get("organizer"),
|
|
"my_partstat": row.get("my_partstat"),
|
|
"origin_message_id": str(origin) if origin else None,
|
|
}
|
|
|
|
|
|
def expand_range(
|
|
base_rows: list[Row],
|
|
override_rows: list[Row],
|
|
range_start: datetime,
|
|
range_end: datetime,
|
|
) -> list[dict[str, Any]]:
|
|
# Overrides keyed by (ical_uid, original-occurrence-start in UTC); also emitted
|
|
# as their own instance at the moved time.
|
|
replaced: set[tuple[str, datetime]] = set()
|
|
out: list[dict[str, Any]] = []
|
|
for o in override_rows:
|
|
replaced.add((o["ical_uid"], o["recurrence_id"].astimezone(UTC)))
|
|
if o["status"] == "cancelled":
|
|
continue
|
|
dur = _duration(o["dtstart"], o["dtend"], o["all_day"])
|
|
start, end = o["dtstart"], o["dtstart"] + dur
|
|
if end > range_start and start < range_end:
|
|
out.append(_instance(o, start, end, o["recurrence_id"]))
|
|
|
|
for b in base_rows:
|
|
dur = _duration(b["dtstart"], b["dtend"], b["all_day"])
|
|
occ = expand_occurrences(
|
|
dtstart=b["dtstart"],
|
|
duration=dur,
|
|
rrule=b["rrule"],
|
|
exdates=list(b["exdates"] or []),
|
|
range_start=range_start,
|
|
range_end=range_end,
|
|
tz=b["tz"],
|
|
all_day=b["all_day"],
|
|
)
|
|
for start, end in occ:
|
|
if (b["ical_uid"], start.astimezone(UTC)) in replaced:
|
|
continue # a detached override provides this instance
|
|
out.append(_instance(b, start, end, start if b["rrule"] else None))
|
|
|
|
out.sort(key=lambda i: i["start"])
|
|
return out
|