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:
2026-08-08 10:27:21 +02:00
co-authored by Claude Opus 4.8
parent 719eeef378
commit a27755a4d3
13 changed files with 1260 additions and 22 deletions
+20
View File
@@ -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",
+13
View File
@@ -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"),
+136 -4
View File
@@ -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">