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,245 @@
"""Construye el grafo agregado de un vault de Obsidian: nodos + aristas.
Funcion impura (lee disco) del grupo de capacidad ``obsidian``. Es la pieza
que cierra la frontera "el grupo obsidian no indexa el grafo agregado":
recorre todas las notas del vault, las convierte en nodos tipados y resuelve
los wikilinks ``[[...]]`` del cuerpo a aristas entre nodos existentes.
Registry-first: compone funciones puras/impuras ya existentes del grupo
``obsidian`` (``list_obsidian_notes``, ``read_obsidian_note``,
``slugify_obsidian_name``) en vez de reimplementar el parseo del vault.
"""
import os
import re
from obsidian.list_obsidian_notes import list_obsidian_notes
from obsidian.read_obsidian_note import read_obsidian_note
from obsidian.slugify_obsidian_name import slugify_obsidian_name
# Carpetas de primer nivel del vault -> tipo de nodo por defecto. El tipo del
# frontmatter (campo ``tipo``) siempre tiene prioridad sobre la carpeta.
_FOLDER_TIPO = {
"personas": "persona",
"organizaciones": "organizacion",
"lugares": "lugar",
"dominios": "dominio",
"casos": "caso",
}
# Encabezados de seccion (## ...) -> kind de las aristas que aparecen debajo.
# Se normaliza el texto del encabezado a un slug para comparar de forma estable
# (acentos, mayusculas y plurales/sinonimos cercanos colapsan al mismo bucket).
_SECTION_KIND = {
"relaciones": "relacion",
"relacionado": "relacion",
"relacion": "relacion",
"lugares": "lugar",
"lugar": "lugar",
"documentos": "documento",
"documento": "documento",
}
# Captura un wikilink [[...]] o embed ![[...]]; el grupo es el interior.
_WIKILINK_RE = re.compile(r"!?\[\[([^\[\]]+?)\]\]")
# Captura un encabezado Markdown ATX (## Titulo). Grupo 1 = texto del titulo.
_HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")
def _tipo_for_note(path: str, vault_dir: str, frontmatter: dict) -> str:
"""Determina el tipo de un nodo: frontmatter['tipo'] o carpeta de 1er nivel.
El campo ``tipo`` del frontmatter manda. Si falta, se usa la primera
carpeta del path relativo al vault mapeada por ``_FOLDER_TIPO``. Si no
encaja en ninguna, el tipo es la propia carpeta de primer nivel (o
``"nota"`` para notas en la raiz del vault).
"""
tipo = frontmatter.get("tipo")
if isinstance(tipo, str) and tipo.strip():
return tipo.strip()
rel = os.path.relpath(path, vault_dir)
parts = rel.split(os.sep)
if len(parts) >= 2:
top = parts[0]
return _FOLDER_TIPO.get(top, top)
return "nota"
def _wikilink_target_slug(raw_target: str) -> str:
"""Reduce el destino de un wikilink a un slug estable para indexar.
El destino que entrega ``extract_obsidian_wikilinks`` ya viene sin alias
(``|...``) ni ancla (``#...``). Aqui se toma el ultimo segmento del path
(Obsidian resuelve ``[[carpeta/nota]]`` por la nota, no por la carpeta) y
se slugifica con ``slugify_obsidian_name`` para que ``[[Maria del Mar]]``
y ``[[maria-del-mar]]`` apunten al mismo nodo.
"""
# Ultimo segmento del path (soporta tanto '/' como '\\' por robustez).
last = re.split(r"[\\/]", raw_target)[-1]
return slugify_obsidian_name(last)
def _iter_body_links_with_kind(body: str):
"""Itera (slug_destino, kind) por cada wikilink del cuerpo, con su seccion.
Recorre el body linea a linea llevando la cuenta de la ultima seccion
``## ...`` vista para asignar el ``kind`` de cada wikilink segun
``_SECTION_KIND`` (relaciones/lugares/documentos). Fuera de esas secciones
el kind por defecto es ``"wikilink"``. No deduplica: cada aparicion produce
un par (la deduplicacion se hace al construir las aristas).
"""
current_kind = "wikilink"
for line in body.splitlines():
heading = _HEADING_RE.match(line)
if heading:
heading_slug = slugify_obsidian_name(heading.group(1))
current_kind = _SECTION_KIND.get(heading_slug, "wikilink")
continue
for match in _WIKILINK_RE.finditer(line):
inner = match.group(1)
target = inner.split("|", 1)[0].split("#", 1)[0].strip()
if not target:
continue
slug = _wikilink_target_slug(target)
if not slug:
continue
yield slug, current_kind
def build_obsidian_graph(vault_dir: str, include_dangling: bool = True) -> dict:
"""Construye el grafo agregado (nodos + aristas) de un vault de Obsidian.
Recorre cada nota ``.md`` del vault (excluyendo ``.obsidian/`` y
``.trash/`` via ``list_obsidian_notes``; ``attachments/`` no contiene
``.md`` y queda fuera de forma natural). Cada nota es un nodo cuyo ``id``
es su slug (nombre de archivo sin ``.md``) y cuyo ``tipo`` sale del campo
``tipo`` del frontmatter o, en su defecto, de la carpeta de primer nivel.
Cada wikilink ``[[...]]`` del cuerpo es una arista dirigida del nodo origen
al nodo destino, resuelto por slug del ultimo segmento del destino.
Args:
vault_dir: Ruta (absoluta o relativa) a la raiz del vault de Obsidian.
include_dangling: Si es ``True`` (por defecto), los wikilinks que no
resuelven a ninguna nota del vault generan un nodo fantasma con
``dangling: true`` y su arista correspondiente. Si es ``False``,
esos wikilinks rotos se descartan (ni nodo fantasma ni arista).
Returns:
dict con dos claves:
- ``nodes``: lista de ``{"id": slug, "tipo": str, "label": str,
"frontmatter": dict}``. Los nodos fantasma anaden
``"dangling": True`` y llevan ``frontmatter`` vacio.
- ``edges``: lista de ``{"source": slug, "target": slug,
"kind": str}`` deduplicada por (source, target, kind). El
``kind`` se deduce de la seccion (``relacion``/``lugar``/
``documento``) o es ``"wikilink"`` por defecto.
Raises:
FileNotFoundError: si ``vault_dir`` no existe.
NotADirectoryError: si ``vault_dir`` no es un directorio.
"""
note_paths = list_obsidian_notes(vault_dir)
# Indice slug -> nodo real. Si dos notas colapsan al mismo slug (raro), la
# primera en orden alfabetico gana (list_obsidian_notes devuelve ordenado).
nodes_by_slug: dict[str, dict] = {}
# Conserva el orden de descubrimiento de los nodos reales para la salida.
real_order: list[str] = []
for path in note_paths:
slug = os.path.splitext(os.path.basename(path))[0]
if not slug or slug in nodes_by_slug:
continue
try:
note = read_obsidian_note(path)
except OSError:
# Una nota ilegible no debe tumbar el grafo entero: se omite.
continue
frontmatter = note.get("frontmatter", {}) or {}
tipo = _tipo_for_note(path, vault_dir, frontmatter)
nombre = frontmatter.get("nombre")
label = str(nombre).strip() if isinstance(nombre, str) and nombre.strip() else slug
nodes_by_slug[slug] = {
"id": slug,
"tipo": tipo,
"label": label,
"frontmatter": frontmatter,
"_path": path, # interno: para resolver enlaces del body
}
real_order.append(slug)
edges: list[dict] = []
seen_edges: set[tuple] = set()
dangling_slugs: list[str] = []
dangling_seen: set[str] = set()
for slug in real_order:
node = nodes_by_slug[slug]
try:
body = read_obsidian_note(node["_path"]).get("body", "") or ""
except OSError:
continue
for target_slug, kind in _iter_body_links_with_kind(body):
if target_slug == slug:
# Auto-enlace: se ignora (no aporta al grafo).
continue
resolved = target_slug in nodes_by_slug
if not resolved:
if not include_dangling:
continue
if target_slug not in dangling_seen:
dangling_seen.add(target_slug)
dangling_slugs.append(target_slug)
edge_key = (slug, target_slug, kind)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
edges.append({"source": slug, "target": target_slug, "kind": kind})
# Serializa nodos reales (sin la clave interna _path) + nodos fantasma.
nodes: list[dict] = []
for slug in real_order:
node = nodes_by_slug[slug]
nodes.append(
{
"id": node["id"],
"tipo": node["tipo"],
"label": node["label"],
"frontmatter": node["frontmatter"],
}
)
if include_dangling:
for slug in dangling_slugs:
nodes.append(
{
"id": slug,
"tipo": "desconocido",
"label": slug,
"frontmatter": {},
"dangling": True,
}
)
return {"nodes": nodes, "edges": edges}
if __name__ == "__main__":
import json
import sys
target_vault = sys.argv[1] if len(sys.argv) > 1 else "/home/enmanuel/Obsidian/osint"
graph = build_obsidian_graph(target_vault)
print(
json.dumps(
{
"vault": target_vault,
"nodes": len(graph["nodes"]),
"edges": len(graph["edges"]),
"dangling": sum(1 for n in graph["nodes"] if n.get("dangling")),
},
ensure_ascii=False,
indent=2,
)
)