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