a76760edba
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)
246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
"""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,
|
|
)
|
|
)
|