Calendar: CalDAV two-way sync (iCloud/generic) + all-day timegrid fix
CalDAV account connect (Settings → Calendars) with RFC 6764 discovery, per-calendar vaulted credentials, and full two-way sync verified live against NextCloud: - caldav.py: discovery, calendar-query REPORT (pull), PUT/DELETE with quoted ETags (If-Match/If-None-Match), iCloud partition-redirect handling, ICS build/parse; clear error codes (auth/private-host/ unreachable) surfaced to the connect UI. - sync.py: ctag-gated pull + push of local changes; dirty rows protected from being clobbered; 15-min cron + immediate enqueue on every create/edit/delete so changes reach the server in ~1s. - Dirty tracking via a BEFORE INSERT/UPDATE trigger (migration 0007), suppressed during sync with a txn-local GUC; deletes tombstone then DELETE server-side. Writability now driven by server privileges. - migration 0006: event uniqueness NULLS NOT DISTINCT — fixes duplicate events on every full re-pull (NULL recurrence_id never matched ON CONFLICT). - 5 CalDAV parsing unit tests. Calendar UI (page.js): all-day events now render in Day/3-days/Week views (were month-only). They share the timed-event column layout so overlapping events sit side-by-side, fill the day column vertically with a sticky label, and are matched by start day (fixes UTC-midnight events spilling into the next local day). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""CalDAV client pure helpers — XML multistatus / REPORT parsing + ICS round-trip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from webmail_plugin_calendar.caldav import (
|
||||
build_ics,
|
||||
event_href,
|
||||
parse_href,
|
||||
parse_multistatus_calendars,
|
||||
parse_report,
|
||||
vevent_to_fields,
|
||||
)
|
||||
|
||||
_PRINCIPAL_XML = """<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response><d:href>/</d:href><d:propstat><d:status>HTTP/1.1 200 OK</d:status>
|
||||
<d:prop><d:current-user-principal><d:href>/123/principal/</d:href></d:current-user-principal></d:prop>
|
||||
</d:propstat></d:response>
|
||||
</d:multistatus>"""
|
||||
|
||||
_CAL_LIST_XML = """<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"
|
||||
xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:response><d:href>/123/calendars/home/</d:href><d:propstat><d:status>HTTP/1.1 200 OK</d:status>
|
||||
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||
<d:response><d:href>/123/calendars/work/</d:href><d:propstat><d:status>HTTP/1.1 200 OK</d:status>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||||
<d:displayname>Work</d:displayname>
|
||||
<ical:calendar-color>#ff0000ff</ical:calendar-color>
|
||||
<cs:getctag>ctag-42</cs:getctag>
|
||||
<d:current-user-privilege-set><d:privilege><d:write/></d:privilege></d:current-user-privilege-set>
|
||||
<c:supported-calendar-component-set>
|
||||
<c:comp name="VEVENT"/></c:supported-calendar-component-set>
|
||||
</d:prop></d:propstat></d:response>
|
||||
<d:response><d:href>/123/calendars/holidays/</d:href><d:propstat>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||||
<d:displayname>Holidays</d:displayname>
|
||||
<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>
|
||||
<c:supported-calendar-component-set>
|
||||
<c:comp name="VEVENT"/></c:supported-calendar-component-set>
|
||||
</d:prop></d:propstat></d:response>
|
||||
</d:multistatus>"""
|
||||
|
||||
_REPORT_XML = """<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response><d:href>/123/calendars/work/ev1.ics</d:href><d:propstat>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
<d:prop><d:getetag>"etag-1"</d:getetag>
|
||||
<c:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:ev1@example.com
|
||||
SUMMARY:Standup
|
||||
DTSTART:20260810T090000Z
|
||||
DTEND:20260810T091500Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR</c:calendar-data></d:prop></d:propstat></d:response>
|
||||
</d:multistatus>"""
|
||||
|
||||
|
||||
def test_parse_href_principal() -> None:
|
||||
assert parse_href(_PRINCIPAL_XML, "{DAV:}current-user-principal") == "/123/principal/"
|
||||
|
||||
|
||||
def test_parse_calendar_list_filters_and_reads_props() -> None:
|
||||
cals = parse_multistatus_calendars(_CAL_LIST_XML, "https://dav.example.com/123/calendars/")
|
||||
# The home collection (no <calendar> resourcetype) is skipped; two calendars remain.
|
||||
by_name = {c.name: c for c in cals}
|
||||
assert set(by_name) == {"Work", "Holidays"}
|
||||
work = by_name["Work"]
|
||||
assert work.url == "https://dav.example.com/123/calendars/work/"
|
||||
assert work.color == "#ff0000" # trimmed to #rrggbb
|
||||
assert work.ctag == "ctag-42"
|
||||
assert work.read_only is False # has <write>
|
||||
assert by_name["Holidays"].read_only is True # read privilege only
|
||||
|
||||
|
||||
def test_parse_report_events() -> None:
|
||||
events = parse_report(_REPORT_XML, "https://dav.example.com/123/calendars/work/")
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev.href == "https://dav.example.com/123/calendars/work/ev1.ics"
|
||||
assert ev.etag == "etag-1" # quotes stripped
|
||||
fields = vevent_to_fields(ev.ics)
|
||||
assert fields is not None
|
||||
assert fields["ical_uid"] == "ev1@example.com"
|
||||
assert fields["summary"] == "Standup"
|
||||
assert fields["dtstart"] == datetime(2026, 8, 10, 9, 0, tzinfo=UTC)
|
||||
assert fields["all_day"] is False
|
||||
|
||||
|
||||
def test_build_ics_roundtrips() -> None:
|
||||
ics = build_ics(
|
||||
{
|
||||
"ical_uid": "made@ultimo",
|
||||
"summary": "Lunch",
|
||||
"location": "Cafe",
|
||||
"dtstart": datetime(2026, 8, 11, 12, 0, tzinfo=UTC),
|
||||
"dtend": datetime(2026, 8, 11, 13, 0, tzinfo=UTC),
|
||||
"all_day": False,
|
||||
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
||||
}
|
||||
)
|
||||
fields = vevent_to_fields(ics)
|
||||
assert fields is not None
|
||||
assert fields["ical_uid"] == "made@ultimo"
|
||||
assert fields["summary"] == "Lunch"
|
||||
assert fields["location"] == "Cafe"
|
||||
assert fields["rrule"] == "FREQ=WEEKLY;BYDAY=MO"
|
||||
assert fields["dtstart"] == datetime(2026, 8, 11, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_event_href() -> None:
|
||||
assert (
|
||||
event_href("https://dav.example.com/cal/work", "abc@ex")
|
||||
== "https://dav.example.com/cal/work/abc@ex.ics"
|
||||
)
|
||||
Reference in New Issue
Block a user