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
+4
View File
@@ -19,6 +19,9 @@ from .slugify_obsidian_name import slugify_obsidian_name
from .extract_obsidian_embeds import extract_obsidian_embeds
from .resolve_obsidian_embed import resolve_obsidian_embed
# Grafo agregado del vault (grupo obsidian)
from .build_obsidian_graph import build_obsidian_graph
__all__ = [
"parse_obsidian_frontmatter",
"extract_obsidian_wikilinks",
@@ -34,4 +37,5 @@ __all__ = [
"slugify_obsidian_name",
"extract_obsidian_embeds",
"resolve_obsidian_embed",
"build_obsidian_graph",
]
@@ -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.
@@ -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,
)
)
@@ -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")