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"
|
||||
)
|
||||
@@ -361,6 +361,26 @@ export const en = {
|
||||
"settings.subscribed": "Subscribed",
|
||||
"settings.comingSoon": "Coming soon",
|
||||
"settings.subscribeError": "Couldn't subscribe to that URL.",
|
||||
"settings.connectTitle": "Connect {provider}",
|
||||
"settings.connect": "Connect",
|
||||
"settings.connecting": "Connecting…",
|
||||
"settings.connectError": "Couldn't connect. Check the details and try again.",
|
||||
"settings.connectAuthFailed":
|
||||
"Sign-in was rejected. If you have two-factor auth on, use an app password (not your normal password).",
|
||||
"settings.connectPrivateHost":
|
||||
"That server is on a private network, which isn't reachable for security reasons. Use a publicly-accessible HTTPS address.",
|
||||
"settings.connectUnreachable":
|
||||
"Couldn't reach that server. Check the URL is correct and uses https://.",
|
||||
"settings.connectNoCalendars": "Connected, but no calendars were found on that account.",
|
||||
"settings.connectNeedsUrl": "Enter the CalDAV server URL.",
|
||||
"settings.davServerUrl": "CalDAV server URL",
|
||||
"settings.appleId": "Apple ID (email)",
|
||||
"settings.username": "Username",
|
||||
"settings.appPassword": "App-specific password",
|
||||
"settings.password": "Password",
|
||||
"settings.icloudHint":
|
||||
"Use an app-specific password from account.apple.com → Sign-In and Security.",
|
||||
"settings.calendarCount": "{count} calendars",
|
||||
"settings.removeSubscription": "Remove subscription",
|
||||
"settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?",
|
||||
"settings.deleteCalendar": "Delete calendar",
|
||||
|
||||
@@ -215,6 +215,19 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
davConnect: (body: { provider: string; server_url?: string; username: string; password: string }) =>
|
||||
apiFetch<{ account_id: string; calendars: { id: string; name: string }[] }>(
|
||||
"/plugins/calendar/dav/connect",
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
),
|
||||
davAccounts: () =>
|
||||
apiFetch<{ account_id: string; provider: string; username: string; calendars: { id: string; name: string }[] }[]>(
|
||||
"/plugins/calendar/dav/accounts",
|
||||
),
|
||||
davDeleteAccount: (accountId: string) =>
|
||||
apiFetch<{ status: string }>(`/plugins/calendar/dav/accounts/${accountId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
// --- saved searches (virtual mailboxes) ---
|
||||
savedSearches: () => apiFetch<SavedSearch[]>("/saved-searches"),
|
||||
|
||||
@@ -965,6 +965,39 @@ function CalendarList({
|
||||
["iCloud", "icloud"],
|
||||
["CalDAV", "caldav"],
|
||||
];
|
||||
// CalDAV account connect (iCloud / generic). Google needs OAuth — left disabled.
|
||||
const davAccounts = useQuery({ queryKey: ["dav-accounts"], queryFn: api.davAccounts });
|
||||
const [connecting, setConnecting] = useState<"icloud" | "caldav" | null>(null);
|
||||
const [davUser, setDavUser] = useState("");
|
||||
const [davPass, setDavPass] = useState("");
|
||||
const [davUrl, setDavUrl] = useState("");
|
||||
const refreshCalendars = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["calendars"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dav-accounts"] });
|
||||
};
|
||||
const closeConnect = () => {
|
||||
setConnecting(null);
|
||||
setDavUser("");
|
||||
setDavPass("");
|
||||
setDavUrl("");
|
||||
};
|
||||
const connect = useMutation({
|
||||
mutationFn: () =>
|
||||
api.davConnect({
|
||||
provider: connecting ?? "caldav",
|
||||
server_url: davUrl.trim(),
|
||||
username: davUser.trim(),
|
||||
password: davPass,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
closeConnect();
|
||||
refreshCalendars();
|
||||
},
|
||||
});
|
||||
const disconnect = useMutation({
|
||||
mutationFn: (accountId: string) => api.davDeleteAccount(accountId),
|
||||
onSuccess: refreshCalendars,
|
||||
});
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("settings.calendars")}</h2>
|
||||
@@ -1010,18 +1043,117 @@ function CalendarList({
|
||||
<div className="pt-4">
|
||||
<h3 className="text-base font-semibold">{t("settings.addAccount")}</h3>
|
||||
<p className="mt-1 text-sm text-app-muted">{t("settings.addAccountHint")}</p>
|
||||
|
||||
{(davAccounts.data ?? []).length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{(davAccounts.data ?? []).map((a) => (
|
||||
<div
|
||||
key={a.account_id}
|
||||
className="flex items-center gap-3 rounded-lg border border-app-border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{a.username || a.provider}</div>
|
||||
<div className="text-xs text-app-muted">
|
||||
{a.provider === "icloud" ? "iCloud" : "CalDAV"} ·{" "}
|
||||
{t("settings.calendarCount", { count: a.calendars.length })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="text-xs text-red-500"
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate(a.account_id)}
|
||||
>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{accounts.map(([label]) => (
|
||||
{accounts.map(([label, key]) => (
|
||||
<button
|
||||
key={label}
|
||||
disabled
|
||||
title={t("settings.comingSoon")}
|
||||
className="rounded-lg border border-app-border px-4 py-2 text-sm opacity-60"
|
||||
disabled={key === "google"}
|
||||
title={key === "google" ? t("settings.comingSoon") : undefined}
|
||||
onClick={() => key !== "google" && setConnecting(key as "icloud" | "caldav")}
|
||||
className={`rounded-lg border border-app-border px-4 py-2 text-sm ${
|
||||
key === "google" ? "opacity-60" : "hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{connecting && (
|
||||
<div className="mt-3 max-w-md space-y-2 rounded-lg border border-app-border p-4">
|
||||
<div className="text-sm font-medium">
|
||||
{t("settings.connectTitle", { provider: connecting === "icloud" ? "iCloud" : "CalDAV" })}
|
||||
</div>
|
||||
{connecting === "caldav" && (
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={t("settings.davServerUrl")}
|
||||
value={davUrl}
|
||||
onChange={(e) => setDavUrl(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={connecting === "icloud" ? t("settings.appleId") : t("settings.username")}
|
||||
value={davUser}
|
||||
onChange={(e) => setDavUser(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={
|
||||
connecting === "icloud" ? t("settings.appPassword") : t("settings.password")
|
||||
}
|
||||
value={davPass}
|
||||
onChange={(e) => setDavPass(e.target.value)}
|
||||
/>
|
||||
{connecting === "icloud" && (
|
||||
<p className="text-xs text-app-faint">{t("settings.icloudHint")}</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg disabled:opacity-50"
|
||||
disabled={
|
||||
connect.isPending ||
|
||||
!davUser.trim() ||
|
||||
!davPass ||
|
||||
(connecting === "caldav" && !davUrl.trim())
|
||||
}
|
||||
onClick={() => connect.mutate()}
|
||||
>
|
||||
{connect.isPending ? t("settings.connecting") : t("settings.connect")}
|
||||
</button>
|
||||
<button className="px-3 py-2 text-sm text-app-muted" onClick={closeConnect}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{connect.isError && (
|
||||
<p className="text-sm text-red-500">
|
||||
{(() => {
|
||||
const code =
|
||||
connect.error instanceof ApiError ? connect.error.message : "";
|
||||
const keys = {
|
||||
auth_failed: "settings.connectAuthFailed",
|
||||
private_host: "settings.connectPrivateHost",
|
||||
unreachable: "settings.connectUnreachable",
|
||||
no_calendars: "settings.connectNoCalendars",
|
||||
server_url_required: "settings.connectNeedsUrl",
|
||||
} as const;
|
||||
const key = keys[code as keyof typeof keys];
|
||||
return t(key ?? "settings.connectError");
|
||||
})()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
|
||||
@@ -11,8 +11,9 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
|
||||
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest
|
||||
from webmail_plugin_calendar.router import router
|
||||
from webmail_plugin_calendar.sync import caldav_sync
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
|
||||
@@ -20,6 +21,8 @@ plugin = Plugin(
|
||||
name="calendar",
|
||||
version="0.1.0",
|
||||
builtin=True,
|
||||
# Refresh connected CalDAV calendars every 15 minutes (also runs on connect).
|
||||
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
|
||||
router=router,
|
||||
ui_manifest=UiManifest(
|
||||
pages=[
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Minimal CalDAV client for two-way sync (SPEC §A.3).
|
||||
|
||||
Speaks CalDAV over the SDK's SSRF-guarded HTTP: RFC 6764 discovery
|
||||
(current-user-principal → calendar-home-set → calendar collections), an RFC 4791
|
||||
``calendar-query`` REPORT to pull events, and PUT/DELETE (with ETags) to push local
|
||||
changes. iCloud redirects to a per-user partition host, so requests follow a few
|
||||
redirects (each still SSRF-validated by the underlying guard).
|
||||
|
||||
The pure helpers — ``parse_multistatus_calendars``, ``parse_report``,
|
||||
``vevent_to_fields``, ``build_ics`` — take/return plain data and are unit-tested
|
||||
without a network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from defusedxml import ElementTree as SafeET
|
||||
|
||||
logger = logging.getLogger("plg.calendar.caldav")
|
||||
|
||||
# Well-known base URLs for providers that don't need a user-supplied server URL.
|
||||
PROVIDER_BASE = {"icloud": "https://caldav.icloud.com/"}
|
||||
|
||||
_DAV = "{DAV:}"
|
||||
_CAL = "{urn:ietf:params:xml:ns:caldav}"
|
||||
_CS = "{http://calendarserver.org/ns/}"
|
||||
_ICAL = "{http://apple.com/ns/ical/}"
|
||||
|
||||
_PRINCIPAL_BODY = (
|
||||
b'<?xml version="1.0" encoding="utf-8"?>'
|
||||
b'<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>'
|
||||
)
|
||||
_HOME_BODY = (
|
||||
b'<?xml version="1.0" encoding="utf-8"?>'
|
||||
b'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||
b"<d:prop><c:calendar-home-set/></d:prop></d:propfind>"
|
||||
)
|
||||
_CAL_LIST_BODY = (
|
||||
b'<?xml version="1.0" encoding="utf-8"?>'
|
||||
b'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"'
|
||||
b' xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">'
|
||||
b"<d:prop><d:resourcetype/><d:displayname/><ical:calendar-color/><cs:getctag/>"
|
||||
b"<d:current-user-privilege-set/><c:supported-calendar-component-set/></d:prop></d:propfind>"
|
||||
)
|
||||
_CTAG_BODY = (
|
||||
b'<?xml version="1.0" encoding="utf-8"?>'
|
||||
b'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">'
|
||||
b"<d:prop><cs:getctag/></d:prop></d:propfind>"
|
||||
)
|
||||
_EVENTS_QUERY = (
|
||||
b'<?xml version="1.0" encoding="utf-8"?>'
|
||||
b'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||
b"<d:prop><d:getetag/><c:calendar-data/></d:prop>"
|
||||
b'<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/>'
|
||||
b"</c:comp-filter></c:filter></c:calendar-query>"
|
||||
)
|
||||
|
||||
|
||||
class CalDavError(Exception):
|
||||
"""A CalDAV request failed (auth, network, or unexpected status)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DavCalendar:
|
||||
url: str
|
||||
name: str
|
||||
color: str = "#3b82f6"
|
||||
ctag: str = ""
|
||||
read_only: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DavEvent:
|
||||
href: str
|
||||
etag: str
|
||||
ics: str
|
||||
|
||||
|
||||
# --- pure parsing/building (unit-tested) -------------------------------------
|
||||
|
||||
|
||||
def _text(el: Any, path: str) -> str:
|
||||
found = el.find(path)
|
||||
return (found.text or "").strip() if found is not None and found.text else ""
|
||||
|
||||
|
||||
def _propstat_ok(response: Any) -> Any | None:
|
||||
"""Return the ``<prop>`` element of the HTTP-200 propstat in a response, or None."""
|
||||
for propstat in response.findall(f"{_DAV}propstat"):
|
||||
status = _text(propstat, f"{_DAV}status")
|
||||
if " 200 " in status:
|
||||
return propstat.find(f"{_DAV}prop")
|
||||
# Some servers omit the status; fall back to the first prop.
|
||||
ps = response.find(f"{_DAV}propstat")
|
||||
return ps.find(f"{_DAV}prop") if ps is not None else None
|
||||
|
||||
|
||||
def parse_href(xml_text: str, prop_tag: str) -> str | None:
|
||||
"""First ``<prop_tag><d:href>`` value in a multistatus (discovery steps)."""
|
||||
root = SafeET.fromstring(xml_text)
|
||||
for response in root.iter(f"{_DAV}response"):
|
||||
prop = _propstat_ok(response)
|
||||
if prop is None:
|
||||
continue
|
||||
target = prop.find(prop_tag)
|
||||
if target is not None:
|
||||
href = target.find(f"{_DAV}href")
|
||||
if href is not None and href.text:
|
||||
return href.text.strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_multistatus_calendars(xml_text: str, base_url: str) -> list[DavCalendar]:
|
||||
"""Calendar collections from a Depth-1 PROPFIND on the calendar-home-set."""
|
||||
root = SafeET.fromstring(xml_text)
|
||||
out: list[DavCalendar] = []
|
||||
for response in root.iter(f"{_DAV}response"):
|
||||
prop = _propstat_ok(response)
|
||||
href_el = response.find(f"{_DAV}href")
|
||||
if prop is None or href_el is None or not href_el.text:
|
||||
continue
|
||||
resourcetype = prop.find(f"{_DAV}resourcetype")
|
||||
if resourcetype is None or resourcetype.find(f"{_CAL}calendar") is None:
|
||||
continue # not a calendar collection (e.g. the home node itself, inbox/outbox)
|
||||
# Only calendars that hold events.
|
||||
comps = prop.find(f"{_CAL}supported-calendar-component-set")
|
||||
if comps is not None:
|
||||
names = {c.get("name") for c in comps.findall(f"{_CAL}comp")}
|
||||
if names and "VEVENT" not in names:
|
||||
continue
|
||||
name = _text(prop, f"{_DAV}displayname") or "Calendar"
|
||||
color = (_text(prop, f"{_ICAL}calendar-color") or "#3b82f6")[:7]
|
||||
ctag = _text(prop, f"{_CS}getctag")
|
||||
# read-only unless the privilege set grants write.
|
||||
read_only = True
|
||||
privset = prop.find(f"{_DAV}current-user-privilege-set")
|
||||
if privset is None:
|
||||
read_only = False # server didn't report; assume writable
|
||||
else:
|
||||
for priv in privset.iter(f"{_DAV}privilege"):
|
||||
if priv.find(f"{_DAV}write") is not None or priv.find(f"{_DAV}all") is not None:
|
||||
read_only = False
|
||||
break
|
||||
out.append(
|
||||
DavCalendar(
|
||||
url=urljoin(base_url, href_el.text.strip()),
|
||||
name=name,
|
||||
color=color or "#3b82f6",
|
||||
ctag=ctag,
|
||||
read_only=read_only,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def parse_report(xml_text: str, base_url: str) -> list[DavEvent]:
|
||||
"""(href, etag, calendar-data) tuples from a calendar-query/multiget REPORT."""
|
||||
root = SafeET.fromstring(xml_text)
|
||||
out: list[DavEvent] = []
|
||||
for response in root.iter(f"{_DAV}response"):
|
||||
href_el = response.find(f"{_DAV}href")
|
||||
prop = _propstat_ok(response)
|
||||
if href_el is None or not href_el.text or prop is None:
|
||||
continue
|
||||
cdata = _text(prop, f"{_CAL}calendar-data")
|
||||
if not cdata:
|
||||
continue
|
||||
out.append(
|
||||
DavEvent(
|
||||
href=urljoin(base_url, href_el.text.strip()),
|
||||
etag=_text(prop, f"{_DAV}getetag").strip('"'),
|
||||
ics=cdata,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _aware(dt: Any) -> datetime:
|
||||
if not isinstance(dt, datetime):
|
||||
dt = datetime(dt.year, dt.month, dt.day)
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def vevent_to_fields(ics_text: str) -> dict[str, Any] | None:
|
||||
"""Map the first VEVENT in an ICS resource to plg_calendar_events columns."""
|
||||
from icalendar import Calendar as ICalendar
|
||||
|
||||
try:
|
||||
cal = ICalendar.from_ical(ics_text)
|
||||
except Exception: # noqa: BLE001 - malformed resource; skip it
|
||||
return None
|
||||
comp = next((c for c in cal.walk("VEVENT")), None)
|
||||
if comp is None:
|
||||
return None
|
||||
start = comp.get("dtstart")
|
||||
if start is None:
|
||||
return None
|
||||
all_day = not isinstance(start.dt, datetime)
|
||||
dtstart = _aware(start.dt)
|
||||
end = comp.get("dtend")
|
||||
dtend = _aware(end.dt) if end is not None else None
|
||||
rrule = comp.get("rrule")
|
||||
return {
|
||||
"ical_uid": str(comp.get("uid") or f"{uuid.uuid4()}@caldav"),
|
||||
"sequence": int(comp.get("sequence") or 0),
|
||||
"summary": str(comp.get("summary") or ""),
|
||||
"description": str(comp.get("description") or ""),
|
||||
"location": str(comp.get("location") or ""),
|
||||
"dtstart": dtstart,
|
||||
"dtend": dtend,
|
||||
"all_day": all_day,
|
||||
"rrule": rrule.to_ical().decode() if rrule is not None else None,
|
||||
"status": str(comp.get("status") or "confirmed").lower(),
|
||||
"raw_ical": ics_text,
|
||||
}
|
||||
|
||||
|
||||
def build_ics(event: dict[str, Any]) -> str:
|
||||
"""A single-VEVENT VCALENDAR for PUTting a locally created/edited event."""
|
||||
from icalendar import Calendar as ICalendar
|
||||
from icalendar import Event as IEvent
|
||||
from icalendar.prop import vRecur
|
||||
|
||||
cal = ICalendar()
|
||||
cal.add("prodid", "-//Ultimo//Calendar//EN")
|
||||
cal.add("version", "2.0")
|
||||
ev = IEvent()
|
||||
ev.add("uid", event["ical_uid"])
|
||||
ev.add("sequence", int(event.get("sequence") or 0))
|
||||
ev.add("summary", event.get("summary") or "")
|
||||
if event.get("description"):
|
||||
ev.add("description", event["description"])
|
||||
if event.get("location"):
|
||||
ev.add("location", event["location"])
|
||||
start, end = event["dtstart"], event.get("dtend")
|
||||
if event.get("all_day"):
|
||||
ev.add("dtstart", start.date() if isinstance(start, datetime) else start)
|
||||
if end is not None:
|
||||
ev.add("dtend", end.date() if isinstance(end, datetime) else end)
|
||||
else:
|
||||
ev.add("dtstart", start)
|
||||
if end is not None:
|
||||
ev.add("dtend", end)
|
||||
if event.get("rrule"):
|
||||
ev.add("rrule", vRecur.from_ical(str(event["rrule"])))
|
||||
for ex in event.get("exdates") or []:
|
||||
ev.add("exdate", ex.date() if event.get("all_day") and isinstance(ex, datetime) else ex)
|
||||
ev.add("dtstamp", datetime.now(UTC))
|
||||
cal.add_component(ev)
|
||||
return cal.to_ical().decode()
|
||||
|
||||
|
||||
# --- network operations ------------------------------------------------------
|
||||
|
||||
|
||||
async def _request(
|
||||
http: Any,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
creds: tuple[str, str],
|
||||
depth: str | None = None,
|
||||
body: bytes | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
"""One CalDAV request, following up to 5 redirects (iCloud partition hosts).
|
||||
|
||||
Each hop goes through ``sdk.http`` (SSRF-guarded). Returns the final response."""
|
||||
headers: dict[str, str] = {"User-Agent": "Ultimo-Webmail/1.0"}
|
||||
if body is not None and method in ("PROPFIND", "REPORT"):
|
||||
headers["Content-Type"] = "application/xml; charset=utf-8"
|
||||
if depth is not None:
|
||||
headers["Depth"] = depth
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
current = url
|
||||
resp = None
|
||||
for _ in range(6):
|
||||
try:
|
||||
resp = await http.request(
|
||||
method, current, headers=headers, content=body, auth=creds
|
||||
)
|
||||
except Exception as exc: # transport error or SSRF guard rejection
|
||||
name = type(exc).__name__.lower()
|
||||
msg = str(exc).lower()
|
||||
if "ssrf" in name or "public" in msg or "private" in msg or "not allowed" in msg:
|
||||
raise CalDavError("private_host") from exc
|
||||
raise CalDavError("unreachable") from exc
|
||||
if resp.status in (301, 302, 307, 308):
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
break
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
return resp
|
||||
return resp
|
||||
|
||||
|
||||
async def discover_calendars(
|
||||
http: Any, base_url: str, creds: tuple[str, str]
|
||||
) -> list[DavCalendar]:
|
||||
"""RFC 6764 discovery: principal → calendar-home-set → calendar collections."""
|
||||
resp = await _request(http, "PROPFIND", base_url, creds=creds, depth="0", body=_PRINCIPAL_BODY)
|
||||
if resp is None or resp.status in (401, 403):
|
||||
raise CalDavError("auth_failed")
|
||||
if resp.status not in (207, 200):
|
||||
raise CalDavError(f"principal lookup failed ({resp.status})")
|
||||
principal = parse_href(resp.text, f"{_DAV}current-user-principal") or base_url
|
||||
principal_url = urljoin(base_url, principal)
|
||||
|
||||
resp = await _request(
|
||||
http, "PROPFIND", principal_url, creds=creds, depth="0", body=_HOME_BODY
|
||||
)
|
||||
if resp is None or resp.status not in (207, 200):
|
||||
raise CalDavError("home-set lookup failed")
|
||||
home = parse_href(resp.text, f"{_CAL}calendar-home-set")
|
||||
home_url = urljoin(principal_url, home) if home else principal_url
|
||||
|
||||
resp = await _request(http, "PROPFIND", home_url, creds=creds, depth="1", body=_CAL_LIST_BODY)
|
||||
if resp is None or resp.status not in (207, 200):
|
||||
raise CalDavError("calendar list failed")
|
||||
return parse_multistatus_calendars(resp.text, home_url)
|
||||
|
||||
|
||||
async def get_ctag(http: Any, calendar_url: str, creds: tuple[str, str]) -> str:
|
||||
resp = await _request(http, "PROPFIND", calendar_url, creds=creds, depth="0", body=_CTAG_BODY)
|
||||
if resp is None or resp.status not in (207, 200):
|
||||
return ""
|
||||
root = SafeET.fromstring(resp.text)
|
||||
for response in root.iter(f"{_DAV}response"):
|
||||
prop = _propstat_ok(response)
|
||||
if prop is not None:
|
||||
return _text(prop, f"{_CS}getctag")
|
||||
return ""
|
||||
|
||||
|
||||
async def pull_events(http: Any, calendar_url: str, creds: tuple[str, str]) -> list[DavEvent]:
|
||||
resp = await _request(
|
||||
http, "REPORT", calendar_url, creds=creds, depth="1", body=_EVENTS_QUERY
|
||||
)
|
||||
if resp is None or resp.status not in (207, 200):
|
||||
raise CalDavError(f"event report failed ({resp.status if resp else 'no response'})")
|
||||
return parse_report(resp.text, calendar_url)
|
||||
|
||||
|
||||
def _if_match(etag: str) -> str:
|
||||
"""Format a stored (quote-stripped) ETag as an HTTP entity-tag for If-Match.
|
||||
|
||||
RFC 7232 requires the value be a quoted-string; Sabre/NextCloud reject a bare
|
||||
token with 412. Weak validators (``W/"…"``) and already-quoted values pass through."""
|
||||
etag = etag.strip()
|
||||
if etag.startswith("W/") or (etag.startswith('"') and etag.endswith('"')):
|
||||
return etag
|
||||
return f'"{etag}"'
|
||||
|
||||
|
||||
async def put_event(
|
||||
http: Any, url: str, ics: str, etag: str | None, creds: tuple[str, str]
|
||||
) -> str:
|
||||
"""Create/update an event resource; return the server's new ETag (may be empty)."""
|
||||
extra = {"Content-Type": "text/calendar; charset=utf-8"}
|
||||
if etag:
|
||||
extra["If-Match"] = _if_match(etag) # update: only if unchanged since we last saw it
|
||||
else:
|
||||
extra["If-None-Match"] = "*" # create: only if it doesn't already exist
|
||||
resp = await _request(http, "PUT", url, creds=creds, body=ics.encode(), extra_headers=extra)
|
||||
if resp is None or resp.status not in (200, 201, 204):
|
||||
raise CalDavError(f"PUT failed ({resp.status if resp else 'no response'})")
|
||||
return (resp.headers.get("etag") or "").strip('"')
|
||||
|
||||
|
||||
async def delete_event(http: Any, url: str, etag: str | None, creds: tuple[str, str]) -> None:
|
||||
extra = {"If-Match": _if_match(etag)} if etag else {}
|
||||
resp = await _request(http, "DELETE", url, creds=creds, extra_headers=extra)
|
||||
# 404/412 (already gone / changed) are treated as done — the local intent (remove) holds.
|
||||
if resp is None or resp.status not in (200, 204, 404, 412):
|
||||
raise CalDavError(f"DELETE failed ({resp.status})")
|
||||
|
||||
|
||||
def event_href(calendar_url: str, ical_uid: str) -> str:
|
||||
"""Deterministic resource path for a locally created event (UID.ics under the collection)."""
|
||||
base = calendar_url if calendar_url.endswith("/") else calendar_url + "/"
|
||||
safe = "".join(ch for ch in ical_uid if ch.isalnum() or ch in "-_.@") or uuid.uuid4().hex
|
||||
return urljoin(base, f"{safe}.ics")
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""calendar: CalDAV two-way sync fields (SPEC §A.3)
|
||||
|
||||
Per-event CalDAV bookkeeping so the sync engine can pull incrementally and push
|
||||
local changes back: ``dav_href`` (the event resource path on the server) and
|
||||
``dav_etag`` (its last-known ETag, for conflict detection). ``dav_dirty`` marks an
|
||||
event created/edited/deleted locally that still needs pushing. Deletions are
|
||||
tombstoned (``dav_deleted``) so the pusher can DELETE them, then drop the row.
|
||||
|
||||
Calendars already carry ``kind`` (local|caldav|ics_subscription), ``source_config``
|
||||
(server/principal/home/collection URLs + an account group id), ``secret_ct`` (vault
|
||||
credentials) and ``sync_token`` (the collection ctag), so no calendar columns change.
|
||||
|
||||
Revision ID: calendar0005
|
||||
Revises: calendar0004
|
||||
Create Date: 2026-08-07
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "calendar0005"
|
||||
down_revision: str | None = "calendar0004"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN dav_href text")
|
||||
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN dav_etag text")
|
||||
op.execute(
|
||||
"ALTER TABLE plg_calendar_events ADD COLUMN dav_dirty boolean NOT NULL DEFAULT false"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE plg_calendar_events ADD COLUMN dav_deleted boolean NOT NULL DEFAULT false"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_deleted")
|
||||
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_dirty")
|
||||
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_etag")
|
||||
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_href")
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""calendar: make the event uniqueness NULLS NOT DISTINCT
|
||||
|
||||
The event key is ``(calendar_id, ical_uid, recurrence_id)``. ``recurrence_id`` is
|
||||
NULL for the common case (non-recurring events and recurring masters). Postgres
|
||||
treats NULLs as *distinct* in a unique index by default, so the CalDAV pull's
|
||||
``INSERT ... ON CONFLICT (calendar_id, ical_uid, recurrence_id)`` never matched
|
||||
those rows and re-inserted a duplicate on every full re-pull. Recreate the
|
||||
constraint with ``NULLS NOT DISTINCT`` (PG 15+) so a NULL ``recurrence_id`` collides
|
||||
as intended and the upsert updates in place.
|
||||
|
||||
Revision ID: calendar0006
|
||||
Revises: calendar0005
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "calendar0006"
|
||||
down_revision: str | None = "calendar0005"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_CONSTRAINT = "plg_calendar_events_calendar_id_ical_uid_recurrence_id_key"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Collapse any duplicates the old (NULLS DISTINCT) index let through, keeping
|
||||
# the most recently inserted row per (calendar_id, ical_uid, recurrence_id).
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM plg_calendar_events e USING plg_calendar_events dup
|
||||
WHERE e.calendar_id = dup.calendar_id
|
||||
AND e.ical_uid = dup.ical_uid
|
||||
AND e.recurrence_id IS NOT DISTINCT FROM dup.recurrence_id
|
||||
AND e.id < dup.id
|
||||
"""
|
||||
)
|
||||
op.execute(f"ALTER TABLE plg_calendar_events DROP CONSTRAINT {_CONSTRAINT}")
|
||||
op.execute(
|
||||
f"ALTER TABLE plg_calendar_events ADD CONSTRAINT {_CONSTRAINT} "
|
||||
"UNIQUE NULLS NOT DISTINCT (calendar_id, ical_uid, recurrence_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(f"ALTER TABLE plg_calendar_events DROP CONSTRAINT {_CONSTRAINT}")
|
||||
op.execute(
|
||||
f"ALTER TABLE plg_calendar_events ADD CONSTRAINT {_CONSTRAINT} "
|
||||
"UNIQUE (calendar_id, ical_uid, recurrence_id)"
|
||||
)
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
"""calendar: auto-mark CalDAV events dirty on local writes
|
||||
|
||||
Two-way sync needs to know which events changed locally so the pusher can PUT/DELETE
|
||||
them back to the server. Rather than thread dirty-marking through every mutation
|
||||
endpoint (create / patch / edit-following / delete with its recurrence scopes), a
|
||||
BEFORE INSERT OR UPDATE trigger sets ``dav_dirty`` on any row belonging to a
|
||||
``caldav`` calendar.
|
||||
|
||||
The pull sync itself writes these rows too, so it must NOT re-mark them dirty (that
|
||||
would push server state straight back). It signals "this write is a sync" by setting
|
||||
the transaction-local GUC ``webmail.dav_sync = 'on'``; the trigger skips when it sees
|
||||
that. Deletions are tombstoned by the endpoint (``dav_deleted``), which is an UPDATE
|
||||
and so also flows through this trigger.
|
||||
|
||||
Revision ID: calendar0007
|
||||
Revises: calendar0006
|
||||
Create Date: 2026-08-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "calendar0007"
|
||||
down_revision: str | None = "calendar0006"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION plg_calendar_mark_dav_dirty() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_setting('webmail.dav_sync', true) IS DISTINCT FROM 'on' THEN
|
||||
IF (SELECT kind FROM plg_calendar_calendars WHERE id = NEW.calendar_id)
|
||||
= 'caldav' THEN
|
||||
NEW.dav_dirty := true;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER plg_calendar_events_dav_dirty
|
||||
BEFORE INSERT OR UPDATE ON plg_calendar_events
|
||||
FOR EACH ROW EXECUTE FUNCTION plg_calendar_mark_dav_dirty();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS plg_calendar_events_dav_dirty ON plg_calendar_events")
|
||||
op.execute("DROP FUNCTION IF EXISTS plg_calendar_mark_dav_dirty()")
|
||||
@@ -27,6 +27,7 @@ from webmail_plugin_calendar.queries import expand_range
|
||||
from webmail_plugin_calendar.schemas import (
|
||||
CalendarIn,
|
||||
CalendarPatch,
|
||||
DavConnectIn,
|
||||
EditFollowingIn,
|
||||
EventIn,
|
||||
EventPatch,
|
||||
@@ -66,6 +67,25 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
|
||||
raise HTTPException(status_code=403, detail="read_only_calendar")
|
||||
|
||||
|
||||
async def _is_caldav(session: AsyncSession, calendar_id: uuid.UUID) -> bool:
|
||||
kind = (
|
||||
await session.execute(
|
||||
text("SELECT kind FROM plg_calendar_calendars WHERE id = :id"),
|
||||
{"id": str(calendar_id)},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return kind == "caldav"
|
||||
|
||||
|
||||
async def _enqueue_dav_sync(calendar_id: uuid.UUID) -> None:
|
||||
"""Kick off an immediate push/pull for a CalDAV calendar so a local create/edit/
|
||||
delete reaches the server right away instead of waiting for the periodic cron.
|
||||
Call *after* the mutation's transaction has committed."""
|
||||
from webmail_plugin_calendar.sync import caldav_sync
|
||||
|
||||
await _SDK.enqueue(caldav_sync, calendar_id=str(calendar_id))
|
||||
|
||||
|
||||
# --- calendars ---------------------------------------------------------------
|
||||
|
||||
def _make_publish_token() -> str:
|
||||
@@ -113,7 +133,7 @@ async def _calendar_event_rows(s: AsyncSession, calendar_id: uuid.UUID) -> list[
|
||||
text(
|
||||
"SELECT ical_uid, summary, description, location, dtstart, dtend, all_day, "
|
||||
"rrule FROM plg_calendar_events "
|
||||
"WHERE calendar_id = :id AND recurrence_id IS NULL"
|
||||
"WHERE calendar_id = :id AND recurrence_id IS NULL AND dav_deleted = false"
|
||||
),
|
||||
{"id": str(calendar_id)},
|
||||
)
|
||||
@@ -314,6 +334,135 @@ async def subscribe_calendar(body: SubscribeIn, user_id: CurrentUserId) -> dict[
|
||||
|
||||
# --- events ------------------------------------------------------------------
|
||||
|
||||
@router.post("/dav/connect", dependencies=[Depends(require_csrf)])
|
||||
async def dav_connect(body: DavConnectIn, user_id: CurrentUserId) -> dict[str, object]:
|
||||
"""Connect a CalDAV account (iCloud or generic): discover its calendars and import
|
||||
each as a read-only ``caldav`` calendar, then kick off an initial pull (SPEC §A.3).
|
||||
|
||||
Credentials are envelope-encrypted per calendar (§10.2). Google is not supported
|
||||
here — its CalDAV needs OAuth (see GOOGLE_OAUTH_ENABLED)."""
|
||||
from webmail_plugin_calendar import caldav
|
||||
from webmail_plugin_calendar.sync import caldav_sync
|
||||
|
||||
base_url = caldav.PROVIDER_BASE.get(body.provider) or body.server_url.strip()
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail="server_url_required")
|
||||
creds = (body.username.strip(), body.password)
|
||||
try:
|
||||
found = await caldav.discover_calendars(_SDK.http, base_url, creds)
|
||||
except caldav.CalDavError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not found:
|
||||
raise HTTPException(status_code=400, detail="no_calendars")
|
||||
|
||||
account_id = uuid.uuid4().hex
|
||||
secret = _SDK.vault.encrypt(body.password.encode("utf-8"))
|
||||
created: list[dict[str, str]] = []
|
||||
async with _SDK.session(user_id) as s:
|
||||
for cal in found:
|
||||
cfg = {
|
||||
"account_id": account_id,
|
||||
"provider": body.provider,
|
||||
"base_url": base_url,
|
||||
"calendar_url": cal.url,
|
||||
"username": body.username.strip(),
|
||||
}
|
||||
cal_id = await _insert_dav_calendar(s, user_id, cal, cfg, secret)
|
||||
if cal_id is not None:
|
||||
created.append({"id": str(cal_id), "name": cal.name})
|
||||
for c in created:
|
||||
await _SDK.enqueue(caldav_sync, calendar_id=c["id"])
|
||||
return {"account_id": account_id, "calendars": created}
|
||||
|
||||
|
||||
async def _insert_dav_calendar(
|
||||
s: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
cal: object,
|
||||
cfg: dict[str, str],
|
||||
secret: bytes,
|
||||
) -> uuid.UUID | None:
|
||||
"""Insert one CalDAV calendar, disambiguating the name against the (user, name)
|
||||
unique constraint. Returns the new id, or None if it couldn't be placed."""
|
||||
base_name = getattr(cal, "name", "Calendar")
|
||||
candidates = (
|
||||
base_name,
|
||||
f"{base_name} ({cfg['provider']})",
|
||||
f"{base_name} ({cfg['username']})",
|
||||
)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
return (
|
||||
await s.execute(
|
||||
text(
|
||||
"INSERT INTO plg_calendar_calendars "
|
||||
"(user_id, name, color, kind, read_only, secret_ct, sync_token, "
|
||||
" source_config) "
|
||||
"VALUES (:u, :n, :c, 'caldav', :ro, :sec, '', cast(:cfg as jsonb)) "
|
||||
"RETURNING id"
|
||||
),
|
||||
{
|
||||
"u": str(user_id),
|
||||
"n": candidate,
|
||||
"c": getattr(cal, "color", "#3b82f6"),
|
||||
"ro": bool(getattr(cal, "read_only", False)),
|
||||
"sec": secret,
|
||||
"cfg": json.dumps(cfg),
|
||||
},
|
||||
)
|
||||
).scalar_one()
|
||||
except IntegrityError:
|
||||
await s.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/dav/accounts")
|
||||
async def list_dav_accounts(user_id: CurrentUserId) -> list[dict[str, object]]:
|
||||
"""Connected CalDAV accounts, grouped by account id, with their calendars."""
|
||||
async with _SDK.session(user_id) as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT id, name, source_config FROM plg_calendar_calendars "
|
||||
"WHERE kind = 'caldav' ORDER BY name"
|
||||
)
|
||||
)
|
||||
).mappings().all()
|
||||
accounts: dict[str, dict[str, object]] = {}
|
||||
for r in rows:
|
||||
cfg = r["source_config"]
|
||||
if isinstance(cfg, str):
|
||||
cfg = json.loads(cfg)
|
||||
aid = str(cfg.get("account_id") or r["id"])
|
||||
acct = accounts.setdefault(
|
||||
aid,
|
||||
{
|
||||
"account_id": aid,
|
||||
"provider": cfg.get("provider", "caldav"),
|
||||
"username": cfg.get("username", ""),
|
||||
"calendars": [],
|
||||
},
|
||||
)
|
||||
cals = acct["calendars"]
|
||||
assert isinstance(cals, list)
|
||||
cals.append({"id": str(r["id"]), "name": r["name"]})
|
||||
return list(accounts.values())
|
||||
|
||||
|
||||
@router.delete("/dav/accounts/{account_id}", dependencies=[Depends(require_csrf)])
|
||||
async def delete_dav_account(account_id: str, user_id: CurrentUserId) -> dict[str, str]:
|
||||
"""Disconnect a CalDAV account: delete its calendars (events cascade)."""
|
||||
async with _SDK.session(user_id) as s:
|
||||
await s.execute(
|
||||
text(
|
||||
"DELETE FROM plg_calendar_calendars "
|
||||
"WHERE kind = 'caldav' AND source_config->>'account_id' = :aid"
|
||||
),
|
||||
{"aid": account_id},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
user_id: CurrentUserId,
|
||||
@@ -326,7 +475,7 @@ async def list_events(
|
||||
the calendar search panel over a wide date window."""
|
||||
range_start, range_end = _aware(start), _aware(end)
|
||||
params: dict[str, object] = {"end": range_end}
|
||||
where = "WHERE e.dtstart < :end"
|
||||
where = "WHERE e.dtstart < :end AND e.dav_deleted = false"
|
||||
term = q.strip()
|
||||
if term:
|
||||
where += (
|
||||
@@ -466,6 +615,9 @@ async def create_event(body: EventIn, user_id: CurrentUserId) -> dict[str, str]:
|
||||
async with _SDK.session(user_id) as s:
|
||||
await _assert_writable(s, body.calendar_id)
|
||||
new_id, ical_uid = await _insert_event(s, user_id, body, attendees)
|
||||
is_dav = await _is_caldav(s, body.calendar_id)
|
||||
if is_dav:
|
||||
await _enqueue_dav_sync(body.calendar_id)
|
||||
# iMIP: email each guest from the organizer's mailbox (best-effort, after commit).
|
||||
await _send_invites_from(user_id, new_id, ical_uid, body, attendees)
|
||||
return {"id": str(new_id), "ical_uid": ical_uid}
|
||||
@@ -535,7 +687,10 @@ async def edit_following(
|
||||
target_id, target_uid = await _insert_event(s, user_id, ev, merged_att)
|
||||
notify = None # brand-new series → notify every guest
|
||||
seq = 0
|
||||
is_dav = await _is_caldav(s, base["calendar_id"])
|
||||
|
||||
if is_dav:
|
||||
await _enqueue_dav_sync(base["calendar_id"])
|
||||
await _send_invites_from(
|
||||
user_id, target_id, target_uid, ev, merged_att, sequence=seq, notify=notify
|
||||
)
|
||||
@@ -603,6 +758,9 @@ async def patch_event(
|
||||
),
|
||||
params,
|
||||
)
|
||||
is_dav = await _is_caldav(s, existing["calendar_id"])
|
||||
if is_dav:
|
||||
await _enqueue_dav_sync(existing["calendar_id"])
|
||||
|
||||
# Email only the newly-added guests; the ICS lists the full (merged) roster.
|
||||
if invite_new and merged_attendees is not None:
|
||||
@@ -645,19 +803,30 @@ async def delete_event(
|
||||
async with _SDK.session(user_id) as s:
|
||||
row = (
|
||||
await s.execute(
|
||||
text("SELECT calendar_id, dtstart, rrule FROM plg_calendar_events WHERE id = :id"),
|
||||
text(
|
||||
"SELECT e.calendar_id, e.dtstart, e.rrule, c.kind "
|
||||
"FROM plg_calendar_events e JOIN plg_calendar_calendars c "
|
||||
"ON c.id = e.calendar_id WHERE e.id = :id"
|
||||
),
|
||||
{"id": str(event_id)},
|
||||
)
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="event_not_found")
|
||||
await _assert_writable(s, row["calendar_id"])
|
||||
# On CalDAV calendars, hard deletes become tombstones so the pusher can DELETE
|
||||
# the resource on the server first, then drop the row. The dav_dirty trigger
|
||||
# fires on this UPDATE. (EXDATE / series-truncation below are ordinary edits
|
||||
# that re-PUT the whole resource, so they need no special handling.)
|
||||
remove_sql = (
|
||||
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
|
||||
if row["kind"] == "caldav"
|
||||
else "DELETE FROM plg_calendar_events WHERE id = :id"
|
||||
)
|
||||
|
||||
recurring = row["rrule"] is not None
|
||||
if not recurring or scope == "all" or occurrence is None:
|
||||
await s.execute(
|
||||
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
|
||||
)
|
||||
await s.execute(text(remove_sql), {"id": str(event_id)})
|
||||
elif scope == "this":
|
||||
await s.execute(
|
||||
text(
|
||||
@@ -670,9 +839,7 @@ async def delete_event(
|
||||
occ = _aware(occurrence)
|
||||
if occ <= _aware(row["dtstart"]):
|
||||
# Truncating from the first occurrence removes everything.
|
||||
await s.execute(
|
||||
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
|
||||
)
|
||||
await s.execute(text(remove_sql), {"id": str(event_id)})
|
||||
else:
|
||||
until = (occ - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
trimmed = _strip_recurrence_end(str(row["rrule"]))
|
||||
@@ -683,6 +850,8 @@ async def delete_event(
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="bad_scope")
|
||||
if row["kind"] == "caldav":
|
||||
await _enqueue_dav_sync(row["calendar_id"])
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ class SubscribeIn(BaseModel):
|
||||
color: str = "#6b7280"
|
||||
|
||||
|
||||
class DavConnectIn(BaseModel):
|
||||
provider: str # "icloud" | "caldav"
|
||||
server_url: str = "" # required for generic caldav; ignored for known providers
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Attendee(BaseModel):
|
||||
email: str
|
||||
name: str = ""
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""CalDAV pull sync (SPEC §A.3, read side).
|
||||
|
||||
Refreshes imported CalDAV calendars from their servers: skip when the collection's
|
||||
ctag is unchanged, else pull every VEVENT and upsert it, dropping local events that
|
||||
vanished remotely. Runs on connect (per calendar) and on a periodic cron (all).
|
||||
Credentials come from the calendar's own vaulted ``secret_ct``.
|
||||
|
||||
Write-back (pushing local edits to the server) is a later stage; imported CalDAV
|
||||
calendars are created ``read_only`` for now, so nothing here has to reconcile local
|
||||
changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.plugins.sdk import sdk
|
||||
from webmail_plugin_calendar import caldav
|
||||
|
||||
logger = logging.getLogger("plg.calendar.sync")
|
||||
|
||||
_SDK = sdk("calendar")
|
||||
|
||||
|
||||
def _creds(source_config: dict[str, Any], secret_ct: bytes | None) -> tuple[str, str]:
|
||||
username = str(source_config.get("username") or "")
|
||||
password = _SDK.vault.decrypt(secret_ct).decode("utf-8") if secret_ct else ""
|
||||
return username, password
|
||||
|
||||
|
||||
async def _push_changes(
|
||||
session: Any, http: Any, cal_id: str, calendar_url: str, creds: tuple[str, str]
|
||||
) -> int:
|
||||
"""Push locally-changed events (``dav_dirty``) to the server. Deletions
|
||||
(``dav_deleted``) are DELETEd then dropped; other changes are PUT. Returns the
|
||||
number of resources pushed. Runs with ``webmail.dav_sync='on'`` so its own writes
|
||||
don't re-mark rows dirty."""
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT id, ical_uid, sequence, summary, description, location, dtstart, "
|
||||
"dtend, all_day, rrule, exdates, dav_href, dav_etag, dav_deleted "
|
||||
"FROM plg_calendar_events WHERE calendar_id = :cal AND dav_dirty = true"
|
||||
),
|
||||
{"cal": cal_id},
|
||||
)
|
||||
).mappings().all()
|
||||
pushed = 0
|
||||
for r in rows:
|
||||
try:
|
||||
if r["dav_deleted"]:
|
||||
if r["dav_href"]:
|
||||
await caldav.delete_event(http, r["dav_href"], r["dav_etag"], creds)
|
||||
await session.execute(
|
||||
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(r["id"])}
|
||||
)
|
||||
else:
|
||||
href = r["dav_href"] or caldav.event_href(calendar_url, r["ical_uid"])
|
||||
ics = caldav.build_ics(dict(r))
|
||||
new_etag = await caldav.put_event(http, href, ics, r["dav_etag"], creds)
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events SET dav_dirty = false, dav_href = :href, "
|
||||
"dav_etag = :etag WHERE id = :id"
|
||||
),
|
||||
{"href": href, "etag": new_etag, "id": str(r["id"])},
|
||||
)
|
||||
pushed += 1
|
||||
except caldav.CalDavError:
|
||||
# Conflict/transport error: leave the row dirty and try again next run.
|
||||
logger.exception("caldav push failed for event %s", r["id"])
|
||||
return pushed
|
||||
|
||||
|
||||
async def sync_calendar(session: Any, cal: dict[str, Any]) -> int:
|
||||
"""Sync one CalDAV calendar: push local changes, then pull the server into
|
||||
``plg_calendar_events``. Returns events upserted on the pull."""
|
||||
cfg = cal["source_config"]
|
||||
if isinstance(cfg, str):
|
||||
cfg = json.loads(cfg)
|
||||
calendar_url = str(cfg.get("calendar_url") or "")
|
||||
if not calendar_url:
|
||||
return 0
|
||||
creds = _creds(cfg, cal["secret_ct"])
|
||||
http = _SDK.http
|
||||
|
||||
# Suppress the dav_dirty trigger for every write we make in this transaction.
|
||||
await session.execute(text("SELECT set_config('webmail.dav_sync', 'on', true)"))
|
||||
pushed = await _push_changes(session, http, str(cal["id"]), calendar_url, creds)
|
||||
|
||||
ctag = await caldav.get_ctag(http, calendar_url, creds)
|
||||
if not pushed and ctag and ctag == (cal.get("sync_token") or ""):
|
||||
return 0 # nothing pushed and collection unchanged since last sync
|
||||
|
||||
events = await caldav.pull_events(http, calendar_url, creds)
|
||||
cal_id = str(cal["id"])
|
||||
user_id = str(cal["user_id"])
|
||||
seen: set[str] = set()
|
||||
upserted = 0
|
||||
for dav in events:
|
||||
fields = caldav.vevent_to_fields(dav.ics)
|
||||
if fields is None:
|
||||
continue
|
||||
seen.add(fields["ical_uid"])
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO plg_calendar_events "
|
||||
"(user_id, calendar_id, ical_uid, sequence, summary, description, location, "
|
||||
" dtstart, dtend, all_day, rrule, status, raw_ical, dav_href, dav_etag) VALUES "
|
||||
"(:u, :cal, :uid, :seq, :sum, :desc, :loc, :start, :end, :allday, :rrule, "
|
||||
" :status, :raw, :href, :etag) "
|
||||
"ON CONFLICT (calendar_id, ical_uid, recurrence_id) DO UPDATE SET "
|
||||
"sequence = EXCLUDED.sequence, summary = EXCLUDED.summary, "
|
||||
"description = EXCLUDED.description, location = EXCLUDED.location, "
|
||||
"dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, all_day = EXCLUDED.all_day, "
|
||||
"rrule = EXCLUDED.rrule, status = EXCLUDED.status, raw_ical = EXCLUDED.raw_ical, "
|
||||
"dav_href = EXCLUDED.dav_href, dav_etag = EXCLUDED.dav_etag "
|
||||
# Don't overwrite a locally-changed row that hasn't been pushed yet.
|
||||
"WHERE plg_calendar_events.dav_dirty = false"
|
||||
),
|
||||
{
|
||||
"u": user_id,
|
||||
"cal": cal_id,
|
||||
"uid": fields["ical_uid"],
|
||||
"seq": fields["sequence"],
|
||||
"sum": fields["summary"],
|
||||
"desc": fields["description"],
|
||||
"loc": fields["location"],
|
||||
"start": fields["dtstart"],
|
||||
"end": fields["dtend"],
|
||||
"allday": fields["all_day"],
|
||||
"rrule": fields["rrule"],
|
||||
"status": fields["status"],
|
||||
"raw": fields["raw_ical"],
|
||||
"href": dav.href,
|
||||
"etag": dav.etag,
|
||||
},
|
||||
)
|
||||
upserted += 1
|
||||
|
||||
# Drop local events that no longer exist on the server, but never touch rows with
|
||||
# unpushed local changes (dav_dirty) — including freshly-created ones not yet PUT.
|
||||
if seen:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM plg_calendar_events "
|
||||
"WHERE calendar_id = :cal AND dav_dirty = false "
|
||||
"AND NOT (ical_uid = ANY(:keep))"
|
||||
),
|
||||
{"cal": cal_id, "keep": list(seen)},
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM plg_calendar_events "
|
||||
"WHERE calendar_id = :cal AND dav_dirty = false"
|
||||
),
|
||||
{"cal": cal_id},
|
||||
)
|
||||
await session.execute(
|
||||
text("UPDATE plg_calendar_calendars SET sync_token = :ct WHERE id = :id"),
|
||||
{"ct": ctag, "id": cal_id},
|
||||
)
|
||||
return upserted
|
||||
|
||||
|
||||
async def caldav_sync(ctx: dict[str, Any], calendar_id: str | None = None) -> None:
|
||||
"""Job: sync one CalDAV calendar (``calendar_id`` given) or all of them (cron).
|
||||
|
||||
Enumerates via a non-RLS session (cron spans users), then applies each calendar's
|
||||
upserts under that user's RLS session."""
|
||||
async with _SDK.public_session() as pub:
|
||||
where = "kind = 'caldav'"
|
||||
params: dict[str, Any] = {}
|
||||
if calendar_id:
|
||||
where += " AND id = :id"
|
||||
params["id"] = calendar_id
|
||||
rows = (
|
||||
await pub.execute(
|
||||
text(
|
||||
"SELECT id, user_id, source_config, secret_ct, sync_token "
|
||||
f"FROM plg_calendar_calendars WHERE {where}"
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
for cal in rows:
|
||||
try:
|
||||
async with _SDK.session(uuid.UUID(str(cal["user_id"]))) as s:
|
||||
n = await sync_calendar(s, dict(cal))
|
||||
logger.info("caldav sync: calendar %s upserted %d event(s)", cal["id"], n)
|
||||
except Exception: # noqa: BLE001 - one bad calendar mustn't stop the rest
|
||||
logger.exception("caldav sync failed for calendar %s", cal["id"])
|
||||
@@ -322,7 +322,12 @@ const CSS = `
|
||||
.dark .cal-col:hover{background:rgba(255,255,255,.05)}
|
||||
.cal-col.today:hover{background:rgba(59,130,246,.11)}
|
||||
.cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))}
|
||||
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box}
|
||||
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box;z-index:2}
|
||||
/* all-day event in the timegrid: a full-height band filling the day column. The band
|
||||
itself is click-through (so click-to-create and timed events still work); only its
|
||||
label chip is interactive. */
|
||||
.cal-aday{position:absolute;top:0;box-sizing:border-box;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;border-radius:5px;pointer-events:none;z-index:1}
|
||||
.cal-aday .lbl{position:sticky;top:var(--cal-head-h,46px);z-index:3;pointer-events:auto;cursor:pointer;display:block;font-size:13px;font-weight:600;line-height:1.3;margin:1px;padding:3px 6px;border-radius:4px;color:rgb(var(--app-fg));background:rgb(var(--app-elevated));box-shadow:0 1px 2px rgba(0,0,0,.25);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4}
|
||||
/* right agenda */
|
||||
.cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))}
|
||||
@@ -1411,17 +1416,33 @@ export default function mount(el, ctx, props) {
|
||||
const num = `${d.getDate()}${d.getDate() === 1 ? " " + MONTHS[d.getMonth()].slice(0, 3) : ""}`;
|
||||
return `<div class="d${today ? " today" : ""}">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${today ? `<span class="num">${d.getDate()}</span>` : num}</b></div>`;
|
||||
}).join("");
|
||||
const gridH = 24 * HOUR_PX;
|
||||
const cols = days.map((d) => {
|
||||
const today = sameDay(d, new Date());
|
||||
const dayStart = startOfDay(d), dayEnd = addDays(dayStart, 1);
|
||||
const lines = Array.from({ length: 24 }, () => `<div class="line"></div>`).join("");
|
||||
const dayEvs = evs.filter((ev) => !ev.all_day && sameDay(ev._s, d));
|
||||
const lay = layoutDay(dayEvs);
|
||||
const blocks = dayEvs.map((ev) => {
|
||||
// Match all-day events by their start day (like month view) so a CalDAV event
|
||||
// stored as UTC-midnight..midnight doesn't spill into the next local day.
|
||||
// All-day and timed events share ONE column layout: all-day events are treated
|
||||
// as spanning the whole day so overlapping events sit next to each other.
|
||||
const items = [
|
||||
...evs.filter((ev) => ev.all_day && sameDay(ev._s, d))
|
||||
.map((ev) => ({ ev, _s: dayStart, _e: dayEnd, allday: true })),
|
||||
...evs.filter((ev) => !ev.all_day && sameDay(ev._s, d))
|
||||
.map((ev) => ({ ev, _s: ev._s, _e: ev._e, allday: false })),
|
||||
];
|
||||
const lay = layoutDay(items);
|
||||
const blocks = items.map((it) => {
|
||||
const ev = it.ev;
|
||||
const pos = lay.get(it) || { col: 0, total: 1 };
|
||||
const w = 100 / pos.total;
|
||||
const xy = `left:calc(${pos.col * w}% + 1px);width:calc(${w}% - 3px)`;
|
||||
if (it.allday) {
|
||||
return `<div class="cal-aday" style="${xy};height:${gridH}px;border-color:${esc(ev.color)};background:${esc(ev.color)}22"><span class="lbl" data-ev="${ev.id}" data-start="${esc(ev.start)}">${esc(ev.summary || "(no title)")}</span></div>`;
|
||||
}
|
||||
const top = (ev._s.getHours() + ev._s.getMinutes() / 60) * HOUR_PX;
|
||||
const h = Math.max(18, ((ev._e - ev._s) / 3600000) * HOUR_PX);
|
||||
const pos = lay.get(ev) || { col: 0, total: 1 };
|
||||
const w = 100 / pos.total;
|
||||
return `<div class="cal-tev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="top:${top}px;height:${h}px;left:calc(${pos.col * w}% + 1px);width:calc(${w}% - 3px);border-color:${esc(ev.color)}"><b>${esc(ev.summary || "(no title)")}</b><div style="color:rgb(var(--app-muted))">${hhmm(ev._s)}</div></div>`;
|
||||
return `<div class="cal-tev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="top:${top}px;height:${h}px;${xy};border-color:${esc(ev.color)}"><b>${esc(ev.summary || "(no title)")}</b><div style="color:rgb(var(--app-muted))">${hhmm(ev._s)}</div></div>`;
|
||||
}).join("");
|
||||
const now = today ? `<div class="cal-now" style="top:${(new Date().getHours() + new Date().getMinutes() / 60) * HOUR_PX}px"></div>` : "";
|
||||
return `<div class="cal-col${today ? " today" : ""}" data-col="${iso(d)}">${lines}${blocks}${now}</div>`;
|
||||
@@ -1678,7 +1699,11 @@ export default function mount(el, ctx, props) {
|
||||
|
||||
function scrollTimegrid() {
|
||||
const view = el.querySelector(".cal-view");
|
||||
if (view) view.scrollTop = 7 * HOUR_PX; // start around 7 AM like the mockups
|
||||
if (!view) return;
|
||||
// Pin all-day labels just below the sticky day-header (its height is dynamic).
|
||||
const head = view.querySelector(".cal-tg-head");
|
||||
if (head) view.style.setProperty("--cal-head-h", head.offsetHeight + "px");
|
||||
view.scrollTop = 7 * HOUR_PX; // start around 7 AM like the mockups
|
||||
}
|
||||
|
||||
async function loadChrome() {
|
||||
|
||||
Reference in New Issue
Block a user