"""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, ) )