feat: push de agenda sin OSINT (compone persona enlazada) + sync inverso por etag

Privacidad (decisión del usuario: al móvil solo datos de agenda):
- _compose_agenda_vcard compone el vCard desde el contacto (fn/tels/emails) + las
  direcciones (ADR) y aliases (NICKNAME) de la persona enlazada por note_path, SIN pasar
  nunca el dict osint a build_vcard → el vCard jamás lleva X-OSINT-* (DNI/sexo/fecha-nac
  quedan solo en DuckDB+Obsidian). Usado en upsert_contact y en el push masivo push_all_dav
  (que antes leía solo contacts y perdía las direcciones).

Sync inverso DAVx5→DuckDB (last-write-wins por etag):
- Tras cada push se captura el etag nuevo del recurso (dav_list_resources) y se persiste en
  contacts.etag, para no confundir el push propio con una edición del móvil.
- POST /api/sync/dav-pull: pull incremental — compara etags, descarga SOLO los recursos
  cambiados/nuevos (dav_get_resource + parse_vcard + upsert), borra los que el móvil quitó,
  re-enlaza. Distinto del ingest_dav (DELETE+INSERT ciego): respeta la verdad de la DB salvo
  donde el etag prueba un cambio externo.

20 tests verdes (18 + 2 nuevos: vCard sin OSINT con direcciones; pull incremental por etag).
This commit is contained in:
2026-06-13 10:53:23 +02:00
parent d672f4f73e
commit b620cc38c2
5 changed files with 465 additions and 15 deletions
+7
View File
@@ -28,6 +28,7 @@ endpoints de datos responden SIEMPRE 200 con status ok|error en el body):
POST /api/query/named ejecuta una query del catálogo {name, max_rows}
POST /api/ingest/vault re-escanea el vault y reconstruye maestras+derivadas
POST /api/ingest/dav baja Xandikos y reconstruye contacts/events+derivadas
POST /api/sync/dav-pull sync inverso incremental por etag (ediciones del móvil)
POST /api/render/note ejecuta query y la upserta como bloque sentinel en una nota
"""
@@ -359,6 +360,12 @@ def create_app(cfg: Config) -> FastAPI:
def push_dav() -> dict:
return _guard(lambda: writes.push_all_dav(cfg))
@app.post("/api/sync/dav-pull")
def sync_dav_pull() -> dict:
# Sync inverso: trae a la DB las ediciones del móvil/DAVx5,
# last-write-wins por etag (incremental, distinto de ingest_dav).
return _guard(lambda: writes.pull_dav(cfg))
return app
+6
View File
@@ -93,6 +93,10 @@ 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: listado incremental (PROPFIND Depth:1 -> [{href, etag}]) y GET de
# un recurso suelto. Base del sync inverso por etag (/api/sync/dav-pull).
dav_list_resources = _load_registry_fn("infra", "dav_list_resources", "dav_list_resources")
dav_get_resource = _load_registry_fn("infra", "dav_get_resource", "dav_get_resource")
# Grupo dav: escritura (push DB -> Xandikos) y creación de colecciones.
carddav_put_vcard = _load_registry_fn("infra", "carddav_put_vcard", "carddav_put_vcard")
@@ -137,6 +141,8 @@ __all__ = [
"dav_get_collection",
"dav_list_calendars",
"dav_list_addressbooks",
"dav_list_resources",
"dav_get_resource",
"carddav_put_vcard",
"caldav_put_event",
"dav_delete_resource",
+291 -15
View File
@@ -28,6 +28,8 @@ from .registry_bridge import (
carddav_put_vcard,
create_obsidian_note,
dav_delete_resource,
dav_get_resource,
dav_list_resources,
dav_make_addressbook,
dav_make_calendar,
duckdb_execute,
@@ -288,6 +290,110 @@ def _default_collection(cfg: Config) -> str:
return cfg.dav_contacts_collection
def _person_agenda_extras(db_path: str, note_path) -> tuple:
"""Devuelve (direcciones, aliases) de la persons enlazada por note_path.
El móvil solo recibe datos de agenda: si el contacto está enlazado a una
ficha de persons, sus direcciones y aliases se incluyen en el vCard (como
ADR y NICKNAME). Los campos OSINT (dni/sexo/fecha_nacimiento/pais/contexto)
NUNCA salen de la DB: no se leen aquí. Devuelve ([], []) si no hay enlace.
"""
if not note_path:
return [], []
res = duckdb_query_readonly(
db_path,
"SELECT direcciones, aliases FROM persons WHERE note_path = ?",
[note_path],
1,
)
if res.get("status") != "ok" or not res.get("rows"):
return [], []
row = res["rows"][0]
return (
_decode_json_field(row.get("direcciones")),
_decode_json_field(row.get("aliases")),
)
def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str:
"""Compone el vCard de AGENDA de un contacto, SIN ningún campo OSINT.
Incluye FN, TEL×N, EMAIL×N y, si el contacto está enlazado a una ficha de
persons (por note_path), las direcciones (ADR×N) y aliases (NICKNAME) de esa
persona. NUNCA pasa el dict ``osint`` a build_vcard, así que el vCard jamás
lleva líneas X-OSINT-* (DNI/sexo/fecha-nac/país/contexto quedan solo en
DuckDB + Obsidian).
Args:
cfg: configuración del service (db_path para resolver la persona).
uid: UID del contacto.
fields: dict con fn/nombre, tels/telefonos, emails/correos,
direcciones/adrs y, opcionalmente, note_path (enlace a persons).
Returns:
Texto vCard 3.0 listo para PUT a Xandikos.
"""
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")
# Direcciones y aliases del contacto explícitos en fields...
adrs = _as_list(fields.get("direcciones") or fields.get("adrs"))
aliases = _as_list(fields.get("aliases"))
# ...más los de la persons enlazada (si la hay), deduplicando.
note_path = fields.get("note_path")
person_adrs, person_aliases = _person_agenda_extras(cfg.db_path, note_path)
adrs = _dedup_keep_order(adrs + person_adrs)
aliases = _dedup_keep_order(aliases + person_aliases)
contact = {
"uid": uid,
"fn": fn,
"tels": tels,
"emails": emails,
"adrs": adrs,
"aliases": aliases,
}
# CLAVE DE PRIVACIDAD: no se pasa 'osint' -> no se emite ninguna X-OSINT-*.
return build_vcard(contact)
def _dedup_keep_order(items: list) -> list:
"""Deduplica una lista de strings preservando orden (case-insensitive)."""
seen, out = set(), []
for it in items:
s = str(it).strip()
key = s.lower()
if s and key not in seen:
seen.add(key)
out.append(s)
return out
def _resource_href_tail(uid: str) -> str:
"""Nombre del recurso .vcf que carddav_put_vcard deriva del UID."""
return _safe_resource(uid) + ".vcf"
def _read_etag_after_push(cfg: Config, pwd: str, collection: str, uid: str):
"""Lee el etag NUEVO del recurso .vcf de un contacto tras su PUT.
Lista la colección (PROPFIND Depth:1) y busca el recurso cuyo href termina
en el nombre .vcf del uid. Devuelve el etag o None si no se encuentra/falla.
Capturar el etag del push propio evita que el sync inverso lo confunda con
una edición hecha desde el móvil.
"""
listing = dav_list_resources(cfg.dav_base, cfg.dav_user, pwd, collection)
if listing.get("status") != "ok":
return None
tail = _resource_href_tail(uid)
for res in listing.get("resources", []):
href = res.get("href") or ""
if href.rstrip("/").rsplit("/", 1)[-1] == tail:
return res.get("etag")
return None
def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
"""Crea/actualiza un contacto en la DB y lo empuja a Xandikos (PUT vCard).
@@ -302,15 +408,18 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
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")),
}
)
# Si el contacto ya existe y está enlazado a una ficha, hereda su note_path
# para que el vCard de agenda incluya las direcciones/aliases de la persona.
note_path = fields.get("note_path")
if note_path is None:
existing = duckdb_query_readonly(
cfg.db_path, "SELECT note_path FROM contacts WHERE uid = ?", [uid], 1
)
if existing.get("status") == "ok" and existing.get("rows"):
note_path = existing["rows"][0].get("note_path")
# vCard de AGENDA: nunca lleva X-OSINT-* (privacidad del móvil).
vcard = _compose_agenda_vcard(cfg, uid, {**fields, "note_path": note_path})
row = {
"uid": uid,
@@ -320,7 +429,7 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
"tels": _json(tels),
"emails": _json(emails),
"raw": vcard,
"note_path": None,
"note_path": note_path,
"updated_at": _now(),
}
res = duckdb_upsert(
@@ -328,7 +437,7 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
"contacts",
[row],
key_cols=["uid"],
update_cols=["collection", "fn", "tels", "emails", "raw", "updated_at"],
update_cols=["collection", "fn", "tels", "emails", "raw", "note_path", "updated_at"],
)
if res.get("status") != "ok":
return {"status": "error", "error": res.get("error")}
@@ -336,17 +445,29 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
# Push DB -> Xandikos fuera de cualquier transacción de la DB.
pwd, err = _resolve_password(cfg)
pushed = None
etag = None
if err is None:
push = carddav_put_vcard(
cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard
)
pushed = push.get("status") == "ok"
if pushed:
# Captura el etag NUEVO del recurso para que el sync inverso no
# confunda este push propio con una edición del móvil.
etag = _read_etag_after_push(cfg, pwd, collection, uid)
if etag:
duckdb_execute(
cfg.db_path,
"UPDATE contacts SET etag = ? WHERE uid = ?",
[etag, uid],
)
return {
"status": "ok",
"uid": uid,
"inserted": res.get("inserted", 0),
"updated": res.get("updated", 0),
"pushed": pushed,
"etag": etag,
"push_error": err,
}
@@ -650,7 +771,7 @@ def push_all_dav(cfg: Config) -> dict:
contacts = duckdb_query_readonly(
cfg.db_path,
"SELECT uid, collection, fn, tels, emails FROM contacts",
"SELECT uid, collection, fn, tels, emails, note_path FROM contacts",
[],
1000000,
)
@@ -659,19 +780,31 @@ def push_all_dav(cfg: Config) -> dict:
for row in contacts["rows"]:
uid = row["uid"]
collection = row.get("collection") or _default_collection(cfg)
vcard = build_vcard(
# vCard de AGENDA: compone con la persona enlazada (direcciones +
# aliases) pero SIN ningún campo OSINT (privacidad del móvil).
vcard = _compose_agenda_vcard(
cfg,
uid,
{
"uid": uid,
"fn": row.get("fn"),
"tels": _decode_json_field(row.get("tels")),
"emails": _decode_json_field(row.get("emails")),
}
"note_path": row.get("note_path"),
},
)
push = carddav_put_vcard(
cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard
)
if push.get("status") == "ok":
c_ok += 1
# Captura el etag nuevo del push (sync inverso fiable).
etag = _read_etag_after_push(cfg, pwd, collection, uid)
if etag:
duckdb_execute(
cfg.db_path,
"UPDATE contacts SET etag = ? WHERE uid = ?",
[etag, uid],
)
else:
c_fail += 1
@@ -700,3 +833,146 @@ def push_all_dav(cfg: Config) -> dict:
"events_pushed": e_ok,
"events_failed": e_fail,
}
# ---------------------------------------------------------------------------
# sync inverso Xandikos -> DB (pull incremental por etag)
# ---------------------------------------------------------------------------
def pull_dav(cfg: Config) -> dict:
"""Trae a la DB las ediciones del móvil/DAVx5, last-write-wins por etag.
A diferencia de ``ingest_dav`` (que hace DELETE + INSERT ciego de TODAS las
colecciones), este pull es INCREMENTAL y respeta la verdad de la DB salvo
donde el etag prueba un cambio externo:
1. Para cada colección registrada en ``addressbooks`` lista los recursos
(PROPFIND Depth:1 -> [{href, etag}]).
2. Por recurso: si su etag difiere del ``contacts.etag`` guardado (o el uid
no existe en la DB) -> GET + parse + upsert con el etag nuevo. Si el
etag coincide -> no se toca (la DB ya está al día).
3. Los uids que la DB tenía en esa colección y que YA no aparecen en el
PROPFIND se borran (el móvil los eliminó).
4. Tras el pull se re-enlazan los contactos con sus fichas y se
reconstruyen las derivadas (bajo el lock single-writer).
Devuelve {status:'ok', pulled, updated, deleted, unchanged} o
{status:'error', error}.
"""
# Late imports: evitan ciclo writes<->ingest a nivel de módulo y reusan la
# lógica de colecciones + enlace + derivadas ya existente (registry-first).
from . import davparse
from .db import write_conn
from .derived import rebuild_derived
from .ingest import _addressbook_collections, _link_contacts
pwd, err = _resolve_password(cfg)
if err is not None:
return {"status": "error", "error": err}
collections = _addressbook_collections(cfg)
pulled = updated = deleted = unchanged = 0
for collection in collections:
listing = dav_list_resources(cfg.dav_base, cfg.dav_user, pwd, collection)
if listing.get("status") != "ok":
return {
"status": "error",
"error": f"PROPFIND {collection}: {listing.get('error')} "
f"(http {listing.get('http_status')})",
}
# Estado actual de la DB para esta colección: uid -> etag.
db_state = duckdb_query_readonly(
cfg.db_path,
"SELECT uid, etag FROM contacts WHERE collection = ?",
[collection],
1000000,
)
db_etags: dict = {}
if db_state.get("status") == "ok":
db_etags = {r["uid"]: r.get("etag") for r in db_state["rows"]}
seen_uids: set = set()
for res in listing.get("resources", []):
href = res.get("href") or ""
remote_etag = res.get("etag")
# GET solo cuando el etag cambió o el recurso es nuevo en la DB.
# uid provisional desde el nombre del recurso para el cruce rápido;
# el uid real se toma del vCard tras el GET.
res_name = href.rstrip("/").rsplit("/", 1)[-1]
guess_uid = os.path.splitext(res_name)[0]
# Si ya conocemos este uid (por nombre) y el etag coincide -> skip.
if guess_uid in db_etags and db_etags[guess_uid] == remote_etag and remote_etag:
seen_uids.add(guess_uid)
unchanged += 1
continue
got = dav_get_resource(cfg.dav_base, cfg.dav_user, pwd, href)
if got.get("status") != "ok":
# Un recurso ilegible no aborta el pull entero.
continue
parsed = davparse.parse_vcard(got.get("text", ""))
uid = parsed["uid"] or guess_uid
seen_uids.add(uid)
existed = uid in db_etags
# Confirmación con el uid real: si el etag ya casa, no es cambio.
if existed and db_etags[uid] == remote_etag and remote_etag:
unchanged += 1
continue
row = {
"uid": uid,
"collection": collection,
"etag": remote_etag,
"fn": parsed["fn"] or None,
"tels": _json(parsed["tels"]),
"emails": _json(parsed["emails"]),
"raw": got.get("text", ""),
# note_path se re-enlaza después; preserva el existente si lo había.
"note_path": None,
"updated_at": _now(),
}
up = duckdb_upsert(
cfg.db_path,
"contacts",
[row],
key_cols=["uid"],
update_cols=["collection", "etag", "fn", "tels", "emails", "raw", "updated_at"],
)
if up.get("status") != "ok":
return {"status": "error", "error": up.get("error")}
pulled += 1
if existed:
updated += 1
# Borra los uids que la DB tenía en esta colección y ya no están remotos.
for uid in db_etags:
if uid not in seen_uids:
rm = duckdb_execute(
cfg.db_path, "DELETE FROM contacts WHERE uid = ?", [uid]
)
if rm.get("status") == "ok":
deleted += rm.get("rowcount", 0)
# Re-enlace de contactos + reconstrucción de derivadas, bajo el lock.
with write_conn(cfg.db_path) as conn:
conn.execute("BEGIN")
try:
_link_contacts(conn)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
rebuild_derived(conn)
return {
"status": "ok",
"pulled": pulled,
"updated": updated,
"deleted": deleted,
"unchanged": unchanged,
}