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:
+190
-1
@@ -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)"),
|
||||
|
||||
Reference in New Issue
Block a user