164 lines
5.3 KiB
Python
164 lines
5.3 KiB
Python
"""ICS export/import utilities — manual parser (no external icalendar dependency)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid as uuid_lib
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from app.plugins.builtins.calendar.models import CalendarEntry
|
|
|
|
|
|
def _fmt_ics_datetime(dt: datetime) -> str:
|
|
"""Format datetime as ICS UTC string: 20260101T120000Z."""
|
|
return dt.strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
def _fmt_ics_date(d: Any) -> str:
|
|
"""Format date as ICS date string: 20260101."""
|
|
if isinstance(d, datetime):
|
|
return d.strftime("%Y%m%d")
|
|
return str(d).replace("-", "")
|
|
|
|
|
|
def export_entries_to_ics(entries: list[CalendarEntry]) -> str:
|
|
"""Export calendar entries to ICS format string."""
|
|
lines = [
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//LeoCRM//Calendar//EN",
|
|
]
|
|
for entry in entries:
|
|
lines.append("BEGIN:VEVENT")
|
|
lines.append(f"UID:{entry.id}@leocrm")
|
|
if entry.start_at:
|
|
lines.append(f"DTSTART:{_fmt_ics_datetime(entry.start_at)}")
|
|
if entry.end_at:
|
|
lines.append(f"DTEND:{_fmt_ics_datetime(entry.end_at)}")
|
|
lines.append(f"SUMMARY:{_escape_ics(entry.title)}")
|
|
if entry.description:
|
|
lines.append(f"DESCRIPTION:{_escape_ics(entry.description)}")
|
|
if entry.location:
|
|
lines.append(f"LOCATION:{_escape_ics(entry.location)}")
|
|
lines.append("END:VEVENT")
|
|
lines.append("END:VCALENDAR")
|
|
return "\r\n".join(lines) + "\r\n"
|
|
|
|
|
|
def _escape_ics(text: str) -> str:
|
|
"""Escape special characters for ICS format."""
|
|
return text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
|
|
|
|
|
|
def parse_ics(content: str) -> list[dict[str, Any]]:
|
|
"""Parse ICS file content into a list of event dicts.
|
|
|
|
Returns list of {uid, dtstart, dtend, summary, description, location}.
|
|
"""
|
|
events: list[dict[str, Any]] = []
|
|
current_event: dict[str, Any] | None = None
|
|
current_key: str | None = None
|
|
current_val: str = ""
|
|
|
|
for raw_line in content.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
# Handle line folding (starts with space)
|
|
if line.startswith(" ") and current_key:
|
|
current_val += line[1:]
|
|
continue
|
|
# Save previous key if we were accumulating
|
|
if current_key and current_event is not None:
|
|
current_event[current_key] = current_val
|
|
current_key = None
|
|
current_val = ""
|
|
|
|
if line == "BEGIN:VEVENT":
|
|
current_event = {}
|
|
elif line == "END:VEVENT":
|
|
if current_key and current_event is not None:
|
|
current_event[current_key] = current_val
|
|
current_key = None
|
|
current_val = ""
|
|
if current_event is not None:
|
|
events.append(current_event)
|
|
current_event = None
|
|
elif current_event is not None and ":" in line:
|
|
# Parse property name:value (may have params like DTSTART;TZID=...)
|
|
prop_part, value = line.split(":", 1)
|
|
prop_name = prop_part.split(";")[0].upper()
|
|
current_key = _map_ics_property(prop_name)
|
|
current_val = value
|
|
|
|
return events
|
|
|
|
|
|
def _map_ics_property(name: str) -> str:
|
|
"""Map ICS property names to internal field names."""
|
|
mapping = {
|
|
"UID": "uid",
|
|
"DTSTART": "dtstart",
|
|
"DTEND": "dtend",
|
|
"SUMMARY": "summary",
|
|
"DESCRIPTION": "description",
|
|
"LOCATION": "location",
|
|
}
|
|
return mapping.get(name, name.lower())
|
|
|
|
|
|
def _parse_ics_datetime(val: str) -> datetime | None:
|
|
"""Parse ICS datetime string (various formats)."""
|
|
val = val.strip()
|
|
# Try UTC format: 20260101T120000Z
|
|
try:
|
|
return datetime.strptime(val, "%Y%m%dT%H%M%SZ")
|
|
except ValueError:
|
|
pass
|
|
# Try local format: 20260101T120000
|
|
try:
|
|
return datetime.strptime(val, "%Y%m%dT%H%M%S")
|
|
except ValueError:
|
|
pass
|
|
# Try date only: 20260101
|
|
try:
|
|
return datetime.strptime(val, "%Y%m%d")
|
|
except ValueError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def ics_events_to_entry_data(
|
|
events: list[dict[str, Any]], calendar_id: Any, tenant_id: Any, user_id: Any
|
|
) -> list[dict[str, Any]]:
|
|
"""Convert parsed ICS events into CalendarEntry-compatible dicts."""
|
|
entries = []
|
|
for event in events:
|
|
title = event.get("summary", "Imported Event")
|
|
start_at = _parse_ics_datetime(event.get("dtstart", ""))
|
|
end_at = _parse_ics_datetime(event.get("dtend", ""))
|
|
entry_data = {
|
|
"id": uuid_lib.uuid4(),
|
|
"tenant_id": tenant_id,
|
|
"calendar_id": calendar_id,
|
|
"entry_type": "appointment",
|
|
"subtype": "normal",
|
|
"title": title,
|
|
"description": event.get("description"),
|
|
"start_at": start_at,
|
|
"end_at": end_at,
|
|
"all_day": False,
|
|
"location": event.get("location"),
|
|
"due_date": None,
|
|
"priority": "medium",
|
|
"status": "open",
|
|
"assigned_to": None,
|
|
"reminder": None,
|
|
"recurrence": None,
|
|
"source_mail_id": None,
|
|
"created_by": user_id,
|
|
"deleted_at": None,
|
|
}
|
|
entries.append(entry_data)
|
|
return entries
|