merge: calendar mes/semana/día + TZ + selector + colores + CRUD de eventos (quick/calendar-week-tz-crud)
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