feat(dav): expand_rrule + dav_make_calendar para recurrencia y multi-calendario

Dos funciones nuevas del grupo de capacidad `dav`:
- expand_rrule_py_infra (pure): expande una RRULE iCalendar a las fechas de
  cada ocurrencia dentro de un rango [from, to]. Solo stdlib (datetime, re).
  Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL, BYDAY. 9 tests.
- dav_make_calendar_py_infra (impure): crea una coleccion de calendario nueva
  via MKCALENDAR + PROPPATCH de nombre/color. Idempotente si ya existe. 11 tests.

Consumidas por la app osint_web (eventos recurrentes + creacion de agendas).
Pagina del grupo dav actualizada con ambas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:30:01 +02:00
parent a76760edba
commit 1c8a86594f
7 changed files with 919 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
---
name: expand_rrule
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def expand_rrule(dtstart_ical: str, rrule: str, range_start: str, range_end: str, all_day: bool = False) -> list[str]"
description: "Expande una RRULE iCalendar a la lista ordenada de fechas DTSTART de cada ocurrencia que cae dentro de un rango [range_start, range_end]. Pura, determinista, solo stdlib (sin python-dateutil). Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL y BYDAY (para WEEKLY)."
tags: [dav, calendar, ical, rrule, recurrence, caldav]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_golden_weekly_count_4", "test_edge_monthly_interval_2", "test_edge_weekly_byday_two_days", "test_edge_all_day_vs_with_time", "test_until_recorta", "test_filtro_por_rango_excluye_fuera", "test_dtstart_anterior_al_rango_pero_serie_entra", "test_componente_no_soportado_se_ignora", "test_sin_count_ni_until_acota_a_range_end"]
test_file_path: "python/functions/infra/expand_rrule_test.py"
file_path: "python/functions/infra/expand_rrule.py"
params:
- name: dtstart_ical
desc: "Fecha de inicio del evento maestro en formato iCal crudo: YYYYMMDD (all-day), YYYYMMDDTHHMMSS o YYYYMMDDTHHMMSSZ. Es la primera ocurrencia (la serie la incluye si cae en rango)."
- name: rrule
desc: "Cuerpo de la RRULE SIN el prefijo 'RRULE:', p.ej. 'FREQ=WEEKLY;INTERVAL=1;COUNT=10' o 'FREQ=MONTHLY;UNTIL=20261231;BYDAY=MO,WE'."
- name: range_start
desc: "Limite inferior del rango como YYYYMMDD (inclusive). Solo se devuelven ocurrencias cuya fecha YYYYMMDD del DTSTART cae en [range_start, range_end]."
- name: range_end
desc: "Limite superior del rango como YYYYMMDD (inclusive). Tambien acota la generacion cuando faltan COUNT y UNTIL en la RRULE."
- name: all_day
desc: "Si True las ocurrencias se devuelven como YYYYMMDD; si False conservan la parte de hora del dtstart original (misma hora local en cada ocurrencia) y devuelven YYYYMMDDTHHMMSS sin sufijo Z. Default False."
output: "Lista ordenada de strings DTSTART iCal, una por ocurrencia en rango. Lista vacia si la RRULE no produce ninguna en [range_start, range_end]."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.expand_rrule import expand_rrule
# Reunion semanal los lunes a las 09:00, 4 ocurrencias, ventana de enero 2026.
fechas = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;COUNT=4",
"20260101",
"20261231",
)
print(fechas)
# ['20260105T090000', '20260112T090000', '20260119T090000', '20260126T090000']
# Evento all-day mensual cada 2 meses, solo las que caen en el primer semestre.
fechas = expand_rrule(
"20260115",
"FREQ=MONTHLY;INTERVAL=2;COUNT=4",
"20260101",
"20260630",
all_day=True,
)
print(fechas)
# ['20260115', '20260315', '20260515']
```
## Cuando usarla
Cuando un cliente CalDAV necesita mostrar las ocurrencias de un evento
recurrente dentro de la ventana visible del calendario: tienes el DTSTART y la
RRULE del VEVENT maestro y quieres la lista concreta de fechas de inicio que
caen entre dos limites para pintarlas en la agenda. Tambien para contar o
iterar instancias de una serie sin instanciar todo el iCal.
## Gotchas
- **No implementa el RFC 5545 completo.** Componentes soportados:
- `FREQ` (obligatorio): `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`.
- `INTERVAL` (default 1).
- `COUNT` (incluye la primera ocurrencia = dtstart).
- `UNTIL` (`YYYYMMDD` o `YYYYMMDDTHHMMSSZ`, inclusive).
- `BYDAY` solo para `FREQ=WEEKLY` (`MO,TU,WE,TH,FR,SA,SU`).
- Cualquier otro componente (`BYMONTHDAY`, `BYSETPOS`, `BYMONTH`, `WKST`
avanzado, EXDATE, RDATE, etc.) se **ignora silenciosamente** — no falla, pero
el resultado puede diferir del esperado por el RFC en esos casos.
- Si faltan **COUNT y UNTIL** a la vez, la generacion se acota por `range_end`
con un tope de seguridad duro de 1000 ocurrencias para no colgar.
- En `FREQ=MONTHLY`/`YEARLY` con dia 29/30/31, los meses sin ese dia recortan al
ultimo dia valido del mes destino.
- No gestiona zonas horarias: con `all_day=False` conserva la hora local del
dtstart sin sufijo `Z`; el llamador es responsable de la tz (TZID/VTIMEZONE).
- El filtro de rango compara solo la parte `YYYYMMDD` del DTSTART, no la hora.