merge: calendar mes/semana/día + TZ + selector + colores + CRUD de eventos (quick/calendar-week-tz-crud)

This commit is contained in:
2026-06-12 00:41:04 +02:00
8 changed files with 2000 additions and 179 deletions
+36 -13
View File
@@ -2,8 +2,8 @@
name: osint_web
lang: py
domain: osint
version: 0.1.0
description: "App web local OSINT: explora el vault de Obsidian osint (grafo sigma.js, tablas por tipo, fichas con galería de attachments) y la agenda/calendario del servidor Xandikos (CardDAV/CalDAV). Backend FastAPI que orquesta los grupos obsidian y dav del registry; escucha solo en 127.0.0.1 (datos sensibles)."
version: 0.2.0
description: "App web local OSINT: explora el vault de Obsidian osint (grafo sigma.js, tablas por tipo, fichas con galería de attachments), la agenda del servidor Xandikos (CardDAV) y un calendario completo (CalDAV) con vista mes/semana/día, zonas horarias, selector de calendario, colores y CRUD de eventos. Backend FastAPI que orquesta los grupos obsidian y dav del registry; escucha solo en 127.0.0.1 (datos sensibles)."
tags: [osint, web, sigma, graph, mantine, dav, obsidian, vault, dashboard]
uses_functions:
- build_obsidian_graph_py_obsidian
@@ -18,7 +18,9 @@ uses_functions:
- search_obsidian_notes_py_obsidian
- dav_get_collection_py_infra
- dav_collection_ctag_py_infra
- dav_list_calendars_py_infra
- carddav_put_vcard_py_infra
- caldav_put_event_py_infra
- dav_delete_resource_py_infra
- split_vcards_py_infra
- pass_get_secret_py_infra
@@ -57,13 +59,22 @@ primaria con `create_obsidian_note` / `update_obsidian_note` /
inmediato (`carddav_put_vcard` / `dav_delete_resource`) para que se vea ya en la
app y en el móvil sin esperar al sync periódico del dag_engine.
Calendario (CRUD de eventos): la app lista las colecciones de calendario, muestra
los eventos en vista mes/semana/día (con zona horaria y color seleccionables) y
permite crear, editar y borrar eventos. Aquí la **fuente de verdad es Xandikos
directamente** (el calendario NO existe en el vault): cada operación escribe el
VCALENDAR/VEVENT en la colección CalDAV (`caldav_put_event` /
`dav_delete_resource`) e invalida la caché de esa colección.
Registry-first: el backend NO parsea el vault ni habla DAV a mano — orquesta las
funciones del grupo `obsidian` (`build_obsidian_graph`, `read_obsidian_note`,
`create_obsidian_note`, `update_obsidian_note`, `delete_obsidian_note`,
`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_get_collection`,
`dav_collection_ctag`, `carddav_put_vcard`, `dav_delete_resource`,
`split_vcards`) más `pass_get_secret` para la credencial, todas declaradas en
`uses_functions`.
`dav_collection_ctag`, `dav_list_calendars`, `carddav_put_vcard`,
`caldav_put_event`, `dav_delete_resource`, `split_vcards`) más `pass_get_secret`
para la credencial, todas declaradas en `uses_functions`. La única lógica propia
de la app sobre el calendario es el parseo robusto de VEVENT (TZID / UTC / todo el
día → ISO con offset, vía `zoneinfo`) y la construcción del VCALENDAR de salida.
## Stack
@@ -95,7 +106,12 @@ no se cachean (se piden a Xandikos en cada llamada).
| GET | `/api/search?q=...` | nodos cuyo contenido matchea la query |
| GET | `/api/contacts` | contactos del addressbook Xandikos (CardDAV) a JSON |
| GET | `/api/contact/<uid>` | un vCard concreto a JSON |
| GET | `/api/calendar?from=&to=` | eventos del calendario Xandikos (CalDAV) en el rango |
| POST/PUT/DELETE | `/api/contact[/<slug>]` | CRUD de contactos (ficha `.md` del vault + reflejo vCard) |
| GET | `/api/calendars` | colecciones de calendario bajo `/enmanuel/calendars/` (nombre + color) |
| GET | `/api/calendar?cal=&from=&to=` | eventos de una colección del calendario (CalDAV) en el rango |
| POST | `/api/event` | crea un VEVENT (`{cal, summary, dtstart, dtend, tz, all_day, location, description, color}`) |
| PUT | `/api/event/<uid>` | edita un VEVENT existente |
| DELETE | `/api/event/<uid>?cal=` | borra un VEVENT |
| POST | `/api/refresh` | re-escanea el vault y reconstruye la caché |
## Configuración Xandikos
@@ -134,12 +150,19 @@ parseo vCard/iCalendar a JSON y degradación de los endpoints DAV sin red.
## Estado / pendiente
- **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) con
13 tests verdes.
- **Pendiente (siguiente agente)**: `frontend/` React + Vite + Mantine v9 +
`@fn_library` con sigma.js + graphology (GraphView, TablesView, NodeCard,
ContactsView, CalendarView). Onboarding previsto: backend en 8470 +
`pnpm dev` en `frontend/` → abrir `http://127.0.0.1:5173`. Ver
`frontend/README.md`.
- **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) +
frontend React/Vite/Mantine v9 (GraphView, TablesView, ContactsView,
CalendarView). Suite backend verde (40 tests + 1 smoke gateado).
- **Calendario (v0.2.0)**: vista mes/semana/día, selector de calendario (con
color), selector de zona horaria, colores por evento y CRUD completo de
eventos (crear en un hueco, editar/borrar). Backend con parseo robusto de
TZID/UTC/todo-el-día y construcción de VCALENDAR (con VTIMEZONE para zonas con
DST). Verificado con un ciclo real crear→editar→borrar contra Xandikos (cero
residuo). Onboarding: backend en 8470 + `pnpm dev` en `frontend/` → abrir
`http://127.0.0.1:5173` → pestaña "Calendario".
- **Gaps conocidos**: MKCALENDAR (crear colección de calendario nueva) NO
implementado — Xandikos tiene hoy una sola colección y la UI no lo necesita;
documentado como pendiente. El frontend usa una rejilla Mantine propia (sin
`react-big-calendar`) para evitar fricción de peer-deps con React 19.
- Cuando exista el manifest de sub-repos del project (issue 0171), añadir esta
app a `projects/osint/subrepos.yaml`.
+60 -1
View File
@@ -123,10 +123,15 @@ export interface ContactsPayload {
export interface CalendarEvent {
uid: string | null;
summary: string | null;
// dtstart/dtend en ISO con offset ("2026-06-15T10:00:00+02:00") o "YYYY-MM-DD"
// para eventos de todo el día. `tz` es el TZID original del evento.
dtstart: string | null;
dtend: string | null;
tz?: string | null;
all_day?: boolean;
location: string | null;
description: string | null;
color?: string | null;
href?: string;
etag?: string;
}
@@ -138,6 +143,41 @@ export interface CalendarPayload {
error?: string;
}
export interface CalendarCollection {
href: string;
name: string;
color: string | null;
}
export interface CalendarsPayload {
status: string;
count?: number;
calendars?: CalendarCollection[];
default?: string;
error?: string;
}
// Cuerpo de POST/PUT de un evento del calendario (VEVENT).
export interface EventInput {
cal?: string | null;
summary: string;
dtstart: string; // ISO local "2026-06-15T10:00" o "2026-06-15" (all_day)
dtend?: string | null;
tz?: string | null;
all_day?: boolean;
location?: string | null;
description?: string | null;
color?: string | null;
}
export interface EventWriteResult {
status: string;
uid: string;
cal?: string;
deleted?: boolean;
dav?: { status?: string; http_status?: number; error?: string };
}
// --- Endpoints ------------------------------------------------------------
export const fetchGraph = () => getJSON<GraphPayload>("/graph");
@@ -216,14 +256,33 @@ export const deleteContact = (slug: string) =>
"DELETE",
);
export const fetchCalendar = (from = "", to = "") => {
export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
export const fetchCalendar = (cal = "", from = "", to = "") => {
const qs = new URLSearchParams();
if (cal) qs.set("cal", cal);
if (from) qs.set("from", from);
if (to) qs.set("to", to);
const tail = qs.toString();
return getJSON<CalendarPayload>(`/calendar${tail ? `?${tail}` : ""}`);
};
// --- CRUD de eventos del calendario (VEVENT sobre CalDAV) ------------------
export const createEvent = (data: EventInput) =>
sendJSON<EventWriteResult>("/event", "POST", data);
export const updateEvent = (uid: string, data: EventInput) =>
sendJSON<EventWriteResult>(`/event/${encodeURIComponent(uid)}`, "PUT", data);
export const deleteEvent = (uid: string, cal = "") => {
const qs = cal ? `?cal=${encodeURIComponent(cal)}` : "";
return sendJSON<EventWriteResult>(
`/event/${encodeURIComponent(uid)}${qs}`,
"DELETE",
);
};
export const refresh = () =>
fetch(`${BASE}/refresh`, { method: "POST" }).then((r) => {
if (!r.ok) throw new Error(`refresh falló: HTTP ${r.status}`);
+152
View File
@@ -0,0 +1,152 @@
// Helpers de calendario: zonas horarias, posicionado de eventos por hora y
// agrupado por día. El backend devuelve dtstart/dtend en ISO con offset
// ("2026-06-15T10:00:00+02:00") o "YYYY-MM-DD" para todo el día; aquí los
// reubicamos en la zona horaria que el usuario elige en el visor (no en la del
// evento) y los convertimos a posiciones de la rejilla semana/día.
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import "dayjs/locale/es";
import type { CalendarEvent } from "./api";
dayjs.extend(utc);
dayjs.extend(timezone);
// Etiquetas de mes/día en español + semana empezando en lunes.
dayjs.locale("es");
export { dayjs };
// Zonas horarias ofrecidas en el selector. Europe/Madrid es el default del
// dominio; el resto cubre los husos más habituales para mirar un evento en otra
// hora local sin tener que listarlas todas.
export const TIMEZONES: { value: string; label: string }[] = [
{ value: "Europe/Madrid", label: "Madrid (CET/CEST)" },
{ value: "UTC", label: "UTC" },
{ value: "Europe/London", label: "Londres" },
{ value: "Europe/Paris", label: "París" },
{ value: "America/New_York", label: "Nueva York" },
{ value: "America/Mexico_City", label: "Ciudad de México" },
{ value: "America/Bogota", label: "Bogotá" },
{ value: "America/Argentina/Buenos_Aires", label: "Buenos Aires" },
{ value: "America/Los_Angeles", label: "Los Ángeles" },
{ value: "Asia/Tokyo", label: "Tokio" },
];
/** Zona horaria local del navegador, para inicializar el selector. */
export function browserTz(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Madrid";
} catch {
return "Europe/Madrid";
}
}
/**
* Instante del evento en la zona `tz` elegida por el visor. Un evento de todo el
* día se ancla al mediodía de su fecha en esa zona (evita que un offset lo
* desplace de día). Devuelve un objeto dayjs ya en `tz`.
*/
export function eventStart(ev: CalendarEvent, tz: string) {
if (!ev.dtstart) return null;
if (ev.all_day || /^\d{4}-\d{2}-\d{2}$/.test(ev.dtstart)) {
return dayjs.tz(ev.dtstart + "T12:00:00", tz);
}
return dayjs(ev.dtstart).tz(tz);
}
/** Instante de fin en la zona `tz`, o null si el evento no tiene dtend. */
export function eventEnd(ev: CalendarEvent, tz: string) {
if (!ev.dtend) return null;
if (ev.all_day || /^\d{4}-\d{2}-\d{2}$/.test(ev.dtend)) {
return dayjs.tz(ev.dtend + "T12:00:00", tz);
}
return dayjs(ev.dtend).tz(tz);
}
/** Clave de día "YYYY-MM-DD" del evento en la zona `tz` (para agrupar). */
export function eventDayKey(ev: CalendarEvent, tz: string): string {
const s = eventStart(ev, tz);
return s ? s.format("YYYY-MM-DD") : "";
}
export interface PositionedEvent {
ev: CalendarEvent;
topPct: number; // 0..100 dentro del día
heightPct: number; // alto mínimo garantizado
allDay: boolean;
}
/**
* Posiciona los eventos de un día concreto en la rejilla 0..24h (porcentajes
* sobre la altura del día). Los de todo el día / sin hora se devuelven aparte
* (allDay=true) para pintarse en una franja superior. `dayKey` es "YYYY-MM-DD".
*/
export function positionEventsForDay(
events: CalendarEvent[],
dayKey: string,
tz: string,
): PositionedEvent[] {
const out: PositionedEvent[] = [];
for (const ev of events) {
if (eventDayKey(ev, tz) !== dayKey) continue;
const start = eventStart(ev, tz);
if (!start) continue;
if (ev.all_day || /^\d{4}-\d{2}-\d{2}$/.test(ev.dtstart ?? "")) {
out.push({ ev, topPct: 0, heightPct: 0, allDay: true });
continue;
}
const end = eventEnd(ev, tz) ?? start.add(1, "hour");
const startMin = start.hour() * 60 + start.minute();
let endMin = end.hour() * 60 + end.minute();
if (end.format("YYYY-MM-DD") !== dayKey) endMin = 24 * 60; // termina otro día
if (endMin <= startMin) endMin = startMin + 30;
const topPct = (startMin / (24 * 60)) * 100;
const heightPct = Math.max(((endMin - startMin) / (24 * 60)) * 100, 2.5);
out.push({ ev, topPct, heightPct, allDay: false });
}
return out;
}
/** Los 7 días (dayjs) de la semana que contiene `date`, empezando en lunes. */
export function weekDays(date: dayjs.Dayjs): dayjs.Dayjs[] {
// dayjs: 0=domingo. Queremos lunes como primer día.
const dow = date.day();
const offsetToMonday = (dow + 6) % 7;
const monday = date.subtract(offsetToMonday, "day").startOf("day");
return Array.from({ length: 7 }, (_, i) => monday.add(i, "day"));
}
/** Matriz de semanas (cada una 7 días) que cubre el mes de `date`. */
export function monthMatrix(date: dayjs.Dayjs): dayjs.Dayjs[][] {
const first = date.startOf("month");
const start = weekDays(first)[0];
const weeks: dayjs.Dayjs[][] = [];
let cursor = start;
for (let w = 0; w < 6; w++) {
const row: dayjs.Dayjs[] = [];
for (let d = 0; d < 7; d++) {
row.push(cursor);
cursor = cursor.add(1, "day");
}
weeks.push(row);
// Parar si ya cubrimos todo el mes y la siguiente fila empieza en otro mes.
if (cursor.month() !== date.month() && w >= 3) break;
}
return weeks;
}
export const HOURS = Array.from({ length: 24 }, (_, h) => h);
export const WEEKDAY_LABELS = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
/** Color efectivo de un evento: el propio del VEVENT o el del calendario. */
export function eventColor(
ev: CalendarEvent,
calColor: string | null | undefined,
): string {
// Apple usa #RRGGBBAA (8 hex); recortamos el alfa para CSS clásico.
const raw = ev.color || calColor || null;
if (!raw) return "#23bdfe"; // brand por defecto
if (/^#[0-9a-fA-F]{8}$/.test(raw)) return raw.slice(0, 7);
return raw;
}
+6 -2
View File
@@ -1,7 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { Notifications } from "@mantine/notifications";
import "dayjs/locale/es";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css";
@@ -12,8 +14,10 @@ import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications position="top-right" />
<App />
<DatesProvider settings={{ locale: "es", firstDayOfWeek: 1 }}>
<Notifications position="top-right" />
<App />
</DatesProvider>
</MantineProvider>
</StrictMode>,
);
+600 -126
View File
@@ -1,45 +1,99 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
ColorSwatch,
Group,
Indicator,
Loader,
Paper,
ScrollArea,
SegmentedControl,
Select,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { Calendar } from "@mantine/dates";
import dayjs from "dayjs";
import { IconClock, IconMapPin } from "@tabler/icons-react";
import { fetchCalendar, type CalendarEvent } from "../api";
import { notifications } from "@mantine/notifications";
import {
dayLabel,
formatICalTime,
icalDayKey,
} from "../format";
IconChevronLeft,
IconChevronRight,
IconPlus,
} from "@tabler/icons-react";
import {
deleteEvent,
fetchCalendar,
fetchCalendars,
type CalendarCollection,
type CalendarEvent,
} from "../api";
import {
dayjs,
eventColor,
eventStart,
HOURS,
monthMatrix,
positionEventsForDay,
TIMEZONES,
WEEKDAY_LABELS,
weekDays,
browserTz,
} from "../calendar";
import { EventModal, type EventDraft } from "./EventModal";
// Calendario: mini-calendario de @mantine/dates a la izquierda (con punto en
// los días que tienen eventos) y la lista de eventos a la derecha. Por defecto
// muestra el mes actual agrupado por día; al elegir un día se filtra a ese día.
type ViewMode = "mes" | "semana" | "dia";
// 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
// completo (crear en un hueco, editar/borrar un evento). El mini-calendario de la
// izquierda navega; el cuerpo muestra la vista activa en la TZ seleccionada.
export function CalendarView() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [activeCal, setActiveCal] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Mantine v9 Calendar usa fechas como string "YYYY-MM-DD" (DateStringValue),
// no Date. `month` controla el mes mostrado; `selectedDay` filtra a un día.
const [month, setMonth] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const [view, setView] = useState<ViewMode>("semana");
const [tz, setTz] = useState<string>(browserTz());
// `cursor` es el día de referencia (la vista lo expande a su mes/semana/día).
const [cursor, setCursor] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [modal, setModal] = useState<EventDraft | null>(null);
const [reloadKey, setReloadKey] = useState(0);
// Carga de calendarios (selector) una vez.
useEffect(() => {
let alive = true;
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);
}
})
.catch(() => {
/* el selector degrada a "calendario" implícito; no es fatal */
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Carga de eventos de la colección activa.
useEffect(() => {
let alive = true;
setLoading(true);
fetchCalendar()
setError(null);
fetchCalendar(activeCal)
.then((d) => {
if (!alive) return;
if (d.status !== "ok") {
@@ -53,33 +107,114 @@ export function CalendarView() {
return () => {
alive = false;
};
}, []);
}, [activeCal, reloadKey]);
// Eventos indexados por día local "AAAA-MM-DD".
const byDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
const calColor = useMemo(() => {
const c = calendars.find((c) => c.href === activeCal);
return c?.color ?? null;
}, [calendars, activeCal]);
// Días con eventos (para el punto en el mini-calendario), en la TZ activa.
const daysWithEvents = useMemo(() => {
const set = new Set<string>();
for (const e of events) {
const key = icalDayKey(e.dtstart);
if (!key) continue;
const list = map.get(key) ?? [];
list.push(e);
map.set(key, list);
const s = eventStart(e, tz);
if (s) set.add(s.format("YYYY-MM-DD"));
}
return map;
}, [events]);
return set;
}, [events, tz]);
// Días visibles: si hay día seleccionado, solo ese; si no, todos los del mes
// mostrado, ordenados.
const visibleDays = useMemo(() => {
const monthPrefix = month.slice(0, 7); // "YYYY-MM"
let keys = [...byDay.keys()];
if (selectedDay) {
keys = keys.filter((k) => k === selectedDay);
} else {
keys = keys.filter((k) => k.startsWith(monthPrefix));
}
return keys.sort();
}, [byDay, month, selectedDay]);
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
const onSaved = useCallback(
(msg: string) => {
notifications.show({ color: "teal", title: "Calendario", message: msg });
setModal(null);
reload();
},
[reload],
);
const onDelete = useCallback(
async (uid: string) => {
try {
await deleteEvent(uid, activeCal);
notifications.show({
color: "teal",
title: "Evento borrado",
message: "",
});
setModal(null);
reload();
} catch (e) {
notifications.show({
color: "red",
title: "No se pudo borrar",
message: String(e),
});
}
},
[activeCal, reload],
);
// Abre el modal en modo "nuevo" en un hueco (día + hora opcional).
const openNew = useCallback(
(day: string, hour?: number) => {
const start =
hour === undefined
? `${day}T09:00`
: `${day}T${String(hour).padStart(2, "0")}:00`;
const endHour = hour === undefined ? 10 : Math.min(hour + 1, 23);
const end =
hour === undefined
? `${day}T10:00`
: `${day}T${String(endHour).padStart(2, "0")}:00`;
setModal({
mode: "new",
summary: "",
dtstart: start,
dtend: end,
tz,
all_day: false,
location: "",
description: "",
color: "",
cal: activeCal,
});
},
[tz, activeCal],
);
// Abre el modal en modo "editar" desde un evento existente.
const openEdit = useCallback(
(ev: CalendarEvent) => {
const s = eventStart(ev, tz);
const e = ev.dtend ? eventStart({ ...ev, dtstart: ev.dtend }, tz) : null;
setModal({
mode: "edit",
uid: ev.uid ?? undefined,
summary: ev.summary ?? "",
dtstart: ev.all_day
? (ev.dtstart ?? "").slice(0, 10)
: (s?.format("YYYY-MM-DDTHH:mm") ?? ""),
dtend: ev.all_day
? (ev.dtend ?? "").slice(0, 10)
: (e?.format("YYYY-MM-DDTHH:mm") ?? ""),
tz: ev.tz || tz,
all_day: !!ev.all_day,
location: ev.location ?? "",
description: ev.description ?? "",
color: ev.color ?? "",
cal: activeCal,
});
},
[tz, activeCal],
);
function navigate(dir: -1 | 1) {
const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day";
setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD"));
}
if (error) {
return (
@@ -95,126 +230,465 @@ export function CalendarView() {
);
}
if (loading) {
return (
<Center h="100%">
<Loader />
</Center>
);
}
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");
return (
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
{/* Panel izquierdo: navegación + selectores */}
<Paper
p="md"
radius={0}
withBorder
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0 }}
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, width: 280 }}
>
<Stack gap="md">
<Button
leftSection={<IconPlus size={16} />}
onClick={() => openNew(cursor)}
fullWidth
>
Nuevo evento
</Button>
<Calendar
date={month}
onDateChange={setMonth}
date={cursor}
onDateChange={(d) => setCursor(d)}
getDayProps={(date) => ({
selected: selectedDay === date,
onClick: () =>
setSelectedDay((prev) => (prev === date ? null : date)),
selected: cursor === date,
onClick: () => {
setCursor(date);
if (view === "mes") setView("dia");
},
})}
renderDay={(date) => {
const has = byDay.has(date);
const has = daysWithEvents.has(date);
const day = Number(date.slice(8, 10));
return (
<Indicator
size={6}
color="brand"
offset={-2}
disabled={!has}
>
<Indicator size={6} color="brand" offset={-2} disabled={!has}>
<div>{day}</div>
</Indicator>
);
}}
/>
<Select
label="Calendario"
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
value={activeCal || null}
onChange={(v) => v && setActiveCal(v)}
leftSection={
<ColorSwatch
size={14}
color={eventColor({} as CalendarEvent, calColor)}
/>
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
<Select
label="Zona horaria"
data={TIMEZONES}
value={tz}
onChange={(v) => v && setTz(v)}
searchable
comboboxProps={{ withinPortal: true }}
/>
<Text size="xs" c="dimmed">
{events.length} eventos en total
{events.length} eventos · TZ {tz}
</Text>
{selectedDay && (
<Badge
variant="light"
style={{ cursor: "pointer" }}
onClick={() => setSelectedDay(null)}
>
Ver todo el mes
</Badge>
)}
</Stack>
</Paper>
<Box style={{ flex: 1, minWidth: 0 }}>
<ScrollArea h="100%">
<Stack p="xl" gap="lg" maw={760}>
<Title order={3}>
{selectedDay
? dayLabel(selectedDay)
: dayjs(month).format("MMMM YYYY")}
{/* Cuerpo: barra de vista + rejilla */}
<Box style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<Group justify="space-between" p="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="nowrap">
<Tooltip label="Anterior">
<ActionIcon variant="default" onClick={() => navigate(-1)}>
<IconChevronLeft size={16} />
</ActionIcon>
</Tooltip>
<Button
variant="default"
size="xs"
onClick={() => setCursor(dayjs().format("YYYY-MM-DD"))}
>
Hoy
</Button>
<Tooltip label="Siguiente">
<ActionIcon variant="default" onClick={() => navigate(1)}>
<IconChevronRight size={16} />
</ActionIcon>
</Tooltip>
<Title order={4} style={{ textTransform: "capitalize" }}>
{headerTitle}
</Title>
</Group>
<SegmentedControl
value={view}
onChange={(v) => setView(v as ViewMode)}
data={[
{ value: "mes", label: "Mes" },
{ value: "semana", label: "Semana" },
{ value: "dia", label: "Día" },
]}
/>
</Group>
{visibleDays.length === 0 && (
<Text c="dimmed">Sin eventos en este periodo.</Text>
)}
{visibleDays.map((day) => (
<Stack key={day} gap="xs">
{!selectedDay && (
<Text fw={600} size="sm" c="brand">
{dayLabel(day)}
</Text>
)}
{(byDay.get(day) ?? [])
.sort((a, b) =>
(a.dtstart ?? "").localeCompare(b.dtstart ?? ""),
)
.map((ev, i) => (
<EventRow key={(ev.uid ?? "") + i} ev={ev} />
))}
</Stack>
))}
</Stack>
</ScrollArea>
<Box style={{ flex: 1, minHeight: 0 }}>
{loading ? (
<Center h="100%">
<Loader />
</Center>
) : view === "mes" ? (
<MonthView
cursor={cursor}
events={events}
tz={tz}
calColor={calColor}
onDay={(d) => {
setCursor(d);
setView("dia");
}}
onEvent={openEdit}
/>
) : view === "semana" ? (
<TimeGrid
days={weekDays(dayjs(cursor))}
events={events}
tz={tz}
calColor={calColor}
onSlot={openNew}
onEvent={openEdit}
/>
) : (
<TimeGrid
days={[dayjs(cursor)]}
events={events}
tz={tz}
calColor={calColor}
onSlot={openNew}
onEvent={openEdit}
/>
)}
</Box>
</Box>
<EventModal
draft={modal}
calendars={calendars}
onClose={() => setModal(null)}
onSaved={onSaved}
onDelete={onDelete}
/>
</Group>
);
}
function EventRow({ ev }: { ev: CalendarEvent }) {
// --- Vista de semana / día: rejilla horas × días ---------------------------
function TimeGrid({
days,
events,
tz,
calColor,
onSlot,
onEvent,
}: {
days: dayjs.Dayjs[];
events: CalendarEvent[];
tz: string;
calColor: string | null;
onSlot: (day: string, hour: number) => void;
onEvent: (ev: CalendarEvent) => void;
}) {
const HOUR_PX = 44;
const today = dayjs().format("YYYY-MM-DD");
return (
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Box style={{ minWidth: 0 }}>
<Text fw={600} size="sm">
{ev.summary || "(sin título)"}
</Text>
{ev.location && (
<Group gap={4} mt={2}>
<IconMapPin size={13} />
<Text size="xs" c="dimmed">
{ev.location}
</Text>
</Group>
)}
{ev.description && (
<Text size="xs" c="dimmed" mt={4} lineClamp={2}>
{ev.description}
</Text>
)}
<ScrollArea h="100%" type="auto">
<Box style={{ display: "flex", minWidth: days.length > 1 ? 640 : 0 }}>
{/* Columna de horas */}
<Box style={{ width: 52, flexShrink: 0 }}>
<Box style={{ height: 28 }} />
{HOURS.map((h) => (
<Box
key={h}
style={{
height: HOUR_PX,
textAlign: "right",
paddingRight: 6,
fontSize: 11,
color: "var(--mantine-color-dimmed)",
transform: "translateY(-7px)",
}}
>
{String(h).padStart(2, "0")}:00
</Box>
))}
</Box>
<Group gap={4} wrap="nowrap">
<IconClock size={13} />
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{formatICalTime(ev.dtstart)}
</Text>
</Group>
</Group>
</Paper>
{/* Una columna por día */}
{days.map((day) => {
const dayKey = day.format("YYYY-MM-DD");
const positioned = positionEventsForDay(events, dayKey, tz);
const allDay = positioned.filter((p) => p.allDay);
const timed = positioned.filter((p) => !p.allDay);
const isToday = dayKey === today;
return (
<Box
key={dayKey}
style={{
flex: 1,
minWidth: 0,
borderLeft: "1px solid var(--mantine-color-default-border)",
}}
>
{/* Cabecera del día */}
<Box
style={{
height: 28,
textAlign: "center",
fontSize: 12,
fontWeight: 600,
color: isToday
? "var(--mantine-color-brand-6)"
: "var(--mantine-color-text)",
}}
>
{WEEKDAY_LABELS[(day.day() + 6) % 7]} {day.format("D")}
</Box>
{/* Franja de "todo el día" */}
{allDay.length > 0 && (
<Box style={{ padding: "2px 3px" }}>
{allDay.map((p, i) => (
<EventChip
key={(p.ev.uid ?? "") + i}
ev={p.ev}
calColor={calColor}
onClick={() => onEvent(p.ev)}
compact
/>
))}
</Box>
)}
{/* Rejilla horaria con posicionado absoluto */}
<Box style={{ position: "relative", height: HOUR_PX * 24 }}>
{HOURS.map((h) => (
<Box
key={h}
onClick={() => onSlot(dayKey, h)}
style={{
position: "absolute",
top: h * HOUR_PX,
left: 0,
right: 0,
height: HOUR_PX,
borderTop: "1px solid var(--mantine-color-default-border)",
cursor: "pointer",
}}
/>
))}
{timed.map((p, i) => (
<Box
key={(p.ev.uid ?? "") + i}
onClick={(e) => {
e.stopPropagation();
onEvent(p.ev);
}}
style={{
position: "absolute",
top: `${p.topPct}%`,
height: `${p.heightPct}%`,
left: 2,
right: 2,
overflow: "hidden",
borderRadius: 4,
padding: "1px 5px",
fontSize: 11,
lineHeight: 1.25,
color: "#fff",
background: eventColor(p.ev, calColor),
cursor: "pointer",
}}
>
<Text size="xs" fw={600} truncate c="#fff">
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
{p.ev.summary || "(sin título)"}
</Text>
</Box>
))}
</Box>
</Box>
);
})}
</Box>
</ScrollArea>
);
}
// --- Vista de mes: rejilla de semanas --------------------------------------
function MonthView({
cursor,
events,
tz,
calColor,
onDay,
onEvent,
}: {
cursor: string;
events: CalendarEvent[];
tz: string;
calColor: string | null;
onDay: (day: string) => void;
onEvent: (ev: CalendarEvent) => void;
}) {
const weeks = monthMatrix(dayjs(cursor));
const month = dayjs(cursor).month();
const today = dayjs().format("YYYY-MM-DD");
const byDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const e of events) {
const s = eventStart(e, tz);
if (!s) continue;
const k = s.format("YYYY-MM-DD");
const list = map.get(k) ?? [];
list.push(e);
map.set(k, list);
}
return map;
}, [events, tz]);
return (
<ScrollArea h="100%">
<Box style={{ display: "flex", padding: "0 8px" }}>
{WEEKDAY_LABELS.map((l) => (
<Box
key={l}
style={{
flex: 1,
textAlign: "center",
fontSize: 11,
fontWeight: 600,
color: "var(--mantine-color-dimmed)",
padding: "4px 0",
}}
>
{l}
</Box>
))}
</Box>
<Stack gap={0} px={8} pb={8}>
{weeks.map((week, wi) => (
<Group key={wi} gap={0} wrap="nowrap" align="stretch">
{week.map((day) => {
const k = day.format("YYYY-MM-DD");
const list = (byDay.get(k) ?? []).sort((a, b) =>
(a.dtstart ?? "").localeCompare(b.dtstart ?? ""),
);
const dim = day.month() !== month;
const isToday = k === today;
return (
<Box
key={k}
onClick={() => onDay(k)}
style={{
flex: 1,
minWidth: 0,
minHeight: 92,
border: "1px solid var(--mantine-color-default-border)",
padding: 3,
cursor: "pointer",
background: dim
? "var(--mantine-color-default-hover)"
: undefined,
}}
>
<Text
size="xs"
ta="right"
fw={isToday ? 700 : 400}
c={
isToday
? "brand"
: dim
? "dimmed"
: undefined
}
>
{day.format("D")}
</Text>
<Stack gap={2} mt={2}>
{list.slice(0, 3).map((ev, i) => (
<EventChip
key={(ev.uid ?? "") + i}
ev={ev}
calColor={calColor}
onClick={() => onEvent(ev)}
compact
/>
))}
{list.length > 3 && (
<Text size="xs" c="dimmed">
+{list.length - 3} más
</Text>
)}
</Stack>
</Box>
);
})}
</Group>
))}
</Stack>
</ScrollArea>
);
}
// Chip compacto de un evento (mes / franja all-day).
function EventChip({
ev,
calColor,
onClick,
compact,
}: {
ev: CalendarEvent;
calColor: string | null;
onClick: () => void;
compact?: boolean;
}) {
const color = eventColor(ev, calColor);
return (
<Badge
onClick={(e) => {
e.stopPropagation();
onClick();
}}
variant="filled"
radius="sm"
fullWidth
style={{
background: color,
cursor: "pointer",
justifyContent: "flex-start",
textTransform: "none",
fontWeight: 500,
height: compact ? 16 : 20,
}}
>
{ev.summary || "(sin título)"}
</Badge>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useEffect, useState } from "react";
import {
Button,
ColorInput,
Group,
Modal,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import {
createEvent,
updateEvent,
type CalendarCollection,
type EventInput,
} from "../api";
import { TIMEZONES } from "../calendar";
// Borrador de evento que el CalendarView pasa al modal. `mode` decide si el
// guardado hace POST (new) o PUT (edit). Las fechas van en ISO local
// "YYYY-MM-DDTHH:mm" (o "YYYY-MM-DD" para todo el día).
export interface EventDraft {
mode: "new" | "edit";
uid?: string;
summary: string;
dtstart: string;
dtend: string;
tz: string;
all_day: boolean;
location: string;
description: string;
color: string;
cal: string;
}
export function EventModal({
draft,
calendars,
onClose,
onSaved,
onDelete,
}: {
draft: EventDraft | null;
calendars: CalendarCollection[];
onClose: () => void;
onSaved: (msg: string) => void;
onDelete: (uid: string) => void;
}) {
const [form, setForm] = useState<EventDraft | null>(draft);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
setForm(draft);
setErr(null);
}, [draft]);
if (!form) return null;
function set<K extends keyof EventDraft>(key: K, value: EventDraft[K]) {
setForm((f) => (f ? { ...f, [key]: value } : f));
}
// Al alternar "todo el día" recorta/expande la parte horaria del valor.
function toggleAllDay(allDay: boolean) {
setForm((f) => {
if (!f) return f;
if (allDay) {
return {
...f,
all_day: true,
dtstart: f.dtstart.slice(0, 10),
dtend: f.dtend.slice(0, 10),
};
}
return {
...f,
all_day: false,
dtstart: f.dtstart.length === 10 ? `${f.dtstart}T09:00` : f.dtstart,
dtend: f.dtend.length === 10 ? `${f.dtend}T10:00` : f.dtend,
};
});
}
async function save() {
if (!form) return;
if (!form.summary.trim()) {
setErr("El título es obligatorio.");
return;
}
if (!form.dtstart) {
setErr("La fecha de inicio es obligatoria.");
return;
}
setSaving(true);
setErr(null);
const payload: EventInput = {
cal: form.cal || null,
summary: form.summary.trim(),
dtstart: form.dtstart,
dtend: form.dtend || null,
tz: form.all_day ? null : form.tz,
all_day: form.all_day,
location: form.location || null,
description: form.description || null,
color: form.color || null,
};
try {
if (form.mode === "edit" && form.uid) {
await updateEvent(form.uid, payload);
onSaved("Evento actualizado.");
} else {
await createEvent(payload);
onSaved("Evento creado.");
}
} catch (e) {
setErr(String(e));
} finally {
setSaving(false);
}
}
// Inputs de fecha/hora nativos: el navegador los muestra en formato local
// (DD/MM/AAAA + 24h en es-ES) pero su `value` es ISO. Mantenemos ISO interno.
const dateType = form.all_day ? "date" : "datetime-local";
return (
<Modal
opened={!!draft}
onClose={onClose}
title={form.mode === "edit" ? "Editar evento" : "Nuevo evento"}
size="md"
centered
>
<Stack gap="sm">
<TextInput
label="Título"
placeholder="Resumen del evento"
value={form.summary}
onChange={(e) => set("summary", e.currentTarget.value)}
required
data-autofocus
/>
<Switch
label="Todo el día"
checked={form.all_day}
onChange={(e) => toggleAllDay(e.currentTarget.checked)}
/>
<Group grow>
<TextInput
label="Inicio"
type={dateType}
value={form.dtstart}
onChange={(e) => set("dtstart", e.currentTarget.value)}
required
/>
<TextInput
label="Fin"
type={dateType}
value={form.dtend}
onChange={(e) => set("dtend", e.currentTarget.value)}
/>
</Group>
{!form.all_day && (
<Select
label="Zona horaria"
data={TIMEZONES}
value={form.tz}
onChange={(v) => v && set("tz", v)}
searchable
comboboxProps={{ withinPortal: true }}
/>
)}
<Select
label="Calendario"
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
value={form.cal || null}
onChange={(v) => v && set("cal", v)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
<ColorInput
label="Color (opcional)"
placeholder="Por defecto: color del calendario"
value={form.color}
onChange={(v) => set("color", v)}
format="hex"
swatches={[
"#23bdfe",
"#16a34a",
"#dc2626",
"#f59e0b",
"#8b5cf6",
"#ec4899",
"#0891b2",
"#64748b",
]}
/>
<TextInput
label="Ubicación"
placeholder="Lugar (opcional)"
value={form.location}
onChange={(e) => set("location", e.currentTarget.value)}
/>
<Textarea
label="Descripción"
placeholder="Notas (opcional)"
value={form.description}
onChange={(e) => set("description", e.currentTarget.value)}
autosize
minRows={2}
maxRows={5}
/>
{err && (
<Text c="red" size="sm">
{err}
</Text>
)}
<Group justify="space-between" mt="xs">
{form.mode === "edit" && form.uid ? (
<Button
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => form.uid && onDelete(form.uid)}
>
Borrar
</Button>
) : (
<span />
)}
<Group gap="xs">
<Button variant="default" onClick={onClose}>
Cancelar
</Button>
<Button onClick={save} loading={saving}>
{form.mode === "edit" ? "Guardar" : "Crear"}
</Button>
</Group>
</Group>
</Stack>
</Modal>
);
}
+617 -36
View File
@@ -33,7 +33,11 @@ Endpoints (JSON salvo /api/attachment):
GET /api/search?q=... nodos cuyo contenido matchea la query
GET /api/contacts contactos del addressbook Xandikos (CardDAV)
GET /api/contact/<uid> un vCard concreto a JSON
GET /api/calendar?from=&to= eventos del calendario Xandikos (CalDAV)
GET /api/calendars colecciones de calendario bajo /enmanuel/calendars/
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV)
POST /api/event crea un VEVENT en una colección de calendario
PUT /api/event/<uid> edita un VEVENT existente
DELETE /api/event/<uid> borra un VEVENT
POST /api/refresh re-escanea el vault y reconstruye la caché
"""
@@ -47,8 +51,18 @@ import re
import sys
import threading
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError: # pragma: no cover - Python < 3.9 sin tzdata
ZoneInfo = None # type: ignore[assignment]
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
pass
def _registry_functions_dir() -> str:
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados.
@@ -147,6 +161,10 @@ pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret")
# inmediato para que la app y el móvil lo vean ya, sin esperar al sync periódico.
carddav_put_vcard = _load_infra_fn("carddav_put_vcard", "carddav_put_vcard")
dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource")
# Calendario (CalDAV): crear/editar eventos (PUT de un VCALENDAR por UID) y listar
# 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")
# ---------------------------------------------------------------------------
@@ -157,6 +175,11 @@ XANDIKOS_BASE_URL = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
XANDIKOS_USERNAME = "enmanuel"
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
# Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
# Colección de calendario por defecto (la única hoy). Sigue siendo el destino
# cuando el cliente no especifica `cal`.
XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
# Caché en disco de los datos DAV ya parseados, indexada por el ctag de la
@@ -166,8 +189,25 @@ XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
# junto al server y está gitignored (datos personales sensibles + regenerable).
_CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".cache")
_CONTACTS_CACHE_FILE = os.path.join(_CACHE_DIR, "contacts.json")
# Caché del calendario por defecto. Para otras colecciones la ruta se deriva del
# nombre de la colección (_calendar_cache_file), así cada calendario tiene su
# propia caché en disco.
_CALENDAR_CACHE_FILE = os.path.join(_CACHE_DIR, "calendar.json")
def _calendar_cache_file(collection_path: str) -> str:
"""Ruta de la caché en disco de una colección de calendario concreta.
La colección por defecto usa ``_CALENDAR_CACHE_FILE`` (compatibilidad con la
caché previa); cualquier otra deriva su archivo del último segmento del path,
saneado, para que cada calendario tenga su propia caché aislada.
"""
if collection_path.strip("/") == XANDIKOS_CALENDAR_COLLECTION.strip("/"):
return _CALENDAR_CACHE_FILE
tail = collection_path.strip("/").rsplit("/", 1)[-1] or "calendar"
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", tail)
return os.path.join(_CACHE_DIR, "calendar_%s.json" % safe)
# Extensiones de imagen que el frontend muestra en la galería con lightbox.
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
@@ -292,9 +332,12 @@ class VaultState:
# validación de ctag en el siguiente acceso.
self._dav_lock = threading.Lock()
self._contacts_cache: Optional[list] = None
self._calendar_cache: Optional[list] = None
# Caché de eventos POR colección de calendario: collection_path → list.
# Permite varios calendarios sin pisarse; cada uno con su ctag.
self._calendar_cache: dict[str, list] = {}
self._calendar_ctag: dict[str, str] = {}
self._calendars_cache: Optional[list] = None # lista de colecciones
self._contacts_ctag: Optional[str] = None
self._calendar_ctag: Optional[str] = None
self._force_reload = False
self.refresh()
@@ -615,34 +658,188 @@ class VaultState:
self._maybe_clear_force_reload()
return contacts
def calendar(self, dt_from: str = "", dt_to: str = "") -> list:
"""Eventos del calendario Xandikos, cacheados; filtrados por rango.
def _resolve_calendar(self, cal: str = "") -> str:
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
Misma caché en dos niveles que ``contacts``. La descarga + parseo
completos se cachean (UNA petición REPORT); el filtro por ``[from, to]``
se aplica sobre la caché en cada llamada (barato). Sin ``from``/``to``
devuelve todos.
Acepta una ruta absoluta (``/enmanuel/calendars/calendar/``), el nombre
corto de la colección (``calendar``), o vacío (→ colección por defecto).
Garantiza barras inicial/final. NO valida contra el servidor (eso lo hace
el propio Xandikos al fallar la petición); solo da forma canónica.
"""
cal = (cal or "").strip()
if not cal:
return XANDIKOS_CALENDAR_COLLECTION
if cal.startswith("/"):
path = cal
else:
# Nombre corto → lo colgamos del calendar-home.
path = XANDIKOS_CALENDAR_HOME.rstrip("/") + "/" + cal.strip("/")
if not path.endswith("/"):
path += "/"
return path
def list_calendars(self) -> list:
"""Colecciones de calendario bajo el calendar-home, con nombre y color.
Cacheada en memoria (``POST /api/refresh`` la invalida). Compone la
función del registry ``dav_list_calendars`` (PROPFIND Depth:1). Devuelve
``[{href, name, color}, ...]`` ordenadas por nombre.
Raises:
RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde.
"""
with self._dav_lock:
if self._calendars_cache is not None and not self._force_reload:
return self._calendars_cache
password = self.xandikos_password()
res = dav_list_calendars(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, XANDIKOS_CALENDAR_HOME
)
if res.get("status") != "ok":
raise DavUnavailable(
"Xandikos no responde: %s" % res.get("error")
)
calendars = res.get("calendars", [])
self._calendars_cache = calendars
self._maybe_clear_force_reload()
return calendars
def calendar(self, cal: str = "", dt_from: str = "", dt_to: str = "") -> list:
"""Eventos de una colección de calendario Xandikos, cacheados y filtrados.
Caché por colección (memoria + disco, validada por ctag). La descarga +
parseo completos se cachean (UNA petición REPORT); el filtro por
``[from, to]`` se aplica sobre la caché. Sin ``cal`` usa la colección por
defecto; sin ``from``/``to`` devuelve todos los eventos.
Raises:
RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
"""
collection = self._resolve_calendar(cal)
with self._dav_lock:
if self._calendar_cache is None or self._force_reload:
cached = self._calendar_cache.get(collection)
if cached is None or self._force_reload:
events, ctag = self._load_collection(
XANDIKOS_CALENDAR_COLLECTION,
collection,
"ical",
_CALENDAR_CACHE_FILE,
_calendar_cache_file(collection),
self._parse_events,
)
self._calendar_cache = events
self._calendar_ctag = ctag
self._calendar_cache[collection] = events
self._calendar_ctag[collection] = ctag
self._maybe_clear_force_reload()
all_events = self._calendar_cache
cached = events
all_events = list(cached)
if not dt_from and not dt_to:
return list(all_events)
return all_events
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
# --- Escritura de eventos del calendario (CalDAV) -----------------------
def create_event(self, data: "EventIn") -> dict:
"""Crea un VEVENT en una colección de calendario (PUT de un VCALENDAR).
Genera un UID nuevo, construye el VCALENDAR/VEVENT (respetando tz/all_day,
ver ``_build_vcalendar``) y lo sube con ``caldav_put_event``. Invalida la
caché de esa colección para que el evento aparezca ya.
Returns:
dict ``{uid, cal, dav}``.
Raises:
HTTPException(400): si la fecha es inválida o falta el summary.
DavUnavailable: si Xandikos rechaza el PUT.
"""
if not data.summary or not data.summary.strip():
raise HTTPException(status_code=400, detail="el summary es obligatorio")
collection = self._resolve_calendar(data.cal or "")
uid = str(uuid.uuid4())
try:
vcal = _build_vcalendar(data, uid)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
dav = self._put_event(collection, uid, vcal)
if dav.get("status") != "ok":
raise DavUnavailable("Xandikos rechazó el evento: %s" % dav.get("error"))
self._invalidate_calendar(collection)
return {"uid": uid, "cal": collection, "dav": dav}
def update_event(self, uid: str, data: "EventIn") -> dict:
"""Edita un VEVENT existente: reescribe el recurso ``<uid>.ics`` (PUT).
Reutiliza el UID (idempotente). Construye el VCALENDAR de nuevo a partir
del cuerpo recibido y lo sube. Invalida la caché de la colección.
Returns:
dict ``{uid, cal, dav}``.
Raises:
HTTPException(400): si la fecha es inválida o falta el summary.
DavUnavailable: si Xandikos rechaza el PUT.
"""
if not data.summary or not data.summary.strip():
raise HTTPException(status_code=400, detail="el summary es obligatorio")
collection = self._resolve_calendar(data.cal or "")
try:
vcal = _build_vcalendar(data, uid)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
dav = self._put_event(collection, uid, vcal)
if dav.get("status") != "ok":
raise DavUnavailable("Xandikos rechazó el evento: %s" % dav.get("error"))
self._invalidate_calendar(collection)
return {"uid": uid, "cal": collection, "dav": dav}
def delete_event(self, uid: str, cal: str = "") -> dict:
"""Borra un VEVENT: elimina el recurso ``<uid>.ics`` de la colección.
Trata 404 como idempotente (ya no existía). Invalida la caché de la
colección.
Returns:
dict ``{uid, deleted, dav}``.
Raises:
DavUnavailable: si Xandikos falla con un error distinto de 404.
"""
collection = self._resolve_calendar(cal)
password = self.xandikos_password()
resource_path = collection + _safe_event_resource(uid)
dav = dav_delete_resource(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, resource_path
)
if dav.get("status") != "ok" and dav.get("http_status") == 404:
dav = {"status": "ok", "http_status": 404, "idempotent": True}
if dav.get("status") != "ok":
raise DavUnavailable("Xandikos no pudo borrar: %s" % dav.get("error"))
self._invalidate_calendar(collection)
return {"uid": uid, "deleted": True, "dav": dav}
def _put_event(self, collection: str, uid: str, vcalendar_text: str) -> dict:
"""Sube (PUT) un VCALENDAR a una colección CalDAV. No lanza por sí sola.
Compone la función del registry ``caldav_put_event`` (deriva el nombre
del recurso de ``safe(uid).ics``). Devuelve su dict
``{status, http_status|error}``.
"""
password = self.xandikos_password()
return caldav_put_event(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
collection,
uid,
vcalendar_text,
)
def _invalidate_calendar(self, collection: str) -> None:
"""Vacía la caché en memoria de una colección de calendario concreta."""
with self._dav_lock:
self._calendar_cache.pop(collection, None)
self._calendar_ctag.pop(collection, None)
def _maybe_clear_force_reload(self) -> None:
"""Apaga el flag de refresh forzado una vez consumido por una recarga.
@@ -662,7 +859,9 @@ class VaultState:
"""
with self._dav_lock:
self._contacts_cache = None
self._calendar_cache = None
self._calendar_cache = {}
self._calendar_ctag = {}
self._calendars_cache = None
self._force_reload = True
# --- Escritura de contactos: ficha .md (verdad) + reflejo en Xandikos ----
@@ -967,41 +1166,135 @@ def _vcard_to_json(vcard_text: str) -> dict:
_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
_ICAL_DT_RE = re.compile(
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$"
)
def _zoneinfo(tzid: str):
"""``ZoneInfo(tzid)`` o ``None`` si el tz no existe / falta tzdata.
Nunca lanza: un TZID desconocido (o un sistema sin base de zonas) degrada a
None y el llamador trata la hora como naive/local, sin tumbar el parseo.
"""
if ZoneInfo is None or not tzid:
return None
try:
return ZoneInfo(tzid)
except (ZoneInfoNotFoundError, ValueError, KeyError):
return None
def _parse_ical_datetime(value: str, params: dict) -> Optional[dict]:
"""Parsea un valor DTSTART/DTEND iCal a una representación normalizada.
Maneja las tres formas del calendario:
- UTC: ``20260611T090000Z`` (sufijo Z).
- con zona: ``DTSTART;TZID=Europe/Madrid:20260611T090000`` (param TZID).
- solo fecha (todo el día): ``DTSTART;VALUE=DATE:20260611`` o sin hora.
Returns:
dict ``{iso, tz, all_day, ical}`` o ``None`` si no parsea. ``iso`` es
ISO 8601 con offset cuando hay zona/UTC (``2026-06-11T11:00:00+02:00``)
o ``YYYY-MM-DD`` para todo el día; ``tz`` es el TZID original
(``Europe/Madrid``, ``UTC``, o None si naive/all-day); ``all_day`` True
si es solo fecha; ``ical`` el prefijo ``YYYYMMDD`` para el filtro de
rango.
"""
value = (value or "").strip()
m = _ICAL_DT_RE.match(value)
if not m:
return None
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
has_time = m.group(4) is not None
is_utc = m.group(7) == "Z"
is_date_value = (params.get("VALUE", "").upper() == "DATE") or not has_time
ical_prefix = "%04d%02d%02d" % (year, month, day)
if is_date_value:
return {
"iso": "%04d-%02d-%02d" % (year, month, day),
"tz": None,
"all_day": True,
"ical": ical_prefix,
}
hour = int(m.group(4))
minute = int(m.group(5))
second = int(m.group(6)) if m.group(6) else 0
tzid = params.get("TZID", "")
if is_utc:
dt = datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
tz_name = "UTC"
elif tzid and _zoneinfo(tzid) is not None:
dt = datetime(year, month, day, hour, minute, second, tzinfo=_zoneinfo(tzid))
tz_name = tzid
else:
# Hora "flotante" (sin Z ni TZID, o TZID desconocido): se interpreta como
# local del visor. La servimos sin offset; el frontend la sitúa en su TZ.
return {
"iso": "%04d-%02d-%02dT%02d:%02d:%02d"
% (year, month, day, hour, minute, second),
"tz": tzid or None,
"all_day": False,
"ical": ical_prefix,
}
return {"iso": dt.isoformat(), "tz": tz_name, "all_day": False, "ical": ical_prefix}
def _vevent_to_json(vevent_block: str) -> dict:
"""Convierte un bloque VEVENT a un dict JSON con los campos de interés.
Extrae: uid, summary, dtstart, dtend, location, description. Las fechas se
devuelven tal cual vienen del servidor (formato iCal, ej. ``20260611T090000Z``
o ``20260611``); el frontend las formatea a europeo. Parseo ligero a mano.
Extrae: uid, summary, dtstart/dtend (ISO con offset cuando hay zona/UTC, o
``YYYY-MM-DD`` para todo el día), la TZ original (``tz``), ``all_day``,
location, description y color (propiedad ``COLOR`` RFC 7986 o
``X-APPLE-CALENDAR-COLOR`` si el evento la trae). ``dtstart_ical`` /
``dtend_ical`` conservan el prefijo ``YYYYMMDD`` crudo para el filtro de
rango. Parseo ligero a mano (sin dependencia externa).
"""
out: dict = {
"uid": None,
"summary": None,
"dtstart": None,
"dtend": None,
"dtstart_ical": None,
"dtend_ical": None,
"tz": None,
"all_day": False,
"location": None,
"description": None,
"color": None,
}
for line in _unfold_lines(vevent_block):
parsed = _parse_property(line)
if not parsed:
continue
name, _params, value = parsed
name, params, value = parsed
value = value.strip()
if name == "UID":
out["uid"] = value
elif name == "SUMMARY":
out["summary"] = _unescape_ical(value)
elif name == "DTSTART":
out["dtstart"] = value
dt = _parse_ical_datetime(value, params)
if dt:
out["dtstart"] = dt["iso"]
out["dtstart_ical"] = dt["ical"]
out["tz"] = dt["tz"]
out["all_day"] = dt["all_day"]
else:
out["dtstart"] = value
elif name == "DTEND":
out["dtend"] = value
dt = _parse_ical_datetime(value, params)
if dt:
out["dtend"] = dt["iso"]
out["dtend_ical"] = dt["ical"]
else:
out["dtend"] = value
elif name == "LOCATION":
out["location"] = _unescape_ical(value)
elif name == "DESCRIPTION":
out["description"] = _unescape_ical(value)
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
out["color"] = value
return out
@@ -1016,13 +1309,15 @@ def _vcalendar_to_events(vcalendar_text: str) -> list:
def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
"""True si el evento cae (por DTSTART) dentro de ``[dt_from, dt_to]``.
Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` que comparten todos
los formatos iCal (date y date-time). Los límites se normalizan quitando los
guiones, así acepta tanto el formato documentado del endpoint
(``2026-06-11``) como el iCal crudo (``20260611``). ``dt_from``/``dt_to``
vacíos desactivan ese extremo del filtro.
Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` (``dtstart_ical``);
si falta, se deriva del ISO de ``dtstart``. Los límites se normalizan
quitando los guiones, así acepta tanto ``2026-06-11`` como ``20260611``.
``dt_from``/``dt_to`` vacíos desactivan ese extremo del filtro.
"""
dtstart = (event.get("dtstart") or "").replace("-", "")[:8]
dtstart = event.get("dtstart_ical") or ""
if not dtstart:
dtstart = (event.get("dtstart") or "").replace("-", "")[:8]
dtstart = dtstart.replace("-", "")[:8]
if not dtstart:
return True
if dt_from and dtstart < dt_from.replace("-", "")[:8]:
@@ -1200,6 +1495,218 @@ def _build_vcard(frontmatter: dict, slug: str) -> str:
return "\r\n".join(lines) + "\r\n"
# ---------------------------------------------------------------------------
# Escritura de eventos del calendario: construcción de VEVENT / VCALENDAR
# ---------------------------------------------------------------------------
_ISO_DT_RE = re.compile(
r"^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?"
r"(Z|[+-]\d{2}:?\d{2})?$"
)
# Saneado del nombre del recurso .ics: DEBE coincidir con el que aplica
# caldav_put_event internamente (mismo patrón), para que el DELETE apunte al
# recurso que el PUT creó.
_UNSAFE_RESOURCE_RE = re.compile(r"[^A-Za-z0-9_.-]")
def _safe_event_resource(uid: str) -> str:
"""Nombre del recurso ``.ics`` de un UID (igual que caldav_put_event)."""
return _UNSAFE_RESOURCE_RE.sub("_", uid)[:120] + ".ics"
class EventIn(BaseModel):
"""Cuerpo de POST/PUT de un evento del calendario (VEVENT).
Las fechas se aceptan en ISO local sin offset (``2026-06-15T10:00``) + un
``tz`` (TZID, p.ej. ``Europe/Madrid``); o con offset/``Z`` ya incluido. El
servidor las normaliza al construir el VEVENT (``DTSTART;TZID=...`` o
``...Z``). Para eventos de todo el día basta ``dtstart`` = ``2026-06-15`` con
``all_day=True``.
"""
cal: Optional[str] = None
summary: str
dtstart: str
dtend: Optional[str] = None
tz: Optional[str] = Field(default="Europe/Madrid")
all_day: bool = False
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
def _parse_iso_input(value: str) -> Optional[dict]:
"""Parsea una fecha de entrada ISO a ``{year,month,day,hour,minute,second,
offset,date_only}`` o ``None``.
Acepta ``2026-06-15``, ``2026-06-15T10:00``, ``2026-06-15T10:00:00`` y
variantes con ``Z`` o ``±HH:MM`` al final. Es tolerante a la separación con
espacio en vez de ``T``.
"""
value = (value or "").strip()
m = _ISO_DT_RE.match(value)
if not m:
return None
has_time = m.group(4) is not None
return {
"year": int(m.group(1)),
"month": int(m.group(2)),
"day": int(m.group(3)),
"hour": int(m.group(4)) if has_time else 0,
"minute": int(m.group(5)) if has_time else 0,
"second": int(m.group(6)) if (has_time and m.group(6)) else 0,
"offset": m.group(7),
"date_only": not has_time,
}
def _ical_dt_property(prop: str, value: str, tz: Optional[str], all_day: bool) -> str:
"""Construye una línea DTSTART/DTEND iCal a partir de una fecha de entrada.
- all_day → ``DTSTART;VALUE=DATE:YYYYMMDD``.
- con offset/``Z`` en la entrada → convierte a UTC: ``DTSTART:...Z``.
- con ``tz`` válido → ``DTSTART;TZID=<tz>:YYYYMMDDTHHMMSS`` (hora local del
tz, el VTIMEZONE lo aporta el VCALENDAR).
- sin tz ni offset → hora flotante ``DTSTART:YYYYMMDDTHHMMSS``.
Raises:
ValueError: si ``value`` no es una fecha ISO reconocible.
"""
p = _parse_iso_input(value)
if p is None:
raise ValueError("fecha inválida: %r (usa ISO YYYY-MM-DD[THH:MM])" % value)
ymd = "%04d%02d%02d" % (p["year"], p["month"], p["day"])
if all_day or p["date_only"]:
return "%s;VALUE=DATE:%s" % (prop, ymd)
hms = "%02d%02d%02d" % (p["hour"], p["minute"], p["second"])
if p["offset"]:
# La entrada ya trae offset/Z: la pasamos a UTC absoluto.
dt = datetime.fromisoformat(
"%04d-%02d-%02dT%02d:%02d:%02d%s"
% (
p["year"],
p["month"],
p["day"],
p["hour"],
p["minute"],
p["second"],
_normalize_offset(p["offset"]),
)
)
dt_utc = dt.astimezone(timezone.utc)
return "%s:%s" % (prop, dt_utc.strftime("%Y%m%dT%H%M%SZ"))
if tz and _zoneinfo(tz) is not None:
return "%s;TZID=%s:%sT%s" % (prop, tz, ymd, hms)
# Hora flotante (sin tz reconocible): la escribimos sin Z.
return "%s:%sT%s" % (prop, ymd, hms)
def _normalize_offset(offset: str) -> str:
"""Normaliza un offset ISO a la forma ``±HH:MM`` que entiende fromisoformat."""
if offset == "Z":
return "+00:00"
if len(offset) == 5 and ":" not in offset: # ±HHMM
return offset[:3] + ":" + offset[3:]
return offset
def _vtimezone_block(tz: str) -> str:
"""Bloque VTIMEZONE mínimo para un TZID, con el offset estándar y de verano.
Calcula los offsets reales del tz para enero (estándar) y julio (verano) del
año actual con ``zoneinfo`` y emite un VTIMEZONE con ambas observancias. Es
una aproximación suficiente para que el cliente (y Xandikos) resuelvan la
hora local; no reproduce las reglas RRULE exactas. Devuelve cadena vacía si
el tz es UTC, desconocido, o no hace falta (el evento se sirve igual sin él).
"""
zone = _zoneinfo(tz)
if zone is None or tz.upper() == "UTC":
return ""
year = datetime.now().year
jan = datetime(year, 1, 15, 12, tzinfo=zone)
jul = datetime(year, 7, 15, 12, tzinfo=zone)
std_off = jan.utcoffset() or timedelta(0)
dst_off = jul.utcoffset() or timedelta(0)
def _fmt(off: timedelta) -> str:
total = int(off.total_seconds())
sign = "+" if total >= 0 else "-"
total = abs(total)
return "%s%02d%02d" % (sign, total // 3600, (total % 3600) // 60)
lines = ["BEGIN:VTIMEZONE", "TZID:%s" % tz]
if dst_off != std_off:
# Tiene horario de verano: dos observancias (estándar + verano).
lines += [
"BEGIN:STANDARD",
"DTSTART:19701025T030000",
"TZOFFSETFROM:%s" % _fmt(dst_off),
"TZOFFSETTO:%s" % _fmt(std_off),
"END:STANDARD",
"BEGIN:DAYLIGHT",
"DTSTART:19700329T020000",
"TZOFFSETFROM:%s" % _fmt(std_off),
"TZOFFSETTO:%s" % _fmt(dst_off),
"END:DAYLIGHT",
]
else:
lines += [
"BEGIN:STANDARD",
"DTSTART:19700101T000000",
"TZOFFSETFROM:%s" % _fmt(std_off),
"TZOFFSETTO:%s" % _fmt(std_off),
"END:STANDARD",
]
lines.append("END:VTIMEZONE")
return "\r\n".join(lines)
def _build_vcalendar(data: "EventIn", uid: str) -> str:
"""Serializa un ``EventIn`` a un VCALENDAR 2.0 con un VEVENT y su UID.
Construye DTSTART/DTEND respetando ``tz``/``all_day``/offset (ver
``_ical_dt_property``), añade un VTIMEZONE si el evento usa un TZID con
horario de verano, y mapea summary/location/description/color. El UID se
reutiliza al editar → idempotente (el recurso ``<uid>.ics`` se sobrescribe).
Raises:
ValueError: si ``dtstart`` no es una fecha ISO reconocible.
"""
dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
tz = data.tz or ""
body = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//osint_web//calendar//ES",
"CALSCALE:GREGORIAN",
]
if not data.all_day and tz and _zoneinfo(tz) is not None:
vtz = _vtimezone_block(tz)
if vtz:
body.append(vtz)
vevent = [
"BEGIN:VEVENT",
"UID:%s" % uid,
"DTSTAMP:%s" % dtstamp,
_ical_dt_property("DTSTART", data.dtstart, tz, data.all_day),
]
if data.dtend:
vevent.append(_ical_dt_property("DTEND", data.dtend, tz, data.all_day))
vevent.append("SUMMARY:%s" % _vcard_escape(data.summary.strip()))
if data.location and data.location.strip():
vevent.append("LOCATION:%s" % _vcard_escape(data.location.strip()))
if data.description and data.description.strip():
vevent.append("DESCRIPTION:%s" % _vcard_escape(data.description.strip()))
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())
vevent.append("END:VEVENT")
body.append("\r\n".join(vevent))
body.append("END:VCALENDAR")
return "\r\n".join(body) + "\r\n"
# ---------------------------------------------------------------------------
# Construcción de la app FastAPI
# ---------------------------------------------------------------------------
@@ -1369,21 +1876,44 @@ def create_app(vault_dir: str) -> FastAPI:
# -- Xandikos: calendario (CalDAV) --
@app.get("/api/calendars")
def api_calendars() -> JSONResponse:
"""Colecciones de calendario bajo el calendar-home, con nombre y color.
Cada una: ``{href, name, color}``. Alimenta el selector de calendario del
frontend. 503 con JSON de error si Xandikos no responde.
"""
try:
calendars = state.list_calendars()
except (RuntimeError, DavUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(
content={
"status": "ok",
"count": len(calendars),
"calendars": calendars,
"default": XANDIKOS_CALENDAR_COLLECTION,
}
)
@app.get("/api/calendar")
def api_calendar(
cal: str = Query("", description="colección de calendario (ruta o nombre)"),
from_: str = Query("", alias="from", description="fecha inicio YYYY-MM-DD"),
to: str = Query("", description="fecha fin YYYY-MM-DD"),
) -> JSONResponse:
"""Eventos del calendario Xandikos en ``[from, to]`` (cacheados).
"""Eventos de una colección del calendario Xandikos en ``[from, to]``.
Cada evento: ``{uid, summary, dtstart, dtend, location, description}``.
La descarga + parseo completos se cachean (``POST /api/refresh`` los
invalida); el filtro por rango se aplica sobre la caché. Sin ``from``/
``to`` devuelve todos. Si Xandikos no responde o falta la password →
503 con JSON de error claro, nunca un crash.
Cada evento: ``{uid, summary, dtstart, dtend, tz, all_day, location,
description, color}`` (dtstart/dtend en ISO con offset). ``cal`` elige la
colección (default la actual). La descarga + parseo se cachean
(``POST /api/refresh`` invalida); el filtro por rango va sobre la caché.
Si Xandikos no responde → 503 con JSON de error claro, nunca un crash.
"""
try:
events = state.calendar(from_, to)
events = state.calendar(cal, from_, to)
except (RuntimeError, DavUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
@@ -1392,6 +1922,57 @@ def create_app(vault_dir: str) -> FastAPI:
content={"status": "ok", "count": len(events), "events": events}
)
# -- Calendario: CRUD de eventos (VEVENT) --
@app.post("/api/event")
def api_create_event(data: EventIn = Body(...)) -> JSONResponse:
"""Crea un VEVENT en una colección de calendario (PUT de un VCALENDAR).
Body: ``{cal?, summary, dtstart, dtend?, tz?, all_day?, location?,
description?, color?}``. Genera el UID. 400 si la fecha es inválida; 503
si Xandikos rechaza el evento. Devuelve ``{uid, cal}``.
"""
try:
result = state.create_event(data)
except DavUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(status_code=201, content={"status": "ok", **result})
@app.put("/api/event/{uid}")
def api_update_event(uid: str, data: EventIn = Body(...)) -> JSONResponse:
"""Edita un VEVENT existente (reescribe ``<uid>.ics``).
Reutiliza el UID. 400 si la fecha es inválida; 503 si Xandikos rechaza.
Devuelve ``{uid, cal}``.
"""
try:
result = state.update_event(uid, data)
except DavUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(content={"status": "ok", **result})
@app.delete("/api/event/{uid}")
def api_delete_event(
uid: str,
cal: str = Query("", description="colección de calendario (ruta o nombre)"),
) -> JSONResponse:
"""Borra un VEVENT (``<uid>.ics``) de una colección de calendario.
404 de Xandikos se trata como idempotente. 503 si falla por otra causa.
Devuelve ``{uid, deleted}``.
"""
try:
result = state.delete_event(uid, cal)
except DavUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(content={"status": "ok", **result})
# -- Refresco de cachés --
@app.post("/api/refresh")
+272 -1
View File
@@ -230,7 +230,12 @@ def test_vevent_to_json_and_range():
assert len(events) == 1
evt = events[0]
assert evt["summary"] == "Reunión OSINT"
assert evt["dtstart"].startswith("20260615")
# Contrato nuevo: dtstart en ISO con offset (UTC -> +00:00) + tz original.
assert evt["dtstart"] == "2026-06-15T09:00:00+00:00"
assert evt["tz"] == "UTC"
assert evt["all_day"] is False
# dtstart_ical conserva el prefijo crudo para el filtro de rango.
assert evt["dtstart_ical"] == "20260615"
assert srv._event_in_range(evt, "20260601", "20260630") is True
assert srv._event_in_range(evt, "20260101", "20260131") is False
@@ -604,6 +609,272 @@ def test_crud_update_preserves_inherited_fields(crud_client, vault):
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
# ---------------------------------------------------------------------------
def test_vevent_tzid_se_normaliza_a_iso_con_offset():
"""Un DTSTART;TZID=Europe/Madrid sale como ISO con el offset de Madrid."""
vcal = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:tz-1\r\n"
"SUMMARY:Con zona\r\n"
"DTSTART;TZID=Europe/Madrid:20260615T100000\r\n"
"DTEND;TZID=Europe/Madrid:20260615T110000\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)
evt = srv._vcalendar_to_events(vcal)[0]
# Junio: Madrid está en CEST (+02:00).
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
assert evt["dtend"] == "2026-06-15T11:00:00+02:00"
assert evt["tz"] == "Europe/Madrid"
assert evt["all_day"] is False
def test_vevent_all_day_value_date():
"""Un DTSTART;VALUE=DATE:YYYYMMDD se marca all_day y sale como fecha ISO."""
vcal = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:ad-1\r\n"
"SUMMARY:Todo el día\r\nDTSTART;VALUE=DATE:20260615\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)
evt = srv._vcalendar_to_events(vcal)[0]
assert evt["all_day"] is True
assert evt["dtstart"] == "2026-06-15"
assert evt["dtstart_ical"] == "20260615"
def test_build_vcalendar_tzid():
"""El builder emite DTSTART;TZID + un VTIMEZONE para una zona con DST."""
data = srv.EventIn(
cal="",
summary="Reunión, con coma",
dtstart="2026-06-15T10:00",
dtend="2026-06-15T11:00",
tz="Europe/Madrid",
all_day=False,
location="Oficina; sala 2",
)
vcal = srv._build_vcalendar(data, "uid-abc")
assert "BEGIN:VEVENT" in vcal
assert "UID:uid-abc" in vcal
assert "DTSTART;TZID=Europe/Madrid:20260615T100000" in vcal
assert "DTEND;TZID=Europe/Madrid:20260615T110000" in vcal
assert "BEGIN:VTIMEZONE" in vcal and "TZID:Europe/Madrid" in vcal
# El summary/location se escapan (coma y punto y coma).
assert "SUMMARY:Reunión\\, con coma" in vcal
assert "LOCATION:Oficina\\; sala 2" in vcal
# Round-trip: parsear lo construido reproduce la hora local de Madrid.
evt = srv._vcalendar_to_events(vcal)[0]
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
assert evt["tz"] == "Europe/Madrid"
def test_build_vcalendar_all_day():
data = srv.EventIn(summary="Festivo", dtstart="2026-06-15", all_day=True)
vcal = srv._build_vcalendar(data, "uid-ad")
assert "DTSTART;VALUE=DATE:20260615" in vcal
assert "BEGIN:VTIMEZONE" not in vcal # all-day no necesita VTIMEZONE
def test_build_vcalendar_con_offset_va_a_utc():
"""Una entrada con offset explícito se convierte a UTC (...Z)."""
data = srv.EventIn(
summary="Con offset", dtstart="2026-06-15T10:00:00+02:00", tz="Europe/Madrid"
)
vcal = srv._build_vcalendar(data, "uid-off")
# 10:00+02:00 == 08:00 UTC.
assert "DTSTART:20260615T080000Z" in vcal
def test_build_vcalendar_fecha_invalida_lanza():
data = srv.EventIn(summary="Mala", dtstart="no-es-fecha")
with pytest.raises(ValueError):
srv._build_vcalendar(data, "uid-x")
def test_safe_event_resource_coincide_con_caldav_put():
"""El nombre .ics del DELETE coincide con el que deriva caldav_put_event."""
uid = "abc/def:ghi 123"
assert srv._safe_event_resource(uid) == "abc_def_ghi_123.ics"
# ---------------------------------------------------------------------------
# Calendario: listado de colecciones + CRUD de eventos (DAV mockeado)
# ---------------------------------------------------------------------------
@pytest.fixture()
def cal_client(vault, monkeypatch, tmp_path):
"""Cliente con el PUT/DELETE/list/get de CalDAV mockeado (CRUD sin red).
Registra los PUT/DELETE intentados y simula un servidor con una colección de
calendario y un store de eventos en memoria (para que el GET tras crear vea
el evento). ``calls`` expone lo que el server intentó.
"""
calls = {"put": [], "delete": []}
# Store en memoria: resource_name -> VCALENDAR text.
store = {}
ctag = {"v": "cal-v1"}
def _put_event(base, user, pw, coll, uid, vcal, **kw):
calls["put"].append({"uid": uid, "vcal": vcal, "coll": coll})
store[srv._safe_event_resource(uid)] = vcal
ctag["v"] = "cal-" + str(len(calls["put"]) + len(calls["delete"]))
return {"status": "ok", "http_status": 201, "url": coll + uid + ".ics"}
def _delete(base, user, pw, resource_path, **kw):
calls["delete"].append(resource_path)
name = resource_path.rstrip("/").rsplit("/", 1)[-1]
existed = store.pop(name, None)
ctag["v"] = "cal-" + str(len(calls["put"]) + len(calls["delete"]))
if existed is None:
return {"status": "error", "http_status": 404, "error": "http 404"}
return {"status": "ok", "http_status": 204, "url": resource_path}
def _get_collection(base, user, pw, collection, content_type="ical", **kw):
res = [
{"href": collection + name, "etag": '"%s"' % name, "data": data}
for name, data in store.items()
]
return {"status": "ok", "http_status": 207, "resources": res}
def _ctag(base, user, pw, collection, **kw):
return {"status": "ok", "http_status": 207, "ctag": ctag["v"]}
def _list_calendars(base, user, pw, home, **kw):
return {
"status": "ok",
"http_status": 207,
"calendars": [
{
"href": "/enmanuel/calendars/calendar/",
"name": "calendar",
"color": None,
},
{
"href": "/enmanuel/calendars/trabajo/",
"name": "Trabajo",
"color": "#FF2968FF",
},
],
}
monkeypatch.setattr(srv, "caldav_put_event", _put_event)
monkeypatch.setattr(srv, "dav_delete_resource", _delete)
monkeypatch.setattr(srv, "dav_get_collection", _get_collection)
monkeypatch.setattr(srv, "dav_collection_ctag", _ctag)
monkeypatch.setattr(srv, "dav_list_calendars", _list_calendars)
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
cache_dir = tmp_path / "cal_cache"
monkeypatch.setattr(srv, "_CALENDAR_CACHE_FILE", str(cache_dir / "calendar.json"))
monkeypatch.setattr(srv, "_CACHE_DIR", str(cache_dir))
app = srv.create_app(vault)
client = TestClient(app)
client._cal_calls = calls # type: ignore[attr-defined]
return client
def test_calendars_endpoint_lista_con_color(cal_client):
r = cal_client.get("/api/calendars")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok" and data["count"] == 2
by_name = {c["name"]: c for c in data["calendars"]}
assert by_name["Trabajo"]["color"] == "#FF2968FF"
assert by_name["calendar"]["color"] is None
def test_event_crud_full_cycle(cal_client):
"""Golden: crear → leer → editar → borrar un evento (VEVENT sobre CalDAV)."""
calls = cal_client._cal_calls
# -- CREATE --
body = {
"summary": "ZZ Test Event",
"dtstart": "2026-06-15T10:00",
"dtend": "2026-06-15T11:00",
"tz": "Europe/Madrid",
"location": "Sala",
"description": "Prueba CRUD",
}
r = cal_client.post("/api/event", json=body)
assert r.status_code == 201, r.text
uid = r.json()["uid"]
assert uid
assert calls["put"], "debió hacer PUT del VEVENT"
assert "SUMMARY:ZZ Test Event" in calls["put"][-1]["vcal"]
assert "DTSTART;TZID=Europe/Madrid:20260615T100000" in calls["put"][-1]["vcal"]
# -- READ (aparece en /api/calendar) --
cal_client.post("/api/refresh") # fuerza recarga desde el store mockeado
g = cal_client.get("/api/calendar", params={"from": "2026-06-01", "to": "2026-06-30"})
assert g.status_code == 200
uids = [e["uid"] for e in g.json()["events"]]
assert uid in uids
evt = next(e for e in g.json()["events"] if e["uid"] == uid)
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
assert evt["tz"] == "Europe/Madrid"
# -- UPDATE --
body2 = dict(body)
body2["summary"] = "ZZ Test Event (editado)"
body2["dtstart"] = "2026-06-15T12:00"
ur = cal_client.put("/api/event/%s" % uid, json=body2)
assert ur.status_code == 200, ur.text
assert "SUMMARY:ZZ Test Event (editado)" in calls["put"][-1]["vcal"]
assert "20260615T120000" in calls["put"][-1]["vcal"]
# El UID se reutiliza (idempotente): mismo recurso.
assert calls["put"][-1]["uid"] == uid
# -- DELETE --
dr = cal_client.delete("/api/event/%s" % uid)
assert dr.status_code == 200, dr.text
assert dr.json()["deleted"] is True
assert any(uid in p for p in calls["delete"])
# Tras borrar, ya no aparece.
cal_client.post("/api/refresh")
g2 = cal_client.get("/api/calendar")
assert uid not in [e["uid"] for e in g2.json()["events"]]
def test_event_create_fecha_invalida_400(cal_client):
r = cal_client.post(
"/api/event", json={"summary": "X", "dtstart": "no-fecha"}
)
assert r.status_code == 400
def test_event_create_summary_vacio_400(cal_client):
r = cal_client.post(
"/api/event", json={"summary": " ", "dtstart": "2026-06-15T10:00"}
)
assert r.status_code == 400
def test_event_delete_idempotente_404_es_ok(cal_client):
"""Borrar un evento inexistente NO es error: Xandikos 404 → idempotente."""
r = cal_client.delete("/api/event/no-existe-uid")
assert r.status_code == 200
assert r.json()["deleted"] is True
def test_calendar_endpoint_degrada_sin_red(client, monkeypatch):
"""Sin Xandikos, /api/calendars y /api/calendar devuelven 503 claro."""
monkeypatch.setattr(
srv, "dav_list_calendars", lambda *a, **k: {"status": "error", "error": "sin red"}
)
monkeypatch.setattr(
srv, "dav_get_collection", lambda *a, **k: {"status": "error", "error": "sin red"}
)
monkeypatch.setattr(
srv, "dav_collection_ctag", lambda *a, **k: {"status": "error", "error": "sin red"}
)
client.app.state.vault._xandikos_password = "x"
assert client.get("/api/calendars").status_code == 503
assert client.get("/api/calendar").status_code == 503
# ---------------------------------------------------------------------------
# Smoke real opcional contra Xandikos (gateado, no corre en CI)
# ---------------------------------------------------------------------------