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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-12 00:40:59 +02:00
parent 43889bfc07
commit e792bc6e17
8 changed files with 2000 additions and 179 deletions
+617 -36
View File
@@ -33,7 +33,11 @@ Endpoints (JSON salvo /api/attachment):
GET /api/search?q=... nodos cuyo contenido matchea la query
GET /api/contacts contactos del addressbook Xandikos (CardDAV)
GET /api/contact/<uid> un vCard concreto a JSON
GET /api/calendar?from=&to= eventos del calendario Xandikos (CalDAV)
GET /api/calendars colecciones de calendario bajo /enmanuel/calendars/
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV)
POST /api/event crea un VEVENT en una colección de calendario
PUT /api/event/<uid> edita un VEVENT existente
DELETE /api/event/<uid> borra un VEVENT
POST /api/refresh re-escanea el vault y reconstruye la caché
"""
@@ -47,8 +51,18 @@ import re
import sys
import threading
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError: # pragma: no cover - Python < 3.9 sin tzdata
ZoneInfo = None # type: ignore[assignment]
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
pass
def _registry_functions_dir() -> str:
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados.
@@ -147,6 +161,10 @@ pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret")
# inmediato para que la app y el móvil lo vean ya, sin esperar al sync periódico.
carddav_put_vcard = _load_infra_fn("carddav_put_vcard", "carddav_put_vcard")
dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource")
# Calendario (CalDAV): crear/editar eventos (PUT de un VCALENDAR por UID) y listar
# las colecciones de calendario del usuario con su nombre y color.
caldav_put_event = _load_infra_fn("caldav_put_event", "caldav_put_event")
dav_list_calendars = _load_infra_fn("dav_list_calendars", "dav_list_calendars")
# ---------------------------------------------------------------------------
@@ -157,6 +175,11 @@ XANDIKOS_BASE_URL = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
XANDIKOS_USERNAME = "enmanuel"
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
# Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
# Colección de calendario por defecto (la única hoy). Sigue siendo el destino
# cuando el cliente no especifica `cal`.
XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
# Caché en disco de los datos DAV ya parseados, indexada por el ctag de la
@@ -166,8 +189,25 @@ XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
# junto al server y está gitignored (datos personales sensibles + regenerable).
_CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".cache")
_CONTACTS_CACHE_FILE = os.path.join(_CACHE_DIR, "contacts.json")
# Caché del calendario por defecto. Para otras colecciones la ruta se deriva del
# nombre de la colección (_calendar_cache_file), así cada calendario tiene su
# propia caché en disco.
_CALENDAR_CACHE_FILE = os.path.join(_CACHE_DIR, "calendar.json")
def _calendar_cache_file(collection_path: str) -> str:
"""Ruta de la caché en disco de una colección de calendario concreta.
La colección por defecto usa ``_CALENDAR_CACHE_FILE`` (compatibilidad con la
caché previa); cualquier otra deriva su archivo del último segmento del path,
saneado, para que cada calendario tenga su propia caché aislada.
"""
if collection_path.strip("/") == XANDIKOS_CALENDAR_COLLECTION.strip("/"):
return _CALENDAR_CACHE_FILE
tail = collection_path.strip("/").rsplit("/", 1)[-1] or "calendar"
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", tail)
return os.path.join(_CACHE_DIR, "calendar_%s.json" % safe)
# Extensiones de imagen que el frontend muestra en la galería con lightbox.
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
@@ -292,9 +332,12 @@ class VaultState:
# validación de ctag en el siguiente acceso.
self._dav_lock = threading.Lock()
self._contacts_cache: Optional[list] = None
self._calendar_cache: Optional[list] = None
# Caché de eventos POR colección de calendario: collection_path → list.
# Permite varios calendarios sin pisarse; cada uno con su ctag.
self._calendar_cache: dict[str, list] = {}
self._calendar_ctag: dict[str, str] = {}
self._calendars_cache: Optional[list] = None # lista de colecciones
self._contacts_ctag: Optional[str] = None
self._calendar_ctag: Optional[str] = None
self._force_reload = False
self.refresh()
@@ -615,34 +658,188 @@ class VaultState:
self._maybe_clear_force_reload()
return contacts
def calendar(self, dt_from: str = "", dt_to: str = "") -> list:
"""Eventos del calendario Xandikos, cacheados; filtrados por rango.
def _resolve_calendar(self, cal: str = "") -> str:
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
Misma caché en dos niveles que ``contacts``. La descarga + parseo
completos se cachean (UNA petición REPORT); el filtro por ``[from, to]``
se aplica sobre la caché en cada llamada (barato). Sin ``from``/``to``
devuelve todos.
Acepta una ruta absoluta (``/enmanuel/calendars/calendar/``), el nombre
corto de la colección (``calendar``), o vacío (→ colección por defecto).
Garantiza barras inicial/final. NO valida contra el servidor (eso lo hace
el propio Xandikos al fallar la petición); solo da forma canónica.
"""
cal = (cal or "").strip()
if not cal:
return XANDIKOS_CALENDAR_COLLECTION
if cal.startswith("/"):
path = cal
else:
# Nombre corto → lo colgamos del calendar-home.
path = XANDIKOS_CALENDAR_HOME.rstrip("/") + "/" + cal.strip("/")
if not path.endswith("/"):
path += "/"
return path
def list_calendars(self) -> list:
"""Colecciones de calendario bajo el calendar-home, con nombre y color.
Cacheada en memoria (``POST /api/refresh`` la invalida). Compone la
función del registry ``dav_list_calendars`` (PROPFIND Depth:1). Devuelve
``[{href, name, color}, ...]`` ordenadas por nombre.
Raises:
RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde.
"""
with self._dav_lock:
if self._calendars_cache is not None and not self._force_reload:
return self._calendars_cache
password = self.xandikos_password()
res = dav_list_calendars(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, XANDIKOS_CALENDAR_HOME
)
if res.get("status") != "ok":
raise DavUnavailable(
"Xandikos no responde: %s" % res.get("error")
)
calendars = res.get("calendars", [])
self._calendars_cache = calendars
self._maybe_clear_force_reload()
return calendars
def calendar(self, cal: str = "", dt_from: str = "", dt_to: str = "") -> list:
"""Eventos de una colección de calendario Xandikos, cacheados y filtrados.
Caché por colección (memoria + disco, validada por ctag). La descarga +
parseo completos se cachean (UNA petición REPORT); el filtro por
``[from, to]`` se aplica sobre la caché. Sin ``cal`` usa la colección por
defecto; sin ``from``/``to`` devuelve todos los eventos.
Raises:
RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
"""
collection = self._resolve_calendar(cal)
with self._dav_lock:
if self._calendar_cache is None or self._force_reload:
cached = self._calendar_cache.get(collection)
if cached is None or self._force_reload:
events, ctag = self._load_collection(
XANDIKOS_CALENDAR_COLLECTION,
collection,
"ical",
_CALENDAR_CACHE_FILE,
_calendar_cache_file(collection),
self._parse_events,
)
self._calendar_cache = events
self._calendar_ctag = ctag
self._calendar_cache[collection] = events
self._calendar_ctag[collection] = ctag
self._maybe_clear_force_reload()
all_events = self._calendar_cache
cached = events
all_events = list(cached)
if not dt_from and not dt_to:
return list(all_events)
return all_events
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
# --- Escritura de eventos del calendario (CalDAV) -----------------------
def create_event(self, data: "EventIn") -> dict:
"""Crea un VEVENT en una colección de calendario (PUT de un VCALENDAR).
Genera un UID nuevo, construye el VCALENDAR/VEVENT (respetando tz/all_day,
ver ``_build_vcalendar``) y lo sube con ``caldav_put_event``. Invalida la
caché de esa colección para que el evento aparezca ya.
Returns:
dict ``{uid, cal, dav}``.
Raises:
HTTPException(400): si la fecha es inválida o falta el summary.
DavUnavailable: si Xandikos rechaza el PUT.
"""
if not data.summary or not data.summary.strip():
raise HTTPException(status_code=400, detail="el summary es obligatorio")
collection = self._resolve_calendar(data.cal or "")
uid = str(uuid.uuid4())
try:
vcal = _build_vcalendar(data, uid)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
dav = self._put_event(collection, uid, vcal)
if dav.get("status") != "ok":
raise DavUnavailable("Xandikos rechazó el evento: %s" % dav.get("error"))
self._invalidate_calendar(collection)
return {"uid": uid, "cal": collection, "dav": dav}
def update_event(self, uid: str, data: "EventIn") -> dict:
"""Edita un VEVENT existente: reescribe el recurso ``<uid>.ics`` (PUT).
Reutiliza el UID (idempotente). Construye el VCALENDAR de nuevo a partir
del cuerpo recibido y lo sube. Invalida la caché de la colección.
Returns:
dict ``{uid, cal, dav}``.
Raises:
HTTPException(400): si la fecha es inválida o falta el summary.
DavUnavailable: si Xandikos rechaza el PUT.
"""
if not data.summary or not data.summary.strip():
raise HTTPException(status_code=400, detail="el summary es obligatorio")
collection = self._resolve_calendar(data.cal or "")
try:
vcal = _build_vcalendar(data, uid)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
dav = self._put_event(collection, uid, vcal)
if dav.get("status") != "ok":
raise DavUnavailable("Xandikos rechazó el evento: %s" % dav.get("error"))
self._invalidate_calendar(collection)
return {"uid": uid, "cal": collection, "dav": dav}
def delete_event(self, uid: str, cal: str = "") -> dict:
"""Borra un VEVENT: elimina el recurso ``<uid>.ics`` de la colección.
Trata 404 como idempotente (ya no existía). Invalida la caché de la
colección.
Returns:
dict ``{uid, deleted, dav}``.
Raises:
DavUnavailable: si Xandikos falla con un error distinto de 404.
"""
collection = self._resolve_calendar(cal)
password = self.xandikos_password()
resource_path = collection + _safe_event_resource(uid)
dav = dav_delete_resource(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, resource_path
)
if dav.get("status") != "ok" and dav.get("http_status") == 404:
dav = {"status": "ok", "http_status": 404, "idempotent": True}
if dav.get("status") != "ok":
raise DavUnavailable("Xandikos no pudo borrar: %s" % dav.get("error"))
self._invalidate_calendar(collection)
return {"uid": uid, "deleted": True, "dav": dav}
def _put_event(self, collection: str, uid: str, vcalendar_text: str) -> dict:
"""Sube (PUT) un VCALENDAR a una colección CalDAV. No lanza por sí sola.
Compone la función del registry ``caldav_put_event`` (deriva el nombre
del recurso de ``safe(uid).ics``). Devuelve su dict
``{status, http_status|error}``.
"""
password = self.xandikos_password()
return caldav_put_event(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
collection,
uid,
vcalendar_text,
)
def _invalidate_calendar(self, collection: str) -> None:
"""Vacía la caché en memoria de una colección de calendario concreta."""
with self._dav_lock:
self._calendar_cache.pop(collection, None)
self._calendar_ctag.pop(collection, None)
def _maybe_clear_force_reload(self) -> None:
"""Apaga el flag de refresh forzado una vez consumido por una recarga.
@@ -662,7 +859,9 @@ class VaultState:
"""
with self._dav_lock:
self._contacts_cache = None
self._calendar_cache = None
self._calendar_cache = {}
self._calendar_ctag = {}
self._calendars_cache = None
self._force_reload = True
# --- Escritura de contactos: ficha .md (verdad) + reflejo en Xandikos ----
@@ -967,41 +1166,135 @@ def _vcard_to_json(vcard_text: str) -> dict:
_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
_ICAL_DT_RE = re.compile(
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$"
)
def _zoneinfo(tzid: str):
"""``ZoneInfo(tzid)`` o ``None`` si el tz no existe / falta tzdata.
Nunca lanza: un TZID desconocido (o un sistema sin base de zonas) degrada a
None y el llamador trata la hora como naive/local, sin tumbar el parseo.
"""
if ZoneInfo is None or not tzid:
return None
try:
return ZoneInfo(tzid)
except (ZoneInfoNotFoundError, ValueError, KeyError):
return None
def _parse_ical_datetime(value: str, params: dict) -> Optional[dict]:
"""Parsea un valor DTSTART/DTEND iCal a una representación normalizada.
Maneja las tres formas del calendario:
- UTC: ``20260611T090000Z`` (sufijo Z).
- con zona: ``DTSTART;TZID=Europe/Madrid:20260611T090000`` (param TZID).
- solo fecha (todo el día): ``DTSTART;VALUE=DATE:20260611`` o sin hora.
Returns:
dict ``{iso, tz, all_day, ical}`` o ``None`` si no parsea. ``iso`` es
ISO 8601 con offset cuando hay zona/UTC (``2026-06-11T11:00:00+02:00``)
o ``YYYY-MM-DD`` para todo el día; ``tz`` es el TZID original
(``Europe/Madrid``, ``UTC``, o None si naive/all-day); ``all_day`` True
si es solo fecha; ``ical`` el prefijo ``YYYYMMDD`` para el filtro de
rango.
"""
value = (value or "").strip()
m = _ICAL_DT_RE.match(value)
if not m:
return None
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
has_time = m.group(4) is not None
is_utc = m.group(7) == "Z"
is_date_value = (params.get("VALUE", "").upper() == "DATE") or not has_time
ical_prefix = "%04d%02d%02d" % (year, month, day)
if is_date_value:
return {
"iso": "%04d-%02d-%02d" % (year, month, day),
"tz": None,
"all_day": True,
"ical": ical_prefix,
}
hour = int(m.group(4))
minute = int(m.group(5))
second = int(m.group(6)) if m.group(6) else 0
tzid = params.get("TZID", "")
if is_utc:
dt = datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
tz_name = "UTC"
elif tzid and _zoneinfo(tzid) is not None:
dt = datetime(year, month, day, hour, minute, second, tzinfo=_zoneinfo(tzid))
tz_name = tzid
else:
# Hora "flotante" (sin Z ni TZID, o TZID desconocido): se interpreta como
# local del visor. La servimos sin offset; el frontend la sitúa en su TZ.
return {
"iso": "%04d-%02d-%02dT%02d:%02d:%02d"
% (year, month, day, hour, minute, second),
"tz": tzid or None,
"all_day": False,
"ical": ical_prefix,
}
return {"iso": dt.isoformat(), "tz": tz_name, "all_day": False, "ical": ical_prefix}
def _vevent_to_json(vevent_block: str) -> dict:
"""Convierte un bloque VEVENT a un dict JSON con los campos de interés.
Extrae: uid, summary, dtstart, dtend, location, description. Las fechas se
devuelven tal cual vienen del servidor (formato iCal, ej. ``20260611T090000Z``
o ``20260611``); el frontend las formatea a europeo. Parseo ligero a mano.
Extrae: uid, summary, dtstart/dtend (ISO con offset cuando hay zona/UTC, o
``YYYY-MM-DD`` para todo el día), la TZ original (``tz``), ``all_day``,
location, description y color (propiedad ``COLOR`` RFC 7986 o
``X-APPLE-CALENDAR-COLOR`` si el evento la trae). ``dtstart_ical`` /
``dtend_ical`` conservan el prefijo ``YYYYMMDD`` crudo para el filtro de
rango. Parseo ligero a mano (sin dependencia externa).
"""
out: dict = {
"uid": None,
"summary": None,
"dtstart": None,
"dtend": None,
"dtstart_ical": None,
"dtend_ical": None,
"tz": None,
"all_day": False,
"location": None,
"description": None,
"color": None,
}
for line in _unfold_lines(vevent_block):
parsed = _parse_property(line)
if not parsed:
continue
name, _params, value = parsed
name, params, value = parsed
value = value.strip()
if name == "UID":
out["uid"] = value
elif name == "SUMMARY":
out["summary"] = _unescape_ical(value)
elif name == "DTSTART":
out["dtstart"] = value
dt = _parse_ical_datetime(value, params)
if dt:
out["dtstart"] = dt["iso"]
out["dtstart_ical"] = dt["ical"]
out["tz"] = dt["tz"]
out["all_day"] = dt["all_day"]
else:
out["dtstart"] = value
elif name == "DTEND":
out["dtend"] = value
dt = _parse_ical_datetime(value, params)
if dt:
out["dtend"] = dt["iso"]
out["dtend_ical"] = dt["ical"]
else:
out["dtend"] = value
elif name == "LOCATION":
out["location"] = _unescape_ical(value)
elif name == "DESCRIPTION":
out["description"] = _unescape_ical(value)
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
out["color"] = value
return out
@@ -1016,13 +1309,15 @@ def _vcalendar_to_events(vcalendar_text: str) -> list:
def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
"""True si el evento cae (por DTSTART) dentro de ``[dt_from, dt_to]``.
Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` que comparten todos
los formatos iCal (date y date-time). Los límites se normalizan quitando los
guiones, así acepta tanto el formato documentado del endpoint
(``2026-06-11``) como el iCal crudo (``20260611``). ``dt_from``/``dt_to``
vacíos desactivan ese extremo del filtro.
Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` (``dtstart_ical``);
si falta, se deriva del ISO de ``dtstart``. Los límites se normalizan
quitando los guiones, así acepta tanto ``2026-06-11`` como ``20260611``.
``dt_from``/``dt_to`` vacíos desactivan ese extremo del filtro.
"""
dtstart = (event.get("dtstart") or "").replace("-", "")[:8]
dtstart = event.get("dtstart_ical") or ""
if not dtstart:
dtstart = (event.get("dtstart") or "").replace("-", "")[:8]
dtstart = dtstart.replace("-", "")[:8]
if not dtstart:
return True
if dt_from and dtstart < dt_from.replace("-", "")[:8]:
@@ -1200,6 +1495,218 @@ def _build_vcard(frontmatter: dict, slug: str) -> str:
return "\r\n".join(lines) + "\r\n"
# ---------------------------------------------------------------------------
# Escritura de eventos del calendario: construcción de VEVENT / VCALENDAR
# ---------------------------------------------------------------------------
_ISO_DT_RE = re.compile(
r"^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?"
r"(Z|[+-]\d{2}:?\d{2})?$"
)
# Saneado del nombre del recurso .ics: DEBE coincidir con el que aplica
# caldav_put_event internamente (mismo patrón), para que el DELETE apunte al
# recurso que el PUT creó.
_UNSAFE_RESOURCE_RE = re.compile(r"[^A-Za-z0-9_.-]")
def _safe_event_resource(uid: str) -> str:
"""Nombre del recurso ``.ics`` de un UID (igual que caldav_put_event)."""
return _UNSAFE_RESOURCE_RE.sub("_", uid)[:120] + ".ics"
class EventIn(BaseModel):
"""Cuerpo de POST/PUT de un evento del calendario (VEVENT).
Las fechas se aceptan en ISO local sin offset (``2026-06-15T10:00``) + un
``tz`` (TZID, p.ej. ``Europe/Madrid``); o con offset/``Z`` ya incluido. El
servidor las normaliza al construir el VEVENT (``DTSTART;TZID=...`` o
``...Z``). Para eventos de todo el día basta ``dtstart`` = ``2026-06-15`` con
``all_day=True``.
"""
cal: Optional[str] = None
summary: str
dtstart: str
dtend: Optional[str] = None
tz: Optional[str] = Field(default="Europe/Madrid")
all_day: bool = False
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
def _parse_iso_input(value: str) -> Optional[dict]:
"""Parsea una fecha de entrada ISO a ``{year,month,day,hour,minute,second,
offset,date_only}`` o ``None``.
Acepta ``2026-06-15``, ``2026-06-15T10:00``, ``2026-06-15T10:00:00`` y
variantes con ``Z`` o ``±HH:MM`` al final. Es tolerante a la separación con
espacio en vez de ``T``.
"""
value = (value or "").strip()
m = _ISO_DT_RE.match(value)
if not m:
return None
has_time = m.group(4) is not None
return {
"year": int(m.group(1)),
"month": int(m.group(2)),
"day": int(m.group(3)),
"hour": int(m.group(4)) if has_time else 0,
"minute": int(m.group(5)) if has_time else 0,
"second": int(m.group(6)) if (has_time and m.group(6)) else 0,
"offset": m.group(7),
"date_only": not has_time,
}
def _ical_dt_property(prop: str, value: str, tz: Optional[str], all_day: bool) -> str:
"""Construye una línea DTSTART/DTEND iCal a partir de una fecha de entrada.
- all_day → ``DTSTART;VALUE=DATE:YYYYMMDD``.
- con offset/``Z`` en la entrada → convierte a UTC: ``DTSTART:...Z``.
- con ``tz`` válido → ``DTSTART;TZID=<tz>:YYYYMMDDTHHMMSS`` (hora local del
tz, el VTIMEZONE lo aporta el VCALENDAR).
- sin tz ni offset → hora flotante ``DTSTART:YYYYMMDDTHHMMSS``.
Raises:
ValueError: si ``value`` no es una fecha ISO reconocible.
"""
p = _parse_iso_input(value)
if p is None:
raise ValueError("fecha inválida: %r (usa ISO YYYY-MM-DD[THH:MM])" % value)
ymd = "%04d%02d%02d" % (p["year"], p["month"], p["day"])
if all_day or p["date_only"]:
return "%s;VALUE=DATE:%s" % (prop, ymd)
hms = "%02d%02d%02d" % (p["hour"], p["minute"], p["second"])
if p["offset"]:
# La entrada ya trae offset/Z: la pasamos a UTC absoluto.
dt = datetime.fromisoformat(
"%04d-%02d-%02dT%02d:%02d:%02d%s"
% (
p["year"],
p["month"],
p["day"],
p["hour"],
p["minute"],
p["second"],
_normalize_offset(p["offset"]),
)
)
dt_utc = dt.astimezone(timezone.utc)
return "%s:%s" % (prop, dt_utc.strftime("%Y%m%dT%H%M%SZ"))
if tz and _zoneinfo(tz) is not None:
return "%s;TZID=%s:%sT%s" % (prop, tz, ymd, hms)
# Hora flotante (sin tz reconocible): la escribimos sin Z.
return "%s:%sT%s" % (prop, ymd, hms)
def _normalize_offset(offset: str) -> str:
"""Normaliza un offset ISO a la forma ``±HH:MM`` que entiende fromisoformat."""
if offset == "Z":
return "+00:00"
if len(offset) == 5 and ":" not in offset: # ±HHMM
return offset[:3] + ":" + offset[3:]
return offset
def _vtimezone_block(tz: str) -> str:
"""Bloque VTIMEZONE mínimo para un TZID, con el offset estándar y de verano.
Calcula los offsets reales del tz para enero (estándar) y julio (verano) del
año actual con ``zoneinfo`` y emite un VTIMEZONE con ambas observancias. Es
una aproximación suficiente para que el cliente (y Xandikos) resuelvan la
hora local; no reproduce las reglas RRULE exactas. Devuelve cadena vacía si
el tz es UTC, desconocido, o no hace falta (el evento se sirve igual sin él).
"""
zone = _zoneinfo(tz)
if zone is None or tz.upper() == "UTC":
return ""
year = datetime.now().year
jan = datetime(year, 1, 15, 12, tzinfo=zone)
jul = datetime(year, 7, 15, 12, tzinfo=zone)
std_off = jan.utcoffset() or timedelta(0)
dst_off = jul.utcoffset() or timedelta(0)
def _fmt(off: timedelta) -> str:
total = int(off.total_seconds())
sign = "+" if total >= 0 else "-"
total = abs(total)
return "%s%02d%02d" % (sign, total // 3600, (total % 3600) // 60)
lines = ["BEGIN:VTIMEZONE", "TZID:%s" % tz]
if dst_off != std_off:
# Tiene horario de verano: dos observancias (estándar + verano).
lines += [
"BEGIN:STANDARD",
"DTSTART:19701025T030000",
"TZOFFSETFROM:%s" % _fmt(dst_off),
"TZOFFSETTO:%s" % _fmt(std_off),
"END:STANDARD",
"BEGIN:DAYLIGHT",
"DTSTART:19700329T020000",
"TZOFFSETFROM:%s" % _fmt(std_off),
"TZOFFSETTO:%s" % _fmt(dst_off),
"END:DAYLIGHT",
]
else:
lines += [
"BEGIN:STANDARD",
"DTSTART:19700101T000000",
"TZOFFSETFROM:%s" % _fmt(std_off),
"TZOFFSETTO:%s" % _fmt(std_off),
"END:STANDARD",
]
lines.append("END:VTIMEZONE")
return "\r\n".join(lines)
def _build_vcalendar(data: "EventIn", uid: str) -> str:
"""Serializa un ``EventIn`` a un VCALENDAR 2.0 con un VEVENT y su UID.
Construye DTSTART/DTEND respetando ``tz``/``all_day``/offset (ver
``_ical_dt_property``), añade un VTIMEZONE si el evento usa un TZID con
horario de verano, y mapea summary/location/description/color. El UID se
reutiliza al editar → idempotente (el recurso ``<uid>.ics`` se sobrescribe).
Raises:
ValueError: si ``dtstart`` no es una fecha ISO reconocible.
"""
dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
tz = data.tz or ""
body = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//osint_web//calendar//ES",
"CALSCALE:GREGORIAN",
]
if not data.all_day and tz and _zoneinfo(tz) is not None:
vtz = _vtimezone_block(tz)
if vtz:
body.append(vtz)
vevent = [
"BEGIN:VEVENT",
"UID:%s" % uid,
"DTSTAMP:%s" % dtstamp,
_ical_dt_property("DTSTART", data.dtstart, tz, data.all_day),
]
if data.dtend:
vevent.append(_ical_dt_property("DTEND", data.dtend, tz, data.all_day))
vevent.append("SUMMARY:%s" % _vcard_escape(data.summary.strip()))
if data.location and data.location.strip():
vevent.append("LOCATION:%s" % _vcard_escape(data.location.strip()))
if data.description and data.description.strip():
vevent.append("DESCRIPTION:%s" % _vcard_escape(data.description.strip()))
if data.color and data.color.strip():
# COLOR (RFC 7986) — nombre CSS3 o, para clientes Apple, el hex va aparte.
vevent.append("X-APPLE-CALENDAR-COLOR:%s" % data.color.strip())
vevent.append("END:VEVENT")
body.append("\r\n".join(vevent))
body.append("END:VCALENDAR")
return "\r\n".join(body) + "\r\n"
# ---------------------------------------------------------------------------
# Construcción de la app FastAPI
# ---------------------------------------------------------------------------
@@ -1369,21 +1876,44 @@ def create_app(vault_dir: str) -> FastAPI:
# -- Xandikos: calendario (CalDAV) --
@app.get("/api/calendars")
def api_calendars() -> JSONResponse:
"""Colecciones de calendario bajo el calendar-home, con nombre y color.
Cada una: ``{href, name, color}``. Alimenta el selector de calendario del
frontend. 503 con JSON de error si Xandikos no responde.
"""
try:
calendars = state.list_calendars()
except (RuntimeError, DavUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(
content={
"status": "ok",
"count": len(calendars),
"calendars": calendars,
"default": XANDIKOS_CALENDAR_COLLECTION,
}
)
@app.get("/api/calendar")
def api_calendar(
cal: str = Query("", description="colección de calendario (ruta o nombre)"),
from_: str = Query("", alias="from", description="fecha inicio YYYY-MM-DD"),
to: str = Query("", description="fecha fin YYYY-MM-DD"),
) -> JSONResponse:
"""Eventos del calendario Xandikos en ``[from, to]`` (cacheados).
"""Eventos de una colección del calendario Xandikos en ``[from, to]``.
Cada evento: ``{uid, summary, dtstart, dtend, location, description}``.
La descarga + parseo completos se cachean (``POST /api/refresh`` los
invalida); el filtro por rango se aplica sobre la caché. Sin ``from``/
``to`` devuelve todos. Si Xandikos no responde o falta la password →
503 con JSON de error claro, nunca un crash.
Cada evento: ``{uid, summary, dtstart, dtend, tz, all_day, location,
description, color}`` (dtstart/dtend en ISO con offset). ``cal`` elige la
colección (default la actual). La descarga + parseo se cachean
(``POST /api/refresh`` invalida); el filtro por rango va sobre la caché.
Si Xandikos no responde → 503 con JSON de error claro, nunca un crash.
"""
try:
events = state.calendar(from_, to)
events = state.calendar(cal, from_, to)
except (RuntimeError, DavUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
@@ -1392,6 +1922,57 @@ def create_app(vault_dir: str) -> FastAPI:
content={"status": "ok", "count": len(events), "events": events}
)
# -- Calendario: CRUD de eventos (VEVENT) --
@app.post("/api/event")
def api_create_event(data: EventIn = Body(...)) -> JSONResponse:
"""Crea un VEVENT en una colección de calendario (PUT de un VCALENDAR).
Body: ``{cal?, summary, dtstart, dtend?, tz?, all_day?, location?,
description?, color?}``. Genera el UID. 400 si la fecha es inválida; 503
si Xandikos rechaza el evento. Devuelve ``{uid, cal}``.
"""
try:
result = state.create_event(data)
except DavUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(status_code=201, content={"status": "ok", **result})
@app.put("/api/event/{uid}")
def api_update_event(uid: str, data: EventIn = Body(...)) -> JSONResponse:
"""Edita un VEVENT existente (reescribe ``<uid>.ics``).
Reutiliza el UID. 400 si la fecha es inválida; 503 si Xandikos rechaza.
Devuelve ``{uid, cal}``.
"""
try:
result = state.update_event(uid, data)
except DavUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(content={"status": "ok", **result})
@app.delete("/api/event/{uid}")
def api_delete_event(
uid: str,
cal: str = Query("", description="colección de calendario (ruta o nombre)"),
) -> JSONResponse:
"""Borra un VEVENT (``<uid>.ics``) de una colección de calendario.
404 de Xandikos se trata como idempotente. 503 si falla por otra causa.
Devuelve ``{uid, deleted}``.
"""
try:
result = state.delete_event(uid, cal)
except DavUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(content={"status": "ok", **result})
# -- Refresco de cachés --
@app.post("/api/refresh")