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:
+291
-15
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user