2026-10-08 23:44:07 +08:00
|
|
|
"""Bounded read-only iCalendar expansion for the next seven days."""
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
from dateutil.rrule import rrulestr
|
|
|
|
|
|
|
|
|
|
from pigway_frame_flow.errors import ErrorCode as E
|
|
|
|
|
from pigway_frame_flow.errors import FlowError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def calendar_events(raw, now=None):
|
|
|
|
|
now = now or datetime.now(UTC)
|
|
|
|
|
end = now + timedelta(days=7)
|
|
|
|
|
text = re.sub(r"\r?\n[ \t]", "", raw.decode("utf-8-sig"))
|
|
|
|
|
blocks = text.split("BEGIN:VEVENT")[1:]
|
|
|
|
|
if len(blocks) > 2000:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Calendar exceeds 2000 events")
|
|
|
|
|
|
|
|
|
|
def stamp(key, value):
|
|
|
|
|
zone = re.search(r"TZID=([^;:]+)", key)
|
|
|
|
|
tz = ZoneInfo(zone[1]) if zone else ZoneInfo("Asia/Shanghai")
|
|
|
|
|
if len(value) == 8:
|
|
|
|
|
return datetime.strptime(value, "%Y%m%d").replace(tzinfo=tz)
|
|
|
|
|
fmt = "%Y%m%dT%H%M%SZ" if value.endswith("Z") else "%Y%m%dT%H%M%S"
|
|
|
|
|
return datetime.strptime(value, fmt).replace(
|
|
|
|
|
tzinfo=UTC if value.endswith("Z") else tz
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
parsed: list[tuple] = []
|
|
|
|
|
for block in blocks:
|
|
|
|
|
props: dict[str, list[tuple[str, str]]] = {}
|
|
|
|
|
for line in block.split("END:VEVENT")[0].splitlines():
|
|
|
|
|
if ":" in line:
|
|
|
|
|
key, value = line.split(":", 1)
|
|
|
|
|
props.setdefault(key.split(";")[0], []).append((key, value))
|
|
|
|
|
if "DTSTART" not in props or "SUMMARY" not in props:
|
|
|
|
|
continue
|
|
|
|
|
start = stamp(*props["DTSTART"][0])
|
|
|
|
|
uid = props.get("UID", [(None, str(len(parsed)))])[0][1]
|
|
|
|
|
override = (
|
|
|
|
|
stamp(*props["RECURRENCE-ID"][0]) if "RECURRENCE-ID" in props else None
|
|
|
|
|
)
|
|
|
|
|
parsed.append((props, start, uid, override))
|
|
|
|
|
overrides = {
|
|
|
|
|
(uid, original.astimezone(UTC)) for _, _, uid, original in parsed if original
|
|
|
|
|
}
|
|
|
|
|
result = []
|
|
|
|
|
for props, start, uid, override in parsed:
|
|
|
|
|
if props.get("STATUS", [(None, "")])[0][1] == "CANCELLED":
|
|
|
|
|
continue
|
2026-10-08 23:52:06 +08:00
|
|
|
if "EXRULE" in props:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.UNSUPPORTED_CAPABILITY,
|
|
|
|
|
"Use EXDATE instead of unsupported EXRULE",
|
|
|
|
|
422,
|
|
|
|
|
)
|
2026-10-08 23:44:07 +08:00
|
|
|
occurrences = [start]
|
|
|
|
|
if "RRULE" in props and not override:
|
|
|
|
|
rule = props["RRULE"][0][1]
|
|
|
|
|
if not re.search(r"(?:^|;)FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(?:;|$)", rule):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.UNSUPPORTED_CAPABILITY,
|
|
|
|
|
"Calendar frequency must be daily or slower",
|
|
|
|
|
422,
|
|
|
|
|
)
|
|
|
|
|
if now.year - start.year > 150:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST, "Calendar recurrence starts too far in the past"
|
|
|
|
|
)
|
2026-10-08 23:52:06 +08:00
|
|
|
if len(rule) > 1000:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST, "Calendar recurrence rule is too long"
|
|
|
|
|
)
|
|
|
|
|
occurrences = []
|
|
|
|
|
for occurrence in rrulestr(rule, dtstart=start).xafter(
|
|
|
|
|
now - timedelta(days=1), count=1001, inc=True
|
|
|
|
|
):
|
|
|
|
|
if occurrence >= end:
|
|
|
|
|
break
|
|
|
|
|
occurrences.append(occurrence)
|
|
|
|
|
if len(occurrences) > 1000:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST, "Calendar recurrence exceeds 1000 instances"
|
|
|
|
|
)
|
2026-10-08 23:44:07 +08:00
|
|
|
for key, value in props.get("RDATE", []):
|
|
|
|
|
occurrences.extend(stamp(key, item) for item in value.split(","))
|
|
|
|
|
excluded = {
|
|
|
|
|
stamp(key, item).astimezone(UTC)
|
|
|
|
|
for key, value in props.get("EXDATE", [])
|
|
|
|
|
for item in value.split(",")
|
|
|
|
|
}
|
|
|
|
|
title = (
|
|
|
|
|
props["SUMMARY"][0][1]
|
|
|
|
|
.replace("\\n", " ")
|
|
|
|
|
.replace("\\,", ",")
|
|
|
|
|
.replace("\\;", ";")
|
|
|
|
|
.replace("\\\\", "\\")
|
|
|
|
|
)
|
|
|
|
|
all_day = len(props["DTSTART"][0][1]) == 8
|
|
|
|
|
for occurrence in sorted(set(occurrences)):
|
|
|
|
|
instant = occurrence.astimezone(UTC)
|
|
|
|
|
lower = (
|
|
|
|
|
now.astimezone(occurrence.tzinfo).replace(
|
|
|
|
|
hour=0, minute=0, second=0, microsecond=0
|
|
|
|
|
)
|
|
|
|
|
if all_day
|
|
|
|
|
else now
|
|
|
|
|
)
|
|
|
|
|
if instant in excluded or (not override and (uid, instant) in overrides):
|
|
|
|
|
continue
|
|
|
|
|
if lower <= instant < end:
|
|
|
|
|
result.append({"title": title, "start": occurrence.isoformat()})
|
|
|
|
|
if len(result) > 1000:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST, "Calendar expansion exceeds 1000 instances"
|
|
|
|
|
)
|
|
|
|
|
return {"events": sorted(result, key=lambda x: x["start"])}
|