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:
@@ -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")
|
||||
Reference in New Issue
Block a user