merge: push agenda sin OSINT + sync inverso por etag (DAVx5→DuckDB)

This commit is contained in:
2026-06-13 10:53:23 +02:00
5 changed files with 465 additions and 15 deletions
+2
View File
@@ -15,6 +15,8 @@ uses_functions:
- dav_list_calendars_py_infra
- dav_list_addressbooks_py_infra
- dav_collection_ctag_py_infra
- dav_list_resources_py_infra
- dav_get_resource_py_infra
- carddav_put_vcard_py_infra
- caldav_put_event_py_infra
- dav_delete_resource_py_infra
+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,
}
+159
View File
@@ -541,3 +541,162 @@ def test_api_person_render_preserva_prosa(client, cfg):
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
# --- F4: push de agenda SIN OSINT + sync inverso por etag ------------------
def test_compose_agenda_vcard_sin_osint_con_direcciones(client, cfg, monkeypatch):
"""upsert_contact compone un vCard de agenda con ADR de la persona y SIN X-OSINT.
Crea una persona con DNI/sexo/dirección y un contacto enlazado por teléfono;
captura el vCard que se manda a Xandikos y verifica que (a) NO lleva ninguna
línea X-OSINT-*, (b) SÍ lleva la dirección de la persona como ADR.
"""
from server import writes
client.post("/api/ingest/vault")
# Persona con campos OSINT (dni/sexo/fecha_nac) + direccion + alias.
client.post(
"/api/person",
json={
"slug": "marca-osint",
"nombre": "Marca Osint",
"dni": "99999999R",
"sexo": "mujer",
"fecha_nacimiento": "1985-01-01",
"pais": "españa",
"contexto": "investigacion",
"telefonos": ["+34 655 100 200"],
"direcciones": ["Calle Falsa 123, Madrid"],
"aliases": ["La Marca"],
},
)
# 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
"uid-marca",
"/enmanuel/contacts/addressbook/",
None,
"Marca Osint",
'["+34 655 100 200"]',
"[]",
"BEGIN:VCARD...",
None,
datetime.now(tz=timezone.utc),
],
)
client.post("/api/ingest/vault") # enlaza uid-marca -> personas/marca-osint.md
# Capturamos el vCard que upsert_contact empuja a Xandikos.
pushed_vcards: list = []
def fake_put(base, user, pwd, collection, uid, vcard, **kw):
pushed_vcards.append(vcard)
return {"status": "ok", "http_status": 201, "url": "x"}
monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"})
monkeypatch.setattr(writes, "carddav_put_vcard", fake_put)
monkeypatch.setattr(
writes,
"dav_list_resources",
lambda *a, **k: {
"status": "ok",
"http_status": 207,
"resources": [{"href": "/enmanuel/contacts/addressbook/uid-marca.vcf", "etag": '"newtag"'}],
},
)
r = client.put(
"/api/contact/uid-marca",
json={"uid": "uid-marca", "fn": "Marca Osint", "tels": ["+34 655 100 200"]},
).json()
assert r["status"] == "ok"
assert r["pushed"] is True
assert r["etag"] == '"newtag"' # etag del recurso capturado tras el push
assert pushed_vcards, "no se empujó ningún vCard"
vcard = pushed_vcards[-1]
# (a) Privacidad: ninguna línea X-OSINT-* (DNI/sexo/fecha-nac/pais/contexto).
assert "X-OSINT-" not in vcard
assert "99999999R" not in vcard # el DNI no se filtra al móvil
# (b) Direcciones y alias de la persona enlazada SÍ viajan (agenda).
assert "ADR;TYPE=HOME:;;Calle Falsa 123\\, Madrid;;;;" in vcard
assert "NICKNAME:La Marca" in vcard
# El etag nuevo quedó persistido en la DB (sync inverso fiable).
q = client.post(
"/api/query", json={"sql": "SELECT etag, raw FROM contacts WHERE uid = 'uid-marca'"}
).json()
assert q["rows"][0]["etag"] == '"newtag"'
assert "X-OSINT-" not in q["rows"][0]["raw"] # el raw guardado tampoco lleva OSINT
def test_pull_dav_incremental_por_etag(client, cfg, monkeypatch):
"""pull_dav actualiza solo el etag cambiado, no toca el igual y borra el ausente."""
from server import writes
client.post("/api/ingest/vault")
coll = "/enmanuel/contacts/addressbook/"
now = datetime.now(tz=timezone.utc)
# Tres contactos en la DB con etags conocidos.
with write_conn(cfg.db_path) as conn:
for uid, etag, fn in [
("c-same", '"e-same"', "Sin Cambios"),
("c-changed", '"e-old"', "Antes"),
("c-gone", '"e-gone"', "Se Borra"),
]:
conn.execute(
"INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[uid, coll, etag, fn, "[]", "[]", "BEGIN:VCARD...", None, now],
)
# Remoto: c-same igual, c-changed con etag nuevo, c-gone ausente.
def fake_list(base, user, pwd, collection, **kw):
return {
"status": "ok",
"http_status": 207,
"resources": [
{"href": coll + "c-same.vcf", "etag": '"e-same"'},
{"href": coll + "c-changed.vcf", "etag": '"e-new"'},
],
}
def fake_get(base, user, pwd, href, **kw):
# Solo debería pedirse el recurso cambiado (c-same tiene etag igual).
assert "c-changed" in href, f"GET inesperado de {href}"
return {
"status": "ok",
"http_status": 200,
"text": (
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c-changed\r\n"
"FN:Despues\r\nTEL;TYPE=CELL:+34 600 000 000\r\nEND:VCARD\r\n"
),
"url": href,
}
monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"})
monkeypatch.setattr(writes, "dav_list_resources", fake_list)
monkeypatch.setattr(writes, "dav_get_resource", fake_get)
r = client.post("/api/sync/dav-pull").json()
assert r["status"] == "ok"
assert r["pulled"] == 1 # solo c-changed se descargó
assert r["updated"] == 1 # y era una actualización (ya existía)
assert r["unchanged"] == 1 # c-same no se tocó
assert r["deleted"] == 1 # c-gone se borró (ausente en el PROPFIND)
rows = client.post(
"/api/query",
json={"sql": "SELECT uid, etag, fn FROM contacts ORDER BY uid"},
).json()["rows"]
by_uid = {row["uid"]: row for row in rows}
assert set(by_uid) == {"c-changed", "c-same"} # c-gone desapareció
assert by_uid["c-same"]["fn"] == "Sin Cambios" # intacto
assert by_uid["c-same"]["etag"] == '"e-same"'
assert by_uid["c-changed"]["fn"] == "Despues" # FN del vCard nuevo
assert by_uid["c-changed"]["etag"] == '"e-new"' # etag remoto persistido