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:
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user