From 371a00aff51cf7fb69c0ac10da79a3327669b813 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 14 Jul 2026 13:22:10 +0200 Subject: [PATCH] intermittent commit --- src/govoplan_calendar/backend/caldav.py | 159 +++++---- src/govoplan_calendar/backend/ical.py | 29 +- src/govoplan_calendar/backend/runtime.py | 38 +-- src/govoplan_calendar/backend/service.py | 323 +++++++++++++------ tests/test_caldav.py | 45 ++- tests/test_sync_sources.py | 104 +++++- webui/src/features/calendar/CalendarPage.tsx | 6 +- webui/src/styles/calendar.css | 138 ++++---- 8 files changed, 561 insertions(+), 281 deletions(-) diff --git a/src/govoplan_calendar/backend/caldav.py b/src/govoplan_calendar/backend/caldav.py index 47e6362..5f564cd 100644 --- a/src/govoplan_calendar/backend/caldav.py +++ b/src/govoplan_calendar/backend/caldav.py @@ -5,8 +5,7 @@ import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass, field -from typing import Mapping, Protocol -from xml.etree import ElementTree +from typing import Any, Mapping, Protocol from defusedxml import ElementTree as SafeElementTree @@ -77,6 +76,18 @@ class _DAVDiscoveryResponse: supported_components: tuple[str, ...] = () +@dataclass(slots=True) +class _DAVDiscoveryDraft: + display_name: str | None = None + color: str | None = None + ctag: str | None = None + sync_token: str | None = None + is_calendar: bool = False + principal_hrefs: list[str] = field(default_factory=list) + calendar_home_set_hrefs: list[str] = field(default_factory=list) + supported_components: list[str] = field(default_factory=list) + + class CalDAVClient: def __init__( self, @@ -313,7 +324,7 @@ def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: by url = validate_http_url(url) try: request = urllib.request.Request(url, data=body, headers=dict(headers), method=method) - with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV HTTP(S) URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected return response.status, dict(response.headers.items()), response.read() except urllib.error.HTTPError as exc: return exc.code, dict(exc.headers.items()), exc.read() @@ -361,62 +372,86 @@ def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]: root = SafeElementTree.fromstring(payload) except SafeElementTree.ParseError as exc: raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc - responses: list[_DAVDiscoveryResponse] = [] - for response in child_elements(root, "response"): - href = first_child_text(response, "href") - if not href: + return [ + parsed + for response in child_elements(root, "response") + if (parsed := _parse_discovery_response(response)) is not None + ] + + +def _parse_discovery_response(response: Any) -> _DAVDiscoveryResponse | None: + href = first_child_text(response, "href") + if not href: + return None + draft = _DAVDiscoveryDraft() + for propstat in child_elements(response, "propstat"): + _apply_discovery_propstat(draft, propstat) + return _DAVDiscoveryResponse( + href=href, + display_name=draft.display_name, + color=draft.color, + ctag=draft.ctag, + sync_token=draft.sync_token, + is_calendar=draft.is_calendar, + principal_hrefs=dedupe_tuple(draft.principal_hrefs), + calendar_home_set_hrefs=dedupe_tuple(draft.calendar_home_set_hrefs), + supported_components=dedupe_tuple(draft.supported_components), + ) + + +def _apply_discovery_propstat(draft: _DAVDiscoveryDraft, propstat: Any) -> None: + if not discovery_propstat_is_success(propstat): + return + prop = first_child(propstat, "prop") + if prop is None: + return + for item in prop: + _apply_discovery_property(draft, item) + + +def discovery_propstat_is_success(propstat: Any) -> bool: + status = first_child_text(propstat, "status") or "" + return not status or " 200 " in status or status.endswith(" 200") or " 207 " in status + + +def _apply_discovery_property(draft: _DAVDiscoveryDraft, item: Any) -> None: + name = local_name(item.tag) + text = item.text.strip() if item.text else "" + if name == "displayname" and text: + draft.display_name = text or draft.display_name + elif name == "calendar-color" and text: + draft.color = text or draft.color + elif name == "getctag" and text: + draft.ctag = text or draft.ctag + elif name == "sync-token" and text: + draft.sync_token = text or draft.sync_token + elif name == "resourcetype": + draft.is_calendar = draft.is_calendar or discovery_resource_is_calendar(item) + elif name in {"current-user-principal", "principal-URL"}: + draft.principal_hrefs.extend(nested_href_texts(item)) + elif name == "calendar-home-set": + draft.calendar_home_set_hrefs.extend(nested_href_texts(item)) + elif name == "supported-calendar-component-set": + draft.supported_components.extend(supported_calendar_component_names(item)) + + +def discovery_resource_is_calendar(item: Any) -> bool: + return any(local_name(child.tag) == "calendar" for child in item) + + +def supported_calendar_component_names(item: Any) -> list[str]: + names: list[str] = [] + for component in item: + if local_name(component.tag) != "comp": continue - display_name = None - color = None - ctag = None - sync_token = None - is_calendar = False - principal_hrefs: list[str] = [] - calendar_home_set_hrefs: list[str] = [] - supported_components: list[str] = [] - for propstat in child_elements(response, "propstat"): - status = first_child_text(propstat, "status") or "" - if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status: - continue - prop = first_child(propstat, "prop") - if prop is None: - continue - for item in prop: - name = local_name(item.tag) - if name == "displayname" and item.text: - display_name = item.text.strip() or display_name - elif name == "calendar-color" and item.text: - color = item.text.strip() or color - elif name == "getctag" and item.text: - ctag = item.text.strip() or ctag - elif name == "sync-token" and item.text: - sync_token = item.text.strip() or sync_token - elif name == "resourcetype": - is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item) - elif name in {"current-user-principal", "principal-URL"}: - principal_hrefs.extend(nested_href_texts(item)) - elif name == "calendar-home-set": - calendar_home_set_hrefs.extend(nested_href_texts(item)) - elif name == "supported-calendar-component-set": - for component in item: - if local_name(component.tag) == "comp": - component_name = (component.attrib.get("name") or "").strip().upper() - if component_name: - supported_components.append(component_name) - responses.append( - _DAVDiscoveryResponse( - href=href, - display_name=display_name, - color=color, - ctag=ctag, - sync_token=sync_token, - is_calendar=is_calendar, - principal_hrefs=tuple(dict.fromkeys(principal_hrefs)), - calendar_home_set_hrefs=tuple(dict.fromkeys(calendar_home_set_hrefs)), - supported_components=tuple(dict.fromkeys(supported_components)), - ) - ) - return responses + component_name = (component.attrib.get("name") or "").strip().upper() + if component_name: + names.append(component_name) + return names + + +def dedupe_tuple(values: list[str]) -> tuple[str, ...]: + return tuple(dict.fromkeys(values)) def ensure_collection_url(value: str) -> str: @@ -457,25 +492,25 @@ def xml_escape(value: str) -> str: return value.replace("&", "&").replace("<", "<").replace(">", ">") -def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None: +def first_child(element: Any, name: str) -> Any | None: for child in element: if local_name(child.tag) == name: return child return None -def first_child_text(element: ElementTree.Element, name: str) -> str | None: +def first_child_text(element: Any, name: str) -> str | None: found = first_child(element, name) if found is None or found.text is None: return None return found.text.strip() -def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]: +def child_elements(element: Any, name: str) -> list[Any]: return [child for child in element if local_name(child.tag) == name] -def nested_href_texts(element: ElementTree.Element) -> list[str]: +def nested_href_texts(element: Any) -> list[str]: hrefs: list[str] = [] for child in element.iter(): if local_name(child.tag) == "href" and child.text and child.text.strip(): diff --git a/src/govoplan_calendar/backend/ical.py b/src/govoplan_calendar/backend/ical.py index 6590abe..340fe86 100644 --- a/src/govoplan_calendar/backend/ical.py +++ b/src/govoplan_calendar/backend/ical.py @@ -153,6 +153,18 @@ def events_to_ics(events: list[Any]) -> str: def event_to_component(event: Any) -> Event: component = Event() + add_event_identity_and_time(component, event) + add_event_status_fields(component, event) + add_event_optional_text_fields(component, event) + add_event_recurrence_and_parties(component, event) + add_event_raw_records(component, event) + add_preserved_properties(component, getattr(event, "icalendar", None) or {}) + for alarm in alarms_for_export(event): + component.add_component(alarm) + return component + + +def add_event_identity_and_time(component: Event, event: Any) -> None: component.add("uid", event.uid) component.add("dtstamp", datetime.now(timezone.utc)) component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone)) @@ -164,23 +176,35 @@ def event_to_component(event: Any) -> Event: component.add("duration", timedelta(seconds=int(event.duration_seconds))) if getattr(event, "recurrence_id", None): add_recurrence_id(component, str(event.recurrence_id)) + + +def add_event_status_fields(component: Event, event: Any) -> None: component.add("summary", event.summary) component.add("sequence", int(event.sequence or 0)) component.add("status", event.status) component.add("transp", event.transparency) component.add("class", event.classification) + + +def add_event_optional_text_fields(component: Event, event: Any) -> None: if event.description: component.add("description", event.description) if event.location: component.add("location", event.location) if event.categories: component.add("categories", list(event.categories)) + + +def add_event_recurrence_and_parties(component: Event, event: Any) -> None: if event.rrule: component.add("rrule", normalized_rrule_for_export(event.rrule)) if event.organizer: component.add("organizer", party_for_export(event.organizer)) for attendee in event.attendees or []: component.add("attendee", party_for_export(attendee)) + + +def add_event_raw_records(component: Event, event: Any) -> None: for record in getattr(event, "rdate", None) or []: add_raw_property(component, "RDATE", record) for record in getattr(event, "exdate", None) or []: @@ -190,11 +214,6 @@ def event_to_component(event: Any) -> Event: for record in getattr(event, "related_to", None) or []: add_raw_property(component, "RELATED-TO", record) - add_preserved_properties(component, getattr(event, "icalendar", None) or {}) - for alarm in alarms_for_export(event): - component.add_component(alarm) - return component - def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]: """Expand an event's recurrence primitives within a range. diff --git a/src/govoplan_calendar/backend/runtime.py b/src/govoplan_calendar/backend/runtime.py index 040643d..5340de4 100644 --- a/src/govoplan_calendar/backend/runtime.py +++ b/src/govoplan_calendar/backend/runtime.py @@ -1,36 +1,10 @@ from __future__ import annotations -from typing import Any +from govoplan_core.core.runtime import ModuleRuntimeState -_runtime_registry: object | None = None -_runtime_settings: object | None = None +_runtime = ModuleRuntimeState("Calendar") - -def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None: - global _runtime_registry, _runtime_settings - if registry is not None: - _runtime_registry = registry - if settings is not None: - _runtime_settings = settings - - -def get_registry() -> object | None: - return _runtime_registry - - -def get_settings() -> object: - if _runtime_settings is not None: - return _runtime_settings - try: - from govoplan_core.settings import settings as legacy_settings - except ModuleNotFoundError as exc: - raise RuntimeError("GovOPlaN Calendar runtime settings are not configured") from exc - return legacy_settings - - -class SettingsProxy: - def __getattr__(self, name: str) -> Any: - return getattr(get_settings(), name) - - -settings = SettingsProxy() +configure_runtime = _runtime.configure_runtime +get_registry = _runtime.get_registry +get_settings = _runtime.get_settings +settings = _runtime.settings diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index 107f785..200e7cc 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -7,7 +7,6 @@ import os import urllib.parse import urllib.error import urllib.request -import xml.etree.ElementTree as ET from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any, Callable @@ -19,7 +18,7 @@ from sqlalchemy.orm import Session from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource -from govoplan_calendar.backend.ical import event_to_ics, events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents +from govoplan_calendar.backend.ical import events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents from govoplan_calendar.backend.runtime import get_registry from govoplan_calendar.backend.schemas import ( CalendarCalDavDiscoveryRequest, @@ -33,6 +32,7 @@ from govoplan_calendar.backend.schemas import ( CalendarSyncSourceCreateRequest, CalendarSyncSourceUpdateRequest, ) +from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider from govoplan_core.db.base import utcnow from govoplan_core.core.change_sequence import record_change from govoplan_core.security.secrets import CAPABILITY_SECURITY_SECRET_PROVIDER, decrypt_secret, encrypt_secret @@ -54,6 +54,7 @@ EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types" CALENDAR_MODULE_ID = "calendar" CALENDAR_EVENTS_COLLECTION = "calendar.events" CALENDAR_EVENT_RESOURCE = "calendar_event" +SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500 def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]: @@ -186,6 +187,107 @@ def sync_source_label(source_kind: str) -> str: }.get(source_kind, source_kind) +def _sync_stats_payload(stats: CalendarCalDavSyncStats | None) -> dict[str, object]: + if stats is None: + return {} + return { + "created": stats.created, + "updated": stats.updated, + "deleted": stats.deleted, + "unchanged": stats.unchanged, + "fetched": stats.fetched, + "full_sync": stats.full_sync, + "used_sync_token": stats.used_sync_token, + "errors": list(stats.errors), + } + + +def _emit_calendar_sync_notification( + session: Session, + *, + source: CalendarSyncSource, + status: str, + previous_status: str | None, + stats: CalendarCalDavSyncStats | None = None, + error: str | None = None, +) -> None: + if status == "ok" and previous_status != "error": + return + if status == "error" and previous_status == "error": + return + provider = notification_dispatch_provider(get_registry()) + if provider is None: + return + + label = source.display_name or sync_source_label(source.source_kind) + calendar_name = source.calendar.name if source.calendar else "calendar" + if status == "ok": + subject = f"Calendar sync recovered: {calendar_name}" + body_text = f"{label} synchronized successfully after a previous failure." + priority = 1 + else: + subject = f"Calendar sync failed: {calendar_name}" + body_text = f"{label} could not be synchronized. {error or 'No error detail was provided.'}" + priority = 5 + try: + provider.enqueue_notification( + session, + NotificationDispatchRequest( + tenant_id=source.tenant_id, + source_module=CALENDAR_MODULE_ID, + source_resource_type="calendar_sync_source", + source_resource_id=source.id, + event_kind=f"calendar.sync.{status}", + channel="inbox", + subject=subject, + body_text=body_text, + action_url="/calendar", + priority=priority, + payload={ + "calendar_id": source.calendar_id, + "source_id": source.id, + "source_kind": source.source_kind, + "status": status, + "previous_status": previous_status, + "error": error, + "stats": _sync_stats_payload(stats), + }, + metadata={"calendar_id": source.calendar_id, "source_kind": source.source_kind}, + ), + enqueue_delivery=False, + ) + except Exception: + return + + +def _finalize_sync_success( + session: Session, + *, + source: CalendarSyncSource, + stats: CalendarCalDavSyncStats | None = None, + mark_calendar: Callable[[CalendarCollection, CalendarSyncSource], None] | None = None, + update_stats_tokens: bool = False, +) -> None: + source.last_synced_at = utcnow() + source.last_status = "ok" + source.last_error = None + schedule_next_caldav_sync(source) + if stats is not None and update_stats_tokens: + stats.sync_token = source.sync_token + stats.ctag = source.ctag + if mark_calendar is not None and source.calendar is not None: + mark_calendar(source.calendar, source) + session.flush() + + +def _finalize_sync_error(session: Session, *, source: CalendarSyncSource, error: BaseException) -> None: + source.last_attempt_at = utcnow() + source.last_status = "error" + source.last_error = str(error) + schedule_next_caldav_sync(source) + session.flush() + + def ensure_default_calendar(session: Session, *, tenant_id: str, user_id: str | None = None) -> CalendarCollection: existing = ( session.query(CalendarCollection) @@ -265,10 +367,10 @@ def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo calendar.is_default = True elif payload.is_default is False: calendar.is_default = False - for field in ("name", "description", "timezone", "color", "visibility"): - value = getattr(payload, field) + for attr in ("name", "description", "timezone", "color", "visibility"): + value = getattr(payload, attr) if value is not None: - setattr(calendar, field, value) + setattr(calendar, attr, value) if payload.metadata is not None: calendar.metadata_ = payload.metadata session.flush() @@ -753,7 +855,7 @@ def http_request( url = validate_http_url(url, label="Calendar source URL") request = urllib.request.Request(url, data=data, method=method, headers=headers or {}) try: - with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated admin-configured sync source URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated admin-configured sync source URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected response_headers = {key.lower(): value for key, value in response.headers.items()} payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace") return int(response.status), response_headers, payload @@ -857,21 +959,16 @@ def sync_caldav_source( apply_caldav_report(session, source=source, client=client, report=report, stats=stats, user_id=user_id) source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token source.ctag = report.ctag or collection_props.ctag or source.ctag - source.last_synced_at = utcnow() - source.last_status = "ok" - source.last_error = None - schedule_next_caldav_sync(source) - stats.sync_token = source.sync_token - stats.ctag = source.ctag - mark_calendar_caldav(source.calendar, source) - session.flush() + _finalize_sync_success( + session, + source=source, + stats=stats, + mark_calendar=mark_calendar_caldav, + update_stats_tokens=True, + ) return source, stats except Exception as exc: - source.last_attempt_at = utcnow() - source.last_status = "error" - source.last_error = str(exc) - schedule_next_caldav_sync(source) - session.flush() + _finalize_sync_error(session, source=source, error=exc) raise @@ -900,11 +997,7 @@ def sync_ics_source( try: status_code, response_headers, body = http_request(source.collection_url, headers=headers) if status_code == 304: - source.last_synced_at = utcnow() - source.last_status = "ok" - source.last_error = None - schedule_next_caldav_sync(source) - session.flush() + _finalize_sync_success(session, source=source, stats=stats) return source, stats stats.fetched = 1 stats.created, stats.updated, stats.unchanged, stats.deleted = import_ics_subscription( @@ -919,19 +1012,10 @@ def sync_ics_source( metadata["last_modified"] = response_headers["last-modified"] source.metadata_ = metadata source.ctag = response_headers.get("etag") or source.ctag - source.last_synced_at = utcnow() - source.last_status = "ok" - source.last_error = None - schedule_next_caldav_sync(source) - mark_calendar_sync_source(source.calendar, source) - session.flush() + _finalize_sync_success(session, source=source, stats=stats, mark_calendar=mark_calendar_sync_source) return source, stats except Exception as exc: - source.last_attempt_at = utcnow() - source.last_status = "error" - source.last_error = str(exc) - schedule_next_caldav_sync(source) - session.flush() + _finalize_sync_error(session, source=source, error=exc) raise @@ -1331,24 +1415,49 @@ def soft_delete_source_href(session: Session, *, source: CalendarSyncSource, hre return count -def soft_delete_unseen_source_events(session: Session, *, source: CalendarSyncSource, seen_hrefs: set[str]) -> int: +def soft_delete_unseen_source_events( + session: Session, + *, + source: CalendarSyncSource, + seen_hrefs: set[str], + source_kind: str | None = None, + href_prefix: str | None = None, + user_id: str | None = None, + batch_size: int = SOURCE_EVENT_CLEANUP_BATCH_SIZE, +) -> int: deleted_at = utcnow() count = 0 - for event in ( - session.query(CalendarEvent) - .filter( + last_id: str | None = None + effective_source_kind = source_kind or source.source_kind + effective_batch_size = max(1, batch_size) + while True: + id_query = session.query(CalendarEvent.id).filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, - CalendarEvent.source_kind == source.source_kind, + CalendarEvent.source_kind == effective_source_kind, CalendarEvent.deleted_at.is_(None), ) - .all() - ): - if event.source_href not in seen_hrefs: - previous = calendar_event_change_payload(event, prefix="previous_") - event.deleted_at = deleted_at - record_calendar_event_change(session, event=event, operation="deleted", user_id=None, previous=previous) - count += 1 + if href_prefix: + id_query = id_query.filter(CalendarEvent.source_href.like(f"{href_prefix}%")) + if last_id: + id_query = id_query.filter(CalendarEvent.id > last_id) + ids = [row[0] for row in id_query.order_by(CalendarEvent.id.asc()).limit(effective_batch_size).all()] + if not ids: + break + last_id = ids[-1] + events = ( + session.query(CalendarEvent) + .filter(CalendarEvent.id.in_(ids)) + .order_by(CalendarEvent.id.asc()) + .all() + ) + for event in events: + if event.source_href not in seen_hrefs: + previous = calendar_event_change_payload(event, prefix="previous_") + event.deleted_at = deleted_at + record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) + count += 1 + session.flush() return count @@ -1438,12 +1547,12 @@ def parse_ews_datetime(value: str | None) -> datetime: return normalize_datetime(datetime.fromisoformat(value.replace("Z", "+00:00"))) -def text_of(item: ET.Element, name: str) -> str | None: +def text_of(item: Any, name: str) -> str | None: child = item.find(f"./{{{EWS_TYPES_NS}}}{name}") return child.text if child is not None else None -def ews_mailbox_record(mailbox: ET.Element | None) -> dict[str, Any] | None: +def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None: if mailbox is None: return None return { @@ -1453,7 +1562,7 @@ def ews_mailbox_record(mailbox: ET.Element | None) -> dict[str, Any] | None: } -def ews_attendees(item: ET.Element) -> list[dict[str, Any]]: +def ews_attendees(item: Any) -> list[dict[str, Any]]: attendees: list[dict[str, Any]] = [] for role, path in (("required", "RequiredAttendees"), ("optional", "OptionalAttendees")): for attendee in item.findall(f"./{{{EWS_TYPES_NS}}}{path}/{{{EWS_TYPES_NS}}}Attendee"): @@ -1467,14 +1576,14 @@ def ews_attendees(item: ET.Element) -> list[dict[str, Any]]: return attendees -def ews_reminders(item: ET.Element) -> list[dict[str, Any]]: +def ews_reminders(item: Any) -> list[dict[str, Any]]: if text_of(item, "ReminderIsSet") == "true": minutes = int_or_default(text_of(item, "ReminderMinutesBeforeStart"), 15) return [{"action": "DISPLAY", "trigger_minutes_before": minutes}] return [] -def element_to_dict(element: ET.Element) -> dict[str, Any]: +def element_to_dict(element: Any) -> dict[str, Any]: tag = element.tag.rsplit("}", 1)[-1] children = list(element) result: dict[str, Any] = {"tag": tag} @@ -1507,35 +1616,7 @@ def sync_due_sources( now: datetime | None = None, client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None, ) -> list[CalendarCalDavDueSyncResult]: - due_at = normalize_datetime(now or utcnow()) - query = session.query(CalendarSyncSource).filter( - CalendarSyncSource.source_kind.in_(SYNC_SOURCE_KINDS), - CalendarSyncSource.sync_enabled.is_(True), - CalendarSyncSource.deleted_at.is_(None), - or_(CalendarSyncSource.next_sync_at.is_(None), CalendarSyncSource.next_sync_at <= due_at), - ) - if tenant_id is not None: - query = query.filter(CalendarSyncSource.tenant_id == tenant_id) - sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all() - results: list[CalendarCalDavDueSyncResult] = [] - for source in sources: - try: - if source.source_kind == "caldav": - client = client_factory(source) if client_factory else None - refreshed, stats = sync_caldav_source( - session, - tenant_id=source.tenant_id, - user_id=user_id, - source_id=source.id, - client=client, - ) - else: - refreshed, stats = sync_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id) - results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats)) - except Exception as exc: - results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc))) - session.flush() - return results + return _sync_due_sources(session, source_kinds=SYNC_SOURCE_KINDS, **_due_sync_options(tenant_id, user_id, limit, now, client_factory)) def sync_due_caldav_sources( @@ -1546,10 +1627,39 @@ def sync_due_caldav_sources( limit: int = 50, now: datetime | None = None, client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None, +) -> list[CalendarCalDavDueSyncResult]: + return _sync_due_sources(session, source_kinds={"caldav"}, **_due_sync_options(tenant_id, user_id, limit, now, client_factory)) + + +def _due_sync_options( + tenant_id: str | None, + user_id: str | None, + limit: int, + now: datetime | None, + client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None, +) -> dict[str, object]: + return { + "tenant_id": tenant_id, + "user_id": user_id, + "limit": limit, + "now": now, + "client_factory": client_factory, + } + + +def _sync_due_sources( + session: Session, + *, + tenant_id: str | None, + user_id: str | None, + limit: int, + now: datetime | None, + source_kinds: set[str], + client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None, ) -> list[CalendarCalDavDueSyncResult]: due_at = normalize_datetime(now or utcnow()) query = session.query(CalendarSyncSource).filter( - CalendarSyncSource.source_kind == "caldav", + CalendarSyncSource.source_kind.in_(source_kinds), CalendarSyncSource.sync_enabled.is_(True), CalendarSyncSource.deleted_at.is_(None), or_(CalendarSyncSource.next_sync_at.is_(None), CalendarSyncSource.next_sync_at <= due_at), @@ -1559,16 +1669,31 @@ def sync_due_caldav_sources( sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all() results: list[CalendarCalDavDueSyncResult] = [] for source in sources: + previous_status = source.last_status try: - client = client_factory(source) if client_factory else None - refreshed, stats = sync_caldav_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id, client=client) + refreshed, stats = _sync_due_source(session, source=source, user_id=user_id, client_factory=client_factory) results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats)) + _emit_calendar_sync_notification(session, source=refreshed, status="ok", previous_status=previous_status, stats=stats) except Exception as exc: results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc))) + _emit_calendar_sync_notification(session, source=source, status="error", previous_status=previous_status, error=str(exc)) session.flush() return results +def _sync_due_source( + session: Session, + *, + source: CalendarSyncSource, + user_id: str | None, + client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None, +) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: + if source.source_kind == "caldav": + client = client_factory(source) if client_factory else None + return sync_caldav_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id, client=client) + return sync_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id) + + def schedule_next_caldav_sync(source: CalendarSyncSource, *, now: datetime | None = None) -> None: if not source.sync_enabled: source.next_sync_at = None @@ -1616,24 +1741,14 @@ def apply_caldav_report( stats.deleted += deleted if stats.full_sync: - query = ( - session.query(CalendarEvent) - .filter( - CalendarEvent.tenant_id == source.tenant_id, - CalendarEvent.calendar_id == source.calendar_id, - CalendarEvent.source_kind == "caldav", - CalendarEvent.deleted_at.is_(None), - CalendarEvent.source_href.like(f"{source.collection_url}%"), - ) - .all() + stats.deleted += soft_delete_unseen_source_events( + session, + source=source, + seen_hrefs=seen_hrefs, + source_kind="caldav", + href_prefix=source.collection_url, + user_id=user_id, ) - deleted_at = utcnow() - for event in query: - if event.source_href not in seen_hrefs: - previous = calendar_event_change_payload(event, prefix="previous_") - event.deleted_at = deleted_at - record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) - stats.deleted += 1 def import_caldav_resource( @@ -2018,10 +2133,10 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event "etag", "icalendar", ) - for field in scalar_fields: - value = getattr(payload, field) + for attr in scalar_fields: + value = getattr(payload, attr) if value is not None: - setattr(event, field, value.upper() if field in {"status", "transparency", "classification"} and isinstance(value, str) else value) + setattr(event, attr, value.upper() if attr in {"status", "transparency", "classification"} and isinstance(value, str) else value) if payload.start_at is not None: event.start_at = normalize_datetime(payload.start_at) if "end_at" in payload.model_fields_set: diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 4d61614..2c39701 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -2,7 +2,6 @@ from __future__ import annotations import unittest from datetime import datetime, timezone -from types import SimpleNamespace from unittest.mock import patch from sqlalchemy import create_engine @@ -10,7 +9,7 @@ from sqlalchemy.orm import sessionmaker from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus -from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource +from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest from govoplan_calendar.backend.service import ( CALDAV_INTERNAL_CREDENTIAL_PREFIX, @@ -84,6 +83,14 @@ class FakeCalDAVClient: return CalDAVWriteResult(href=href, status=204) +class FakeNotificationProvider: + def __init__(self) -> None: + self.requests: list[object] = [] + + def enqueue_notification(self, _session, request, **_kwargs) -> None: + self.requests.append(request) + + class CalDAVParsingTests(unittest.TestCase): def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None: result = parse_multistatus( @@ -216,10 +223,8 @@ END:VCALENDAR self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/") def test_discovery_reports_relative_url_as_caldav_error(self) -> None: - client = CalDAVClient(collection_url="/remote.php/dav") - - with self.assertRaisesRegex(CalDAVError, "unknown url type"): - client.discover_calendars() + with self.assertRaisesRegex(CalDAVError, "absolute HTTP"): + CalDAVClient(collection_url="/remote.php/dav") class CalDAVSyncTests(unittest.TestCase): @@ -359,6 +364,34 @@ class CalDAVSyncTests(unittest.TestCase): next_sync_at = source.next_sync_at if source.next_sync_at.tzinfo else source.next_sync_at.replace(tzinfo=timezone.utc) self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc)) + def test_due_sync_emits_recovery_notification_after_previous_error(self) -> None: + session = self.Session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) + source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"), + ) + source.last_status = "error" + source.next_sync_at = datetime(2026, 7, 7, 8, 0, tzinfo=timezone.utc) + session.commit() + + provider = FakeNotificationProvider() + fake = FakeCalDAVClient(list_result=CalDAVReportResult(sync_token="token-1")) + with patch("govoplan_calendar.backend.service.notification_dispatch_provider", return_value=provider): + results = sync_due_caldav_sources( + session, + tenant_id="tenant-1", + now=datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc), + client_factory=lambda _source: fake, + ) + + self.assertEqual(results[0].status, "ok") + self.assertEqual(len(provider.requests), 1) + self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok") + def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None: session = self.Session() session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) diff --git a/tests/test_sync_sources.py b/tests/test_sync_sources.py index 0038ae4..92de81a 100644 --- a/tests/test_sync_sources.py +++ b/tests/test_sync_sources.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables from govoplan_calendar.backend.db.models import CalendarEvent from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest -from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, sync_source +from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, soft_delete_unseen_source_events, sync_source from govoplan_core.db.base import Base from govoplan_core.tenancy.scope import create_scope_tables from govoplan_tenancy.backend.db.models import Tenant @@ -78,6 +78,55 @@ END:VCALENDAR ), ) + def test_ics_not_modified_sync_clears_error_and_reschedules(self) -> None: + session, calendar = self.session_with_calendar() + source = create_sync_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarSyncSourceCreateRequest( + source_kind="ics", + calendar_id=calendar.id, + collection_url="https://calendar.example.test/feed.ics", + ), + ) + source.ctag = '"feed-1"' + source.last_status = "error" + source.last_error = "previous failure" + + with patch("govoplan_calendar.backend.service.http_request", return_value=(304, {}, "")): + refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id) + + self.assertEqual(refreshed.last_status, "ok") + self.assertIsNone(refreshed.last_error) + self.assertIsNotNone(refreshed.last_synced_at) + self.assertIsNotNone(refreshed.next_sync_at) + self.assertEqual(stats.created, 0) + self.assertEqual(stats.updated, 0) + self.assertEqual(stats.deleted, 0) + + def test_ics_sync_failure_records_error_and_reschedules(self) -> None: + session, calendar = self.session_with_calendar() + source = create_sync_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarSyncSourceCreateRequest( + source_kind="ics", + calendar_id=calendar.id, + collection_url="https://calendar.example.test/feed.ics", + ), + ) + + with patch("govoplan_calendar.backend.service.http_request", side_effect=RuntimeError("network down")): + with self.assertRaisesRegex(RuntimeError, "network down"): + sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id) + + self.assertEqual(source.last_status, "error") + self.assertIn("network down", source.last_error or "") + self.assertIsNotNone(source.last_attempt_at) + self.assertIsNotNone(source.next_sync_at) + def test_graph_sync_imports_delta_events(self) -> None: session, calendar = self.session_with_calendar() source = create_sync_source( @@ -168,6 +217,59 @@ END:VCALENDAR self.assertEqual(event.summary, "EWS item") self.assertTrue(request.call_args.kwargs["body"].startswith(" None: + session, calendar = self.session_with_calendar() + source = create_sync_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarSyncSourceCreateRequest( + source_kind="ics", + calendar_id=calendar.id, + collection_url="https://calendar.example.test/events.ics", + ), + ) + for index in range(3): + session.add( + CalendarEvent( + tenant_id="tenant-1", + calendar_id=calendar.id, + uid=f"event-{index}@example.test", + recurrence_id=None, + summary=f"Event {index}", + status="CONFIRMED", + transparency="OPAQUE", + classification="PUBLIC", + start_at=datetime(2026, 7, 8, 9 + index, tzinfo=timezone.utc), + all_day=False, + attendees=[], + categories=[], + rdate=[], + exdate=[], + reminders=[], + attachments=[], + related_to=[], + source_kind="ics", + source_href=f"ics-event-{index}", + icalendar={}, + ) + ) + session.flush() + + deleted = soft_delete_unseen_source_events( + session, + source=source, + seen_hrefs={"ics-event-1"}, + batch_size=1, + ) + + active_hrefs = { + event.source_href + for event in session.query(CalendarEvent).filter(CalendarEvent.deleted_at.is_(None)).all() + } + self.assertEqual(deleted, 2) + self.assertEqual(active_hrefs, {"ics-event-1"}) + if __name__ == "__main__": unittest.main() diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index 34d40cf..3eadf57 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -234,8 +234,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; setLoading(true); setError(""); try { - const response = await listCalendars(settings); - const sourceResponse = await listSyncSources(settings); + const [response, sourceResponse] = await Promise.all([ + listCalendars(settings), + listSyncSources(settings) + ]); setCalendars(response.calendars); setSyncSources(sourceResponse.sources); const ids = response.calendars.map((calendar) => calendar.id); diff --git a/webui/src/styles/calendar.css b/webui/src/styles/calendar.css index e36c81b..46a9ff2 100644 --- a/webui/src/styles/calendar.css +++ b/webui/src/styles/calendar.css @@ -28,7 +28,7 @@ display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); overflow: hidden; - border: 1px solid var(--line); + border: var(--border-line); border-radius: 0; background: var(--panel); } @@ -51,7 +51,7 @@ align-items: center; gap: 14px; padding: 10px 14px; - border-bottom: 1px solid var(--line); + border-bottom: var(--border-line); background: var(--panel-header); } @@ -121,14 +121,14 @@ display: flex; flex-direction: column; overflow: hidden; - border-right: 1px solid var(--line); + border-right: var(--border-line); background: linear-gradient(180deg, var(--panel-soft), var(--panel)); } .calendar-sidebar-heading { flex: 0 0 auto; padding: 13px 14px; - border-bottom: 1px solid var(--line); + border-bottom: var(--border-line); color: var(--muted); font-size: 12px; font-weight: 800; @@ -137,7 +137,7 @@ } .calendar-sidebar-heading.is-secondary { - border-top: 1px solid var(--line); + border-top: var(--border-line); } .calendar-list, @@ -201,9 +201,9 @@ display: inline-flex; align-items: center; padding: 2px; - border: 1px solid #c9c3b9; + border: 1px solid var(--control-border); border-radius: 999px; - background: #d8d3cb; + background: var(--calendar-switch-bg); cursor: pointer; transition: background .16s ease, border-color .16s ease; } @@ -217,8 +217,8 @@ width: 14px; height: 14px; border-radius: 50%; - background: #fff; - box-shadow: 0 1px 2px rgba(0, 0, 0, .18); + background: var(--surface); + box-shadow: var(--shadow-thumb); transform: translateX(0); transition: transform .16s ease; } @@ -306,7 +306,7 @@ min-width: 0; margin-top: 6px; padding-top: 8px; - border-top: 1px solid var(--line); + border-top: var(--border-line); } .calendar-create-action .btn { @@ -331,7 +331,7 @@ .calendar-agenda-group + .calendar-agenda-group { margin-top: 8px; padding-top: 8px; - border-top: 1px solid var(--line); + border-top: var(--border-line); } .calendar-agenda-group h3 { @@ -346,15 +346,15 @@ .calendar-agenda-item { --calendar-event-color: var(--green); - --calendar-event-bg: #edf8f5; - --calendar-event-border: #b9d7d0; + --calendar-event-bg: var(--calendar-event-bg-default); + --calendar-event-border: var(--calendar-event-border-default); display: block; min-height: 28px; padding: 5px 7px; border: 1px solid var(--calendar-event-border); border-left: 4px solid var(--calendar-event-color, var(--green)); background: var(--calendar-event-bg); - color: #21453e; + color: var(--calendar-event-text); } .calendar-agenda-item strong { @@ -369,7 +369,7 @@ .calendar-agenda-item .calendar-event-separator, .calendar-agenda p { margin: 0; - color: #4d6764; + color: var(--calendar-event-muted-text); font-size: 12px; } @@ -401,9 +401,9 @@ inset: 0 auto auto 0; z-index: 4; padding: 10px; - border-right: 1px solid var(--line); - border-bottom: 1px solid var(--line); - background: rgba(255, 255, 255, 0.9); + border-right: var(--border-line); + border-bottom: var(--border-line); + background: var(--panel-glass-bright); } .calendar-week-rows { @@ -439,7 +439,7 @@ top: 0; z-index: 2; flex: 0 0 auto; - border-bottom: 1px solid var(--line-dark); + border-bottom: var(--border-line-dark); background: var(--panel); } @@ -459,7 +459,7 @@ .calendar-week-row { min-height: 132px; - border-bottom: 1px solid var(--line); + border-bottom: var(--border-line); } .calendar-week-rows.is-continuous .calendar-week-row { @@ -476,22 +476,22 @@ flex-direction: column; gap: 6px; padding: 8px; - border-right: 1px solid var(--line); + border-right: var(--border-line); background: var(--surface); overflow: hidden; } .calendar-day-cell.is-even-month { - background: #ffffff; + background: var(--surface); } .calendar-day-cell.is-odd-month { - background: #f8f6f2; + background: var(--calendar-grid-bg); } .calendar-day-cell.is-muted { - color: #8a8278; - background: #f1efeb; + color: var(--muted); + background: var(--control-gradient-end-muted); } .calendar-day-cell.is-today { @@ -499,7 +499,7 @@ } .calendar-day-cell.is-drop-target { - background: #e3f3ef; + background: var(--calendar-today-bg); box-shadow: inset 0 0 0 2px var(--green); } @@ -535,8 +535,8 @@ .calendar-event-chip { --calendar-event-color: var(--green); - --calendar-event-bg: #edf8f5; - --calendar-event-border: #b9d7d0; + --calendar-event-bg: var(--calendar-event-bg-default); + --calendar-event-border: var(--calendar-event-border-default); min-width: 0; min-height: 26px; display: block; @@ -545,7 +545,7 @@ border-left: 4px solid var(--calendar-event-color); border-radius: 4px; background: var(--calendar-event-bg); - color: #21453e; + color: var(--calendar-event-text); cursor: pointer; font: inherit; font-size: 12px; @@ -597,7 +597,7 @@ .calendar-event-time, .calendar-event-separator { flex: 0 0 auto; - color: #4d6764; + color: var(--calendar-event-muted-text); } .calendar-more-events { @@ -635,8 +635,8 @@ .calendar-time-corner, .calendar-all-day-label, .calendar-all-day-cell { - border-bottom: 1px solid var(--line-dark); - border-right: 1px solid var(--line); + border-bottom: var(--border-line-dark); + border-right: var(--border-line); background: var(--panel); } @@ -649,12 +649,12 @@ } .calendar-time-header > header.is-today { - background: #e0f0ea; + background: var(--calendar-selected-bg); } .calendar-time-header > header.is-weekend, .calendar-all-day-cell.is-weekend { - background: #f1efeb; + background: var(--control-gradient-end-muted); } .calendar-time-header > header span { @@ -665,7 +665,7 @@ .calendar-all-day-strip { flex: 0 0 auto; min-height: 42px; - border-bottom: 1px solid var(--line-dark); + border-bottom: var(--border-line-dark); } .calendar-all-day-label { @@ -683,7 +683,7 @@ } .calendar-all-day-cell.is-drop-target { - background: #e3f3ef; + background: var(--calendar-today-bg); box-shadow: inset 0 0 0 2px var(--green); } @@ -712,8 +712,8 @@ .calendar-hour-label { min-height: 64px; - border-bottom: 1px solid var(--line); - border-right: 1px solid var(--line); + border-bottom: var(--border-line); + border-right: var(--border-line); padding: 8px; color: var(--muted); font-size: 12px; @@ -724,17 +724,17 @@ position: relative; height: 1536px; min-height: 1536px; - border-right: 1px solid var(--line); + border-right: var(--border-line); background: linear-gradient(to bottom, var(--calendar-day-shade), var(--calendar-day-shade)), linear-gradient( to bottom, - rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 0, - rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-start-y), + rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 0, + rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-start-y), transparent var(--calendar-work-start-y), transparent var(--calendar-work-end-y), - rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-end-y), - rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 100% + rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-end-y), + rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 100% ), repeating-linear-gradient( to bottom, @@ -746,11 +746,11 @@ } .calendar-day-time-column.is-weekend { - --calendar-day-shade: rgba(241, 239, 235, .74); + --calendar-day-shade: var(--calendar-day-shade); } .calendar-day-time-column.is-drop-target { - box-shadow: inset 0 0 0 2px rgba(90, 169, 155, .55); + box-shadow: var(--calendar-focus-inset); } .calendar-time-drop-marker { @@ -778,10 +778,10 @@ top: -13px; left: 10px; padding: 2px 6px; - border: 1px solid #4f9489; + border: 1px solid var(--calendar-success-border); border-radius: 4px; background: var(--green); - color: #fff; + color: var(--on-accent); font-size: 11px; font-weight: 800; line-height: 1.2; @@ -799,15 +799,15 @@ .calendar-timed-event { --calendar-event-color: var(--green); - --calendar-event-bg: #edf8f5; - --calendar-event-border: #b9d7d0; + --calendar-event-bg: var(--calendar-event-bg-default); + --calendar-event-border: var(--calendar-event-border-default); display: block; padding: 0; overflow: hidden; border: 1px solid var(--calendar-event-border); border-left: 4px solid var(--calendar-event-color); background: var(--calendar-event-bg); - color: #21453e; + color: var(--calendar-event-text); cursor: pointer; font-size: 12px; } @@ -817,8 +817,8 @@ .calendar-timed-event.is-linked-hover, .calendar-timed-overflow:hover, .calendar-timed-overflow:focus-visible { - border-color: var(--calendar-event-color, #5aa99b); - background: var(--calendar-event-bg, #e3f3ef); + border-color: var(--calendar-event-color, var(--success-border)); + background: var(--calendar-event-bg, var(--calendar-today-bg)); outline: none; } @@ -837,7 +837,7 @@ } .calendar-timed-event-content:focus-visible { - outline: 2px solid rgba(90, 169, 155, .35); + outline: var(--calendar-focus-outline); outline-offset: -2px; } @@ -864,7 +864,7 @@ width: 34px; height: 2px; border-radius: 999px; - background: rgba(33, 69, 62, .46); + background: var(--calendar-overlay); content: ""; opacity: 0; transform: translateX(-50%); @@ -890,7 +890,7 @@ height: 28px; display: grid; place-items: center; - border: 1px solid var(--line-dark); + border: var(--border-line-dark); background: var(--panel-soft); color: var(--text-strong); cursor: pointer; @@ -963,7 +963,7 @@ display: grid; gap: 12px; padding: 12px; - border: 1px solid var(--line); + border: var(--border-line); border-radius: 6px; background: var(--panel-soft); } @@ -1051,7 +1051,7 @@ } .calendar-vevent-details { - border-top: 1px solid var(--line); + border-top: var(--border-line); padding-top: 4px; } @@ -1059,7 +1059,7 @@ display: grid; gap: 10px; padding: 12px 0; - border-top: 1px solid var(--line); + border-top: var(--border-line); } .calendar-vevent-section:first-of-type { @@ -1085,7 +1085,7 @@ .calendar-sync-status { padding: 3px 8px; - border: 1px solid var(--line-dark); + border: var(--border-line-dark); border-radius: 999px; background: var(--surface); color: var(--muted); @@ -1094,8 +1094,8 @@ } .calendar-sync-status.is-error { - border-color: #f0b8b2; - background: #fff2f0; + border-color: var(--calendar-danger-border); + background: var(--calendar-danger-bg); color: var(--red); } @@ -1115,7 +1115,7 @@ display: grid; gap: 10px; padding: 10px; - border: 1px solid var(--line); + border: var(--border-line); border-radius: 6px; background: var(--surface); } @@ -1150,9 +1150,9 @@ .calendar-form-error { margin: 0; padding: 9px 10px; - border: 1px solid #f0b8b2; + border: 1px solid var(--calendar-danger-border); border-radius: 4px; - background: #fff2f0; + background: var(--calendar-danger-bg); color: var(--red); font-size: 13px; } @@ -1166,13 +1166,13 @@ } .calendar-delete-warning { - border: 1px solid #f0b8b2; - background: #fff2f0; + border: 1px solid var(--calendar-danger-border); + background: var(--calendar-danger-bg); color: var(--red); } .calendar-form-note { - border: 1px solid var(--line); + border: var(--border-line); background: var(--panel-soft); color: var(--muted); } @@ -1182,7 +1182,7 @@ gap: 10px; margin: 0; padding: 10px; - border: 1px solid var(--line); + border: var(--border-line); border-radius: 6px; background: var(--panel-soft); } @@ -1275,7 +1275,7 @@ .calendar-sidebar { border-right: 0; - border-bottom: 1px solid var(--line); + border-bottom: var(--border-line); } .calendar-mode-switch {