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,216 @@
"""Tests para build_obsidian_graph.
Construyen mini-vaults temporales con notas en personas/ y organizaciones/,
wikilinks entre ellas (incluido uno roto y uno con acentos) y comprueban el
grafo resultante: numero de nodos/aristas, resolucion de wikilinks acentuados,
marcado de dangling y robustez ante enlaces rotos.
"""
import os
import tempfile
from obsidian.build_obsidian_graph import build_obsidian_graph
def _write(vault: str, rel_path: str, content: str) -> None:
"""Escribe una nota .md en el mini-vault, creando carpetas si hace falta."""
full = os.path.join(vault, rel_path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
def _build_sample_vault(vault: str) -> None:
"""Crea un vault de prueba con 4 notas reales + 1 wikilink dangling.
Grafo esperado (aristas dirigidas):
ana -> bruno (## Relaciones, kind relacion)
ana -> maria-del-mar (## Relaciones, kind relacion, acento)
ana -> acme-sl (## Documentos, kind documento)
bruno -> persona-fantasma (## Relaciones, NO existe -> dangling)
acme-sl-> ana (cuerpo suelto, kind wikilink)
"""
# .obsidian/ debe ignorarse por completo.
_write(vault, ".obsidian/app.json", "{}")
_write(
vault,
"personas/ana.md",
"---\n"
"tipo: persona\n"
"nombre: Ana Gómez\n"
"---\n\n"
"## Relaciones\n"
"- [[bruno]] — amigo\n"
"- [[María del Mar]] — vecina\n\n"
"## Documentos\n"
"- [[organizaciones/acme-sl|Acme SL]]\n",
)
_write(
vault,
"personas/bruno.md",
"---\n"
"tipo: persona\n"
"nombre: Bruno Ruiz\n"
"---\n\n"
"## Relaciones\n"
"- [[Persona Fantasma]] — desconocido\n",
)
# Nota con acento en el nombre de archivo cuyo slug es maria-del-mar.
_write(
vault,
"personas/maria-del-mar.md",
"---\n"
"tipo: persona\n"
"nombre: María del Mar\n"
"---\n\n"
"## Notas\n"
"Sin enlaces.\n",
)
_write(
vault,
"organizaciones/acme-sl.md",
"---\n"
"tipo: organizacion\n"
"nombre: Acme SL\n"
"---\n\n"
"Cliente de [[ana]].\n",
)
def test_golden_graph_node_and_edge_counts():
"""Golden: el grafo del mini-vault tiene los nodos y aristas esperados."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
graph = build_obsidian_graph(vault, include_dangling=True)
real = [n for n in graph["nodes"] if not n.get("dangling")]
dangling = [n for n in graph["nodes"] if n.get("dangling")]
# 4 notas reales + 1 nodo fantasma (persona-fantasma).
assert len(real) == 4, [n["id"] for n in real]
assert len(dangling) == 1, [n["id"] for n in dangling]
# 5 aristas: ana->bruno, ana->maria-del-mar, ana->acme-sl,
# bruno->persona-fantasma, acme-sl->ana.
assert len(graph["edges"]) == 5, graph["edges"]
ids = {n["id"] for n in real}
assert ids == {"ana", "bruno", "maria-del-mar", "acme-sl"}, ids
# El tipo y el label salen del frontmatter.
ana = next(n for n in real if n["id"] == "ana")
assert ana["tipo"] == "persona"
assert ana["label"] == "Ana Gómez"
assert ana["frontmatter"]["nombre"] == "Ana Gómez"
def test_edge_resolves_wikilink_with_accents():
"""Edge: [[María del Mar]] resuelve al nodo slug maria-del-mar."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
graph = build_obsidian_graph(vault, include_dangling=True)
edge = next(
(e for e in graph["edges"] if e["source"] == "ana" and e["target"] == "maria-del-mar"),
None,
)
assert edge is not None, graph["edges"]
assert edge["kind"] == "relacion", edge
def test_edge_kind_from_section():
"""Edge: el kind de la arista se deduce de la seccion ## donde aparece."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
graph = build_obsidian_graph(vault, include_dangling=True)
kinds = {(e["source"], e["target"]): e["kind"] for e in graph["edges"]}
assert kinds[("ana", "bruno")] == "relacion", kinds
assert kinds[("ana", "acme-sl")] == "documento", kinds
# acme-sl -> ana esta fuera de cualquier seccion -> wikilink por defecto.
assert kinds[("acme-sl", "ana")] == "wikilink", kinds
def test_edge_dangling_marked_and_excluded():
"""Edge: dangling=True crea nodo fantasma; dangling=False lo descarta."""
with tempfile.TemporaryDirectory() as vault:
_build_sample_vault(vault)
with_dangling = build_obsidian_graph(vault, include_dangling=True)
ghost = next(
(n for n in with_dangling["nodes"] if n["id"] == "persona-fantasma"),
None,
)
assert ghost is not None and ghost.get("dangling") is True, with_dangling["nodes"]
assert ghost["tipo"] == "desconocido", ghost
# La arista hacia el fantasma existe.
assert any(
e["target"] == "persona-fantasma" for e in with_dangling["edges"]
), with_dangling["edges"]
without_dangling = build_obsidian_graph(vault, include_dangling=False)
assert all(
n["id"] != "persona-fantasma" for n in without_dangling["nodes"]
), without_dangling["nodes"]
# La arista rota se descarta junto con el nodo fantasma.
assert all(
e["target"] != "persona-fantasma" for e in without_dangling["edges"]
), without_dangling["edges"]
# Pasamos de 5 a 4 aristas (se elimina bruno->persona-fantasma).
assert len(without_dangling["edges"]) == 4, without_dangling["edges"]
def test_edge_tipo_falls_back_to_folder():
"""Edge: sin campo 'tipo' en frontmatter, el tipo sale de la carpeta."""
with tempfile.TemporaryDirectory() as vault:
_write(vault, "personas/sin-tipo.md", "---\nnombre: Sin Tipo\n---\n\nCuerpo.\n")
_write(vault, "organizaciones/org-sin-tipo.md", "Sin frontmatter.\n")
graph = build_obsidian_graph(vault)
by_id = {n["id"]: n for n in graph["nodes"]}
assert by_id["sin-tipo"]["tipo"] == "persona", by_id["sin-tipo"]
assert by_id["org-sin-tipo"]["tipo"] == "organizacion", by_id["org-sin-tipo"]
# Sin frontmatter ni nombre, el label cae al slug.
assert by_id["org-sin-tipo"]["label"] == "org-sin-tipo", by_id["org-sin-tipo"]
def test_error_path_broken_wikilink_no_crash():
"""Error path: un wikilink sintacticamente roto no tumba el grafo."""
with tempfile.TemporaryDirectory() as vault:
# Wikilink sin cerrar, doble corchete suelto y wikilink vacio.
_write(
vault,
"personas/raro.md",
"---\ntipo: persona\nnombre: Raro\n---\n\n"
"Texto con [[ roto y [[ ]] vacio y [[bien]] valido.\n",
)
_write(vault, "personas/bien.md", "---\ntipo: persona\nnombre: Bien\n---\n\nOK\n")
graph = build_obsidian_graph(vault, include_dangling=True)
# No crash; el unico enlace valido se resuelve a 'bien'.
edges = [(e["source"], e["target"]) for e in graph["edges"]]
assert ("raro", "bien") in edges, edges
# El wikilink vacio no genera arista ni nodo fantasma vacio.
assert all(n["id"] for n in graph["nodes"]), graph["nodes"]
def test_error_path_missing_vault_raises():
"""Error path: un vault inexistente lanza FileNotFoundError, no 500 mudo."""
raised = False
try:
build_obsidian_graph("/no/existe/vault/osint")
except FileNotFoundError:
raised = True
assert raised, "build_obsidian_graph deberia lanzar FileNotFoundError"
if __name__ == "__main__":
test_golden_graph_node_and_edge_counts()
test_edge_resolves_wikilink_with_accents()
test_edge_kind_from_section()
test_edge_dangling_marked_and_excluded()
test_edge_tipo_falls_back_to_folder()
test_error_path_broken_wikilink_no_crash()
test_error_path_missing_vault_raises()
print("build_obsidian_graph tests OK")