Compare commits
1 Commits
main
...
mai/leibni
| Author | SHA1 | Date | |
|---|---|---|---|
| ea38db9e94 |
@@ -1700,7 +1700,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.bindings.modal.edit_title": "Kalender bearbeiten",
|
||||
"caldav.bindings.modal.source": "Kalender",
|
||||
"caldav.bindings.modal.source.loading": "Lädt …",
|
||||
"caldav.bindings.modal.source.existing": "Vorhandenen Kalender wählen",
|
||||
"caldav.bindings.modal.source.create": "Neuen Kalender erstellen",
|
||||
"caldav.bindings.modal.source.custom": "Eigene URL eingeben",
|
||||
"caldav.bindings.modal.source.degrade": "Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV. Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.",
|
||||
"caldav.bindings.modal.source.discover_failed": "Kalender konnten nicht ermittelt werden — eigene URL eingeben.",
|
||||
"caldav.bindings.modal.source.discover_empty": "Keine Kalender gefunden — eigene URL eingeben.",
|
||||
"caldav.bindings.modal.display_name": "Anzeigename (optional)",
|
||||
@@ -1717,6 +1720,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.bindings.error.scope": "Bitte einen Inhaltsbereich wählen.",
|
||||
"caldav.bindings.error.scope_project": "Bitte ein Projekt auswählen.",
|
||||
"caldav.bindings.error.path": "Bitte einen Kalender wählen oder eine URL eingeben.",
|
||||
"caldav.bindings.error.create_name_required": "Bitte einen Anzeigenamen eingeben.",
|
||||
"caldav.bindings.error.create_name_taken": "Name bereits vergeben — bitte einen anderen Anzeigenamen wählen.",
|
||||
"caldav.bindings.error.create_unsupported": "Dein Anbieter unterstützt das Erstellen neuer Kalender nicht. Bitte 'Eigene URL eingeben' verwenden.",
|
||||
|
||||
// Notizen (polymorphic notes — Phase I)
|
||||
"notes.section.title": "Notizen",
|
||||
@@ -4360,7 +4366,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.bindings.modal.edit_title": "Edit calendar",
|
||||
"caldav.bindings.modal.source": "Calendar",
|
||||
"caldav.bindings.modal.source.loading": "Loading…",
|
||||
"caldav.bindings.modal.source.existing": "Pick existing calendar",
|
||||
"caldav.bindings.modal.source.create": "Create new calendar",
|
||||
"caldav.bindings.modal.source.custom": "Enter custom URL",
|
||||
"caldav.bindings.modal.source.degrade": "This provider doesn't allow creating calendars via CalDAV. Please create the calendar in your provider's UI and add it here by URL.",
|
||||
"caldav.bindings.modal.source.discover_failed": "Couldn't discover calendars — enter URL manually.",
|
||||
"caldav.bindings.modal.source.discover_empty": "No calendars found — enter URL manually.",
|
||||
"caldav.bindings.modal.display_name": "Display name (optional)",
|
||||
@@ -4377,6 +4386,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.bindings.error.scope": "Please pick a content scope.",
|
||||
"caldav.bindings.error.scope_project": "Please pick a project.",
|
||||
"caldav.bindings.error.path": "Please pick a calendar or enter a URL.",
|
||||
"caldav.bindings.error.create_name_required": "Please enter a display name.",
|
||||
"caldav.bindings.error.create_name_taken": "Name already in use — please pick a different display name.",
|
||||
"caldav.bindings.error.create_unsupported": "Your provider doesn't support creating calendars. Please use 'Enter custom URL' instead.",
|
||||
|
||||
// Notizen (polymorphic notes — Phase I)
|
||||
"notes.section.title": "Notes",
|
||||
|
||||
@@ -634,6 +634,10 @@ let bindings: UserCalendarBinding[] = [];
|
||||
let discoveredCalendars: DiscoveredCalendar[] = [];
|
||||
let bindingProjects: ProjectListItem[] = [];
|
||||
let editingBindingID: string | null = null;
|
||||
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
|
||||
// true = MKCALENDAR supported (show "Create new calendar" radio),
|
||||
// false = degrade UX (hide radio, surface bilingual notice).
|
||||
let supportsMKCalendar: boolean | null = null;
|
||||
|
||||
async function loadBindings(): Promise<void> {
|
||||
const section = document.getElementById("caldav-bindings-section");
|
||||
@@ -731,29 +735,75 @@ async function loadDiscoveredCalendars(): Promise<void> {
|
||||
const resp = await fetch("/api/caldav-discover");
|
||||
if (!resp.ok) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as { calendars: DiscoveredCalendar[] };
|
||||
const data = (await resp.json()) as {
|
||||
calendars: DiscoveredCalendar[];
|
||||
supports_mkcalendar?: boolean | null;
|
||||
};
|
||||
discoveredCalendars = data.calendars || [];
|
||||
supportsMKCalendar = data.supports_mkcalendar ?? null;
|
||||
if (!discoveredCalendars.length) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
|
||||
return;
|
||||
} else {
|
||||
sel.innerHTML = discoveredCalendars
|
||||
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
sel.innerHTML = discoveredCalendars
|
||||
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
|
||||
.join("");
|
||||
syncBindingSourceModeUI();
|
||||
} catch {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
}
|
||||
}
|
||||
|
||||
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
|
||||
// radio + the Google-degrade notice based on the cached
|
||||
// supports_mkcalendar capability. Also flips the visible input
|
||||
// (dropdown vs URL text box) to match the currently selected mode.
|
||||
function syncBindingSourceModeUI(): void {
|
||||
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
|
||||
const degrade = document.getElementById("caldav-binding-degrade-notice");
|
||||
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
|
||||
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
|
||||
|
||||
// If supports_mkcalendar flipped to false while "create" was selected,
|
||||
// fall back to "existing" so the user isn't staring at a hidden radio.
|
||||
if (supportsMKCalendar !== true) {
|
||||
const createRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="create"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (createRadio?.checked) {
|
||||
const existing = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existing) existing.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = currentBindingSourceMode();
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
sel.style.display = mode === "existing" ? "" : "none";
|
||||
customInput.style.display = mode === "custom" ? "" : "none";
|
||||
}
|
||||
|
||||
function currentBindingSourceMode(): "existing" | "create" | "custom" {
|
||||
const checked = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"]:checked',
|
||||
) as HTMLInputElement | null;
|
||||
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
|
||||
}
|
||||
|
||||
function openBindingModal(b: UserCalendarBinding | null) {
|
||||
editingBindingID = b ? b.id : null;
|
||||
const modal = document.getElementById("caldav-binding-modal")!;
|
||||
const title = document.getElementById("caldav-binding-modal-title")!;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
|
||||
const sourceField = document.getElementById("caldav-binding-source-field")!;
|
||||
const customToggle = document.getElementById("caldav-binding-custom-toggle") as HTMLInputElement;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
@@ -771,8 +821,11 @@ function openBindingModal(b: UserCalendarBinding | null) {
|
||||
title.textContent = t("caldav.bindings.modal.add_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
|
||||
sourceField.style.display = "";
|
||||
customToggle.checked = false;
|
||||
customInput.style.display = "none";
|
||||
// Reset the 3-way source-mode radio to "existing" (most common path).
|
||||
const existingRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existingRadio) existingRadio.checked = true;
|
||||
customInput.value = "";
|
||||
nameInput.value = "";
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
|
||||
@@ -789,6 +842,7 @@ function openBindingModal(b: UserCalendarBinding | null) {
|
||||
projectSel.disabled = false;
|
||||
}
|
||||
syncBindingScopeUI();
|
||||
syncBindingSourceModeUI();
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
@@ -808,7 +862,6 @@ async function submitBindingModal(ev: Event): Promise<void> {
|
||||
ev.preventDefault();
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
const customToggle = document.getElementById("caldav-binding-custom-toggle") as HTMLInputElement;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
@@ -821,27 +874,25 @@ async function submitBindingModal(ev: Event): Promise<void> {
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") {
|
||||
if (!projectSel.value) {
|
||||
msg.textContent = t("caldav.bindings.error.scope_project");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
payload.scope_id = projectSel.value;
|
||||
if (scope === "project" && !projectSel.value) {
|
||||
msg.textContent = t("caldav.bindings.error.scope_project");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
if (editingBindingID) {
|
||||
const patchPayload: Record<string, unknown> = {
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") patchPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
@@ -850,27 +901,75 @@ async function submitBindingModal(ev: Event): Promise<void> {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const path = customToggle.checked ? customInput.value.trim() : sel.value;
|
||||
if (!path) {
|
||||
msg.textContent = t("caldav.bindings.error.path");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
payload.calendar_path = path;
|
||||
if (!payload.display_name && !customToggle.checked) {
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
payload.display_name = opt ? opt.text : "";
|
||||
}
|
||||
const resp = await fetch("/api/caldav-bindings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
const mode = currentBindingSourceMode();
|
||||
if (mode === "create") {
|
||||
// Slice 2c MKCALENDAR path.
|
||||
const displayName = nameInput.value.trim();
|
||||
if (!displayName) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const createPayload: Record<string, unknown> = {
|
||||
display_name: displayName,
|
||||
scope_kind: scope,
|
||||
};
|
||||
if (scope === "project") createPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch("/api/caldav-mkcalendar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(createPayload),
|
||||
});
|
||||
if (resp.status === 501) {
|
||||
// Race: probe flipped to false between modal-open and submit.
|
||||
// Re-sync the UI and surface a helpful message.
|
||||
supportsMKCalendar = false;
|
||||
syncBindingSourceModeUI();
|
||||
msg.textContent = t("caldav.bindings.error.create_unsupported");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_taken");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// existing | custom — POST /api/caldav-bindings with the path.
|
||||
const path = mode === "custom" ? customInput.value.trim() : sel.value;
|
||||
if (!path) {
|
||||
msg.textContent = t("caldav.bindings.error.path");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const postPayload: Record<string, unknown> = {
|
||||
calendar_path: path,
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") postPayload.scope_id = projectSel.value;
|
||||
if (!postPayload.display_name && mode === "existing") {
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
postPayload.display_name = opt ? opt.text : "";
|
||||
}
|
||||
const resp = await fetch("/api/caldav-bindings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(postPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
closeBindingModal();
|
||||
@@ -1033,17 +1132,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
|
||||
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
|
||||
|
||||
// CalDAV bindings (Slice 2b) — add/edit modal wiring.
|
||||
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
|
||||
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
|
||||
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
|
||||
const customToggle = document.getElementById("caldav-binding-custom-toggle") as HTMLInputElement | null;
|
||||
customToggle?.addEventListener("change", () => {
|
||||
const path = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
path.style.display = customToggle.checked ? "" : "none";
|
||||
sel.style.display = customToggle.checked ? "none" : "";
|
||||
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingSourceModeUI);
|
||||
});
|
||||
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingScopeUI);
|
||||
|
||||
@@ -716,6 +716,9 @@ export type I18nKey =
|
||||
| "caldav.bindings.delete.confirm"
|
||||
| "caldav.bindings.delete.failed"
|
||||
| "caldav.bindings.empty"
|
||||
| "caldav.bindings.error.create_name_required"
|
||||
| "caldav.bindings.error.create_name_taken"
|
||||
| "caldav.bindings.error.create_unsupported"
|
||||
| "caldav.bindings.error.path"
|
||||
| "caldav.bindings.error.scope"
|
||||
| "caldav.bindings.error.scope_project"
|
||||
@@ -731,9 +734,12 @@ export type I18nKey =
|
||||
| "caldav.bindings.modal.scope.project"
|
||||
| "caldav.bindings.modal.scope.project.loading"
|
||||
| "caldav.bindings.modal.source"
|
||||
| "caldav.bindings.modal.source.create"
|
||||
| "caldav.bindings.modal.source.custom"
|
||||
| "caldav.bindings.modal.source.degrade"
|
||||
| "caldav.bindings.modal.source.discover_empty"
|
||||
| "caldav.bindings.modal.source.discover_failed"
|
||||
| "caldav.bindings.modal.source.existing"
|
||||
| "caldav.bindings.modal.source.loading"
|
||||
| "caldav.bindings.modal.submit_add"
|
||||
| "caldav.bindings.modal.submit_edit"
|
||||
|
||||
@@ -425,19 +425,37 @@ export function renderSettings(): string {
|
||||
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
|
||||
<div className="form-field" id="caldav-binding-source-field">
|
||||
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
|
||||
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
|
||||
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender wählen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="create" />
|
||||
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="custom" />
|
||||
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="caldav-binding-discover-select">
|
||||
<option value="" data-i18n="caldav.bindings.modal.source.loading">Lädt…</option>
|
||||
</select>
|
||||
<label className="caldav-toggle-label caldav-binding-custom-toggle">
|
||||
<input type="checkbox" id="caldav-binding-custom-toggle" />
|
||||
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="caldav-binding-custom-path"
|
||||
placeholder="https://..."
|
||||
style="display:none"
|
||||
/>
|
||||
{/* Slice 2c — Google-degrade notice. Shown when
|
||||
supports_mkcalendar=false; the create-new radio is
|
||||
hidden in that state, so users are nudged to the
|
||||
custom-URL path. */}
|
||||
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
|
||||
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
|
||||
Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Reverse of 108: drop the capability columns.
|
||||
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
DROP COLUMN IF EXISTS supports_mkcalendar,
|
||||
DROP COLUMN IF EXISTS mkcalendar_probed_at;
|
||||
@@ -0,0 +1,67 @@
|
||||
-- t-paliad-212 — Slice 2c of CalDAV multi-calendar.
|
||||
--
|
||||
-- Adds the MKCALENDAR-capability tri-state to paliad.user_caldav_config:
|
||||
-- * supports_mkcalendar = NULL → unprobed (probe runs lazily on
|
||||
-- the first /api/caldav-discover or
|
||||
-- /api/caldav-mkcalendar call).
|
||||
-- * supports_mkcalendar = TRUE → server accepts MKCALENDAR; the
|
||||
-- "Create new calendar" affordance
|
||||
-- in the picker is visible.
|
||||
-- * supports_mkcalendar = FALSE → Google-style degrade; UI hides the
|
||||
-- create button and surfaces the
|
||||
-- "create it in your provider's UI"
|
||||
-- notice with a manual-URL input.
|
||||
-- The probed_at timestamp lets us re-probe stale-cached results when
|
||||
-- the user changes credentials (SaveConfig invalidates by SetNull in
|
||||
-- the Go service layer; the column is here so the next round of
|
||||
-- probing has somewhere to land).
|
||||
--
|
||||
-- Idempotent (column-exists DO block) + assertion at the bottom.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 108: add user_caldav_config.supports_mkcalendar tri-state for t-paliad-212 Slice 2c capability probe',
|
||||
true);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'supports_mkcalendar'
|
||||
) THEN
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
ADD COLUMN supports_mkcalendar boolean;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'mkcalendar_probed_at'
|
||||
) THEN
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
ADD COLUMN mkcalendar_probed_at timestamptz;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Assertion — both columns present and nullable.
|
||||
DO $$
|
||||
DECLARE
|
||||
sup_nullable text;
|
||||
probed_nullable text;
|
||||
BEGIN
|
||||
SELECT is_nullable INTO sup_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'supports_mkcalendar';
|
||||
SELECT is_nullable INTO probed_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'mkcalendar_probed_at';
|
||||
IF sup_nullable <> 'YES' OR probed_nullable <> 'YES' THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 108 assertion failed: expected both columns nullable, got supports=% probed=%',
|
||||
sup_nullable, probed_nullable;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -461,6 +461,58 @@ func handleDeleteCalDAVBinding(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/caldav-mkcalendar — creates a new calendar on the user's
|
||||
// CalDAV server via MKCALENDAR + a matching binding row in one logical
|
||||
// transaction. Slice 2c only — visible when /api/caldav-discover
|
||||
// reports supports_mkcalendar=true. Errors:
|
||||
// - 501 when supports_mkcalendar=false (caller should show the
|
||||
// Google-degrade UX with the manual-URL input).
|
||||
// - 409 when the slugified name + 3 retries all collide on the
|
||||
// server. UI should ask the user to type their own name.
|
||||
func handleCalDAVMakeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateCalendarInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
result, err := dbSvc.caldav.MakeCalendar(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrMKCalendarUnsupported):
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{
|
||||
"error": err.Error(),
|
||||
"supports_mkcalendar": false,
|
||||
})
|
||||
case errors.Is(err, services.ErrCalendarNameTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
default:
|
||||
// Binding-create / push errors carry the partial result so
|
||||
// the UI can surface "created remotely but binding failed".
|
||||
if result != nil {
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"calendar_path": result.CalendarPath,
|
||||
"binding": result.Binding,
|
||||
"initial_pushed": result.InitialPushed,
|
||||
"initial_sync_error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeCalDAVError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// GET /api/caldav-discover — walks the calendar-home-set chain on the
|
||||
// user's CalDAV server and returns the calendars they own. Cached
|
||||
// server-side for 5 minutes per user (Q4 of Slice 2 brief).
|
||||
|
||||
@@ -363,6 +363,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/caldav-bindings/{id}", handleDeleteCalDAVBinding)
|
||||
// /api/caldav-discover — calendar-home-set walk (RFC 6764) for picker.
|
||||
protected.HandleFunc("GET /api/caldav-discover", handleCalDAVDiscover)
|
||||
// Slice 2c — MKCALENDAR ("Create new calendar" affordance in picker).
|
||||
protected.HandleFunc("POST /api/caldav-mkcalendar", handleCalDAVMakeCalendar)
|
||||
|
||||
// t-paliad-088 — Event Types (categorization for Deadlines).
|
||||
protected.HandleFunc("GET /api/event-types", handleListEventTypes)
|
||||
|
||||
@@ -425,16 +425,19 @@ type ChecklistInstanceWithProject struct {
|
||||
// UserCalDAVConfig holds one user's external CalDAV connection. The password
|
||||
// is never returned in API responses; only the public fields are exposed.
|
||||
type UserCalDAVConfig struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
URL string `db:"url" json:"url"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
URL string `db:"url" json:"url"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// MKCALENDAR-capability tri-state (mig 108, Slice 2c). NULL = unprobed.
|
||||
SupportsMKCalendar *bool `db:"supports_mkcalendar" json:"supports_mkcalendar,omitempty"`
|
||||
MKCalendarProbedAt *time.Time `db:"mkcalendar_probed_at" json:"mkcalendar_probed_at,omitempty"`
|
||||
}
|
||||
|
||||
// CalDAVSyncLogEntry is one historical sync record. BindingID is populated
|
||||
|
||||
@@ -2,7 +2,10 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,6 +15,15 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrCalendarNameTaken is returned by MakeCalendar when the server
|
||||
// rejects MKCALENDAR with 405 — name already in use.
|
||||
var ErrCalendarNameTaken = errors.New("calendar name already taken on server")
|
||||
|
||||
// ErrMKCalendarUnsupported is returned by MakeCalendar when the server
|
||||
// outright rejects MKCALENDAR (403/501) — should never fire after a
|
||||
// successful probe, but kept as a defence so we don't loop.
|
||||
var ErrMKCalendarUnsupported = errors.New("server does not support MKCALENDAR")
|
||||
|
||||
// Tiny CalDAV HTTP client — only the verbs Paliad needs:
|
||||
// - PUT (create / replace event)
|
||||
// - GET (fetch event by path)
|
||||
@@ -443,6 +455,165 @@ func (c *calDAVClient) propfindHrefs(ctx context.Context, urlPath, depth, body,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- MKCALENDAR capability probe + provisioning (Slice 2c) ---
|
||||
|
||||
// ProbeMKCalendar reports whether the CalDAV server accepts MKCALENDAR
|
||||
// against the calendar-home-set. Two-step per design §4.2:
|
||||
//
|
||||
// 1. OPTIONS on the home URL — if the server returns `Allow:` listing
|
||||
// MKCALENDAR, we're done.
|
||||
// 2. Synthetic probe — issue MKCALENDAR against a random
|
||||
// `.paliad-probe-<short>/` path and DELETE it. Catches legacy SOGo
|
||||
// and misconfigured Radicales that don't list MKCALENDAR in Allow
|
||||
// but still accept it. Servers that 405/501 the synthetic probe
|
||||
// are recorded as no-MKCALENDAR; further attempts skip the probe.
|
||||
//
|
||||
// The probe never persists state — that's the service-layer's job via
|
||||
// CalDAVService.MakeCalendar.
|
||||
func (c *calDAVClient) ProbeMKCalendar(ctx context.Context, homePath string) (bool, error) {
|
||||
if allows, err := c.optionsAllows(ctx, homePath); err == nil {
|
||||
if slices.Contains(allows, "MKCALENDAR") {
|
||||
return true, nil
|
||||
}
|
||||
// OPTIONS responded but doesn't list MKCALENDAR — fall through to
|
||||
// synthetic probe; some servers omit MKCALENDAR from Allow even
|
||||
// when they accept it. OPTIONS-returns-no-MKCALENDAR is not a
|
||||
// hard negative.
|
||||
}
|
||||
// Synthetic probe — a single MKCALENDAR against a randomised name
|
||||
// that the server is overwhelmingly unlikely to already have.
|
||||
probePath := joinPath(homePath, ".paliad-probe-"+randomToken(6)+"/")
|
||||
mkBody := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set><D:prop><D:displayname>paliad-probe</D:displayname></D:prop></D:set>
|
||||
</C:mkcalendar>`
|
||||
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(probePath), strings.NewReader(mkBody))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("MKCALENDAR probe: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
// Server accepted the probe. Tear down the probe collection so
|
||||
// we don't leak a junk calendar; if the DELETE fails we shrug
|
||||
// (best effort — the user's calendar list will have one
|
||||
// .paliad-probe-* entry; not the end of the world).
|
||||
_ = c.deleteCollection(ctx, probePath)
|
||||
return true, nil
|
||||
case http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusForbidden:
|
||||
return false, nil
|
||||
default:
|
||||
// Unknown — treat as no-MKCALENDAR to be safe; the user can
|
||||
// still bind by URL.
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// MakeCalendar issues MKCALENDAR against home/<calendarName>/ and
|
||||
// returns the absolute path that was created. The caller is
|
||||
// responsible for picking a free slug; 405 from the server means
|
||||
// "name already taken — pick another".
|
||||
func (c *calDAVClient) MakeCalendar(ctx context.Context, homePath, calendarName, displayName string) (string, error) {
|
||||
path := joinPath(homePath, calendarName+"/")
|
||||
body := mkcalendarBody(displayName)
|
||||
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(path), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MKCALENDAR: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
return path, nil
|
||||
case http.StatusMethodNotAllowed:
|
||||
return "", ErrCalendarNameTaken
|
||||
case http.StatusForbidden, http.StatusNotImplemented:
|
||||
return "", ErrMKCalendarUnsupported
|
||||
default:
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("MKCALENDAR %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func mkcalendarBody(displayName string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>`)
|
||||
_ = xml.EscapeText(&b, []byte(displayName))
|
||||
b.WriteString(`</D:displayname>
|
||||
<C:supported-calendar-component-set>
|
||||
<C:comp name="VEVENT"/>
|
||||
</C:supported-calendar-component-set>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</C:mkcalendar>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// optionsAllows returns the methods listed in the Allow header of an
|
||||
// OPTIONS response. Caseless match per RFC 7231 §7.4.1.
|
||||
func (c *calDAVClient) optionsAllows(ctx context.Context, path string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "OPTIONS", c.absURL(path), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OPTIONS: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("OPTIONS %s: %d", path, resp.StatusCode)
|
||||
}
|
||||
out := []string{}
|
||||
for _, h := range resp.Header.Values("Allow") {
|
||||
for _, m := range strings.Split(h, ",") {
|
||||
out = append(out, strings.ToUpper(strings.TrimSpace(m)))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deleteCollection sends a DELETE that doesn't care about 404.
|
||||
func (c *calDAVClient) deleteCollection(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", c.absURL(path), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// randomToken returns a short hex string of `n` bytes. Used for the
|
||||
// synthetic MKCALENDAR probe path; doesn't need to be cryptographically
|
||||
// strong (the worst-case is a collision with an existing calendar of
|
||||
// the same name, which we catch as ErrCalendarNameTaken upstream).
|
||||
func randomToken(n int) string {
|
||||
buf := make([]byte, n)
|
||||
_, _ = rand.Read(buf)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// joinPath cleans up double slashes between calendar path and uid.
|
||||
func joinPath(base, name string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -143,7 +144,8 @@ func (s *CalDAVService) GetConfig(ctx context.Context, userID uuid.UUID) (*Publi
|
||||
var c models.UserCalDAVConfig
|
||||
err := s.db.GetContext(ctx, &c,
|
||||
`SELECT user_id, url, username, password_encrypted, calendar_path,
|
||||
enabled, last_sync_at, last_sync_error, created_at, updated_at
|
||||
enabled, last_sync_at, last_sync_error, created_at, updated_at,
|
||||
supports_mkcalendar, mkcalendar_probed_at
|
||||
FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
@@ -313,11 +315,12 @@ func (s *CalDAVService) SyncLog(ctx context.Context, userID uuid.UUID, n int) ([
|
||||
// --- Discovery (Slice 2b) ---
|
||||
|
||||
// DiscoveredCalendars is the response shape of /api/caldav-discover.
|
||||
// Slice 2c adds a SupportsMKCALENDAR field; in Slice 2b the field is
|
||||
// always omitted (clients should treat its absence as "unknown").
|
||||
// SupportsMKCalendar is nil-on-unprobed and TRUE/FALSE after the
|
||||
// Slice 2c probe runs (lazily on the first discovery call).
|
||||
type DiscoveredCalendars struct {
|
||||
Calendars []DiscoveredCalendarOut `json:"calendars"`
|
||||
CalendarHome string `json:"calendar_home,omitempty"`
|
||||
Calendars []DiscoveredCalendarOut `json:"calendars"`
|
||||
CalendarHome string `json:"calendar_home,omitempty"`
|
||||
SupportsMKCalendar *bool `json:"supports_mkcalendar,omitempty"`
|
||||
}
|
||||
|
||||
// DiscoveredCalendarOut is the JSON shape returned to the picker.
|
||||
@@ -370,6 +373,18 @@ func (s *CalDAVService) DiscoverCalendars(ctx context.Context, userID uuid.UUID)
|
||||
})
|
||||
}
|
||||
|
||||
// Slice 2c — lazy MKCALENDAR capability probe. If we've never
|
||||
// probed this user (supports_mkcalendar IS NULL) and we just
|
||||
// learned the calendar-home-set URL, run the probe and stash
|
||||
// the result. Probe failure is non-fatal — the field just stays
|
||||
// NULL and the next discovery will retry.
|
||||
if home != "" {
|
||||
probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
|
||||
if perr == nil {
|
||||
out.SupportsMKCalendar = probed
|
||||
}
|
||||
}
|
||||
|
||||
s.discoveryMu.Lock()
|
||||
if s.discoveryCache == nil {
|
||||
s.discoveryCache = map[uuid.UUID]discoveryCacheEntry{}
|
||||
@@ -384,11 +399,200 @@ func (s *CalDAVService) DiscoverCalendars(ctx context.Context, userID uuid.UUID)
|
||||
|
||||
// InvalidateDiscoveryCache clears the cached discovery result for the
|
||||
// user. Called from SaveConfig so a credential change forces a fresh
|
||||
// PROPFIND on the next picker open.
|
||||
// PROPFIND on the next picker open. Also clears the persisted
|
||||
// supports_mkcalendar capability since credentials may point at a
|
||||
// different server.
|
||||
func (s *CalDAVService) InvalidateDiscoveryCache(userID uuid.UUID) {
|
||||
s.discoveryMu.Lock()
|
||||
delete(s.discoveryCache, userID)
|
||||
s.discoveryMu.Unlock()
|
||||
_, _ = s.db.ExecContext(context.Background(),
|
||||
`UPDATE paliad.user_caldav_config
|
||||
SET supports_mkcalendar = NULL, mkcalendar_probed_at = NULL
|
||||
WHERE user_id = $1`, userID)
|
||||
}
|
||||
|
||||
// ensureMKCalendarProbed runs the probe iff the user_caldav_config
|
||||
// row's supports_mkcalendar is NULL. Returns the cached value when set.
|
||||
// Probe failure leaves the column NULL (next call retries) and returns
|
||||
// (nil, err) — caller treats that as "unknown" and omits the field
|
||||
// from the JSON response.
|
||||
func (s *CalDAVService) ensureMKCalendarProbed(ctx context.Context, userID uuid.UUID, homePath string, cli *calDAVClient) (*bool, error) {
|
||||
var current *bool
|
||||
if err := s.db.GetContext(ctx, ¤t,
|
||||
`SELECT supports_mkcalendar
|
||||
FROM paliad.user_caldav_config
|
||||
WHERE user_id = $1`, userID); err != nil {
|
||||
return nil, fmt.Errorf("read mkcalendar capability: %w", err)
|
||||
}
|
||||
if current != nil {
|
||||
return current, nil
|
||||
}
|
||||
ok, err := cli.ProbeMKCalendar(ctx, homePath)
|
||||
if err != nil {
|
||||
// Don't persist a negative on a transient error — leave NULL so
|
||||
// the next round retries; just surface "unknown" to caller.
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.user_caldav_config
|
||||
SET supports_mkcalendar = $1, mkcalendar_probed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $2`, ok, userID); err != nil {
|
||||
slog.Warn("CalDAV: persist mkcalendar capability failed", "user_id", userID, "error", err)
|
||||
}
|
||||
return &ok, nil
|
||||
}
|
||||
|
||||
// CreateCalendarInput is the payload for POST /api/caldav-mkcalendar.
|
||||
type CreateCalendarInput struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
ScopeKind string `json:"scope_kind"`
|
||||
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
|
||||
IncludePersonal bool `json:"include_personal"`
|
||||
}
|
||||
|
||||
// CreateCalendarResult is the response shape for POST
|
||||
// /api/caldav-mkcalendar — the new remote calendar path plus the
|
||||
// binding that was created in the same transaction.
|
||||
type CreateCalendarResult struct {
|
||||
CalendarPath string `json:"calendar_path"`
|
||||
Binding *models.UserCalendarBinding `json:"binding"`
|
||||
InitialPushed int `json:"initial_pushed"`
|
||||
}
|
||||
|
||||
// MakeCalendar issues MKCALENDAR against the user's calendar-home-set,
|
||||
// then creates a paliad.user_calendar_bindings row pointing at the new
|
||||
// path. On MKCALENDAR success but binding-create failure we leave the
|
||||
// remote calendar in place; on MKCALENDAR failure we never touch the
|
||||
// binding table. Slice 2c API.
|
||||
//
|
||||
// Errors:
|
||||
// - ErrCalDAVNoKey when cipher is missing.
|
||||
// - ErrInvalidInput on missing display name / disallowed scope.
|
||||
// - ErrMKCalendarUnsupported (or persisted supports_mkcalendar=false)
|
||||
// → the caller maps this to 501.
|
||||
// - ErrCalendarNameTaken → 409.
|
||||
func (s *CalDAVService) MakeCalendar(ctx context.Context, userID uuid.UUID, in CreateCalendarInput) (*CreateCalendarResult, error) {
|
||||
if s.cipher == nil {
|
||||
return nil, ErrCalDAVNoKey
|
||||
}
|
||||
if strings.TrimSpace(in.DisplayName) == "" {
|
||||
return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
|
||||
}
|
||||
cfg, err := s.loadDecryptedConfig(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("%w: no CalDAV config", ErrInvalidInput)
|
||||
}
|
||||
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
|
||||
|
||||
// Discover home + probe capability if we haven't yet.
|
||||
_, home, err := cli.DiscoverCalendars(ctx, cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover home: %w", err)
|
||||
}
|
||||
probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("probe mkcalendar: %w", perr)
|
||||
}
|
||||
if probed == nil || !*probed {
|
||||
return nil, ErrMKCalendarUnsupported
|
||||
}
|
||||
|
||||
// Try the slugified name; retry with -N suffix on 405 (name taken).
|
||||
slug := slugifyCalendarName(in.DisplayName)
|
||||
if slug == "" {
|
||||
slug = "paliad-" + randomToken(3)
|
||||
}
|
||||
path, err := s.mkcalendarWithRetry(ctx, cli, home, slug, in.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create the matching binding row.
|
||||
binding, err := s.bindings.Create(ctx, userID, CreateBindingInput{
|
||||
CalendarPath: path,
|
||||
DisplayName: in.DisplayName,
|
||||
ScopeKind: in.ScopeKind,
|
||||
ScopeID: in.ScopeID,
|
||||
IncludePersonal: in.IncludePersonal,
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
// MKCALENDAR already succeeded — the remote calendar exists.
|
||||
// Don't try to clean it up: the user can re-bind via the
|
||||
// custom-URL path the next time. Returning the error surfaces
|
||||
// the validation failure to the caller.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Synchronous first push so the user sees events immediately
|
||||
// (matches POST /api/caldav-bindings Q5 semantics).
|
||||
pushed, pushErr := s.PushBindingNow(ctx, userID, binding)
|
||||
if pushErr != nil {
|
||||
// Binding row exists; push failed — surface both bits.
|
||||
return &CreateCalendarResult{
|
||||
CalendarPath: path,
|
||||
Binding: binding,
|
||||
InitialPushed: pushed,
|
||||
}, pushErr
|
||||
}
|
||||
s.EnsureLoop(userID)
|
||||
return &CreateCalendarResult{
|
||||
CalendarPath: path,
|
||||
Binding: binding,
|
||||
InitialPushed: pushed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mkcalendarWithRetry issues MKCALENDAR with up to 3 disambiguating
|
||||
// suffixes when the server returns 405 (name taken). Gives up after
|
||||
// 3 tries with the original error so the UI can ask the user to type
|
||||
// their own name.
|
||||
func (s *CalDAVService) mkcalendarWithRetry(ctx context.Context, cli *calDAVClient, home, slug, displayName string) (string, error) {
|
||||
tryName := slug
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
path, err := cli.MakeCalendar(ctx, home, tryName, displayName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if !errors.Is(err, ErrCalendarNameTaken) {
|
||||
return "", err
|
||||
}
|
||||
tryName = slug + "-" + randomToken(2)
|
||||
}
|
||||
return "", ErrCalendarNameTaken
|
||||
}
|
||||
|
||||
// slugifyCalendarName turns "Acme v Bosch" → "acme-v-bosch". Trims to
|
||||
// 32 chars to keep the URL readable. Empty input → empty output (the
|
||||
// caller falls back to a random token).
|
||||
func slugifyCalendarName(name string) string {
|
||||
const maxLen = 32
|
||||
var b strings.Builder
|
||||
prevDash := true
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r - 'A' + 'a')
|
||||
prevDash = false
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
case prevDash:
|
||||
// skip
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
prevDash = true
|
||||
}
|
||||
if b.Len() >= maxLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
// --- Binding lifecycle hooks (Slice 2b) ---
|
||||
@@ -950,7 +1154,8 @@ func (s *CalDAVService) loadDecryptedConfig(ctx context.Context, userID uuid.UUI
|
||||
var c models.UserCalDAVConfig
|
||||
err := s.db.GetContext(ctx, &c,
|
||||
`SELECT user_id, url, username, password_encrypted, calendar_path,
|
||||
enabled, last_sync_at, last_sync_error, created_at, updated_at
|
||||
enabled, last_sync_at, last_sync_error, created_at, updated_at,
|
||||
supports_mkcalendar, mkcalendar_probed_at
|
||||
FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
|
||||
Reference in New Issue
Block a user