intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent c071235ad7
commit 371a00aff5
8 changed files with 561 additions and 281 deletions

View File

@@ -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"):
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:
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] = []
return None
draft = _DAVDiscoveryDraft()
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
_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:
continue
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)
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
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":
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item)
draft.is_calendar = draft.is_calendar or discovery_resource_is_calendar(item)
elif name in {"current-user-principal", "principal-URL"}:
principal_hrefs.extend(nested_href_texts(item))
draft.principal_hrefs.extend(nested_href_texts(item))
elif name == "calendar-home-set":
calendar_home_set_hrefs.extend(nested_href_texts(item))
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":
if local_name(component.tag) != "comp":
continue
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
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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():

View File

@@ -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.

View File

@@ -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

View File

@@ -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),
)
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=None, previous=previous)
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}%"),
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,
)
.all()
)
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:

View File

@@ -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</C:calendar-data>
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"))

View File

@@ -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("<?xml"))
def test_full_sync_cleanup_soft_deletes_unseen_events_in_batches(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/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()

View File

@@ -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);

View File

@@ -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 {