feat(calendar): vista mes/semana/día, TZ, selector de calendario, colores y CRUD de eventos

Backend (server/main.py):
- GET /api/calendars: lista las colecciones de calendario bajo el calendar-home
  con nombre y color (compone dav_list_calendars del registry).
- GET /api/calendar?cal=&from=&to=: eventos de una colección concreta (caché por
  colección validada por ctag). dtstart/dtend ahora en ISO con offset + tz
  original + all_day; parseo robusto de TZID/UTC/todo-el-día con zoneinfo.
- POST/PUT/DELETE /api/event[/<uid>]: CRUD de VEVENT contra Xandikos (fuente de
  verdad). Construye el VCALENDAR (con VTIMEZONE para zonas con DST), reutiliza el
  UID al editar (idempotente), trata 404 del DELETE como idempotente, invalida la
  caché de la colección tras escribir.

Frontend:
- CalendarView reescrita: conmutador Mes/Semana/Día con rejilla horaria propia
  (Mantine + dayjs, sin react-big-calendar para evitar fricción con React 19),
  mini-calendario de navegación, selector de calendario (con color), selector de
  zona horaria que recoloca los eventos, colores por evento (del VEVENT o del
  calendario).
- EventModal: alta/edición/borrado con summary, inicio/fin, todo-el-día, TZ,
  calendario, color, ubicación y descripción. Fechas en formato local 24h.
- calendar.ts: helpers de TZ (dayjs utc+timezone), posicionado por hora, semana
  empezando en lunes, locale es. api.ts: tipos y funciones de eventos/calendarios.

