diff --git a/server/derived.py b/server/derived.py index 14ff345..4ba768c 100644 --- a/server/derived.py +++ b/server/derived.py @@ -9,9 +9,18 @@ el último estado de las maestras. from __future__ import annotations import json +import re # Nombres de las derivadas que este módulo gestiona (para reportar en ingest). -DERIVED_TABLES = ("person_stats", "event_monthly", "contact_link_quality") +DERIVED_TABLES = ( + "person_stats", + "event_monthly", + "contact_link_quality", + "org_contacts", +) + +# Relación persona -> organización en el frontmatter: "[[org-slug]] — rol". +_RELACION_RE = re.compile(r"\[\[([^\]]+)\]\]\s*(?:—|-|–)?\s*(.*)") def rebuild_derived(conn) -> list: @@ -22,6 +31,7 @@ def rebuild_derived(conn) -> list: _rebuild_person_stats(conn) _rebuild_event_monthly(conn) _rebuild_contact_link_quality(conn) + _rebuild_org_contacts(conn) return [f"derived.{name}" for name in DERIVED_TABLES] @@ -81,3 +91,76 @@ def _rebuild_contact_link_quality(conn) -> None: "COUNT(*) FILTER (WHERE note_path IS NULL) AS unlinked " "FROM contacts" ) + + +def _rebuild_org_contacts(conn) -> None: + """Teléfonos de las personas de contacto de cada organización. + + Deriva del campo ``relaciones`` del frontmatter de cada persona (forma + ``"[[org-slug]] — rol"``): por cada persona relacionada con una organización + se emite una fila (org_slug, org_nombre, persona, rol, telefono) por cada + teléfono que tenga la persona. Permite que, al buscar una empresa, aparezcan + todos los teléfonos de sus contactos con el nombre de la persona y su rol. + + Solo datos computados (regla del módulo: sin note_path). El parseo del JSON + de ``extra_fm`` y de los wikilinks se hace en Python para no depender de las + funciones JSON del motor, igual que ``_rebuild_person_stats`` con los tags. + """ + org_names = { + slug: nombre + for slug, nombre in conn.execute( + "SELECT slug, nombre FROM organizations" + ).fetchall() + } + persons = conn.execute( + "SELECT nombre, telefono, telefonos, extra_fm FROM persons" + ).fetchall() + + rows: list = [] + seen: set = set() # (org_slug, persona, telefono) para no duplicar + for nombre, telefono, telefonos_json, extra_fm in persons: + if not extra_fm: + continue + try: + fm = json.loads(extra_fm) + except (TypeError, ValueError): + continue + relaciones = fm.get("relaciones") or [] + if not isinstance(relaciones, list): + relaciones = [relaciones] + + try: + tels = json.loads(telefonos_json) if telefonos_json else [] + except (TypeError, ValueError): + tels = [] + if not isinstance(tels, list): + tels = [tels] + if not tels and telefono: + tels = [telefono] + tels = [str(t).strip() for t in tels if str(t).strip()] + if not tels: + continue + + for rel in relaciones: + m = _RELACION_RE.match(str(rel)) + if not m: + continue + org_slug = m.group(1).strip() + rol = m.group(2).strip() or "contacto" + org_nombre = org_names.get(org_slug, org_slug) + for tel in tels: + key = (org_slug, nombre, tel) + if key in seen: + continue + seen.add(key) + rows.append([org_slug, org_nombre, nombre, rol, tel]) + + conn.execute("DROP TABLE IF EXISTS derived.org_contacts") + conn.execute( + "CREATE TABLE derived.org_contacts (" + "org_slug TEXT, org_nombre TEXT, persona TEXT, rol TEXT, telefono TEXT)" + ) + if rows: + conn.executemany( + "INSERT INTO derived.org_contacts VALUES (?, ?, ?, ?, ?)", rows + ) diff --git a/server/main.py b/server/main.py index a8c328a..24b27ae 100644 --- a/server/main.py +++ b/server/main.py @@ -393,6 +393,12 @@ def create_app(cfg: Config) -> FastAPI: # last-write-wins por etag (incremental, distinto de ingest_dav). return _guard(lambda: writes.pull_dav(cfg)) + @app.post("/api/org/render-contacts") + def org_render_contacts() -> dict: + # Materializa en la ficha .md de cada organización la tabla de sus + # personas de contacto con teléfono y rol (desde derived.org_contacts). + return _guard(lambda: writes.render_all_org_contacts(cfg)) + return app diff --git a/server/writes.py b/server/writes.py index bf33ece..7c652cc 100644 --- a/server/writes.py +++ b/server/writes.py @@ -26,7 +26,7 @@ import tempfile import time from datetime import datetime, timezone -from .config import Config +from .config import SENTINEL_MARKER, Config from .registry_bridge import ( build_vcard, caldav_put_event, @@ -41,7 +41,10 @@ from .registry_bridge import ( duckdb_query_readonly, duckdb_upsert, pass_get_secret, + read_obsidian_note, + render_markdown_table, update_obsidian_note, + upsert_sentinel_block, ) # Columnas de persons gobernadas por la API estructurada (sin slug, que es la @@ -1295,3 +1298,98 @@ def pull_dav(cfg: Config) -> dict: "deleted": deleted, "unchanged": unchanged, } + + +# --------------------------------------------------------------------------- +# Materialización de los contactos de una organización en su ficha .md +# --------------------------------------------------------------------------- + + +def render_org_contacts(cfg: Config, slug: str) -> dict: + """Escribe en la ficha de una organización la tabla de sus contactos. + + Lee de ``derived.org_contacts`` las personas relacionadas con la organización + (con su rol y teléfono) y vuelca una tabla Markdown dentro de un bloque + sentinel ``org-contacts`` en el cuerpo de la nota, sin tocar el resto de la + prosa. Idempotente: re-renderizar reescribe solo ese bloque. + + Devuelve {status:'ok', slug, contactos:N, note_path} o {status:'skip'|'error'}. + """ + slug = (slug or "").strip() + if not slug: + return {"status": "error", "error": "slug vacío"} + + org = duckdb_query_readonly( + cfg.db_path, "SELECT note_path FROM organizations WHERE slug = ?", [slug], 1 + ) + if org.get("status") != "ok" or not org.get("rows"): + return {"status": "error", "error": f"organización desconocida: {slug!r}"} + note_path = org["rows"][0].get("note_path") + if not note_path: + return {"status": "skip", "slug": slug, "reason": "sin note_path"} + + contacts = duckdb_query_readonly( + cfg.db_path, + "SELECT persona, rol, telefono FROM derived.org_contacts " + "WHERE org_slug = ? ORDER BY persona, telefono", + [slug], + 500, + ) + rows = contacts.get("rows") or [] + if not rows: + return {"status": "skip", "slug": slug, "reason": "sin contactos"} + + table_md = render_markdown_table( + rows, columns=["persona", "rol", "telefono"], max_rows=500 + ) + content = "### Contactos\n\n" + (table_md or "_(sin contactos)_") + + rel = note_path if note_path.endswith(".md") else note_path + ".md" + abs_path = os.path.abspath(os.path.join(cfg.vault_dir, rel)) + vault_real = os.path.realpath(cfg.vault_dir) + if not os.path.realpath(abs_path).startswith(vault_real + os.sep): + return {"status": "error", "error": f"note_path fuera del vault: {rel!r}"} + if not os.path.exists(abs_path): + return {"status": "skip", "slug": slug, "reason": "nota inexistente"} + + note = read_obsidian_note(abs_path) + new_body = upsert_sentinel_block( + note.get("body", "") or "", "org-contacts", content, marker=SENTINEL_MARKER + ) + update_obsidian_note(abs_path, body=new_body) + return { + "status": "ok", + "slug": slug, + "contactos": len(rows), + "note_path": rel, + } + + +def render_all_org_contacts(cfg: Config) -> dict: + """Materializa la tabla de contactos en TODAS las organizaciones que tengan. + + Recorre las organizaciones con al menos una fila en ``derived.org_contacts`` + y llama a ``render_org_contacts`` por cada una. Devuelve conteos agregados. + """ + orgs = duckdb_query_readonly( + cfg.db_path, + "SELECT DISTINCT org_slug FROM derived.org_contacts ORDER BY org_slug", + [], + 100000, + ) + rendered = skipped = errors = 0 + for row in orgs.get("rows") or []: + res = render_org_contacts(cfg, row["org_slug"]) + status = res.get("status") + if status == "ok": + rendered += 1 + elif status == "skip": + skipped += 1 + else: + errors += 1 + return { + "status": "ok", + "rendered": rendered, + "skipped": skipped, + "errors": errors, + }