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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user