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,76 @@
---
name: build_obsidian_graph
kind: function
lang: py
domain: obsidian
version: "1.0.0"
purity: impure
signature: "def build_obsidian_graph(vault_dir: str, include_dangling: bool = True) -> dict"
description: "Construye el grafo agregado (nodos + aristas) de un vault de Obsidian leyendo todas sus notas .md. Cada nota es un nodo tipado (tipo por carpeta o frontmatter, id = slug, label = frontmatter['nombre'] o slug) y cada wikilink [[...]] del cuerpo es una arista dirigida resuelta por slug del ultimo segmento del destino. Los wikilinks rotos se incluyen como nodos fantasma dangling o se descartan segun include_dangling. Compone list_obsidian_notes, read_obsidian_note y slugify_obsidian_name del grupo obsidian. Es la pieza que cierra la frontera 'el grupo obsidian no indexa el grafo agregado'. Base de la vista grafo (sigma.js) de la app osint_web."
tags: [obsidian, graph, vault, nodes, edges, wikilinks, sigma, osint]
uses_functions: ["list_obsidian_notes_py_obsidian", "read_obsidian_note_py_obsidian", "slugify_obsidian_name_py_obsidian"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "re"]
params:
- name: vault_dir
desc: "ruta (absoluta o relativa) a la raiz del vault de Obsidian a indexar; se excluyen .obsidian/ y .trash/"
- name: include_dangling
desc: "si True (por defecto) los wikilinks que no resuelven a ninguna nota generan un nodo fantasma con dangling=True y su arista; si False, esos enlaces rotos se descartan"
output: "dict con 'nodes' (lista de {id: slug, tipo: str, label: str, frontmatter: dict}; los fantasma anaden dangling=True y frontmatter vacio) y 'edges' (lista de {source: slug, target: slug, kind: str} deduplicada; kind es relacion/lugar/documento segun la seccion ## donde aparece el wikilink, o 'wikilink' por defecto)"
tested: true
tests:
- "golden grafo del mini-vault con nodos y aristas esperados"
- "resuelve wikilink con acentos maria del mar al slug"
- "el kind de la arista sale de la seccion del cuerpo"
- "dangling marcado con true y excluido con false"
- "el tipo cae a la carpeta si falta en frontmatter"
- "wikilink sintacticamente roto no tumba el grafo"
- "vault inexistente lanza filenotfounderror"
test_file_path: "python/functions/obsidian/build_obsidian_graph_test.py"
file_path: "python/functions/obsidian/build_obsidian_graph.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from obsidian.build_obsidian_graph import build_obsidian_graph
graph = build_obsidian_graph("/home/enmanuel/Obsidian/osint")
print(len(graph["nodes"]), "nodos,", len(graph["edges"]), "aristas")
# 1199 nodos (984 reales + 215 fantasma), 618 aristas
# Un nodo tipado listo para sigma.js: color por tipo, label visible.
n = next(x for x in graph["nodes"] if x["tipo"] == "persona")
print(n["id"], n["label"], n["tipo"]) # ana-gomez Ana Gómez persona
# Aristas con su kind (relacion / lugar / documento / wikilink).
for e in graph["edges"][:3]:
print(e["source"], "->", e["target"], f"({e['kind']})")
```
Lanzable directo sobre el vault real (imprime conteo de nodos/aristas/dangling):
```bash
cd /home/enmanuel/fn_registry
PYTHONPATH=python/functions python/.venv/bin/python3 \
python/functions/obsidian/build_obsidian_graph.py /home/enmanuel/Obsidian/osint
```
## Cuando usarla
Cuando necesites el grafo entero de un vault de Obsidian de una sola pasada: para pintar una vista de nodos navegable (sigma.js / graphology), para detectar objetivos OSINT aun sin fichar (nodos `dangling`), o para alimentar el endpoint `/api/graph` de una app que lee el vault sin BD intermedia. Es el agregado que las funciones atomicas del grupo `obsidian` (`list_obsidian_notes`, `read_obsidian_note`) no daban por si solas.
## Gotchas
- **Lee todo el vault de disco** (I/O impuro): el grafo refleja el estado de los `.md` en ese instante; vuelve a llamar para refrescar tras editar notas.
- **El `id` de un nodo es el slug = nombre de archivo sin `.md`**. Si dos notas en carpetas distintas comparten ese nombre (caso real en el vault osint: `dni.md`, `fotos.md`, `certificado-digital.md` repetidos dentro de cada subcarpeta `personas/<slug>/`), colapsan al **mismo nodo** y solo la primera en orden alfabetico sobrevive. Por eso el vault con 1022 `.md` produce 984 nodos reales (13 grupos de slugs colisionan). Para evitarlo habria que usar el path relativo como id — se dejo el slug por compatibilidad con la resolucion de wikilinks `[[slug]]`.
- **Resolucion de wikilinks por ultimo segmento**: `[[organizaciones/acme-sl|Acme SL]]` resuelve a la nota cuyo slug es `acme-sl`; el alias (`|...`) y el ancla (`#...`) se ignoran. Nombres con acentos/mayusculas (`[[María del Mar]]`) se slugifican con `slugify_obsidian_name` antes de buscar, asi que resuelven igual que `[[maria-del-mar]]`.
- **`kind` por seccion es heuristico**: depende del texto del encabezado `## ...` mas cercano por encima del wikilink (`Relaciones`/`Relacionado` -> `relacion`, `Lugares` -> `lugar`, `Documentos` -> `documento`, resto -> `wikilink`). Un wikilink fuera de cualquier seccion conocida es `wikilink`.
- **Auto-enlaces ignorados**: si una nota se enlaza a si misma, esa arista no se emite.
- **Nodos fantasma** (`dangling: true`) llevan `tipo: "desconocido"` y `frontmatter` vacio; no representan un `.md` en disco. Con `include_dangling=False` no aparecen ni ellos ni sus aristas.
- Lanza `FileNotFoundError` si `vault_dir` no existe y `NotADirectoryError` si no es un directorio (heredado de `list_obsidian_notes`). Una nota individual ilegible se omite sin tumbar el grafo.