diff --git a/app.md b/app.md index 82c079d..5dc3698 100644 --- a/app.md +++ b/app.md @@ -13,9 +13,18 @@ uses_functions: - slugify_obsidian_name_py_obsidian - dav_get_collection_py_infra - dav_list_calendars_py_infra + - dav_list_addressbooks_py_infra - dav_collection_ctag_py_infra + - carddav_put_vcard_py_infra + - caldav_put_event_py_infra + - dav_delete_resource_py_infra + - dav_make_addressbook_py_infra + - dav_make_calendar_py_infra - pass_get_secret_py_infra - duckdb_query_readonly_py_infra + - duckdb_execute_py_infra + - duckdb_upsert_py_infra + - build_vcard_py_core - render_markdown_table_py_core - upsert_sentinel_block_py_core uses_types: [] @@ -59,7 +68,14 @@ en el body (el plugin parsea el body, no el código HTTP). completo del vault) + `persons`, `organizations`, `domains`, `cases`, `places` (fichas de nivel-1 de cada carpeta de entidades, excluyendo las notas con prefijo `_`). Cada una lleva `note_path`: el path relativo de la - nota dentro del vault. + nota dentro del vault. **`persons` es dueña de sus campos estructurados** + (multi-valor `telefonos`/`emails`/`direcciones` JSON + singulares de compat + `telefono`/`email`/`direccion`): la API los edita y los materializa a la + nota. El ingest del vault es **selectivo** para `persons` — una ficha que ya + existe en la DB solo refresca `note_path` + `extra_fm` (el frontmatter + no-owned), conservando los campos OWNED; una ficha nueva se inserta completa + (bootstrap desde el frontmatter). `addressbooks` (schema `main`) registra + las libretas CardDAV: el ingest DAV las recorre todas (no solo la fija). 2. **Maestras DAV** (schema `main`): `contacts` y `events` importados de Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path` se enlaza contra `persons` matcheando por UID `osint-`, por el @@ -104,9 +120,16 @@ Health check: `curl http://127.0.0.1:8771/api/health`. | POST | `/api/query` | `{sql, params, max_rows}` → respuesta exacta de `duckdb_query_readonly` (solo lectura) | | GET | `/api/queries` | catálogo de queries con nombre (`server/named_queries.py`) | | POST | `/api/query/named` | `{name, max_rows}` → misma shape que `/api/query` | -| POST | `/api/ingest/vault` | escanea el vault completo y reconstruye notes + entidades + derivadas | -| POST | `/api/ingest/dav` | baja Xandikos (CardDAV + cada calendario CalDAV), reconstruye contacts/events, enlaza y reconstruye derivadas | +| POST | `/api/ingest/vault` | escanea el vault completo; notes y entidades de espejo puro se reemplazan, persons se ingesta SELECTIVO (existentes solo `note_path`+`extra_fm`, nuevas bootstrap completo) | +| POST | `/api/ingest/dav` | baja TODAS las libretas registradas en `addressbooks` + cada calendario CalDAV, reconstruye contacts/events, enlaza y reconstruye derivadas | | POST | `/api/render/note` | `{note_path, block_id, sql\|query, title?}` → tabla Markdown upsertada como bloque sentinel `osintdb` en la nota (la crea si no existe) | +| POST/PUT/DELETE | `/api/person[/{slug}]` | CRUD de personas multi-valor (`telefonos`/`emails`/`direcciones` listas). Tras escribir la DB, materializa la ficha DB→nota (singulares = `lista[0]`) sin tocar la prosa | +| POST | `/api/person/{slug}/render` | re-materializa la ficha DB→nota (frontmatter OWNED + merge `extra_fm`, preserva el body) | +| POST/PUT/DELETE | `/api/contact[/{uid}]` | CRUD de contactos CardDAV (`tels`/`emails` listas). Tras la DB, push DB→Xandikos (`build_vcard`+`carddav_put_vcard`, o `dav_delete_resource` en delete) fuera de la transacción | +| POST/PUT/DELETE | `/api/event[/{uid}]` | CRUD de eventos CalDAV. Push `caldav_put_event`/`dav_delete_resource` | +| POST | `/api/addressbook` | `{slug, display_name?, description?, color?}` → `dav_make_addressbook` + INSERT en `addressbooks` | +| POST | `/api/calendar` | `{slug, display_name?, color?}` → `dav_make_calendar` (paridad) | +| POST | `/api/push/dav` | reconcilia en bloque: recorre `contacts` y `events` de la DB y los empuja a Xandikos (PUT, sin borrar). Útil tras la migración | Queries con nombre incluidas: `personas_por_contexto`, `personas_recientes`, `eventos_proximos`, `contactos_sin_nota`, `stats_personas`, diff --git a/migrations/002_multivalue.sql b/migrations/002_multivalue.sql new file mode 100644 index 0000000..bf266f7 --- /dev/null +++ b/migrations/002_multivalue.sql @@ -0,0 +1,26 @@ +-- Migración 002: campos multi-valor en persons + frontmatter no-owned. +-- +-- La inversión "DuckDB como fuente de verdad" hace que las fichas de persona +-- puedan tener varios teléfonos, emails y direcciones (no solo el singular del +-- frontmatter). Se añaden columnas JSON con la lista completa y se mantienen las +-- columnas singulares (telefono/email/direccion) por compatibilidad: el service +-- las rellena con el primer elemento de cada lista al materializar la ficha. +-- +-- extra_fm guarda las claves del frontmatter de la nota que NO son campos OWNED +-- de la DB ni claves de control (slug/tipo/fuente/note_path): así un re-ingest +-- del vault puede refrescarlas sin pisar lo que la DB posee. +-- +-- DuckDB 1.5.3: soporta ALTER TABLE ADD COLUMN IF NOT EXISTS y to_json([...]). +-- Aditiva e idempotente. + +ALTER TABLE persons ADD COLUMN IF NOT EXISTS telefonos JSON; +ALTER TABLE persons ADD COLUMN IF NOT EXISTS emails JSON; +ALTER TABLE persons ADD COLUMN IF NOT EXISTS direcciones JSON; +ALTER TABLE persons ADD COLUMN IF NOT EXISTS extra_fm JSON; + +-- Backfill: deriva las listas desde los singulares existentes la primera vez. +-- COALESCE deja '[]' donde no había singular, para no dejar NULLs en las listas. +UPDATE persons SET + telefonos = CASE WHEN telefonos IS NULL AND telefono IS NOT NULL THEN to_json([telefono]) ELSE COALESCE(telefonos, '[]') END, + emails = CASE WHEN emails IS NULL AND email IS NOT NULL THEN to_json([email]) ELSE COALESCE(emails, '[]') END, + direcciones = CASE WHEN direcciones IS NULL AND direccion IS NOT NULL THEN to_json([direccion]) ELSE COALESCE(direcciones,'[]') END; diff --git a/migrations/003_addressbooks.sql b/migrations/003_addressbooks.sql new file mode 100644 index 0000000..cef259b --- /dev/null +++ b/migrations/003_addressbooks.sql @@ -0,0 +1,23 @@ +-- Migración 003: tabla de libretas CardDAV (addressbooks). +-- +-- La DB es la fuente de verdad de las libretas de contactos: el ingest DAV +-- itera todas las libretas registradas aquí (no solo la colección fija) y cada +-- contacto guarda su collection real. Los endpoints de escritura crean libretas +-- nuevas en Xandikos y las registran aquí. +-- +-- Aditiva e idempotente: CREATE TABLE IF NOT EXISTS + seed ON CONFLICT DO NOTHING. + +CREATE TABLE IF NOT EXISTS addressbooks ( + slug VARCHAR PRIMARY KEY, + display_name VARCHAR, + collection_path VARCHAR, + description VARCHAR, + color VARCHAR, + created_at TIMESTAMP DEFAULT now() +); + +-- Seed idempotente de la libreta por defecto (la que apunta config.py por +-- defecto). Re-aplicar la migración no la duplica. +INSERT INTO addressbooks (slug, display_name, collection_path, description, color) +VALUES ('addressbook', 'Contactos', '/enmanuel/contacts/addressbook/', NULL, NULL) +ON CONFLICT (slug) DO NOTHING; diff --git a/server/ingest.py b/server/ingest.py index 6e72c3c..96ab45f 100644 --- a/server/ingest.py +++ b/server/ingest.py @@ -1,9 +1,21 @@ """Ingests del service osint_db: vault Obsidian y servidor DAV (Xandikos). -Las tablas maestras se reconstruyen por reemplazo completo (DELETE + INSERT en -una transacción): el vault y Xandikos son las fuentes de verdad, así que cada -ingest deja la base exactamente como el origen. Tras cada ingest se re-enlazan -los contactos con sus fichas y se reconstruyen las tablas derivadas. +Inversión "DuckDB como fuente de verdad": las tablas de espejo puro (notes, y +las entidades que solo guardan frontmatter JSON: organizations/domains/cases/ +places) se reconstruyen por reemplazo completo (DELETE + INSERT). Pero persons +ya NO es un espejo del frontmatter: la DB es la dueña de sus campos estructurados +(teléfonos, emails, direcciones, dni, ...), que se editan por la API y se +materializan a la nota. Por eso el ingest del vault es SELECTIVO para persons: + + - slug que YA existe en la DB -> solo refresca note_path + extra_fm (el + frontmatter no-owned); los campos OWNED de la DB se conservan. + - slug NUEVO -> INSERT completo (bootstrap): adopta el frontmatter como valor + inicial, poblando las listas multi-valor desde los singulares o las listas + del frontmatter. + +contacts y events siguen siendo espejo puro de Xandikos (DELETE + INSERT). Tras +cada ingest se re-enlazan los contactos con sus fichas y se reconstruyen las +derivadas. Todo bajo el lock single-writer del service. """ from __future__ import annotations @@ -19,12 +31,65 @@ from .db import write_conn from .derived import rebuild_derived from .registry_bridge import ( dav_get_collection, + dav_list_addressbooks, dav_list_calendars, + duckdb_upsert, list_obsidian_notes, pass_get_secret, read_obsidian_note, ) +# Campos de persons de los que la DB es dueña: un re-ingest del vault NO los +# pisa cuando la ficha ya existe. Incluye los singulares (derivados de las +# listas) y fuente (origen de la ficha). dav_uid se deriva de fuente. +PERSON_OWNED = ( + "nombre", + "aliases", + "sexo", + "fecha_nacimiento", + "dni", + "telefono", + "email", + "direccion", + "telefonos", + "emails", + "direcciones", + "pais", + "contexto", + "fuente", + "dav_uid", + "tags", +) + +# Claves de control del frontmatter que no van a extra_fm (ni son OWNED): las +# gestiona el propio ingest o son metadata de la nota. +PERSON_CONTROL = ("slug", "tipo", "note_path") + +# Orden de columnas de la tabla persons (debe casar con migrations 001 + 002). +PERSON_COLUMNS = ( + "slug", + "note_path", + "nombre", + "aliases", + "sexo", + "fecha_nacimiento", + "dni", + "telefono", + "email", + "direccion", + "pais", + "contexto", + "fuente", + "dav_uid", + "tags", + "updated_at", + "telefonos", + "emails", + "direcciones", + "extra_fm", +) + + def _norm(value): """Normaliza 'null'/''/None del frontmatter a None real.""" if value is None: @@ -48,6 +113,22 @@ def _as_list(value) -> list: return v if isinstance(v, list) else [v] +def _multi(fm: dict, plural: str, singular: str) -> list: + """Lista multi-valor desde el frontmatter: prioriza la clave plural. + + Si el frontmatter trae la clave plural (telefonos/emails/direcciones) la + usa; si no, envuelve el singular (telefono/email/direccion) en lista. Cada + valor se normaliza a str y se descartan los vacíos. + """ + raw = _as_list(fm.get(plural)) or _as_list(fm.get(singular)) + out = [] + for item in raw: + s = _as_str(item) + if s: + out.append(s) + return out + + def _json(value) -> str: """Serializa un valor a JSON compacto (sin escapar acentos).""" return json.dumps(value, ensure_ascii=False, default=str) @@ -61,17 +142,75 @@ def _dav_uid_from_fuente(fuente) -> str | None: return m.group(1) if m else None +def _extra_fm(fm: dict) -> str: + """JSON con las claves del frontmatter que NO son OWNED ni de control. + + Permite que un re-ingest refresque el frontmatter no estructurado de la + ficha (campos libres que la DB no posee) sin pisar lo que la DB sí posee. + Las claves plurales multi-valor también se excluyen (son OWNED). + """ + skip = set(PERSON_OWNED) | set(PERSON_CONTROL) + extra = {k: v for k, v in fm.items() if k not in skip} + return _json(extra) + + +def _person_row_from_fm(slug: str, rel_path: str, fm: dict, mtime, base: str) -> dict: + """Construye la fila completa de persons desde el frontmatter (bootstrap). + + Se usa SOLO para fichas cuyo slug aún no existe en la DB: adopta el + frontmatter como valor inicial de los campos OWNED, derivando las listas + multi-valor desde el plural o el singular del frontmatter y rellenando los + singulares con el primer elemento de cada lista. + """ + telefonos = _multi(fm, "telefonos", "telefono") + emails = _multi(fm, "emails", "email") + direcciones = _multi(fm, "direcciones", "direccion") + return { + "slug": slug, + "note_path": rel_path, + "nombre": _as_str(fm.get("nombre")) or base, + "aliases": _json(_as_list(fm.get("aliases"))), + "sexo": _as_str(fm.get("sexo")), + "fecha_nacimiento": _as_str(fm.get("fecha_nacimiento")), + "dni": _as_str(fm.get("dni")), + "telefono": telefonos[0] if telefonos else None, + "email": emails[0] if emails else None, + "direccion": direcciones[0] if direcciones else None, + "pais": _as_str(fm.get("pais")), + "contexto": _as_str(fm.get("contexto")), + "fuente": _as_str(fm.get("fuente")), + "dav_uid": _dav_uid_from_fuente(fm.get("fuente")), + "tags": _json(_as_list(fm.get("tags"))), + "updated_at": mtime, + "telefonos": _json(telefonos), + "emails": _json(emails), + "direcciones": _json(direcciones), + "extra_fm": _extra_fm(fm), + } + + def ingest_vault(cfg: Config) -> dict: """Escanea el vault completo y reconstruye notes + tablas de entidades. - Devuelve {status:'ok', notes:N, persons:N, organizations:N, domains:N, - cases:N, places:N, skipped_unreadable:N, derived_rebuilt:[...]}. + notes y las entidades de espejo puro (organizations/domains/cases/places) + se reemplazan por completo. persons se ingesta de forma SELECTIVA: las + fichas existentes solo refrescan note_path + extra_fm (conservando los + campos OWNED de la DB), las nuevas se insertan completas (bootstrap). + + Devuelve {status:'ok', notes:N, persons:N, persons_inserted:N, + persons_updated:N, organizations:N, domains:N, cases:N, places:N, + skipped_unreadable:N, derived_rebuilt:[...]}. """ if not os.path.isdir(cfg.vault_dir): return {"status": "error", "error": f"vault no encontrado: {cfg.vault_dir}"} note_rows: list = [] - entity_rows: dict = {table: [] for _, _, table in cfg.entity_folders} + # Filas de espejo puro (todas las entidades menos persons). + mirror_rows: dict = { + table: [] for _, _, table in cfg.entity_folders if table != "persons" + } + # Fichas de persona, deduplicadas por slug, como dicts {slug, fm, ...}. + person_fichas: dict = {} folder_to_table = {folder: table for folder, _, table in cfg.entity_folders} skipped = 0 @@ -105,28 +244,17 @@ def ingest_vault(cfg: Config) -> dict: if top_folder in folder_to_table and is_level1 and not base.startswith("_"): table = folder_to_table[top_folder] if table == "persons": - entity_rows[table].append( - [ - slug, - rel_path, - _as_str(fm.get("nombre")) or base, - _json(_as_list(fm.get("aliases"))), - _as_str(fm.get("sexo")), - _as_str(fm.get("fecha_nacimiento")), - _as_str(fm.get("dni")), - _as_str(fm.get("telefono")), - _as_str(fm.get("email")), - _as_str(fm.get("direccion")), - _as_str(fm.get("pais")), - _as_str(fm.get("contexto")), - _as_str(fm.get("fuente")), - _dav_uid_from_fuente(fm.get("fuente")), - _json(_as_list(fm.get("tags"))), - mtime, - ] - ) + # Gana la primera ficha vista con cada slug (respeta la PK). + if slug not in person_fichas: + person_fichas[slug] = { + "slug": slug, + "rel_path": rel_path, + "fm": fm, + "mtime": mtime, + "base": base, + } else: - entity_rows[table].append( + mirror_rows[table].append( [ slug, rel_path, @@ -137,7 +265,6 @@ def ingest_vault(cfg: Config) -> dict: ] ) - derived_rebuilt: list = [] with write_conn(cfg.db_path) as conn: conn.execute("BEGIN") try: @@ -146,20 +273,27 @@ def ingest_vault(cfg: Config) -> dict: conn.executemany( "INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?)", note_rows ) - conn.execute("DELETE FROM persons") - if entity_rows["persons"]: - conn.executemany( - "INSERT INTO persons VALUES " - "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - _dedup_by_slug(entity_rows["persons"]), - ) for table in ("organizations", "domains", "cases", "places"): conn.execute(f"DELETE FROM {table}") - if entity_rows[table]: + if mirror_rows[table]: conn.executemany( f"INSERT INTO {table} VALUES (?, ?, ?, ?, ?, ?)", - _dedup_by_slug(entity_rows[table]), + _dedup_by_slug(mirror_rows[table]), ) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + + # persons: ingest selectivo via duckdb_upsert (ownership de campos OWNED). + # Se hace fuera de la transacción de espejo puro pero bajo el mismo lock de + # proceso (write_conn ya lo liberó): single-writer respetado. + p_inserted, p_updated = _ingest_persons_selective(cfg.db_path, person_fichas) + + # Re-enlace de contactos + derivadas, de nuevo bajo el lock de escritura. + with write_conn(cfg.db_path) as conn: + conn.execute("BEGIN") + try: _link_contacts(conn) conn.execute("COMMIT") except Exception: @@ -170,16 +304,89 @@ def ingest_vault(cfg: Config) -> dict: return { "status": "ok", "notes": len(note_rows), - "persons": len(_dedup_by_slug(entity_rows["persons"])), - "organizations": len(_dedup_by_slug(entity_rows["organizations"])), - "domains": len(_dedup_by_slug(entity_rows["domains"])), - "cases": len(_dedup_by_slug(entity_rows["cases"])), - "places": len(_dedup_by_slug(entity_rows["places"])), + "persons": len(person_fichas), + "persons_inserted": p_inserted, + "persons_updated": p_updated, + "organizations": len(_dedup_by_slug(mirror_rows["organizations"])), + "domains": len(_dedup_by_slug(mirror_rows["domains"])), + "cases": len(_dedup_by_slug(mirror_rows["cases"])), + "places": len(_dedup_by_slug(mirror_rows["places"])), "skipped_unreadable": skipped, "derived_rebuilt": derived_rebuilt, } +def _ingest_persons_selective(db_path: str, person_fichas: dict) -> tuple: + """Upsert selectivo de persons: existentes solo note_path+extra_fm, nuevas full. + + Lee qué slugs existen ya en la DB y reparte las fichas en dos lotes: + - existentes -> duckdb_upsert con update_cols=['note_path','extra_fm'], + de modo que los campos OWNED de la DB no se pisan. + - nuevas -> duckdb_upsert con update_cols=None (full insert de bootstrap). + + Devuelve (inserted, updated). Bajo el lock single-writer del proceso: + duckdb_upsert abre su propia conexión, pero DuckDB comparte la instancia + de la base dentro del proceso, así que no hay conflicto de lock. + + Raises: + RuntimeError: si algún upsert de persons devuelve status 'error'. + """ + if not person_fichas: + return 0, 0 + + # Slugs ya presentes en la DB (lectura read-only, sin tocar el writer). + import duckdb + + conn = duckdb.connect(db_path, read_only=True) + try: + existing = {row[0] for row in conn.execute("SELECT slug FROM persons").fetchall()} + finally: + conn.close() + + existing_rows: list = [] + new_rows: list = [] + for slug, ficha in person_fichas.items(): + full = _person_row_from_fm( + ficha["slug"], ficha["rel_path"], ficha["fm"], ficha["mtime"], ficha["base"] + ) + if slug in existing: + # Solo los campos que el vault sigue gobernando para fichas vivas: + # dónde está la nota y el frontmatter no-owned. + existing_rows.append( + { + "slug": full["slug"], + "note_path": full["note_path"], + "extra_fm": full["extra_fm"], + } + ) + else: + new_rows.append(full) + + inserted = updated = 0 + if existing_rows: + res = duckdb_upsert( + db_path, + "persons", + existing_rows, + key_cols=["slug"], + update_cols=["note_path", "extra_fm"], + ) + if res.get("status") != "ok": + raise RuntimeError(f"persons upsert (existentes): {res.get('error')}") + updated += res.get("updated", 0) + # Filas "existentes" que en realidad ya no estaban (carrera) se insertan. + inserted += res.get("inserted", 0) + if new_rows: + res = duckdb_upsert( + db_path, "persons", new_rows, key_cols=["slug"], update_cols=None + ) + if res.get("status") != "ok": + raise RuntimeError(f"persons upsert (nuevas): {res.get('error')}") + inserted += res.get("inserted", 0) + updated += res.get("updated", 0) + return inserted, updated + + def _dedup_by_slug(rows: list) -> list: """Quita filas con slug repetido (gana la primera) para respetar la PK.""" seen, out = set(), [] @@ -194,7 +401,10 @@ def _dedup_by_slug(rows: list) -> list: def ingest_dav(cfg: Config) -> dict: """Baja las colecciones de Xandikos y reconstruye contacts + events. - Devuelve {status:'ok', contacts:N, events:N, calendars:[...], + Itera TODAS las libretas CardDAV registradas en la tabla addressbooks (no + solo la colección fija): cada contacto guarda su collection real. Si la + tabla está vacía cae a la colección por defecto de la config. Devuelve + {status:'ok', contacts:N, events:N, addressbooks:[...], calendars:[...], contacts_linked:N, derived_rebuilt:[...]} o {status:'error', error}. """ secret = pass_get_secret(cfg.pass_secret) @@ -206,37 +416,42 @@ def ingest_dav(cfg: Config) -> dict: } pwd = secret["value"] # sensible: nunca logear - coll = dav_get_collection( - cfg.dav_base, cfg.dav_user, pwd, cfg.dav_contacts_collection, "vcard" - ) - if coll.get("status") != "ok": - return { - "status": "error", - "error": f"CardDAV: {coll.get('error')} (http {coll.get('http_status')})", - } + collections = _addressbook_collections(cfg) now = datetime.now(tz=timezone.utc) contact_rows: list = [] seen_uids: set = set() - for res in coll.get("resources", []): - parsed = davparse.parse_vcard(res.get("data", "")) - uid = parsed["uid"] or os.path.splitext(os.path.basename(res["href"]))[0] - if uid in seen_uids: - continue - seen_uids.add(uid) - contact_rows.append( - [ - uid, - cfg.dav_contacts_collection, - res.get("etag"), - parsed["fn"] or None, - _json(parsed["tels"]), - _json(parsed["emails"]), - res.get("data", ""), - None, # note_path se rellena en el enlace posterior - now, - ] + used_addressbooks: list = [] + for collection in collections: + coll = dav_get_collection( + cfg.dav_base, cfg.dav_user, pwd, collection, "vcard" ) + if coll.get("status") != "ok": + return { + "status": "error", + "error": f"CardDAV {collection}: {coll.get('error')} " + f"(http {coll.get('http_status')})", + } + used_addressbooks.append(collection) + for res in coll.get("resources", []): + parsed = davparse.parse_vcard(res.get("data", "")) + uid = parsed["uid"] or os.path.splitext(os.path.basename(res["href"]))[0] + if uid in seen_uids: + continue + seen_uids.add(uid) + contact_rows.append( + [ + uid, + collection, + res.get("etag"), + parsed["fn"] or None, + _json(parsed["tels"]), + _json(parsed["emails"]), + res.get("data", ""), + None, # note_path se rellena en el enlace posterior + now, + ] + ) cals = dav_list_calendars(cfg.dav_base, cfg.dav_user, pwd, cfg.dav_calendar_home) if cals.get("status") != "ok": @@ -308,33 +523,63 @@ def ingest_dav(cfg: Config) -> dict: "status": "ok", "contacts": len(contact_rows), "events": len(event_rows), + "addressbooks": used_addressbooks, "calendars": calendar_names, "contacts_linked": linked, "derived_rebuilt": derived_rebuilt, } +def _addressbook_collections(cfg: Config) -> list: + """Colecciones CardDAV a recorrer en el ingest DAV. + + Fuente de verdad: la tabla addressbooks de la DB. Si está vacía (o ilegible) + cae a la colección por defecto de la config para no romper el ingest. + """ + try: + import duckdb + + conn = duckdb.connect(cfg.db_path, read_only=True) + try: + rows = conn.execute( + "SELECT collection_path FROM addressbooks " + "WHERE collection_path IS NOT NULL ORDER BY slug" + ).fetchall() + finally: + conn.close() + collections = [r[0] for r in rows if r[0]] + except Exception: # noqa: BLE001 — tabla ausente/ilegible -> default + collections = [] + if not collections: + collections = [cfg.dav_contacts_collection] + return collections + + def _link_contacts(conn) -> int: """Enlaza contacts.note_path contra las fichas de persons. Orden de matching por fiabilidad: UID estilo osint- (creado por el - push del vault), dav_uid registrado en la ficha, teléfono normalizado y - por último email. Devuelve el número de contactos enlazados. + push del vault), dav_uid registrado en la ficha, teléfono normalizado + (singular o cualquiera de la lista telefonos[]) y por último email (singular + o cualquiera de emails[]). Devuelve el número de contactos enlazados. """ persons = conn.execute( - "SELECT slug, note_path, telefono, email, dav_uid FROM persons" + "SELECT slug, note_path, telefono, email, dav_uid, telefonos, emails " + "FROM persons" ).fetchall() by_slug, by_dav_uid, by_phone, by_email = {}, {}, {}, {} - for slug, note_path, telefono, email, dav_uid in persons: + for slug, note_path, telefono, email, dav_uid, telefonos, emails in persons: by_slug[slug] = note_path if dav_uid: by_dav_uid.setdefault(dav_uid, note_path) - if telefono: - key = davparse.norm_phone(telefono) + # Teléfonos: singular + todos los de la lista multi-valor. + for tel in _coalesce_values(telefono, telefonos): + key = davparse.norm_phone(tel) if key: by_phone.setdefault(key, note_path) - if email: - by_email.setdefault(str(email).strip().lower(), note_path) + # Emails: singular + todos los de la lista multi-valor. + for em in _coalesce_values(email, emails): + by_email.setdefault(str(em).strip().lower(), note_path) contacts = conn.execute("SELECT uid, tels, emails FROM contacts").fetchall() linked = 0 @@ -362,3 +607,24 @@ def _link_contacts(conn) -> int: ) linked += 1 return linked + + +def _coalesce_values(singular, list_json) -> list: + """Une el valor singular con la lista JSON multi-valor (sin vacíos ni dups).""" + out, seen = [], set() + candidates = [] + if singular: + candidates.append(singular) + try: + parsed = json.loads(list_json) if list_json else [] + except (TypeError, ValueError): + parsed = [] + if isinstance(parsed, list): + candidates.extend(parsed) + for v in candidates: + s = str(v).strip() + key = s.lower() + if s and key not in seen: + seen.add(key) + out.append(s) + return out diff --git a/server/main.py b/server/main.py index 8a5852e..c7a0c83 100644 --- a/server/main.py +++ b/server/main.py @@ -55,6 +55,7 @@ from server.registry_bridge import ( # noqa: E402 update_obsidian_note, upsert_sentinel_block, ) +from server import writes # noqa: E402 # Tope de filas que un render vuelca en una nota (las notas no son un export). RENDER_MAX_ROWS = 200 @@ -85,6 +86,71 @@ class RenderNoteBody(BaseModel): title: str | None = None +class PersonBody(BaseModel): + """Body de POST/PUT /api/person: ficha de persona multi-valor. + + Las listas telefonos/emails/direcciones son la fuente de verdad; los + singulares de la nota se derivan del primer elemento al materializar. + """ + + slug: str | None = None + nombre: str | None = None + aliases: list = Field(default_factory=list) + sexo: str | None = None + fecha_nacimiento: str | None = None + dni: str | None = None + pais: str | None = None + contexto: str | None = None + telefonos: list = Field(default_factory=list) + emails: list = Field(default_factory=list) + direcciones: list = Field(default_factory=list) + tags: list = Field(default_factory=list) + + +class ContactBody(BaseModel): + """Body de POST/PUT /api/contact: contacto CardDAV multi-valor.""" + + uid: str | None = None + nombre: str | None = None + fn: str | None = None + collection: str | None = None + tels: list = Field(default_factory=list) + telefonos: list = Field(default_factory=list) + emails: list = Field(default_factory=list) + correos: list = Field(default_factory=list) + direcciones: list = Field(default_factory=list) + + +class EventBody(BaseModel): + """Body de POST/PUT /api/event: evento CalDAV.""" + + uid: str | None = None + calendar: str | None = None + summary: str | None = None + dtstart: str | None = None + dtend: str | None = None + all_day: bool = False + location: str | None = None + rrule: str | None = None + + +class AddressbookBody(BaseModel): + """Body de POST /api/addressbook: crea libreta CardDAV.""" + + slug: str + display_name: str | None = None + description: str | None = None + color: str | None = None + + +class CalendarBody(BaseModel): + """Body de POST /api/calendar: crea calendario CalDAV.""" + + slug: str + display_name: str | None = None + color: str | None = None + + def create_app(cfg: Config) -> FastAPI: """Construye la app FastAPI con la configuración dada (inyectable en tests).""" app = FastAPI(title="osint_db", docs_url=None, redoc_url=None) @@ -215,6 +281,74 @@ def create_app(cfg: Config) -> FastAPI: "rows_rendered": result["row_count"], } + # --- Escritura estructurada (DB fuente de verdad) --------------------- + # Todos responden 200 + {status}. La escritura DB va bajo el lock del + # service; el push DAV y el render ocurren tras cerrar la transacción. + + def _guard(fn) -> dict: + try: + return fn() + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + + @app.post("/api/person") + def create_person(body: PersonBody) -> dict: + if not body.slug: + return {"status": "error", "error": "falta 'slug'"} + return _guard(lambda: writes.upsert_person(cfg, body.slug, body.model_dump())) + + @app.put("/api/person/{slug}") + def update_person(slug: str, body: PersonBody) -> dict: + return _guard(lambda: writes.upsert_person(cfg, slug, body.model_dump())) + + @app.delete("/api/person/{slug}") + def remove_person(slug: str) -> dict: + return _guard(lambda: writes.delete_person(cfg, slug)) + + @app.post("/api/person/{slug}/render") + def materialize_person(slug: str) -> dict: + return _guard(lambda: writes.render_person(cfg, slug)) + + @app.post("/api/contact") + def create_contact(body: ContactBody) -> dict: + if not body.uid: + return {"status": "error", "error": "falta 'uid'"} + return _guard(lambda: writes.upsert_contact(cfg, body.uid, body.model_dump())) + + @app.put("/api/contact/{uid}") + def update_contact(uid: str, body: ContactBody) -> dict: + return _guard(lambda: writes.upsert_contact(cfg, uid, body.model_dump())) + + @app.delete("/api/contact/{uid}") + def remove_contact(uid: str) -> dict: + return _guard(lambda: writes.delete_contact(cfg, uid)) + + @app.post("/api/event") + def create_event(body: EventBody) -> dict: + if not body.uid: + return {"status": "error", "error": "falta 'uid'"} + return _guard(lambda: writes.upsert_event(cfg, body.uid, body.model_dump())) + + @app.put("/api/event/{uid}") + def update_event(uid: str, body: EventBody) -> dict: + return _guard(lambda: writes.upsert_event(cfg, uid, body.model_dump())) + + @app.delete("/api/event/{uid}") + def remove_event(uid: str) -> dict: + return _guard(lambda: writes.delete_event(cfg, uid)) + + @app.post("/api/addressbook") + def create_addressbook(body: AddressbookBody) -> dict: + return _guard(lambda: writes.make_addressbook(cfg, body.model_dump())) + + @app.post("/api/calendar") + def create_calendar(body: CalendarBody) -> dict: + return _guard(lambda: writes.make_calendar(cfg, body.model_dump())) + + @app.post("/api/push/dav") + def push_dav() -> dict: + return _guard(lambda: writes.push_all_dav(cfg)) + return app diff --git a/server/registry_bridge.py b/server/registry_bridge.py index a003e7e..f0539dd 100644 --- a/server/registry_bridge.py +++ b/server/registry_bridge.py @@ -89,8 +89,22 @@ def _load_registry_fn(package: str, module_name: str, attr: str): # Grupo dav: lectura bulk de colecciones Xandikos (CardDAV/CalDAV). dav_get_collection = _load_registry_fn("infra", "dav_get_collection", "dav_get_collection") dav_list_calendars = _load_registry_fn("infra", "dav_list_calendars", "dav_list_calendars") +dav_list_addressbooks = _load_registry_fn( + "infra", "dav_list_addressbooks", "dav_list_addressbooks" +) dav_collection_ctag = _load_registry_fn("infra", "dav_collection_ctag", "dav_collection_ctag") +# Grupo dav: escritura (push DB -> Xandikos) y creación de colecciones. +carddav_put_vcard = _load_registry_fn("infra", "carddav_put_vcard", "carddav_put_vcard") +caldav_put_event = _load_registry_fn("infra", "caldav_put_event", "caldav_put_event") +dav_delete_resource = _load_registry_fn( + "infra", "dav_delete_resource", "dav_delete_resource" +) +dav_make_addressbook = _load_registry_fn( + "infra", "dav_make_addressbook", "dav_make_addressbook" +) +dav_make_calendar = _load_registry_fn("infra", "dav_make_calendar", "dav_make_calendar") + # Secretos via pass (credencial Xandikos, nunca hardcodeada). pass_get_secret = _load_registry_fn("infra", "pass_get_secret", "pass_get_secret") @@ -98,6 +112,12 @@ pass_get_secret = _load_registry_fn("infra", "pass_get_secret", "pass_get_secret duckdb_query_readonly = _load_registry_fn( "infra", "duckdb_query_readonly", "duckdb_query_readonly" ) +# Escritura DuckDB del grupo: DDL/DML directo + UPSERT con ownership selectivo. +duckdb_execute = _load_registry_fn("infra", "duckdb_execute", "duckdb_execute") +duckdb_upsert = _load_registry_fn("infra", "duckdb_upsert", "duckdb_upsert") + +# Composición del vCard multi-valor (DB -> Xandikos), puro. +build_vcard = _load_registry_fn("core", "build_vcard", "build_vcard") # Render de tablas Markdown + bloques sentinel idempotentes para las notas. render_markdown_table = _load_registry_fn( @@ -116,8 +136,17 @@ __all__ = [ "dav_collection_ctag", "dav_get_collection", "dav_list_calendars", + "dav_list_addressbooks", + "carddav_put_vcard", + "caldav_put_event", + "dav_delete_resource", + "dav_make_addressbook", + "dav_make_calendar", "pass_get_secret", "duckdb_query_readonly", + "duckdb_execute", + "duckdb_upsert", + "build_vcard", "render_markdown_table", "upsert_sentinel_block", ] diff --git a/server/writes.py b/server/writes.py new file mode 100644 index 0000000..18f26b2 --- /dev/null +++ b/server/writes.py @@ -0,0 +1,678 @@ +"""Escritura estructurada del service osint_db (DB como fuente de verdad). + +Estos helpers implementan los endpoints de escritura de /api/person, +/api/contact, /api/event, /api/addressbook, /api/calendar y /api/push/dav. El +patrón común: + + 1. Se escribe en la DB DuckDB bajo el lock single-writer del service. + 2. El push a Xandikos (CardDAV/CalDAV) y el render DB->nota se hacen DESPUÉS + de cerrar la transacción, para no bloquear la DB con la latencia de red. + +persons es dueña de sus campos estructurados (multi-valor): los singulares +telefono/email/direccion se rellenan con el primer elemento de cada lista al +materializar la ficha, y la nota Markdown se reescribe SIN tocar su prosa +(update_obsidian_note con set_frontmatter hace merge del frontmatter y conserva +el body). +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone + +from .config import Config +from .registry_bridge import ( + build_vcard, + caldav_put_event, + carddav_put_vcard, + create_obsidian_note, + dav_delete_resource, + dav_make_addressbook, + dav_make_calendar, + duckdb_execute, + duckdb_query_readonly, + duckdb_upsert, + pass_get_secret, + update_obsidian_note, +) + +# Columnas de persons gobernadas por la API estructurada (sin slug, que es la +# clave, ni note_path/extra_fm que gestiona el ingest del vault). +_PERSON_API_COLS = ( + "nombre", + "aliases", + "sexo", + "fecha_nacimiento", + "dni", + "pais", + "contexto", + "telefonos", + "emails", + "direcciones", + "tags", +) + + +def _now(): + return datetime.now(tz=timezone.utc) + + +def _as_list(value) -> list: + """Normaliza a lista de strings no vacíos (string suelto -> [string]).""" + if value is None: + return [] + seq = value if isinstance(value, list) else [value] + out = [] + for v in seq: + s = str(v).strip() + if s: + out.append(s) + return out + + +def _json(value) -> str: + return json.dumps(value, ensure_ascii=False, default=str) + + +def _read_person(db_path: str, slug: str) -> dict | None: + """Lee una ficha de persons como dict (o None si no existe).""" + res = duckdb_query_readonly( + db_path, + "SELECT slug, note_path, nombre, aliases, sexo, fecha_nacimiento, dni, " + "telefono, email, direccion, pais, contexto, fuente, dav_uid, tags, " + "telefonos, emails, direcciones, extra_fm FROM persons WHERE slug = ?", + [slug], + 1, + ) + if res.get("status") != "ok" or not res.get("rows"): + return None + return res["rows"][0] + + +def _decode_json_field(value) -> list: + """Decodifica un campo JSON de la DB a lista (tolera None/str/list).""" + if value is None: + return [] + if isinstance(value, list): + return value + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return [] + return parsed if isinstance(parsed, list) else [parsed] + + +def _decode_extra_fm(value) -> dict: + """Decodifica extra_fm (objeto JSON de la DB) a dict (o {} si no aplica).""" + if value is None: + return {} + if isinstance(value, dict): + return value + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +# --------------------------------------------------------------------------- +# persons +# --------------------------------------------------------------------------- + + +def upsert_person(cfg: Config, slug: str, fields: dict, *, render: bool = True) -> dict: + """Crea/actualiza una persona multi-valor y materializa su nota. + + Escribe los campos estructurados en la DB (la DB es dueña), rellena los + singulares con el primer elemento de cada lista, y tras cerrar la escritura + materializa la ficha DB->nota (frontmatter OWNED + merge de extra_fm) sin + tocar la prosa de la nota. + """ + slug = (slug or "").strip() + if not slug: + return {"status": "error", "error": "slug vacío"} + + telefonos = _as_list(fields.get("telefonos")) + emails = _as_list(fields.get("emails")) + direcciones = _as_list(fields.get("direcciones")) + aliases = _as_list(fields.get("aliases")) + tags = _as_list(fields.get("tags")) + + existing = _read_person(cfg.db_path, slug) + note_path = (existing.get("note_path") if existing else None) or os.path.join( + "personas", f"{slug}.md" + ) + + row = { + "slug": slug, + "note_path": note_path, + "nombre": (fields.get("nombre") or slug), + "aliases": _json(aliases), + "sexo": fields.get("sexo"), + "fecha_nacimiento": fields.get("fecha_nacimiento"), + "dni": fields.get("dni"), + "telefono": telefonos[0] if telefonos else None, + "email": emails[0] if emails else None, + "direccion": direcciones[0] if direcciones else None, + "pais": fields.get("pais"), + "contexto": fields.get("contexto"), + "tags": _json(tags), + "telefonos": _json(telefonos), + "emails": _json(emails), + "direcciones": _json(direcciones), + "updated_at": _now(), + } + # update_cols = todo lo que la API gobierna (no pisa fuente/dav_uid/extra_fm + # que pertenecen al ingest del vault). + update_cols = [c for c in row if c not in ("slug",)] + res = duckdb_upsert( + cfg.db_path, "persons", [row], key_cols=["slug"], update_cols=update_cols + ) + if res.get("status") != "ok": + return {"status": "error", "error": res.get("error")} + + materialized = False + if render: + r = render_person(cfg, slug) + materialized = r.get("status") == "ok" + + return { + "status": "ok", + "slug": slug, + "inserted": res.get("inserted", 0), + "updated": res.get("updated", 0), + "note_path": note_path, + "materialized": materialized, + } + + +def delete_person(cfg: Config, slug: str) -> dict: + """Borra una ficha de persons de la DB (no borra la nota del vault).""" + slug = (slug or "").strip() + if not slug: + return {"status": "error", "error": "slug vacío"} + res = duckdb_execute(cfg.db_path, "DELETE FROM persons WHERE slug = ?", [slug]) + if res.get("status") != "ok": + return {"status": "error", "error": res.get("error")} + return {"status": "ok", "slug": slug, "deleted": res.get("rowcount", 0)} + + +def render_person(cfg: Config, slug: str) -> dict: + """Materializa una ficha DB->nota: frontmatter OWNED + extra_fm, sin prosa. + + Lee la fila de persons, compone el frontmatter (campos OWNED como listas + + merge de extra_fm) y lo escribe en la nota con update_obsidian_note (que + conserva el body). Si la nota no existe la crea con un body mínimo. + """ + slug = (slug or "").strip() + person = _read_person(cfg.db_path, slug) + if person is None: + return {"status": "error", "error": f"persona desconocida: {slug!r}"} + + rel = person.get("note_path") or os.path.join("personas", f"{slug}.md") + if not rel.endswith(".md"): + rel = rel + ".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}"} + + telefonos = _decode_json_field(person.get("telefonos")) + emails = _decode_json_field(person.get("emails")) + direcciones = _decode_json_field(person.get("direcciones")) + aliases = _decode_json_field(person.get("aliases")) + tags = _decode_json_field(person.get("tags")) + + frontmatter = { + "tipo": "persona", + "slug": slug, + "nombre": person.get("nombre") or slug, + "aliases": aliases, + "sexo": person.get("sexo"), + "fecha_nacimiento": person.get("fecha_nacimiento"), + "dni": person.get("dni"), + "telefonos": telefonos, + "emails": emails, + "direcciones": direcciones, + # singulares por compatibilidad con consumidores que aún los leen. + "telefono": telefonos[0] if telefonos else None, + "email": emails[0] if emails else None, + "direccion": direcciones[0] if direcciones else None, + "pais": person.get("pais"), + "contexto": person.get("contexto"), + "fuente": person.get("fuente"), + "tags": tags, + } + # Merge del frontmatter no-owned capturado del vault (no pisa las claves + # OWNED de arriba). extra_fm es un objeto JSON (dict) en la DB. + extra = _decode_extra_fm(person.get("extra_fm")) + if extra: + merged = dict(extra) + merged.update(frontmatter) + frontmatter = merged + + try: + if os.path.exists(abs_path): + # set_frontmatter hace merge y NO toca el body (prosa preservada). + update_obsidian_note(abs_path, set_frontmatter=frontmatter) + else: + create_obsidian_note( + cfg.vault_dir, + rel, + body="## Notas\n", + frontmatter=frontmatter, + ) + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + + return {"status": "ok", "slug": slug, "note_path": rel} + + +# --------------------------------------------------------------------------- +# contacts (DB -> Xandikos) +# --------------------------------------------------------------------------- + + +def _resolve_password(cfg: Config) -> tuple: + """Resuelve la password de Xandikos desde pass. Devuelve (pwd|None, error|None).""" + secret = pass_get_secret(cfg.pass_secret) + if secret.get("status") != "ok": + return None, ( + f"pass no devolvió el secreto {cfg.pass_secret!r}: {secret.get('error')}" + ) + return secret["value"], None + + +def _default_collection(cfg: Config) -> str: + return cfg.dav_contacts_collection + + +def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict: + """Crea/actualiza un contacto en la DB y lo empuja a Xandikos (PUT vCard). + + La escritura DB se hace bajo el lock; el push DAV ocurre después. + """ + uid = (uid or "").strip() + if not uid: + return {"status": "error", "error": "uid vacío"} + + tels = _as_list(fields.get("tels") or fields.get("telefonos")) + emails = _as_list(fields.get("emails") or fields.get("correos")) + fn = fields.get("fn") or fields.get("nombre") + collection = fields.get("collection") or _default_collection(cfg) + + vcard = build_vcard( + { + "uid": uid, + "fn": fn, + "tels": tels, + "emails": emails, + "adrs": _as_list(fields.get("direcciones") or fields.get("adrs")), + } + ) + + row = { + "uid": uid, + "collection": collection, + "etag": None, + "fn": fn, + "tels": _json(tels), + "emails": _json(emails), + "raw": vcard, + "note_path": None, + "updated_at": _now(), + } + res = duckdb_upsert( + cfg.db_path, + "contacts", + [row], + key_cols=["uid"], + update_cols=["collection", "fn", "tels", "emails", "raw", "updated_at"], + ) + if res.get("status") != "ok": + return {"status": "error", "error": res.get("error")} + + # Push DB -> Xandikos fuera de cualquier transacción de la DB. + pwd, err = _resolve_password(cfg) + pushed = None + if err is None: + push = carddav_put_vcard( + cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard + ) + pushed = push.get("status") == "ok" + return { + "status": "ok", + "uid": uid, + "inserted": res.get("inserted", 0), + "updated": res.get("updated", 0), + "pushed": pushed, + "push_error": err, + } + + +def delete_contact(cfg: Config, uid: str) -> dict: + """Borra un contacto de la DB y del servidor Xandikos (DELETE del recurso).""" + uid = (uid or "").strip() + if not uid: + return {"status": "error", "error": "uid vacío"} + + person = duckdb_query_readonly( + cfg.db_path, "SELECT collection FROM contacts WHERE uid = ?", [uid], 1 + ) + collection = _default_collection(cfg) + if person.get("status") == "ok" and person.get("rows"): + collection = person["rows"][0].get("collection") or collection + + res = duckdb_execute(cfg.db_path, "DELETE FROM contacts WHERE uid = ?", [uid]) + if res.get("status") != "ok": + return {"status": "error", "error": res.get("error")} + + # Borrado remoto del recurso .vcf (DESTRUCTIVO, explícito por el endpoint). + pwd, err = _resolve_password(cfg) + deleted_remote = None + if err is None: + resource = collection.rstrip("/") + "/" + _safe_resource(uid) + ".vcf" + rm = dav_delete_resource(cfg.dav_base, cfg.dav_user, pwd, resource) + deleted_remote = rm.get("status") == "ok" + return { + "status": "ok", + "uid": uid, + "deleted": res.get("rowcount", 0), + "deleted_remote": deleted_remote, + "push_error": err, + } + + +def _safe_resource(uid: str) -> str: + """Sanea un UID al mismo nombre de recurso que carddav_put_vcard/caldav_put_event.""" + import re + + return re.sub(r"[^A-Za-z0-9_.-]", "_", uid)[:120] + + +# --------------------------------------------------------------------------- +# events (DB -> Xandikos) +# --------------------------------------------------------------------------- + + +def _build_vcalendar(uid: str, fields: dict) -> str: + """Compone un VCALENDAR mínimo con un VEVENT desde los campos del evento.""" + dtstart = (fields.get("dtstart") or "").replace("-", "").replace(":", "") + dtend = (fields.get("dtend") or "").replace("-", "").replace(":", "") + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//osint_db//events//EN", + "BEGIN:VEVENT", + f"UID:{uid}", + f"SUMMARY:{fields.get('summary') or ''}", + ] + if dtstart: + lines.append(f"DTSTART:{dtstart}") + if dtend: + lines.append(f"DTEND:{dtend}") + if fields.get("location"): + lines.append(f"LOCATION:{fields['location']}") + if fields.get("rrule"): + lines.append(f"RRULE:{fields['rrule']}") + lines += ["END:VEVENT", "END:VCALENDAR"] + return "\r\n".join(lines) + "\r\n" + + +def upsert_event(cfg: Config, uid: str, fields: dict) -> dict: + """Crea/actualiza un evento en la DB y lo empuja a Xandikos (PUT VCALENDAR).""" + uid = (uid or "").strip() + if not uid: + return {"status": "error", "error": "uid vacío"} + + calendar = fields.get("calendar") or "default" + raw = _build_vcalendar(uid, fields) + row = { + "uid": uid, + "calendar": calendar, + "etag": None, + "dtstart": fields.get("dtstart"), + "dtend": fields.get("dtend"), + "all_day": bool(fields.get("all_day")), + "summary": fields.get("summary"), + "location": fields.get("location"), + "rrule": fields.get("rrule"), + "raw": raw, + "updated_at": _now(), + } + res = duckdb_upsert( + cfg.db_path, + "events", + [row], + key_cols=["uid"], + update_cols=[ + "calendar", + "dtstart", + "dtend", + "all_day", + "summary", + "location", + "rrule", + "raw", + "updated_at", + ], + ) + if res.get("status") != "ok": + return {"status": "error", "error": res.get("error")} + + # El calendario CalDAV destino se resuelve por su path; usamos el calendar + # home + slug del calendario. Push fuera de transacción. + pwd, err = _resolve_password(cfg) + pushed = None + if err is None: + collection = cfg.dav_calendar_home.rstrip("/") + "/" + calendar + "/" + push = caldav_put_event( + cfg.dav_base, cfg.dav_user, pwd, collection, uid, raw + ) + pushed = push.get("status") == "ok" + return { + "status": "ok", + "uid": uid, + "inserted": res.get("inserted", 0), + "updated": res.get("updated", 0), + "pushed": pushed, + "push_error": err, + } + + +def delete_event(cfg: Config, uid: str) -> dict: + """Borra un evento de la DB y del servidor Xandikos.""" + uid = (uid or "").strip() + if not uid: + return {"status": "error", "error": "uid vacío"} + + row = duckdb_query_readonly( + cfg.db_path, "SELECT calendar FROM events WHERE uid = ?", [uid], 1 + ) + calendar = "default" + if row.get("status") == "ok" and row.get("rows"): + calendar = row["rows"][0].get("calendar") or calendar + + res = duckdb_execute(cfg.db_path, "DELETE FROM events WHERE uid = ?", [uid]) + if res.get("status") != "ok": + return {"status": "error", "error": res.get("error")} + + pwd, err = _resolve_password(cfg) + deleted_remote = None + if err is None: + collection = cfg.dav_calendar_home.rstrip("/") + "/" + calendar + "/" + resource = collection + _safe_resource(uid) + ".ics" + rm = dav_delete_resource(cfg.dav_base, cfg.dav_user, pwd, resource) + deleted_remote = rm.get("status") == "ok" + return { + "status": "ok", + "uid": uid, + "deleted": res.get("rowcount", 0), + "deleted_remote": deleted_remote, + "push_error": err, + } + + +# --------------------------------------------------------------------------- +# addressbooks / calendars +# --------------------------------------------------------------------------- + + +def make_addressbook(cfg: Config, fields: dict) -> dict: + """Crea una libreta CardDAV en Xandikos y la registra en la tabla addressbooks.""" + slug = (fields.get("slug") or "").strip() + if not slug: + return {"status": "error", "error": "slug vacío"} + display_name = fields.get("display_name") or "" + description = fields.get("description") or "" + color = fields.get("color") or "" + + pwd, err = _resolve_password(cfg) + if err is not None: + return {"status": "error", "error": err} + + # contacts_home = el directorio padre de la colección por defecto + # (/enmanuel/contacts/addressbook/ -> /enmanuel/contacts/). + contacts_home = "/" + "/".join( + cfg.dav_contacts_collection.strip("/").split("/")[:-1] + ) + if not contacts_home.endswith("/"): + contacts_home += "/" + + mk = dav_make_addressbook( + cfg.dav_base, + cfg.dav_user, + pwd, + contacts_home, + slug, + display_name, + description, + ) + if mk.get("status") != "ok": + return {"status": "error", "error": mk.get("error"), "http_status": mk.get("http_status")} + + collection_path = mk.get("href") or (contacts_home + slug + "/") + row = { + "slug": slug, + "display_name": display_name or slug, + "collection_path": collection_path, + "description": description or None, + "color": color or None, + "created_at": _now(), + } + res = duckdb_upsert( + cfg.db_path, + "addressbooks", + [row], + key_cols=["slug"], + update_cols=["display_name", "collection_path", "description", "color"], + ) + if res.get("status") != "ok": + return {"status": "error", "error": res.get("error")} + return { + "status": "ok", + "slug": slug, + "collection_path": collection_path, + "existed": mk.get("existed", False), + } + + +def make_calendar(cfg: Config, fields: dict) -> dict: + """Crea un calendario CalDAV en Xandikos (paridad con make_addressbook).""" + slug = (fields.get("slug") or "").strip() + if not slug: + return {"status": "error", "error": "slug vacío"} + display_name = fields.get("display_name") or "" + color = fields.get("color") or "" + + pwd, err = _resolve_password(cfg) + if err is not None: + return {"status": "error", "error": err} + + mk = dav_make_calendar( + cfg.dav_base, + cfg.dav_user, + pwd, + cfg.dav_calendar_home, + slug, + display_name, + color, + ) + if mk.get("status") != "ok": + return {"status": "error", "error": mk.get("error"), "http_status": mk.get("http_status")} + return { + "status": "ok", + "slug": slug, + "href": mk.get("href"), + "existed": mk.get("existed", False), + } + + +# --------------------------------------------------------------------------- +# push masivo DB -> Xandikos +# --------------------------------------------------------------------------- + + +def push_all_dav(cfg: Config) -> dict: + """Reconcilia en bloque: empuja todos los contacts y events de la DB a Xandikos. + + Útil tras una migración para volcar lo que vive solo en la DB. Devuelve los + conteos de éxito/fallo por tipo. NO borra nada en remoto (solo PUT). + """ + pwd, err = _resolve_password(cfg) + if err is not None: + return {"status": "error", "error": err} + + contacts = duckdb_query_readonly( + cfg.db_path, + "SELECT uid, collection, fn, tels, emails FROM contacts", + [], + 1000000, + ) + c_ok = c_fail = 0 + if contacts.get("status") == "ok": + for row in contacts["rows"]: + uid = row["uid"] + collection = row.get("collection") or _default_collection(cfg) + vcard = build_vcard( + { + "uid": uid, + "fn": row.get("fn"), + "tels": _decode_json_field(row.get("tels")), + "emails": _decode_json_field(row.get("emails")), + } + ) + push = carddav_put_vcard( + cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard + ) + if push.get("status") == "ok": + c_ok += 1 + else: + c_fail += 1 + + events = duckdb_query_readonly( + cfg.db_path, "SELECT uid, calendar, raw FROM events", [], 1000000 + ) + e_ok = e_fail = 0 + if events.get("status") == "ok": + for row in events["rows"]: + uid = row["uid"] + calendar = row.get("calendar") or "default" + collection = cfg.dav_calendar_home.rstrip("/") + "/" + calendar + "/" + raw = row.get("raw") or _build_vcalendar(uid, {}) + push = caldav_put_event( + cfg.dav_base, cfg.dav_user, pwd, collection, uid, raw + ) + if push.get("status") == "ok": + e_ok += 1 + else: + e_fail += 1 + + return { + "status": "ok", + "contacts_pushed": c_ok, + "contacts_failed": c_fail, + "events_pushed": e_ok, + "events_failed": e_fail, + } diff --git a/tests/test_osint_db.py b/tests/test_osint_db.py index 34594d7..de0e144 100644 --- a/tests/test_osint_db.py +++ b/tests/test_osint_db.py @@ -8,6 +8,7 @@ ingest del vault, que re-enlaza. from __future__ import annotations +import json import os import sys from datetime import datetime, timezone @@ -354,3 +355,189 @@ def test_render_note_valida_inputs(client): ).json() assert r["status"] == "error" assert "fuera del vault" in r["error"] + + +# --- F1: migraciones multi-valor + addressbooks ---------------------------- + + +def test_migracion_multivalue_y_addressbooks(client, cfg): + """002 añade columnas multi-valor a persons; 003 crea+seed addressbooks.""" + r = client.get("/api/tables").json() + by_name = {(t["schema"], t["name"]): t for t in r["tables"]} + persons_cols = {c["name"] for c in by_name[("main", "persons")]["columns"]} + assert {"telefonos", "emails", "direcciones", "extra_fm"} <= persons_cols + # Las singulares siguen existiendo (compat). + assert {"telefono", "email", "direccion"} <= persons_cols + # La libreta por defecto quedó sembrada. + assert ("main", "addressbooks") in by_name + ab = client.post( + "/api/query", json={"sql": "SELECT slug, collection_path FROM addressbooks"} + ).json() + rows = {row["slug"]: row["collection_path"] for row in ab["rows"]} + assert rows["addressbook"] == "/enmanuel/contacts/addressbook/" + + +# --- F2: ingest selectivo (la DB es dueña de los campos OWNED) -------------- + + +def test_ingest_vault_no_pisa_campo_owned(client, cfg): + """Un valor escrito por la API persiste tras re-ingestar el vault. + + Simula la escritura de un teléfono por la futura API con un UPDATE directo + a la DB; el re-ingest del vault NO debe pisarlo con el frontmatter viejo de + la nota (que tenía '+34 600 111 222'). + """ + client.post("/api/ingest/vault") + # La API escribe un teléfono nuevo (multi-valor) en la DB. + with write_conn(cfg.db_path) as conn: + conn.execute( + "UPDATE persons SET telefonos = ?, telefono = ? WHERE slug = ?", + ['["+34 999 888 777"]', "+34 999 888 777", "ana-garcia-perez"], + ) + # Re-ingest del vault: la ficha de Ana YA existe -> solo refresca + # note_path + extra_fm, NO los campos OWNED. + r = client.post("/api/ingest/vault").json() + assert r["status"] == "ok" + assert r["persons"] == 2 + assert r["persons_updated"] == 2 # ambas fichas ya existían + assert r["persons_inserted"] == 0 + + q = client.post( + "/api/query", + json={ + "sql": "SELECT telefono, telefonos FROM persons WHERE slug = 'ana-garcia-perez'" + }, + ).json() + assert q["rows"][0]["telefono"] == "+34 999 888 777" # el valor de la API, NO el del FM + assert "+34 999 888 777" in q["rows"][0]["telefonos"] + + +def test_ingest_vault_bootstrapea_ficha_nueva(client, cfg): + """Una ficha cuyo slug no está en la DB se inserta completa desde el FM.""" + # Primer ingest: solo las dos fichas del fixture. + client.post("/api/ingest/vault") + # Añadimos una ficha nueva al vault con teléfono singular en el FM. + nueva = ( + "---\n" + "tipo: persona\n" + 'nombre: "Marta Ruiz"\n' + "slug: marta-ruiz\n" + 'telefono: "+34 611 000 111"\n' + "contexto: trabajo\n" + "campo_libre: valor_raro\n" + "tags: [persona]\n" + "---\n\n## Notas\n" + ) + with open( + os.path.join(cfg.vault_dir, "personas", "marta-ruiz.md"), "w", encoding="utf-8" + ) as fh: + fh.write(nueva) + + r = client.post("/api/ingest/vault").json() + assert r["status"] == "ok" + assert r["persons"] == 3 + assert r["persons_inserted"] == 1 # marta-ruiz nueva + + q = client.post( + "/api/query", + json={ + "sql": "SELECT telefono, telefonos, contexto, extra_fm " + "FROM persons WHERE slug = 'marta-ruiz'" + }, + ).json() + row = q["rows"][0] + assert row["telefono"] == "+34 611 000 111" # singular derivado de la lista + assert "+34 611 000 111" in row["telefonos"] # lista poblada desde el singular + assert row["contexto"] == "trabajo" + # extra_fm captura el frontmatter no-owned (campo_libre), no los OWNED. + extra = json.loads(row["extra_fm"]) + assert extra.get("campo_libre") == "valor_raro" + assert "telefono" not in extra and "contexto" not in extra + + +# --- F3: endpoints de escritura estructurada (persons, sin red) ------------ + + +def test_api_person_crud_y_materializa(client, cfg): + """POST /api/person con 2 teléfonos -> fila en DB + nota .md materializada.""" + client.post("/api/ingest/vault") + body = { + "slug": "nuevo-contacto", + "nombre": "Nuevo Contacto", + "telefonos": ["+34 600 000 001", "+34 600 000 002"], + "emails": ["nc@example.com"], + "contexto": "trabajo", + "tags": ["persona"], + } + r = client.post("/api/person", json=body).json() + assert r["status"] == "ok" + assert r["inserted"] == 1 + assert r["materialized"] is True + + # Fila en DB: telefonos como lista, singular = primer elemento. + q = client.post( + "/api/query", + json={ + "sql": "SELECT telefono, telefonos, emails, email FROM persons " + "WHERE slug = 'nuevo-contacto'" + }, + ).json() + row = q["rows"][0] + assert json.loads(row["telefonos"]) == ["+34 600 000 001", "+34 600 000 002"] + assert row["telefono"] == "+34 600 000 001" + assert row["email"] == "nc@example.com" + + # Nota .md materializada con la lista telefonos. + note_file = os.path.join(cfg.vault_dir, "personas", "nuevo-contacto.md") + assert os.path.exists(note_file) + content = open(note_file, encoding="utf-8").read() + assert "telefonos:" in content + assert "+34 600 000 001" in content + assert "+34 600 000 002" in content + + # PUT actualiza (un solo teléfono ahora). + r = client.put( + "/api/person/nuevo-contacto", + json={"slug": "nuevo-contacto", "nombre": "NC", "telefonos": ["+34 600 000 009"]}, + ).json() + assert r["status"] == "ok" + assert r["updated"] == 1 + q = client.post( + "/api/query", + json={"sql": "SELECT telefono FROM persons WHERE slug = 'nuevo-contacto'"}, + ).json() + assert q["rows"][0]["telefono"] == "+34 600 000 009" + + # DELETE quita la fila de la DB. + r = client.request("DELETE", "/api/person/nuevo-contacto").json() + assert r["status"] == "ok" + q = client.post( + "/api/query", + json={"sql": "SELECT COUNT(*) AS n FROM persons WHERE slug = 'nuevo-contacto'"}, + ).json() + assert q["rows"][0]["n"] == 0 + + +def test_api_person_render_preserva_prosa(client, cfg): + """POST /api/person/{slug}/render reescribe el frontmatter SIN tocar la prosa.""" + client.post("/api/ingest/vault") + # Ana ya tiene cuerpo "## Notas\nFicha de prueba." en el fixture. + note_file = os.path.join(cfg.vault_dir, "personas", "ana-garcia-perez.md") + before = open(note_file, encoding="utf-8").read() + assert "Ficha de prueba." in before + + # Cambiamos el teléfono por la API y re-materializamos. + client.put( + "/api/person/ana-garcia-perez", + json={ + "slug": "ana-garcia-perez", + "nombre": "Ana García Pérez", + "telefonos": ["+34 622 333 444"], + }, + ) + r = client.post("/api/person/ana-garcia-perez/render").json() + assert r["status"] == "ok" + + after = open(note_file, encoding="utf-8").read() + assert "Ficha de prueba." in after # prosa preservada + assert "+34 622 333 444" in after # frontmatter actualizado