feat(dav,obsidian): grupo dav completo (CardDAV/CalDAV client + split vcf/ics + import pipelines) + build_obsidian_graph + dav_list_calendars

Funciones reutilizables creadas esta sesion para el sistema self-hosted de contactos/calendario (Xandikos) y la app osint_web:
- grupo dav (infra): split_vcards, split_vevents_to_vcalendars, extract_or_make_uid, carddav_put_vcard, caldav_put_event, dav_list_resources, dav_get_resource, dav_list_calendars
- pipelines: import_vcf_to_carddav, import_ics_to_caldav
- obsidian: build_obsidian_graph (grafo agregado del vault)
This commit is contained in:
2026-06-12 00:43:59 +02:00
parent 4a0f0e9dc0
commit a76760edba
32 changed files with 2814 additions and 0 deletions
@@ -0,0 +1,87 @@
---
name: import_ics_to_caldav
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def import_ics_to_caldav(ics_path: str, base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True, uid_prefix: str = 'goog-') -> dict"
description: "Pipeline que importa un archivo .ics completo a una coleccion CalDAV. Lee el .ics del disco, parte el VCALENDAR en N VCALENDARs independientes con un VEVENT cada uno (split_vevents_to_vcalendars, replicando las VTIMEZONE), por cada uno extrae o sintetiza el UID (extract_or_make_uid) y lo sube por HTTP PUT (caldav_put_event). Devuelve {ok, fail, total, errors}. Idempotente por UID: re-importar el mismo .ics sobrescribe en vez de duplicar. Formaliza el heredoc ad-hoc usado para migrar 98 eventos de Google Calendar a Xandikos. Solo stdlib."
tags: [dav, caldav, ical, ics, vevent, import, calendar, migration, pipelines]
uses_functions: [split_vevents_to_vcalendars_py_infra, extract_or_make_uid_py_infra, caldav_put_event_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, sys, json]
params:
- name: ics_path
desc: "ruta en disco del archivo .ics a importar (export de Google Calendar: un VCALENDAR con N VEVENT)."
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: collection_path
desc: "ruta de la coleccion CalDAV destino (p.ej. '/enmanuel/calendars/calendar/')."
- name: timeout_s
desc: "timeout por PUT individual en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
- name: uid_prefix
desc: "prefijo del UID sintetico para eventos sin UID. Default 'goog-'."
output: "dict: {ok:int, fail:int, total:int, errors:[{uid, error}, ...]}. ok=eventos subidos con exito, fail=eventos que fallaron, total=VEVENT en el .ics, errors=detalle de los fallos (uid + mensaje, sin datos sensibles)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/import_ics_to_caldav.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from pipelines.import_ics_to_caldav import import_ics_to_caldav
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
summary = import_ics_to_caldav(
ics_path="/home/enmanuel/Descargas/calendar.ics",
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/calendars/calendar/",
)
print(summary["ok"], summary["fail"], summary["total"]) # 98 0 98
```
Desde la CLI del registry (resuelve la pass tu mismo y pasala como arg):
```bash
PW=$(pass show dav/xandikos-enmanuel | head -n1)
./fn run import_ics_to_caldav /home/enmanuel/Descargas/calendar.ics \
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
enmanuel "$PW" /enmanuel/calendars/calendar/
```
## Cuando usarla
Cuando tienes un `.ics` exportado (Google Calendar, otro CalDAV) con todos los
eventos en un unico VCALENDAR y quieres volcarlo entero a Xandikos en una sola
llamada. Reemplaza el flujo ad-hoc hecho a mano en la migracion. Re-ejecutable:
por idempotencia de UID, correrlo dos veces no duplica eventos.
## Gotchas
- Solo importa VEVENT. Si el .ics trae VTODO o VJOURNAL, esos componentes se
ignoran (split_vevents_to_vcalendars solo extrae VEVENT).
- Las VTIMEZONE del original se replican en CADA evento subido (conservador):
garantiza que cualquier TZID referenciado este definido.
- Escritura remota masiva secuencial: una request por evento. Cada PUT respeta
`timeout_s`.
- Password por HTTP Basic sobre TLS; leela de `pass`, no la hardcodees ni la
dejes en el historial del shell sin cuidado.
- `errors` lista solo uid + mensaje, nunca el contenido del evento.
@@ -0,0 +1,83 @@
"""Pipeline: importa un archivo .ics completo a una coleccion CalDAV.
Compone funciones del registry: lee el .ics del disco, parte el VCALENDAR en N
VCALENDARs independientes con un VEVENT cada uno (split_vevents_to_vcalendars),
por cada uno extrae o sintetiza el UID (extract_or_make_uid) y lo sube via HTTP
PUT (caldav_put_event). Devuelve un resumen {ok, fail, total, errors}. Impuro
(I/O de disco + red). Solo stdlib.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from infra.split_vevents_to_vcalendars import split_vevents_to_vcalendars
from infra.extract_or_make_uid import extract_or_make_uid
from infra.caldav_put_event import caldav_put_event
def import_ics_to_caldav(
ics_path: str,
base_url: str,
username: str,
password: str,
collection_path: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
uid_prefix: str = "goog-",
) -> dict:
"""Importa todos los eventos de un .ics a una coleccion CalDAV.
Args:
ics_path: ruta en disco del archivo .ics a importar.
base_url: URL base del servidor DAV.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth.
collection_path: ruta de la coleccion CalDAV destino.
timeout_s: timeout por PUT en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
uid_prefix: prefijo del UID sintetico cuando un evento no trae UID.
Returns:
dict: {ok:int, fail:int, total:int, errors:[{uid:str, error:str}, ...]}.
ok = eventos subidos con exito; fail = eventos que fallaron; total =
eventos (VEVENT) encontrados en el .ics.
"""
with open(ics_path, "r", encoding="utf-8", errors="replace") as fh:
data = fh.read()
vcalendars = split_vevents_to_vcalendars(data)
ok = 0
fail = 0
errors = []
for cal in vcalendars:
uid = extract_or_make_uid(cal, prefix=uid_prefix)
res = caldav_put_event(
base_url, username, password, collection_path, uid, cal,
timeout_s=timeout_s, verify_tls=verify_tls,
)
if res.get("status") == "ok":
ok += 1
else:
fail += 1
errors.append({"uid": uid, "error": res.get("error", "unknown")})
return {"ok": ok, "fail": fail, "total": len(vcalendars), "errors": errors}
if __name__ == "__main__":
import json
if len(sys.argv) < 6:
print(
"uso: import_ics_to_caldav.py <ics_path> <base_url> <username> "
"<password> <collection_path>",
file=sys.stderr,
)
sys.exit(2)
summary = import_ics_to_caldav(
sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
)
print(json.dumps({k: summary[k] for k in ("ok", "fail", "total")}))
@@ -0,0 +1,88 @@
---
name: import_vcf_to_carddav
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def import_vcf_to_carddav(vcf_path: str, base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True, uid_prefix: str = 'goog-') -> dict"
description: "Pipeline que importa un archivo .vcf completo a una coleccion CardDAV. Lee el .vcf del disco, lo parte en VCARDs (split_vcards), por cada tarjeta extrae o sintetiza el UID (extract_or_make_uid), inyecta el UID en la tarjeta si faltaba, y la sube por HTTP PUT (carddav_put_vcard). Devuelve {ok, fail, total, errors}. Idempotente por UID: re-importar el mismo .vcf sobrescribe en vez de duplicar. Formaliza el heredoc ad-hoc usado para migrar 820 contactos de Google a Xandikos. Solo stdlib."
tags: [dav, carddav, vcard, import, contacts, migration, pipelines]
uses_functions: [split_vcards_py_infra, extract_or_make_uid_py_infra, carddav_put_vcard_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, sys, json]
params:
- name: vcf_path
desc: "ruta en disco del archivo .vcf a importar (export de Google Contacts con N tarjetas concatenadas)."
- name: base_url
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
- name: username
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
- name: password
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
- name: collection_path
desc: "ruta de la coleccion CardDAV destino (p.ej. '/enmanuel/contacts/addressbook/')."
- name: timeout_s
desc: "timeout por PUT individual en segundos. Default 20.0."
- name: verify_tls
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
- name: uid_prefix
desc: "prefijo del UID sintetico para tarjetas sin UID. Default 'goog-'."
output: "dict: {ok:int, fail:int, total:int, errors:[{uid, error}, ...]}. ok=tarjetas subidas con exito, fail=tarjetas que fallaron, total=tarjetas en el .vcf, errors=detalle de los fallos (uid + mensaje, sin datos sensibles)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/import_vcf_to_carddav.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from infra.pass_get_secret import pass_get_secret
from pipelines.import_vcf_to_carddav import import_vcf_to_carddav
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
summary = import_vcf_to_carddav(
vcf_path="/home/enmanuel/Descargas/contacts.vcf",
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
username="enmanuel",
password=pw,
collection_path="/enmanuel/contacts/addressbook/",
)
print(summary["ok"], summary["fail"], summary["total"]) # 820 0 820
```
Desde la CLI del registry (resuelve la pass tu mismo y pasala como arg):
```bash
PW=$(pass show dav/xandikos-enmanuel | head -n1)
./fn run import_vcf_to_carddav /home/enmanuel/Descargas/contacts.vcf \
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
enmanuel "$PW" /enmanuel/contacts/addressbook/
```
## Cuando usarla
Cuando tienes un `.vcf` exportado (Google Contacts, iCloud, otro CardDAV) y
quieres volcarlo entero a Xandikos en una sola llamada en vez de subir tarjeta a
tarjeta con heredocs. Reemplaza el flujo ad-hoc que se hizo a mano para la
migracion. Re-ejecutable: por idempotencia de UID, correrlo dos veces no
duplica contactos.
## Gotchas
- Escritura remota masiva: sube una request por tarjeta secuencialmente. Para
miles de contactos puede tardar; cada PUT respeta `timeout_s`.
- Lee TODO el .vcf en memoria; para archivos de cientos de MB considera trocear.
- La password va por HTTP Basic sobre TLS; leela de `pass`, no la hardcodees ni
la pongas en el historial del shell sin cuidado (usa una variable como en el
ejemplo CLI).
- `errors` lista solo uid + mensaje, nunca el contenido de la tarjeta.
- Si una tarjeta no traia UID, el pipeline inyecta `UID:<goog-md5>` antes del
END:VCARD para que el campo UID: y el nombre del recurso queden consistentes.
@@ -0,0 +1,87 @@
"""Pipeline: importa un archivo .vcf completo a una coleccion CardDAV.
Compone funciones del registry: lee el .vcf del disco, lo parte en VCARDs
individuales (split_vcards), por cada tarjeta extrae o sintetiza el UID
(extract_or_make_uid) y la sube via HTTP PUT (carddav_put_vcard). Devuelve un
resumen {ok, fail, total, errors}. Impuro (I/O de disco + red). Solo stdlib.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from infra.split_vcards import split_vcards
from infra.extract_or_make_uid import extract_or_make_uid
from infra.carddav_put_vcard import carddav_put_vcard
def import_vcf_to_carddav(
vcf_path: str,
base_url: str,
username: str,
password: str,
collection_path: str,
*,
timeout_s: float = 20.0,
verify_tls: bool = True,
uid_prefix: str = "goog-",
) -> dict:
"""Importa todas las tarjetas de un .vcf a una coleccion CardDAV.
Args:
vcf_path: ruta en disco del archivo .vcf a importar.
base_url: URL base del servidor DAV.
username: usuario para HTTP Basic auth.
password: contrasena para HTTP Basic auth.
collection_path: ruta de la coleccion CardDAV destino.
timeout_s: timeout por PUT en segundos. Default 20.0.
verify_tls: si True (default) verifica el certificado TLS.
uid_prefix: prefijo del UID sintetico cuando una tarjeta no trae UID.
Returns:
dict: {ok:int, fail:int, total:int, errors:[{uid:str, error:str}, ...]}.
ok = tarjetas subidas con exito; fail = tarjetas que fallaron; total =
tarjetas encontradas en el .vcf.
"""
with open(vcf_path, "r", encoding="utf-8", errors="replace") as fh:
data = fh.read()
cards = split_vcards(data)
ok = 0
fail = 0
errors = []
for card in cards:
uid = extract_or_make_uid(card, prefix=uid_prefix)
# Si la tarjeta no declaraba UID, inyectarlo antes del END:VCARD para que
# el campo UID: y el nombre del recurso queden consistentes.
if "UID:" not in card:
card = card.replace("END:VCARD", "UID:%s\r\nEND:VCARD" % uid)
res = carddav_put_vcard(
base_url, username, password, collection_path, uid, card,
timeout_s=timeout_s, verify_tls=verify_tls,
)
if res.get("status") == "ok":
ok += 1
else:
fail += 1
errors.append({"uid": uid, "error": res.get("error", "unknown")})
return {"ok": ok, "fail": fail, "total": len(cards), "errors": errors}
if __name__ == "__main__":
import json
if len(sys.argv) < 6:
print(
"uso: import_vcf_to_carddav.py <vcf_path> <base_url> <username> "
"<password> <collection_path>",
file=sys.stderr,
)
sys.exit(2)
summary = import_vcf_to_carddav(
sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
)
# No imprimir errores con datos sensibles; solo conteos + uids.
print(json.dumps({k: summary[k] for k in ("ok", "fail", "total")}))