Verificado: ciclo real crear→editar→borrar contra Xandikos (cero residuo),
render del calendario en navegador (React 19 + Mantine v9 montan), pnpm build
verde, 40 tests verdes (+ smoke gateado). MKCALENDAR queda fuera (documentado).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-12 00:40:59 +02:00
parent 43889bfc07
commit e792bc6e17
8 changed files with 2000 additions and 179 deletions
+36 -13
View File
@@ -2,8 +2,8 @@
name: osint_web name: osint_web
lang: py lang: py
domain: osint domain: osint
version: 0.1.0 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) 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)." 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] tags: [osint, web, sigma, graph, mantine, dav, obsidian, vault, dashboard]
uses_functions: uses_functions:
- build_obsidian_graph_py_obsidian - build_obsidian_graph_py_obsidian
@@ -18,7 +18,9 @@ uses_functions:
- search_obsidian_notes_py_obsidian - search_obsidian_notes_py_obsidian
- dav_get_collection_py_infra - dav_get_collection_py_infra
- dav_collection_ctag_py_infra - dav_collection_ctag_py_infra
- dav_list_calendars_py_infra
- carddav_put_vcard_py_infra - carddav_put_vcard_py_infra
- caldav_put_event_py_infra
- dav_delete_resource_py_infra - dav_delete_resource_py_infra
- split_vcards_py_infra - split_vcards_py_infra
- pass_get_secret_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 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. 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 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`, funciones del grupo `obsidian` (`build_obsidian_graph`, `read_obsidian_note`,
`create_obsidian_note`, `update_obsidian_note`, `delete_obsidian_note`, `create_obsidian_note`, `update_obsidian_note`, `delete_obsidian_note`,
`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_get_collection`, `resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_get_collection`,
`dav_collection_ctag`, `carddav_put_vcard`, `dav_delete_resource`, `dav_collection_ctag`, `dav_list_calendars`, `carddav_put_vcard`,
`split_vcards`) más `pass_get_secret` para la credencial, todas declaradas en `caldav_put_event`, `dav_delete_resource`, `split_vcards`) más `pass_get_secret`
`uses_functions`. 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 ## 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/search?q=...` | nodos cuyo contenido matchea la query |
| GET | `/api/contacts` | contactos del addressbook Xandikos (CardDAV) a JSON | | GET | `/api/contacts` | contactos del addressbook Xandikos (CardDAV) a JSON |
| GET | `/api/contact/<uid>` | un vCard concreto 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é | | POST | `/api/refresh` | re-escanea el vault y reconstruye la caché |
## Configuración Xandikos ## Configuración Xandikos
@@ -134,12 +150,19 @@ parseo vCard/iCalendar a JSON y degradación de los endpoints DAV sin red.
## Estado / pendiente ## Estado / pendiente
- **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) con - **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) +
13 tests verdes. frontend React/Vite/Mantine v9 (GraphView, TablesView, ContactsView,
- **Pendiente (siguiente agente)**: `frontend/` React + Vite + Mantine v9 + CalendarView). Suite backend verde (40 tests + 1 smoke gateado).
`@fn_library` con sigma.js + graphology (GraphView, TablesView, NodeCard, - **Calendario (v0.2.0)**: vista mes/semana/día, selector de calendario (con
ContactsView, CalendarView). Onboarding previsto: backend en 8470 + color), selector de zona horaria, colores por evento y CRUD completo de
`pnpm dev` en `frontend/` → abrir `http://127.0.0.1:5173`. Ver eventos (crear en un hueco, editar/borrar). Backend con parseo robusto de
`frontend/README.md`. 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 - Cuando exista el manifest de sub-repos del project (issue 0171), añadir esta
app a `projects/osint/subrepos.yaml`. app a `projects/osint/subrepos.yaml`.
+60 -1
View File
@@ -123,10 +123,15 @@ export interface ContactsPayload {
export interface CalendarEvent { export interface CalendarEvent {
uid: string | null; uid: string | null;
summary: 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; dtstart: string | null;
dtend: string | null; dtend: string | null;
tz?: string | null;
all_day?: boolean;
location: string | null; location: string | null;
description: string | null; description: string | null;
color?: string | null;
href?: string; href?: string;
etag?: string; etag?: string;
} }
@@ -138,6 +143,41 @@ export interface CalendarPayload {
error?: string; 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 ------------------------------------------------------------ // --- Endpoints ------------------------------------------------------------
export const fetchGraph = () => getJSON<GraphPayload>("/graph"); export const fetchGraph = () => getJSON<GraphPayload>("/graph");
@@ -216,14 +256,33 @@ export const deleteContact = (slug: string) =>
"DELETE", "DELETE",
); );
export const fetchCalendar = (from = "", to = "") => { export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
export const fetchCalendar = (cal = "", from = "", to = "") => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (cal) qs.set("cal", cal);
if (from) qs.set("from", from); if (from) qs.set("from", from);
if (to) qs.set("to", to); if (to) qs.set("to", to);
const tail = qs.toString(); const tail = qs.toString();
return getJSON<CalendarPayload>(`/calendar${tail ? `?${tail}` : ""}`); 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 = () => export const refresh = () =>
fetch(`${BASE}/refresh`, { method: "POST" }).then((r) => { fetch(`${BASE}/refresh`, { method: "POST" }).then((r) => {
if (!r.ok) throw new Error(`refresh falló: HTTP ${r.status}`); 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 { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import "dayjs/locale/es";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
@@ -12,8 +14,10 @@ import { App } from "./App";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark"> <MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications position="top-right" /> <DatesProvider settings={{ locale: "es", firstDayOfWeek: 1 }}>
<App /> <Notifications position="top-right" />
<App />
</DatesProvider>
</MantineProvider> </MantineProvider>
</StrictMode>, </StrictMode>,
); );
+600 -126
View File
@@ -1,45 +1,99 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ActionIcon,
Alert, Alert,
Badge, Badge,
Box, Box,
Button,
Center, Center,
ColorSwatch,
Group, Group,
Indicator, Indicator,
Loader, Loader,
Paper, Paper,
ScrollArea, ScrollArea,
SegmentedControl,
Select,
Stack, Stack,
Text, Text,
Title, Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { Calendar } from "@mantine/dates"; import { Calendar } from "@mantine/dates";
import dayjs from "dayjs"; import { notifications } from "@mantine/notifications";
import { IconClock, IconMapPin } from "@tabler/icons-react";
import { fetchCalendar, type CalendarEvent } from "../api";
import { import {
dayLabel, IconChevronLeft,
formatICalTime, IconChevronRight,
icalDayKey, IconPlus,
} from "../format"; } 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 type ViewMode = "mes" | "semana" | "dia";
// 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. // 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() { export function CalendarView() {
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [activeCal, setActiveCal] = useState<string>("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Mantine v9 Calendar usa fechas como string "YYYY-MM-DD" (DateStringValue), const [view, setView] = useState<ViewMode>("semana");
// no Date. `month` controla el mes mostrado; `selectedDay` filtra a un día. const [tz, setTz] = useState<string>(browserTz());
const [month, setMonth] = useState<string>(dayjs().format("YYYY-MM-DD")); // `cursor` es el día de referencia (la vista lo expande a su mes/semana/día).
const [selectedDay, setSelectedDay] = useState<string | null>(null); 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(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
fetchCalendar() setError(null);
fetchCalendar(activeCal)
.then((d) => { .then((d) => {
if (!alive) return; if (!alive) return;
if (d.status !== "ok") { if (d.status !== "ok") {
@@ -53,33 +107,114 @@ export function CalendarView() {
return () => { return () => {
alive = false; alive = false;
}; };
}, []); }, [activeCal, reloadKey]);
// Eventos indexados por día local "AAAA-MM-DD". const calColor = useMemo(() => {
const byDay = useMemo(() => { const c = calendars.find((c) => c.href === activeCal);
const map = new Map<string, CalendarEvent[]>(); 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) { for (const e of events) {
const key = icalDayKey(e.dtstart); const s = eventStart(e, tz);
if (!key) continue; if (s) set.add(s.format("YYYY-MM-DD"));
const list = map.get(key) ?? [];
list.push(e);
map.set(key, list);
} }
return map; return set;
}, [events]); }, [events, tz]);
// Días visibles: si hay día seleccionado, solo ese; si no, todos los del mes const reload = useCallback(() => setReloadKey((k) => k + 1), []);
// mostrado, ordenados.
const visibleDays = useMemo(() => { const onSaved = useCallback(
const monthPrefix = month.slice(0, 7); // "YYYY-MM" (msg: string) => {
let keys = [...byDay.keys()]; notifications.show({ color: "teal", title: "Calendario", message: msg });
if (selectedDay) { setModal(null);
keys = keys.filter((k) => k === selectedDay); reload();
} else { },
keys = keys.filter((k) => k.startsWith(monthPrefix)); [reload],
} );
return keys.sort();
}, [byDay, month, selectedDay]); 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) { if (error) {
return ( return (
@@ -95,126 +230,465 @@ export function CalendarView() {
); );
} }
if (loading) { const headerTitle =
return ( view === "mes"
<Center h="100%"> ? dayjs(cursor).format("MMMM YYYY")
<Loader /> : view === "semana"
</Center> ? (() => {
); 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 ( return (
<Group h="100%" gap={0} wrap="nowrap" align="stretch"> <Group h="100%" gap={0} wrap="nowrap" align="stretch">
{/* Panel izquierdo: navegación + selectores */}
<Paper <Paper
p="md" p="md"
radius={0} radius={0}
withBorder withBorder
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0 }} style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, width: 280 }}
> >
<Stack gap="md"> <Stack gap="md">
<Button
leftSection={<IconPlus size={16} />}
onClick={() => openNew(cursor)}
fullWidth
>
Nuevo evento
</Button>
<Calendar <Calendar
date={month} date={cursor}
onDateChange={setMonth} onDateChange={(d) => setCursor(d)}
getDayProps={(date) => ({ getDayProps={(date) => ({
selected: selectedDay === date, selected: cursor === date,
onClick: () => onClick: () => {
setSelectedDay((prev) => (prev === date ? null : date)), setCursor(date);
if (view === "mes") setView("dia");
},
})} })}
renderDay={(date) => { renderDay={(date) => {
const has = byDay.has(date); const has = daysWithEvents.has(date);
const day = Number(date.slice(8, 10)); const day = Number(date.slice(8, 10));
return ( return (
<Indicator <Indicator size={6} color="brand" offset={-2} disabled={!has}>
size={6}
color="brand"
offset={-2}
disabled={!has}
>
<div>{day}</div> <div>{day}</div>
</Indicator> </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"> <Text size="xs" c="dimmed">
{events.length} eventos en total {events.length} eventos · TZ {tz}
</Text> </Text>
{selectedDay && (
<Badge
variant="light"
style={{ cursor: "pointer" }}
onClick={() => setSelectedDay(null)}
>
Ver todo el mes
</Badge>
)}
</Stack> </Stack>
</Paper> </Paper>
<Box style={{ flex: 1, minWidth: 0 }}> {/* Cuerpo: barra de vista + rejilla */}
<ScrollArea h="100%"> <Box style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<Stack p="xl" gap="lg" maw={760}> <Group justify="space-between" p="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
<Title order={3}> <Group gap="xs" wrap="nowrap">
{selectedDay <Tooltip label="Anterior">
? dayLabel(selectedDay) <ActionIcon variant="default" onClick={() => navigate(-1)}>
: dayjs(month).format("MMMM YYYY")} <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> </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 && ( <Box style={{ flex: 1, minHeight: 0 }}>
<Text c="dimmed">Sin eventos en este periodo.</Text> {loading ? (
)} <Center h="100%">
<Loader />
{visibleDays.map((day) => ( </Center>
<Stack key={day} gap="xs"> ) : view === "mes" ? (
{!selectedDay && ( <MonthView
<Text fw={600} size="sm" c="brand"> cursor={cursor}
{dayLabel(day)} events={events}
</Text> tz={tz}
)} calColor={calColor}
{(byDay.get(day) ?? []) onDay={(d) => {
.sort((a, b) => setCursor(d);
(a.dtstart ?? "").localeCompare(b.dtstart ?? ""), setView("dia");
) }}
.map((ev, i) => ( onEvent={openEdit}
<EventRow key={(ev.uid ?? "") + i} ev={ev} /> />
))} ) : view === "semana" ? (
</Stack> <TimeGrid
))} days={weekDays(dayjs(cursor))}
</Stack> events={events}
</ScrollArea> tz={tz}
calColor={calColor}
onSlot={openNew}
onEvent={openEdit}
/>
) : (
<TimeGrid
days={[dayjs(cursor)]}
events={events}
tz={tz}
calColor={calColor}
onSlot={openNew}
onEvent={openEdit}
/>
)}
</Box>
</Box> </Box>
<EventModal
draft={modal}
calendars={calendars}
onClose={() => setModal(null)}
onSaved={onSaved}
onDelete={onDelete}
/>
</Group> </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 ( return (
<Paper withBorder p="sm" radius="md"> <ScrollArea h="100%" type="auto">
<Group justify="space-between" wrap="nowrap" align="flex-start"> <Box style={{ display: "flex", minWidth: days.length > 1 ? 640 : 0 }}>
<Box style={{ minWidth: 0 }}> {/* Columna de horas */}
<Text fw={600} size="sm"> <Box style={{ width: 52, flexShrink: 0 }}>
{ev.summary || "(sin título)"} <Box style={{ height: 28 }} />
</Text> {HOURS.map((h) => (
{ev.location && ( <Box
<Group gap={4} mt={2}> key={h}
<IconMapPin size={13} /> style={{
<Text size="xs" c="dimmed"> height: HOUR_PX,
{ev.location} textAlign: "right",
</Text> paddingRight: 6,
</Group> fontSize: 11,
)} color: "var(--mantine-color-dimmed)",
{ev.description && ( transform: "translateY(-7px)",
<Text size="xs" c="dimmed" mt={4} lineClamp={2}> }}
{ev.description} >
</Text> {String(h).padStart(2, "0")}:00
)} </Box>
))}
</Box> </Box>
<Group gap={4} wrap="nowrap">
<IconClock size={13} /> {/* Una columna por día */}
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> {days.map((day) => {
{formatICalTime(ev.dtstart)} const dayKey = day.format("YYYY-MM-DD");
</Text> const positioned = positionEventsForDay(events, dayKey, tz);
</Group> const allDay = positioned.filter((p) => p.allDay);
</Group> const timed = positioned.filter((p) => !p.allDay);
</Paper> 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/search?q=... nodos cuyo contenido matchea la query
GET /api/contacts contactos del addressbook Xandikos (CardDAV) GET /api/contacts contactos del addressbook Xandikos (CardDAV)
GET /api/contact/<uid> un vCard concreto a JSON 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é POST /api/refresh re-escanea el vault y reconstruye la caché
""" """
@@ -47,8 +51,18 @@ import re
import sys import sys
import threading import threading
import time import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional 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: def _registry_functions_dir() -> str:
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados. """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. # 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") carddav_put_vcard = _load_infra_fn("carddav_put_vcard", "carddav_put_vcard")
dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource") 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_USERNAME = "enmanuel"
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel" XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/" 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/" XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
# Caché en disco de los datos DAV ya parseados, indexada por el ctag de la # 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). # junto al server y está gitignored (datos personales sensibles + regenerable).
_CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".cache") _CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".cache")
_CONTACTS_CACHE_FILE = os.path.join(_CACHE_DIR, "contacts.json") _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") _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. # Extensiones de imagen que el frontend muestra en la galería con lightbox.
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
@@ -292,9 +332,12 @@ class VaultState:
# validación de ctag en el siguiente acceso. # validación de ctag en el siguiente acceso.
self._dav_lock = threading.Lock() self._dav_lock = threading.Lock()
self._contacts_cache: Optional[list] = None 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._contacts_ctag: Optional[str] = None
self._calendar_ctag: Optional[str] = None
self._force_reload = False self._force_reload = False
self.refresh() self.refresh()
@@ -615,34 +658,188 @@ class VaultState:
self._maybe_clear_force_reload() self._maybe_clear_force_reload()
return contacts return contacts
def calendar(self, dt_from: str = "", dt_to: str = "") -> list: def _resolve_calendar(self, cal: str = "") -> str:
"""Eventos del calendario Xandikos, cacheados; filtrados por rango. """Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
Misma caché en dos niveles que ``contacts``. La descarga + parseo Acepta una ruta absoluta (``/enmanuel/calendars/calendar/``), el nombre
completos se cachean (UNA petición REPORT); el filtro por ``[from, to]`` corto de la colección (``calendar``), o vacío (→ colección por defecto).
se aplica sobre la caché en cada llamada (barato). Sin ``from``/``to`` Garantiza barras inicial/final. NO valida contra el servidor (eso lo hace
devuelve todos. 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: Raises:
RuntimeError: si no se puede leer la password de ``pass``. RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde (sin red, timeout, auth). DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
""" """
collection = self._resolve_calendar(cal)
with self._dav_lock: 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( events, ctag = self._load_collection(
XANDIKOS_CALENDAR_COLLECTION, collection,
"ical", "ical",
_CALENDAR_CACHE_FILE, _calendar_cache_file(collection),
self._parse_events, self._parse_events,
) )
self._calendar_cache = events self._calendar_cache[collection] = events
self._calendar_ctag = ctag self._calendar_ctag[collection] = ctag
self._maybe_clear_force_reload() self._maybe_clear_force_reload()
all_events = self._calendar_cache cached = events
all_events = list(cached)
if not dt_from and not dt_to: 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)] 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: def _maybe_clear_force_reload(self) -> None:
"""Apaga el flag de refresh forzado una vez consumido por una recarga. """Apaga el flag de refresh forzado una vez consumido por una recarga.
@@ -662,7 +859,9 @@ class VaultState:
""" """
with self._dav_lock: with self._dav_lock:
self._contacts_cache = None self._contacts_cache = None
self._calendar_cache = None self._calendar_cache = {}
self._calendar_ctag = {}
self._calendars_cache = None
self._force_reload = True self._force_reload = True
# --- Escritura de contactos: ficha .md (verdad) + reflejo en Xandikos ---- # --- 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) _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: def _vevent_to_json(vevent_block: str) -> dict:
"""Convierte un bloque VEVENT a un dict JSON con los campos de interés. """Convierte un bloque VEVENT a un dict JSON con los campos de interés.
Extrae: uid, summary, dtstart, dtend, location, description. Las fechas se Extrae: uid, summary, dtstart/dtend (ISO con offset cuando hay zona/UTC, o
devuelven tal cual vienen del servidor (formato iCal, ej. ``20260611T090000Z`` ``YYYY-MM-DD`` para todo el día), la TZ original (``tz``), ``all_day``,
o ``20260611``); el frontend las formatea a europeo. Parseo ligero a mano. 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 = { out: dict = {
"uid": None, "uid": None,
"summary": None, "summary": None,
"dtstart": None, "dtstart": None,
"dtend": None, "dtend": None,
"dtstart_ical": None,
"dtend_ical": None,
"tz": None,
"all_day": False,
"location": None, "location": None,
"description": None, "description": None,
"color": None,
} }
for line in _unfold_lines(vevent_block): for line in _unfold_lines(vevent_block):
parsed = _parse_property(line) parsed = _parse_property(line)
if not parsed: if not parsed:
continue continue
name, _params, value = parsed name, params, value = parsed
value = value.strip() value = value.strip()
if name == "UID": if name == "UID":
out["uid"] = value out["uid"] = value
elif name == "SUMMARY": elif name == "SUMMARY":
out["summary"] = _unescape_ical(value) out["summary"] = _unescape_ical(value)
elif name == "DTSTART": 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": 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": elif name == "LOCATION":
out["location"] = _unescape_ical(value) out["location"] = _unescape_ical(value)
elif name == "DESCRIPTION": elif name == "DESCRIPTION":
out["description"] = _unescape_ical(value) out["description"] = _unescape_ical(value)
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
out["color"] = value
return out 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: 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]``. """True si el evento cae (por DTSTART) dentro de ``[dt_from, dt_to]``.
Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` que comparten todos Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` (``dtstart_ical``);
los formatos iCal (date y date-time). Los límites se normalizan quitando los si falta, se deriva del ISO de ``dtstart``. Los límites se normalizan
guiones, así acepta tanto el formato documentado del endpoint quitando los guiones, así acepta tanto ``2026-06-11`` como ``20260611``.
(``2026-06-11``) como el iCal crudo (``20260611``). ``dt_from``/``dt_to`` ``dt_from``/``dt_to`` vacíos desactivan ese extremo del filtro.
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: if not dtstart:
return True return True
if dt_from and dtstart < dt_from.replace("-", "")[:8]: 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" 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 # Construcción de la app FastAPI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1369,21 +1876,44 @@ def create_app(vault_dir: str) -> FastAPI:
# -- Xandikos: calendario (CalDAV) -- # -- 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") @app.get("/api/calendar")
def 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"), from_: str = Query("", alias="from", description="fecha inicio YYYY-MM-DD"),
to: str = Query("", description="fecha fin YYYY-MM-DD"), to: str = Query("", description="fecha fin YYYY-MM-DD"),
) -> JSONResponse: ) -> 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}``. Cada evento: ``{uid, summary, dtstart, dtend, tz, all_day, location,
La descarga + parseo completos se cachean (``POST /api/refresh`` los description, color}`` (dtstart/dtend en ISO con offset). ``cal`` elige la
invalida); el filtro por rango se aplica sobre la caché. Sin ``from``/ colección (default la actual). La descarga + parseo se cachean
``to`` devuelve todos. Si Xandikos no responde o falta la password → (``POST /api/refresh`` invalida); el filtro por rango va sobre la caché.
503 con JSON de error claro, nunca un crash. Si Xandikos no responde → 503 con JSON de error claro, nunca un crash.
""" """
try: try:
events = state.calendar(from_, to) events = state.calendar(cal, from_, to)
except (RuntimeError, DavUnavailable) as exc: except (RuntimeError, DavUnavailable) as exc:
return JSONResponse( return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)} 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} 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 -- # -- Refresco de cachés --
@app.post("/api/refresh") @app.post("/api/refresh")
+272 -1
View File
@@ -230,7 +230,12 @@ def test_vevent_to_json_and_range():
assert len(events) == 1 assert len(events) == 1
evt = events[0] evt = events[0]
assert evt["summary"] == "Reunión OSINT" 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, "20260601", "20260630") is True
assert srv._event_in_range(evt, "20260101", "20260131") is False 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) 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) # Smoke real opcional contra Xandikos (gateado, no corre en CI)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------