diff --git a/server/ingest.py b/server/ingest.py index 96ab45f..17bb7b8 100644 --- a/server/ingest.py +++ b/server/ingest.py @@ -30,6 +30,7 @@ from .config import Config from .db import write_conn from .derived import rebuild_derived from .registry_bridge import ( + contact_import_key, dav_get_collection, dav_list_addressbooks, dav_list_calendars, @@ -450,6 +451,9 @@ def ingest_dav(cfg: Config) -> dict: res.get("data", ""), None, # note_path se rellena en el enlace posterior now, + # Clave de importación determinística: nace con el contacto + # para que los re-imports lo localicen sin match frágil. + contact_import_key(parsed["fn"] or "", parsed["tels"], parsed["emails"]), ] ) @@ -503,7 +507,9 @@ def ingest_dav(cfg: Config) -> dict: conn.execute("DELETE FROM contacts") if contact_rows: conn.executemany( - "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO contacts (uid, collection, etag, fn, tels, " + "emails, raw, note_path, updated_at, import_key) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", contact_rows, ) conn.execute("DELETE FROM events") diff --git a/server/registry_bridge.py b/server/registry_bridge.py index 3f454a2..8050a48 100644 --- a/server/registry_bridge.py +++ b/server/registry_bridge.py @@ -123,6 +123,12 @@ 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") +# Clave de importación determinística (tel > email > nombre) para imports +# idempotentes de contactos. Pura. +contact_import_key = _load_registry_fn( + "core", "contact_import_key", "contact_import_key" +) + # Render de tablas Markdown + bloques sentinel idempotentes para las notas. render_markdown_table = _load_registry_fn( "core", "render_markdown_table", "render_markdown_table" @@ -153,6 +159,7 @@ __all__ = [ "duckdb_execute", "duckdb_upsert", "build_vcard", + "contact_import_key", "render_markdown_table", "upsert_sentinel_block", ] diff --git a/server/writes.py b/server/writes.py index 7c652cc..14bae1f 100644 --- a/server/writes.py +++ b/server/writes.py @@ -848,7 +848,7 @@ def push_all_dav(cfg: Config) -> dict: uid = row["uid"] calendar = row.get("calendar") or "default" collection = cfg.dav_calendar_home.rstrip("/") + "/" + calendar + "/" - raw = row.get("raw") or _build_vcalendar(uid, {}) + raw = _ensure_vcalendar(row.get("raw")) or _build_vcalendar(uid, {}) push = caldav_put_event( cfg.dav_base, cfg.dav_user, pwd, collection, uid, raw ) @@ -866,6 +866,31 @@ def push_all_dav(cfg: Config) -> dict: } +def _ensure_vcalendar(raw) -> str: + """Garantiza que un recurso de evento tenga el envoltorio VCALENDAR. + + El ``raw`` de un evento a veces guarda SOLO el bloque ``BEGIN:VEVENT ... + END:VEVENT`` (así lo extrae el parser del ingest DAV). Subir eso a CalDAV + produce un recurso ``.ics`` inválido: Xandikos falla al pedir la propiedad + ``schedule-tag`` (``assert isinstance(cal, Calendar)``) y devuelve 500 para + todo el calendario. Esta función envuelve el VEVENT en un VCALENDAR mínimo + cuando falta, normalizando a CRLF; si el raw ya es un VCALENDAR lo deja igual. + Devuelve cadena vacía si no hay contenido (el llamador cae a _build_vcalendar). + """ + text = (raw or "").strip() + if not text: + return "" + if "BEGIN:VCALENDAR" in text.upper(): + return raw if raw.endswith("\r\n") else raw + "\r\n" + text = text.replace("\r\n", "\n").replace("\r", "\n") + body = "\r\n".join(text.split("\n")) + return ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//osint_db//events//EN\r\n" + + body + + "\r\nEND:VCALENDAR\r\n" + ) + + # --------------------------------------------------------------------------- # push masivo POR DISCO (vía rápida: 1 rsync + 1 commit + 1 PROPFIND) # --------------------------------------------------------------------------- diff --git a/tests/test_osint_db.py b/tests/test_osint_db.py index c884bd5..0d87385 100644 --- a/tests/test_osint_db.py +++ b/tests/test_osint_db.py @@ -140,6 +140,7 @@ def test_ingest_vault_cuenta_entidades(client): assert sorted(r["derived_rebuilt"]) == [ "derived.contact_link_quality", "derived.event_monthly", + "derived.org_contacts", "derived.person_stats", ] @@ -237,7 +238,7 @@ def test_derivadas_sin_note_path(client): ).json() assert r["status"] == "ok" assert r["rows"] == [] - # Y las tres derivadas existen de verdad. + # Y las derivadas existen de verdad. t = client.post( "/api/query", json={ @@ -250,6 +251,7 @@ def test_derivadas_sin_note_path(client): assert [row["table_name"] for row in t["rows"]] == [ "contact_link_quality", "event_monthly", + "org_contacts", "person_stats", ] @@ -260,7 +262,7 @@ def test_link_contacts_por_telefono(client, cfg): now = datetime.now(tz=timezone.utc) with write_conn(cfg.db_path) as conn: conn.execute( - "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO contacts (uid, collection, etag, fn, tels, emails, raw, note_path, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [ "uid-movil-1", "/enmanuel/contacts/addressbook/", @@ -274,7 +276,7 @@ def test_link_contacts_por_telefono(client, cfg): ], ) conn.execute( - "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO contacts (uid, collection, etag, fn, tels, emails, raw, note_path, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [ "uid-movil-2", "/enmanuel/contacts/addressbook/", @@ -576,7 +578,7 @@ def test_compose_agenda_vcard_sin_osint_con_direcciones(client, cfg, monkeypatch # El contacto se enlaza a la ficha por teléfono al re-ingestar el vault. with write_conn(cfg.db_path) as conn: conn.execute( - "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO contacts (uid, collection, etag, fn, tels, emails, raw, note_path, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [ "uid-marca", "/enmanuel/contacts/addressbook/", @@ -651,7 +653,7 @@ def test_pull_dav_incremental_por_etag(client, cfg, monkeypatch): ("c-gone", '"e-gone"', "Se Borra"), ]: conn.execute( - "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO contacts (uid, collection, etag, fn, tels, emails, raw, note_path, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [uid, coll, etag, fn, "[]", "[]", "BEGIN:VCARD...", None, now], ) @@ -731,7 +733,7 @@ def test_write_agenda_vcards_to_dir_nombres_y_sin_osint(client, cfg, tmp_path): now = datetime.now(tz=timezone.utc) with write_conn(cfg.db_path) as conn: conn.execute( - "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO contacts (uid, collection, etag, fn, tels, emails, raw, note_path, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [ "uid con espacios/raro", # fuerza saneo del nombre del recurso "/enmanuel/contacts/addressbook/", @@ -790,7 +792,7 @@ def test_push_all_dav_bulk_flujo_mockeado(client, cfg, monkeypatch): with write_conn(cfg.db_path) as conn: for uid, fn in [("c-a", "Contacto A"), ("c-b", "Contacto B")]: conn.execute( - "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO contacts (uid, collection, etag, fn, tels, emails, raw, note_path, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [uid, coll, None, fn, "[]", "[]", "BEGIN:VCARD...", None, now], )