feat(calendar): recurrencia (RRULE), multi-agenda, vista lista y linea de ahora

Backend (server/main.py):
- EventIn.rrule + emision/parseo de RRULE en el VCALENDAR.
- calendar() expande las series recurrentes a sus ocurrencias dentro de [from,to]
  (compone expand_rrule del registry); helpers _expand_event_occurrences /
  _occurrence_clone preservan hora local, offset y duracion por ocurrencia.
- POST /api/calendars: crea una coleccion de calendario nueva (compone
  dav_make_calendar); invalida la cache de colecciones.

Frontend:
- EventModal: controles de repeticion (frecuencia, intervalo, BYDAY para semanal,
  fin por N veces / hasta fecha); parseRrule/buildRrule; aviso 'afecta a la serie'.
- CalendarView: vista Lista/Agenda (eventos por dia, click para editar, nuevo
  evento), linea roja de hora actual (refresco cada 60s, solo columna de hoy),
  boton Nuevo calendario (modal nombre/color), indicador de recurrencia (IconRepeat).
- api.ts/calendar.ts: rrule/recurring/occurrence en los tipos, createCalendar,
  helpers nowLinePct/slugifyCalendar.

Verificado: tsc -b + vite build limpios; smoke backend (FREQ=WEEKLY;COUNT=3 -> 3
ocurrencias con hora/offset/duracion correctas); render en navegador (vista Lista,
Nuevo calendario, Nuevo evento, selectores presentes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:30:14 +02:00
parent 4a487b3d33
commit 5d5ce65e88
5 changed files with 866 additions and 41 deletions
+190 -1
View File
@@ -165,6 +165,10 @@ dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource
# 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")
# Crear una colección de calendario nueva (MKCALENDAR + PROPPATCH nombre/color).
dav_make_calendar = _load_infra_fn("dav_make_calendar", "dav_make_calendar")
# Expandir una RRULE a las fechas de cada ocurrencia dentro de un rango (pura).
expand_rrule = _load_infra_fn("expand_rrule", "expand_rrule")
# ---------------------------------------------------------------------------
@@ -705,6 +709,42 @@ class VaultState:
self._maybe_clear_force_reload()
return calendars
def create_calendar(self, data: "CalendarIn") -> dict:
"""Crea una colección de calendario nueva bajo el calendar-home (MKCALENDAR).
Compone la función del registry ``dav_make_calendar`` (MKCALENDAR +
PROPPATCH de nombre/color). Invalida la caché de colecciones para que el
calendario nuevo aparezca en el selector al recargar.
Returns:
dict ``{status, href, existed?}`` de la función del registry.
Raises:
HTTPException(400): si el slug/nombre queda vacío tras sanear.
DavUnavailable: si Xandikos rechaza la creación.
"""
slug = (data.slug or data.name or "").strip()
if not slug:
raise HTTPException(status_code=400, detail="el nombre del calendario es obligatorio")
password = self.xandikos_password()
res = dav_make_calendar(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
XANDIKOS_CALENDAR_HOME,
slug,
data.name or slug,
data.color or "",
data.description or "",
)
if res.get("status") != "ok":
raise DavUnavailable(
"Xandikos no pudo crear el calendario: %s" % res.get("error")
)
with self._dav_lock:
self._calendars_cache = None
return res
def calendar(self, cal: str = "", dt_from: str = "", dt_to: str = "") -> list:
"""Eventos de una colección de calendario Xandikos, cacheados y filtrados.
@@ -732,9 +772,18 @@ class VaultState:
self._maybe_clear_force_reload()
cached = events
all_events = list(cached)
# Sin rango: devolvemos los eventos maestros tal cual (no expandimos
# series infinitas). Con rango: cada serie recurrente se expande a sus
# ocurrencias dentro de [from, to]; los puntuales se filtran por fecha.
if not dt_from and not dt_to:
return all_events
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
out: list = []
for ev in all_events:
if ev.get("rrule"):
out.extend(_expand_event_occurrences(ev, dt_from, dt_to))
elif _event_in_range(ev, dt_from, dt_to):
out.append(ev)
return out
# --- Escritura de eventos del calendario (CalDAV) -----------------------
@@ -1262,6 +1311,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
"location": None,
"description": None,
"color": None,
"rrule": None,
"recurring": False,
"occurrence": False,
}
for line in _unfold_lines(vevent_block):
parsed = _parse_property(line)
@@ -1295,6 +1347,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
out["description"] = _unescape_ical(value)
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
out["color"] = value
elif name == "RRULE":
out["rrule"] = value
out["recurring"] = True
return out
@@ -1327,6 +1382,98 @@ def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
return True
def _default_expand_start() -> str:
"""Límite inferior por defecto al expandir una serie sin rango explícito."""
return (datetime.now(timezone.utc) - timedelta(days=366)).strftime("%Y%m%d")
def _default_expand_end() -> str:
"""Límite superior por defecto al expandir una serie sin rango explícito."""
return (datetime.now(timezone.utc) + timedelta(days=731)).strftime("%Y%m%d")
def _shift_iso_days(value: str, days: int) -> str:
"""Desplaza la parte de fecha de un ISO (``YYYY-MM-DD`` o con ``T...``).
Conserva intacta la parte horaria/offset (``T10:00:00+02:00``) y solo mueve
la fecha ``days`` días. Para una fecha pura mueve la fecha sola. Si el valor
no parsea, lo devuelve sin tocar (defensivo).
"""
if not value:
return value
date_part = value[:10]
rest = value[10:]
try:
base = datetime.strptime(date_part, "%Y-%m-%d")
except ValueError:
return value
shifted = (base + timedelta(days=days)).strftime("%Y-%m-%d")
return shifted + rest
def _occurrence_clone(event: dict, occ_ymd: str) -> dict:
"""Clona un evento maestro recurrente reubicado en la fecha ``occ_ymd``.
Mantiene la hora local / offset del maestro (solo cambia la fecha) y aplica el
mismo desplazamiento de días al ``dtend`` para preservar la duración. Marca
``occurrence=True`` cuando la fecha difiere de la del maestro (la primera
ocurrencia coincide con el maestro y queda ``occurrence=False``).
"""
master_ymd = (
event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "")
)[:8]
new_date_iso = "%s-%s-%s" % (occ_ymd[:4], occ_ymd[4:6], occ_ymd[6:8])
clone = dict(event)
ds = event.get("dtstart") or ""
if event.get("all_day") or len(ds) == 10:
clone["dtstart"] = new_date_iso
else:
clone["dtstart"] = new_date_iso + ds[10:]
clone["dtstart_ical"] = occ_ymd
try:
delta = (
datetime.strptime(occ_ymd, "%Y%m%d")
- datetime.strptime(master_ymd, "%Y%m%d")
).days
except ValueError:
delta = 0
de = event.get("dtend")
if de:
clone["dtend"] = _shift_iso_days(de, delta)
de_ical = event.get("dtend_ical")
if de_ical:
clone["dtend_ical"] = (clone["dtend"] or "").replace("-", "")[:8]
clone["occurrence"] = occ_ymd != master_ymd
return clone
def _expand_event_occurrences(event: dict, dt_from: str, dt_to: str) -> list:
"""Expande un evento recurrente a sus ocurrencias dentro de ``[from, to]``.
Compone la función pura del registry ``expand_rrule`` (solo necesita las
FECHAS de cada ocurrencia; la hora local se preserva clonando el maestro). Si
el evento no tiene ``rrule``, o algo no parsea, devuelve ``[event]`` sin
tocar — nunca pierde el evento original.
"""
rrule = event.get("rrule")
if not rrule:
return [event]
master_ymd = (
event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "")
)[:8]
if len(master_ymd) < 8:
return [event]
rs = (dt_from or "").replace("-", "")[:8] or _default_expand_start()
re_ = (dt_to or "").replace("-", "")[:8] or _default_expand_end()
try:
occ_dates = expand_rrule(master_ymd, rrule, rs, re_, all_day=True)
except Exception:
return [event]
if not occ_dates:
return []
return [_occurrence_clone(event, d) for d in occ_dates]
# ---------------------------------------------------------------------------
# Escritura de contactos: ficha .md del vault (fuente de verdad) + vCard
# ---------------------------------------------------------------------------
@@ -1534,6 +1681,25 @@ class EventIn(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
# Regla de recurrencia iCalendar SIN el prefijo "RRULE:" (p.ej.
# "FREQ=WEEKLY;INTERVAL=1;COUNT=10"). None / "" → evento puntual. Editar un
# evento recurrente reescribe toda la serie (no se soporta editar una sola
# ocurrencia).
rrule: Optional[str] = None
class CalendarIn(BaseModel):
"""Cuerpo de POST /api/calendars: crea una colección de calendario nueva.
El ``slug`` es el segmento de URL de la colección (lo sanea la función del
registry ``dav_make_calendar`` a ``[a-z0-9_-]``). ``name`` es el nombre
visible; ``color`` un hex ``#rrggbb`` opcional.
"""
slug: str
name: Optional[str] = ""
color: Optional[str] = None
description: Optional[str] = None
def _parse_iso_input(value: str) -> Optional[dict]:
@@ -1701,6 +1867,13 @@ def _build_vcalendar(data: "EventIn", uid: str) -> str:
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())
rrule = (data.rrule or "").strip()
if rrule:
# Acepta tanto "FREQ=..." como "RRULE:FREQ=..."; normaliza a la línea
# canónica "RRULE:<cuerpo>" que entienden Xandikos y los clientes (DAVx5).
if rrule.upper().startswith("RRULE:"):
rrule = rrule[len("RRULE:"):].strip()
vevent.append("RRULE:%s" % rrule)
vevent.append("END:VEVENT")
body.append("\r\n".join(vevent))
body.append("END:VCALENDAR")
@@ -1898,6 +2071,22 @@ def create_app(vault_dir: str) -> FastAPI:
}
)
@app.post("/api/calendars")
def api_create_calendar(data: CalendarIn = Body(...)) -> JSONResponse:
"""Crea una colección de calendario nueva (MKCALENDAR + nombre/color).
Body: ``{slug, name?, color?, description?}``. Idempotente si ya existe.
Devuelve ``{status, href, existed?}``. 400 si falta el nombre; 503 si
Xandikos no responde.
"""
try:
res = state.create_calendar(data)
except (RuntimeError, DavUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(status_code=201, content={"status": "ok", **res})
@app.get("/api/calendar")
def api_calendar(
cal: str = Query("", description="colección de calendario (ruta o nombre)"),