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