diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 897b023..2602b5c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -132,6 +132,12 @@ export interface CalendarEvent { location: string | null; description: string | null; color?: string | null; + // Regla RRULE cruda del evento maestro (sin prefijo "RRULE:"), p.ej. + // "FREQ=WEEKLY;INTERVAL=1;COUNT=10". `recurring` true si el evento repite; + // `occurrence` true si esta entrada es una ocurrencia expandida de una serie. + rrule?: string | null; + recurring?: boolean; + occurrence?: boolean; href?: string; etag?: string; } @@ -157,6 +163,21 @@ export interface CalendarsPayload { error?: string; } +// Cuerpo de POST /calendars para crear una colección de calendario nueva. +export interface CalendarInput { + slug: string; + name: string; + color?: string | null; + description?: string | null; +} + +export interface CalendarWriteResult { + status: string; + href?: string; + existed?: boolean; + error?: string; +} + // Cuerpo de POST/PUT de un evento del calendario (VEVENT). export interface EventInput { cal?: string | null; @@ -168,6 +189,9 @@ export interface EventInput { location?: string | null; description?: string | null; color?: string | null; + // Cuerpo RRULE SIN el prefijo "RRULE:" (o null si no repite). Editar un evento + // recurrente reescribe toda la serie. + rrule?: string | null; } export interface EventWriteResult { @@ -258,6 +282,9 @@ export const deleteContact = (slug: string) => export const fetchCalendars = () => getJSON("/calendars"); +export const createCalendar = (data: CalendarInput) => + sendJSON("/calendars", "POST", data); + export const fetchCalendar = (cal = "", from = "", to = "") => { const qs = new URLSearchParams(); if (cal) qs.set("cal", cal); diff --git a/frontend/src/calendar.ts b/frontend/src/calendar.ts index 717fe12..ba2a5e0 100644 --- a/frontend/src/calendar.ts +++ b/frontend/src/calendar.ts @@ -139,6 +139,129 @@ export function monthMatrix(date: dayjs.Dayjs): dayjs.Dayjs[][] { export const HOURS = Array.from({ length: 24 }, (_, h) => h); export const WEEKDAY_LABELS = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"]; +// --- Recurrencia (RRULE) --------------------------------------------------- + +export type RruleFreq = "none" | "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; +export type RruleEndMode = "never" | "count" | "until"; + +// Días de la semana en orden L-M-X-J-V-S-D con su código BYDAY de iCalendar. +export const BYDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; +export const BYDAY_LABELS = ["L", "M", "X", "J", "V", "S", "D"]; + +export interface RruleParts { + freq: RruleFreq; + interval: number; + endMode: RruleEndMode; + count: number; + until: string; // input date "YYYY-MM-DD" (vacío si no aplica) + byday: string[]; // códigos BYDAY ("MO", "TU", ...) +} + +/** + * Construye el cuerpo RRULE (sin prefijo "RRULE:") a partir de los controles + * del modal. Devuelve "" si freq==="none". El `until` ("YYYY-MM-DD") se serializa + * como "UNTIL=YYYYMMDD" (sin guiones). Si endMode no es count/until, no añade + * cláusula de fin (recurrencia infinita). + */ +export function buildRrule( + freq: RruleFreq, + interval: number, + endMode: RruleEndMode, + count: number, + until: string, + byday?: string[], +): string { + if (freq === "none") return ""; + const parts: string[] = [`FREQ=${freq}`]; + const iv = Math.max(1, Math.floor(interval || 1)); + parts.push(`INTERVAL=${iv}`); + if (freq === "WEEKLY" && byday && byday.length > 0) { + parts.push(`BYDAY=${byday.join(",")}`); + } + if (endMode === "count") { + parts.push(`COUNT=${Math.max(1, Math.floor(count || 1))}`); + } else if (endMode === "until" && until) { + parts.push(`UNTIL=${until.replace(/-/g, "")}`); + } + return parts.join(";"); +} + +/** + * Parsea un cuerpo RRULE crudo a los controles del modal. Tolerante: cadena + * vacía/indefinida → freq "none". Acepta un prefijo "RRULE:" residual por si el + * backend lo deja. UNTIL admite "YYYYMMDD" o "YYYYMMDDTHHMMSSZ". + */ +export function parseRrule(rrule: string | null | undefined): RruleParts { + const base: RruleParts = { + freq: "none", + interval: 1, + endMode: "never", + count: 10, + until: "", + byday: [], + }; + if (!rrule) return base; + const body = rrule.replace(/^RRULE:/i, "").trim(); + if (!body) return base; + const map = new Map(); + for (const seg of body.split(";")) { + const [k, v] = seg.split("="); + if (k && v !== undefined) map.set(k.toUpperCase(), v); + } + const freq = (map.get("FREQ") || "").toUpperCase(); + if ( + freq === "DAILY" || + freq === "WEEKLY" || + freq === "MONTHLY" || + freq === "YEARLY" + ) { + base.freq = freq as RruleFreq; + } + const iv = parseInt(map.get("INTERVAL") || "1", 10); + if (!Number.isNaN(iv) && iv > 0) base.interval = iv; + if (map.has("COUNT")) { + const c = parseInt(map.get("COUNT") || "", 10); + if (!Number.isNaN(c) && c > 0) { + base.endMode = "count"; + base.count = c; + } + } else if (map.has("UNTIL")) { + const raw = map.get("UNTIL") || ""; + const ymd = raw.slice(0, 8); + if (/^\d{8}$/.test(ymd)) { + base.endMode = "until"; + base.until = `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`; + } + } + if (map.has("BYDAY")) { + base.byday = (map.get("BYDAY") || "") + .split(",") + .map((d) => d.trim().toUpperCase()) + .filter((d) => BYDAY_CODES.includes(d)); + } + return base; +} + +/** Posición vertical (0..100) de la línea "ahora" dentro de la rejilla 0–24h. */ +export function nowLinePct(now: dayjs.Dayjs): number { + return ((now.hour() * 60 + now.minute()) / (24 * 60)) * 100; +} + +/** + * Deriva un slug de calendario de un nombre: minúsculas, espacios y caracteres + * fuera de [a-z0-9_-] → "-", colapsa guiones y recorta los de los extremos. + */ +export function slugifyCalendar(name: string): string { + return name + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") // quita acentos (combining marks) + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + /** Color efectivo de un evento: el propio del VEVENT o el del calendario. */ export function eventColor( ev: CalendarEvent, diff --git a/frontend/src/views/CalendarView.tsx b/frontend/src/views/CalendarView.tsx index 4efda44..1f3b8ff 100644 --- a/frontend/src/views/CalendarView.tsx +++ b/frontend/src/views/CalendarView.tsx @@ -6,16 +6,19 @@ import { Box, Button, Center, + ColorInput, ColorSwatch, Group, Indicator, Loader, + Modal, Paper, ScrollArea, SegmentedControl, Select, Stack, Text, + TextInput, Title, Tooltip, } from "@mantine/core"; @@ -25,8 +28,10 @@ import { IconChevronLeft, IconChevronRight, IconPlus, + IconRepeat, } from "@tabler/icons-react"; import { + createCalendar, deleteEvent, fetchCalendar, fetchCalendars, @@ -36,10 +41,13 @@ import { import { dayjs, eventColor, + eventEnd, eventStart, HOURS, monthMatrix, + nowLinePct, positionEventsForDay, + slugifyCalendar, TIMEZONES, WEEKDAY_LABELS, weekDays, @@ -47,7 +55,7 @@ import { } from "../calendar"; import { EventModal, type EventDraft } from "./EventModal"; -type ViewMode = "mes" | "semana" | "dia"; +type ViewMode = "mes" | "semana" | "dia" | "lista"; // Calendario: vista Mes/Semana/Día con eventos posicionados por hora, selector // de calendario (con color), selector de zona horaria, colores por evento y CRUD @@ -66,34 +74,90 @@ export function CalendarView() { const [cursor, setCursor] = useState(dayjs().format("YYYY-MM-DD")); const [modal, setModal] = useState(null); const [reloadKey, setReloadKey] = useState(0); + // Instante "ahora" en la TZ del visor, refrescado cada minuto para la línea roja. + const [now, setNow] = useState(() => dayjs().tz(tz)); + // Modal "nuevo calendario". + const [newCalOpen, setNewCalOpen] = useState(false); + const [newCalName, setNewCalName] = useState(""); + const [newCalColor, setNewCalColor] = useState(""); + const [newCalErr, setNewCalErr] = useState(null); + const [newCalSaving, setNewCalSaving] = useState(false); - // Carga de calendarios (selector) una vez. + // Rango visible [from, to] (YYYY-MM-DD) según vista+cursor. El backend expande + // las series recurrentes dentro de este rango (una entrada por ocurrencia). + const range = useMemo(() => { + const c = dayjs(cursor); + if (view === "mes") { + const weeks = monthMatrix(c); + return { + from: weeks[0][0].format("YYYY-MM-DD"), + to: weeks[weeks.length - 1][6].add(1, "day").format("YYYY-MM-DD"), + }; + } + if (view === "semana") { + const wd = weekDays(c); + return { + from: wd[0].format("YYYY-MM-DD"), + to: wd[6].add(1, "day").format("YYYY-MM-DD"), + }; + } + if (view === "dia") { + return { + from: c.format("YYYY-MM-DD"), + to: c.add(1, "day").format("YYYY-MM-DD"), + }; + } + // lista: ventana amplia desde hoy (~90 días) para una agenda útil. + return { + from: dayjs().format("YYYY-MM-DD"), + to: dayjs().add(90, "day").format("YYYY-MM-DD"), + }; + }, [cursor, view]); + + // Refresca el instante "ahora" cada minuto (línea roja en semana/día). useEffect(() => { - let alive = true; - fetchCalendars() + setNow(dayjs().tz(tz)); + const id = window.setInterval(() => setNow(dayjs().tz(tz)), 60000); + return () => window.clearInterval(id); + }, [tz]); + + // Carga de calendarios (selector). `selectHref` permite seleccionar uno recién + // creado por su href cuando la lista se refresca tras crearlo. + const loadCalendars = useCallback((selectHref?: string) => { + return fetchCalendars() .then((d) => { - if (!alive) return; if (d.status === "ok" && d.calendars) { setCalendars(d.calendars); - if (!activeCal && d.default) setActiveCal(d.default); - else if (!activeCal && d.calendars[0]) setActiveCal(d.calendars[0].href); + if (selectHref) { + setActiveCal(selectHref); + } else { + setActiveCal((cur) => { + if (cur) return cur; + if (d.default) return d.default; + if (d.calendars && d.calendars[0]) return d.calendars[0].href; + return cur; + }); + } } + return d; }) .catch(() => { /* el selector degrada a "calendario" implícito; no es fatal */ + return null; }); - return () => { - alive = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Carga de eventos de la colección activa. + useEffect(() => { + loadCalendars(); + }, [loadCalendars]); + + // Carga de eventos de la colección activa, dentro del rango visible. Pasar + // from/to hace que el backend expanda las series recurrentes por ocurrencia. useEffect(() => { let alive = true; setLoading(true); setError(null); - fetchCalendar(activeCal) + fetchCalendar(activeCal, range.from, range.to) .then((d) => { if (!alive) return; if (d.status !== "ok") { @@ -107,7 +171,7 @@ export function CalendarView() { return () => { alive = false; }; - }, [activeCal, reloadKey]); + }, [activeCal, reloadKey, range.from, range.to]); const calColor = useMemo(() => { const c = calendars.find((c) => c.href === activeCal); @@ -180,6 +244,7 @@ export function CalendarView() { description: "", color: "", cal: activeCal, + rrule: "", }); }, [tz, activeCal], @@ -206,11 +271,53 @@ export function CalendarView() { description: ev.description ?? "", color: ev.color ?? "", cal: activeCal, + rrule: ev.rrule ?? "", }); }, [tz, activeCal], ); + // Crea un calendario nuevo derivando el slug del nombre, refresca la lista y + // selecciona el nuevo por su href. + const createNewCalendar = useCallback(async () => { + const name = newCalName.trim(); + if (!name) { + setNewCalErr("El nombre es obligatorio."); + return; + } + const slug = slugifyCalendar(name); + if (!slug) { + setNewCalErr("El nombre no produce un identificador válido."); + return; + } + setNewCalSaving(true); + setNewCalErr(null); + try { + const res = await createCalendar({ + slug, + name, + color: newCalColor || null, + }); + if (res.status !== "ok") { + setNewCalErr(res.error || "No se pudo crear el calendario."); + return; + } + await loadCalendars(res.href); + notifications.show({ + color: "teal", + title: "Calendario creado", + message: name, + }); + setNewCalOpen(false); + setNewCalName(""); + setNewCalColor(""); + } catch (e) { + setNewCalErr(String(e)); + } finally { + setNewCalSaving(false); + } + }, [newCalName, newCalColor, loadCalendars]); + function navigate(dir: -1 | 1) { const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day"; setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD")); @@ -233,12 +340,14 @@ export function CalendarView() { const headerTitle = view === "mes" ? dayjs(cursor).format("MMMM YYYY") - : view === "semana" - ? (() => { - const wd = weekDays(dayjs(cursor)); - return `${wd[0].format("D MMM")} – ${wd[6].format("D MMM YYYY")}`; - })() - : dayjs(cursor).format("dddd D [de] MMMM YYYY"); + : view === "lista" + ? "Agenda" + : view === "semana" + ? (() => { + const wd = weekDays(dayjs(cursor)); + return `${wd[0].format("D MMM")} – ${wd[6].format("D MMM YYYY")}`; + })() + : dayjs(cursor).format("dddd D [de] MMMM YYYY"); return ( @@ -279,20 +388,38 @@ export function CalendarView() { }} /> - ({ value: c.href, label: c.name }))} + value={activeCal || null} + onChange={(v) => v && setActiveCal(v)} + leftSection={ + + } + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + style={{ flex: 1, minWidth: 0 }} + /> + + { + setNewCalErr(null); + setNewCalName(""); + setNewCalColor(""); + setNewCalOpen(true); + }} + > + + + + v && setFreq(v as RruleFreq)} + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + /> + + {recurs && ( + <> + + setInterval(typeof v === "number" ? v : 1)} + style={{ width: 110 }} + /> + + {INTERVAL_SUFFIX[freq]} + + + + {freq === "WEEKLY" && ( + + + {BYDAY_CODES.map((code, i) => ( + toggleByday(code)} + > + {BYDAY_LABELS[i]} + + ))} + + + )} + + ({ value: c.href, label: c.name }))} diff --git a/server/main.py b/server/main.py index 7e36abd..7157ee7 100644 --- a/server/main.py +++ b/server/main.py @@ -165,6 +165,10 @@ dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource # las colecciones de calendario del usuario con su nombre y color. caldav_put_event = _load_infra_fn("caldav_put_event", "caldav_put_event") dav_list_calendars = _load_infra_fn("dav_list_calendars", "dav_list_calendars") +# Crear una colección de calendario nueva (MKCALENDAR + PROPPATCH nombre/color). +dav_make_calendar = _load_infra_fn("dav_make_calendar", "dav_make_calendar") +# Expandir una RRULE a las fechas de cada ocurrencia dentro de un rango (pura). +expand_rrule = _load_infra_fn("expand_rrule", "expand_rrule") # --------------------------------------------------------------------------- @@ -705,6 +709,42 @@ class VaultState: self._maybe_clear_force_reload() return calendars + def create_calendar(self, data: "CalendarIn") -> dict: + """Crea una colección de calendario nueva bajo el calendar-home (MKCALENDAR). + + Compone la función del registry ``dav_make_calendar`` (MKCALENDAR + + PROPPATCH de nombre/color). Invalida la caché de colecciones para que el + calendario nuevo aparezca en el selector al recargar. + + Returns: + dict ``{status, href, existed?}`` de la función del registry. + + Raises: + HTTPException(400): si el slug/nombre queda vacío tras sanear. + DavUnavailable: si Xandikos rechaza la creación. + """ + slug = (data.slug or data.name or "").strip() + if not slug: + raise HTTPException(status_code=400, detail="el nombre del calendario es obligatorio") + password = self.xandikos_password() + res = dav_make_calendar( + XANDIKOS_BASE_URL, + XANDIKOS_USERNAME, + password, + XANDIKOS_CALENDAR_HOME, + slug, + data.name or slug, + data.color or "", + data.description or "", + ) + if res.get("status") != "ok": + raise DavUnavailable( + "Xandikos no pudo crear el calendario: %s" % res.get("error") + ) + with self._dav_lock: + self._calendars_cache = None + return res + def calendar(self, cal: str = "", dt_from: str = "", dt_to: str = "") -> list: """Eventos de una colección de calendario Xandikos, cacheados y filtrados. @@ -732,9 +772,18 @@ class VaultState: self._maybe_clear_force_reload() cached = events all_events = list(cached) + # Sin rango: devolvemos los eventos maestros tal cual (no expandimos + # series infinitas). Con rango: cada serie recurrente se expande a sus + # ocurrencias dentro de [from, to]; los puntuales se filtran por fecha. if not dt_from and not dt_to: return all_events - return [e for e in all_events if _event_in_range(e, dt_from, dt_to)] + out: list = [] + for ev in all_events: + if ev.get("rrule"): + out.extend(_expand_event_occurrences(ev, dt_from, dt_to)) + elif _event_in_range(ev, dt_from, dt_to): + out.append(ev) + return out # --- Escritura de eventos del calendario (CalDAV) ----------------------- @@ -1262,6 +1311,9 @@ def _vevent_to_json(vevent_block: str) -> dict: "location": None, "description": None, "color": None, + "rrule": None, + "recurring": False, + "occurrence": False, } for line in _unfold_lines(vevent_block): parsed = _parse_property(line) @@ -1295,6 +1347,9 @@ def _vevent_to_json(vevent_block: str) -> dict: out["description"] = _unescape_ical(value) elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"): out["color"] = value + elif name == "RRULE": + out["rrule"] = value + out["recurring"] = True return out @@ -1327,6 +1382,98 @@ def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool: return True +def _default_expand_start() -> str: + """Límite inferior por defecto al expandir una serie sin rango explícito.""" + return (datetime.now(timezone.utc) - timedelta(days=366)).strftime("%Y%m%d") + + +def _default_expand_end() -> str: + """Límite superior por defecto al expandir una serie sin rango explícito.""" + return (datetime.now(timezone.utc) + timedelta(days=731)).strftime("%Y%m%d") + + +def _shift_iso_days(value: str, days: int) -> str: + """Desplaza la parte de fecha de un ISO (``YYYY-MM-DD`` o con ``T...``). + + Conserva intacta la parte horaria/offset (``T10:00:00+02:00``) y solo mueve + la fecha ``days`` días. Para una fecha pura mueve la fecha sola. Si el valor + no parsea, lo devuelve sin tocar (defensivo). + """ + if not value: + return value + date_part = value[:10] + rest = value[10:] + try: + base = datetime.strptime(date_part, "%Y-%m-%d") + except ValueError: + return value + shifted = (base + timedelta(days=days)).strftime("%Y-%m-%d") + return shifted + rest + + +def _occurrence_clone(event: dict, occ_ymd: str) -> dict: + """Clona un evento maestro recurrente reubicado en la fecha ``occ_ymd``. + + Mantiene la hora local / offset del maestro (solo cambia la fecha) y aplica el + mismo desplazamiento de días al ``dtend`` para preservar la duración. Marca + ``occurrence=True`` cuando la fecha difiere de la del maestro (la primera + ocurrencia coincide con el maestro y queda ``occurrence=False``). + """ + master_ymd = ( + event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "") + )[:8] + new_date_iso = "%s-%s-%s" % (occ_ymd[:4], occ_ymd[4:6], occ_ymd[6:8]) + clone = dict(event) + ds = event.get("dtstart") or "" + if event.get("all_day") or len(ds) == 10: + clone["dtstart"] = new_date_iso + else: + clone["dtstart"] = new_date_iso + ds[10:] + clone["dtstart_ical"] = occ_ymd + try: + delta = ( + datetime.strptime(occ_ymd, "%Y%m%d") + - datetime.strptime(master_ymd, "%Y%m%d") + ).days + except ValueError: + delta = 0 + de = event.get("dtend") + if de: + clone["dtend"] = _shift_iso_days(de, delta) + de_ical = event.get("dtend_ical") + if de_ical: + clone["dtend_ical"] = (clone["dtend"] or "").replace("-", "")[:8] + clone["occurrence"] = occ_ymd != master_ymd + return clone + + +def _expand_event_occurrences(event: dict, dt_from: str, dt_to: str) -> list: + """Expande un evento recurrente a sus ocurrencias dentro de ``[from, to]``. + + Compone la función pura del registry ``expand_rrule`` (solo necesita las + FECHAS de cada ocurrencia; la hora local se preserva clonando el maestro). Si + el evento no tiene ``rrule``, o algo no parsea, devuelve ``[event]`` sin + tocar — nunca pierde el evento original. + """ + rrule = event.get("rrule") + if not rrule: + return [event] + master_ymd = ( + event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "") + )[:8] + if len(master_ymd) < 8: + return [event] + rs = (dt_from or "").replace("-", "")[:8] or _default_expand_start() + re_ = (dt_to or "").replace("-", "")[:8] or _default_expand_end() + try: + occ_dates = expand_rrule(master_ymd, rrule, rs, re_, all_day=True) + except Exception: + return [event] + if not occ_dates: + return [] + return [_occurrence_clone(event, d) for d in occ_dates] + + # --------------------------------------------------------------------------- # Escritura de contactos: ficha .md del vault (fuente de verdad) + vCard # --------------------------------------------------------------------------- @@ -1534,6 +1681,25 @@ class EventIn(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + # Regla de recurrencia iCalendar SIN el prefijo "RRULE:" (p.ej. + # "FREQ=WEEKLY;INTERVAL=1;COUNT=10"). None / "" → evento puntual. Editar un + # evento recurrente reescribe toda la serie (no se soporta editar una sola + # ocurrencia). + rrule: Optional[str] = None + + +class CalendarIn(BaseModel): + """Cuerpo de POST /api/calendars: crea una colección de calendario nueva. + + El ``slug`` es el segmento de URL de la colección (lo sanea la función del + registry ``dav_make_calendar`` a ``[a-z0-9_-]``). ``name`` es el nombre + visible; ``color`` un hex ``#rrggbb`` opcional. + """ + + slug: str + name: Optional[str] = "" + color: Optional[str] = None + description: Optional[str] = None def _parse_iso_input(value: str) -> Optional[dict]: @@ -1701,6 +1867,13 @@ def _build_vcalendar(data: "EventIn", uid: str) -> str: if data.color and data.color.strip(): # COLOR (RFC 7986) — nombre CSS3 o, para clientes Apple, el hex va aparte. vevent.append("X-APPLE-CALENDAR-COLOR:%s" % data.color.strip()) + rrule = (data.rrule or "").strip() + if rrule: + # Acepta tanto "FREQ=..." como "RRULE:FREQ=..."; normaliza a la línea + # canónica "RRULE:" que entienden Xandikos y los clientes (DAVx5). + if rrule.upper().startswith("RRULE:"): + rrule = rrule[len("RRULE:"):].strip() + vevent.append("RRULE:%s" % rrule) vevent.append("END:VEVENT") body.append("\r\n".join(vevent)) body.append("END:VCALENDAR") @@ -1898,6 +2071,22 @@ def create_app(vault_dir: str) -> FastAPI: } ) + @app.post("/api/calendars") + def api_create_calendar(data: CalendarIn = Body(...)) -> JSONResponse: + """Crea una colección de calendario nueva (MKCALENDAR + nombre/color). + + Body: ``{slug, name?, color?, description?}``. Idempotente si ya existe. + Devuelve ``{status, href, existed?}``. 400 si falta el nombre; 503 si + Xandikos no responde. + """ + try: + res = state.create_calendar(data) + except (RuntimeError, DavUnavailable) as exc: + return JSONResponse( + status_code=503, content={"status": "error", "error": str(exc)} + ) + return JSONResponse(status_code=201, content={"status": "ok", **res}) + @app.get("/api/calendar") def api_calendar( cal: str = Query("", description="colección de calendario (ruta o nombre)"),