diff --git a/.gitignore b/.gitignore index 29f4107..5f09539 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ analysis/*/ vaults/* !vaults/.gitkeep !vaults/vault.yaml + +# Estado local del sync DAV (per-PC, no secretos pero efimero) y caches. +tools/.sync_state.json +tools/__pycache__/ +**/__pycache__/ diff --git a/tools/sync_dav_to_osint.py b/tools/sync_dav_to_osint.py new file mode 100644 index 0000000..7c97bda --- /dev/null +++ b/tools/sync_dav_to_osint.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +"""Sincroniza el servidor CardDAV (Xandikos) HACIA el vault OSINT (sentido +inverso de sync_osint_to_dav.py). + +El movil (DAVx5) edita la libreta de contactos de Xandikos: anade contactos +nuevos, corrige telefonos/nombres. Este tool trae esos cambios de vuelta al +vault SIN pisar la capa OSINT (relaciones, dni, contexto, fuente, tags) que es +autoritativa en el lado del vault. + +MODELO DE RECONCILIACION +======================== + - El vault es la FUENTE DE VERDAD de los campos OSINT. + - Xandikos es la fuente de los cambios de AGENDA (nombre, telefono, email, + aliases) que llegan del movil. + + Por cada vCard del servidor decidimos su accion comparando contra: + 1. el estado persistente (.sync_state.json): UID -> {etag, vault_slug, ...} + 2. el vault (match por telefono/email normalizado, o por slug del UID osint-) + + Acciones: + - CREATE : UID nuevo (no en estado) y sin ficha que case por tel/email. + -> crea personas/.md con contexto: movil. + - UPDATE : el etag cambio respecto al estado (el movil lo edito). + -> actualiza SOLO los campos de agenda (nombre, telefono, + email, aliases), PRESERVANDO los campos OSINT. + - LINK : UID nuevo pero ya hay ficha que case por tel/email (no es un + contacto nuevo, es el mismo que ya esta en el vault). + -> registra el UID en el estado y, si el etag implica edicion, + aplica el UPDATE de agenda. No crea ficha duplicada. + - SKIP : etag igual al del estado -> sin cambios desde el ultimo sync. + + last-write-wins por timestamp en campos de agenda: cuando el etag de Xandikos + cambia, el movil gano la ultima escritura de ese contacto -> se aplica al + vault. El push posterior (sync_osint_to_dav --apply, en el DAG) re-emite el + vault ya autoritativo en OSINT. + +MODOS +===== + --dry-run (DEFAULT) reporta que crearia/actualizaria. NO escribe el vault ni + el estado. + --apply escribe el vault (create/update notes) y reescribe + .sync_state.json. + +Tool de PROYECTO (vive en projects/osint/tools/). NO es funcion del registry, +NO se indexa. +""" +import sys +import os +import re +import json +import time +import argparse + +sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions") + +from infra.pass_get_secret import pass_get_secret # noqa: E402 +from infra.dav_get_collection import dav_get_collection # noqa: E402 +from obsidian import ( # noqa: E402 + slugify_obsidian_name, + list_obsidian_notes, + read_obsidian_note, + create_obsidian_note, + update_obsidian_note, +) + +# -------------------------------------------------------------------------- +# Configuracion (espejo de sync_osint_to_dav.py) +# -------------------------------------------------------------------------- + +OSINT = "/home/enmanuel/Obsidian/osint" +DAV_BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com" +DAV_USER = "enmanuel" +DAV_COLLECTION = "/enmanuel/contacts/addressbook/" +PASS_SECRET = "dav/xandikos-enmanuel" + +STATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), + ".sync_state.json") + +# Carpetas del vault que representan contactos (mismo set que el push). +SUBFOLDERS = (("personas", "persona"), ("organizaciones", "organizacion")) + +# Frontmatter canonico de persona (CONVENTIONS.md 3b), en orden. Las fichas que +# creamos aqui lo respetan campo a campo. +PERSON_CANON = [ + "tipo", "nombre", "slug", "aliases", "sexo", "fecha_nacimiento", "dni", + "telefono", "email", "direccion", "pais", "relaciones", "contexto", + "fuente", "tags", +] + +# Campos de AGENDA que el movil puede editar y que actualizamos en un UPDATE. +# El resto del frontmatter (campos OSINT) NUNCA se toca en un UPDATE. +AGENDA_FIELDS = ("nombre", "telefono", "email", "aliases") + + +# -------------------------------------------------------------------------- +# Parseo de vCard (entrada) +# -------------------------------------------------------------------------- + +def _unfold(vcard_text: str) -> str: + """Deshace el folding de lineas de vCard (continuacion con espacio/tab).""" + return re.sub(r"\r?\n[ \t]", "", vcard_text) + + +def _vcard_values(vcard_text: str, prop: str) -> list: + """Devuelve todos los valores de una propiedad (TEL, EMAIL, UID, ...). + + Acepta `PROP;PARAMS:valor` y `PROP:valor`. Decodifica los escapes simples de + vCard (\\, , \\; , \\n) en el valor. + """ + vals = [] + for line in vcard_text.splitlines(): + m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE) + if m: + v = m.group(1).strip() + v = (v.replace("\\n", "\n").replace("\\,", ",") + .replace("\\;", ";").replace("\\\\", "\\")) + v = v.strip() + if v: + vals.append(v) + return vals + + +def parse_vcard(vcard_text: str) -> dict: + """Extrae los campos de agenda + OSINT de un vCard. + + Devuelve {uid, fn, tels[], emails[], nicknames[], sexo, dni, pais, + bday, categories[], note}. Solo lee; no escribe. + """ + txt = _unfold(vcard_text) + nick_raw = _vcard_values(txt, "NICKNAME") + nicknames = [] + for nr in nick_raw: + nicknames.extend([a.strip() for a in nr.split(",") if a.strip()]) + cat_raw = _vcard_values(txt, "CATEGORIES") + categories = [] + for cr in cat_raw: + categories.extend([c.strip() for c in cr.split(",") if c.strip()]) + return { + "uid": (_vcard_values(txt, "UID") or [""])[0], + "fn": (_vcard_values(txt, "FN") or [""])[0], + "tels": _dedup_keep_order(_vcard_values(txt, "TEL")), + "emails": _dedup_keep_order(_vcard_values(txt, "EMAIL")), + "nicknames": _dedup_keep_order(nicknames), + "sexo": (_vcard_values(txt, "X-OSINT-SEXO") or [None])[0], + "dni": (_vcard_values(txt, "X-OSINT-DNI") or [None])[0], + "pais": (_vcard_values(txt, "X-OSINT-PAIS") or [None])[0], + "bday": (_vcard_values(txt, "BDAY") or [None])[0], + "categories": categories, + "note": (_vcard_values(txt, "NOTE") or [""])[0], + } + + +def _dedup_keep_order(items: list) -> list: + seen, out = set(), [] + for it in items: + key = str(it).strip().lower() + if key and key not in seen: + seen.add(key) + out.append(str(it).strip()) + return out + + +def _norm_phone(p) -> str: + """Normaliza un telefono a sus ultimos 9 digitos (numero nacional ES).""" + if not p: + return "" + d = re.sub(r"\D", "", str(p)) + return d[-9:] if len(d) >= 9 else d + + +# -------------------------------------------------------------------------- +# Indice del vault: telefono/email/slug -> ficha existente +# -------------------------------------------------------------------------- + +def _norm(v): + """Normaliza 'null'/''/None del frontmatter a None real.""" + if v is None: + return None + if isinstance(v, str) and v.strip().lower() in ("null", "none", ""): + return None + return v + + +def load_vault_index() -> dict: + """Recorre las fichas del vault y construye los indices de match. + + Devuelve {by_phone, by_email, by_slug, by_path, count} donde cada indice + mapea la clave -> dict de la ficha {slug, path, nombre, telefono, email, + aliases, mtime}. by_slug mapea slug -> ficha (para casar UID osint-). + """ + by_phone, by_email, by_slug, by_path = {}, {}, {}, {} + count = 0 + for subfolder, _tipo in SUBFOLDERS: + folder = os.path.join(OSINT, subfolder) + if not os.path.isdir(folder): + continue + for p in list_obsidian_notes(OSINT, subfolder=subfolder): + # Solo fichas de nivel-1 (personas/.md), no las sub-notas. + if os.path.basename(os.path.dirname(p)) != subfolder: + continue + base = os.path.splitext(os.path.basename(p))[0] + if base.startswith("_"): + continue + try: + nd = read_obsidian_note(p) + except Exception: # noqa: BLE001 + continue + fm = nd.get("frontmatter") or {} + slug = _norm(fm.get("slug")) or base + tel = _norm(fm.get("telefono")) + em = _norm(fm.get("email")) + aliases = fm.get("aliases") or [] + if not isinstance(aliases, list): + aliases = [aliases] + ficha = { + "slug": slug, + "path": p, + "nombre": _norm(fm.get("nombre")) or base.replace("-", " "), + "telefono": tel, + "email": em, + "aliases": aliases, + "mtime": os.path.getmtime(p), + } + count += 1 + by_slug[slug] = ficha + by_path[p] = ficha + if tel: + by_phone.setdefault(_norm_phone(tel), ficha) + if em: + by_email.setdefault(str(em).strip().lower(), ficha) + return {"by_phone": by_phone, "by_email": by_email, + "by_slug": by_slug, "by_path": by_path, "count": count} + + +def match_vault(parsed: dict, index: dict): + """Devuelve la ficha del vault que casa con un vCard (o None). + + Match por telefono primero (mas fiable), luego email, luego por el slug del + UID si es del estilo osint- (creado por el push). Devuelve (ficha, + source) con source in {phone, email, slug_uid} o (None, None). + """ + for tel in parsed["tels"]: + hit = index["by_phone"].get(_norm_phone(tel)) + if hit: + return hit, "phone" + for em in parsed["emails"]: + hit = index["by_email"].get(str(em).strip().lower()) + if hit: + return hit, "email" + uid = parsed.get("uid") or "" + if uid.startswith("osint-"): + hit = index["by_slug"].get(uid[len("osint-"):]) + if hit: + return hit, "slug_uid" + return None, None + + +# -------------------------------------------------------------------------- +# Estado persistente +# -------------------------------------------------------------------------- + +def load_state() -> dict: + """Carga .sync_state.json. {} si no existe o esta corrupto.""" + try: + with open(STATE_PATH, "r", encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def save_state(state: dict): + """Reescribe .sync_state.json de forma atomica (tmp + rename).""" + tmp = STATE_PATH + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(state, fh, ensure_ascii=False, indent=2, sort_keys=True) + os.replace(tmp, STATE_PATH) + + +# -------------------------------------------------------------------------- +# Construccion de fichas y de updates de agenda +# -------------------------------------------------------------------------- + +def _slug_for(parsed: dict) -> str: + """Slug estable para una ficha nueva: del FN, o del UID si no hay FN.""" + if parsed.get("fn"): + s = slugify_obsidian_name(parsed["fn"]) + if s: + return s + return slugify_obsidian_name(parsed.get("uid") or "contacto-movil") or "contacto-movil" + + +def _unique_slug(slug: str, index: dict, planned_slugs: set) -> str: + """Evita colision de slug: si ya existe, sufija -2, -3, ...""" + if slug not in index["by_slug"] and slug not in planned_slugs: + return slug + i = 2 + while f"{slug}-{i}" in index["by_slug"] or f"{slug}-{i}" in planned_slugs: + i += 1 + return f"{slug}-{i}" + + +def build_new_frontmatter(parsed: dict, slug: str) -> dict: + """Frontmatter canonico (3b) para una ficha creada desde el movil. + + contexto: movil (marca el origen). fuente: el UID de Xandikos. Los campos + OSINT que el vCard NO trae se dejan en null/[] como exige el esquema. + """ + bday = parsed.get("bday") + if bday and re.match(r"^\d{8}$", str(bday)): + bday = f"{bday[:4]}-{bday[4:6]}-{bday[6:8]}" + fm = { + "tipo": "persona", + "nombre": parsed.get("fn") or slug.replace("-", " "), + "slug": slug, + "aliases": parsed.get("nicknames") or [], + "sexo": parsed.get("sexo"), + "fecha_nacimiento": bday, + "dni": parsed.get("dni"), + "telefono": parsed["tels"][0] if parsed["tels"] else None, + "email": parsed["emails"][0] if parsed["emails"] else None, + "direccion": None, + "pais": parsed.get("pais"), + "relaciones": [], + "contexto": "movil", + "fuente": f"Xandikos UID {parsed.get('uid')}".strip(), + "tags": ["persona", "osint", "movil"], + } + # Reordenar segun el canon (las claves extra van al final). + ordered = {k: fm.get(k) for k in PERSON_CANON if k in fm} + for k, v in fm.items(): + if k not in ordered: + ordered[k] = v + return ordered + + +def agenda_update_from_vcard(parsed: dict, ficha: dict) -> dict: + """Calcula el diff de AGENDA (solo campos de agenda) entre vCard y ficha. + + Devuelve {changes: {campo: (viejo, nuevo)}, set_frontmatter: {...}} con SOLO + los campos de agenda que difieren. NUNCA toca campos OSINT. Si no hay cambios + de agenda, changes queda vacio. + """ + changes = {} + new_fm = {} + + new_nombre = parsed.get("fn") + if new_nombre and new_nombre != ficha.get("nombre"): + changes["nombre"] = (ficha.get("nombre"), new_nombre) + new_fm["nombre"] = new_nombre + + new_tel = parsed["tels"][0] if parsed["tels"] else None + cur_tel = ficha.get("telefono") + if new_tel and _norm_phone(new_tel) != _norm_phone(cur_tel or ""): + changes["telefono"] = (cur_tel, new_tel) + new_fm["telefono"] = new_tel + + new_email = parsed["emails"][0] if parsed["emails"] else None + cur_email = ficha.get("email") + if new_email and str(new_email).strip().lower() != str(cur_email or "").strip().lower(): + changes["email"] = (cur_email, new_email) + new_fm["email"] = new_email + + # aliases: union (el movil puede anadir un NICKNAME nuevo). No borramos los + # que ya hubiera en el vault. + new_nicks = parsed.get("nicknames") or [] + cur_aliases = ficha.get("aliases") or [] + merged = list(cur_aliases) + added = [] + for nk in new_nicks: + if nk not in merged: + merged.append(nk) + added.append(nk) + if added: + changes["aliases"] = (cur_aliases, merged) + new_fm["aliases"] = merged + + return {"changes": changes, "set_frontmatter": new_fm} + + +# -------------------------------------------------------------------------- +# Plan: clasificar cada vCard en CREATE / UPDATE / LINK / SKIP +# -------------------------------------------------------------------------- + +def plan_pull(resources: list, index: dict, state: dict) -> dict: + """Clasifica cada vCard del servidor. Devuelve {actions, counts}. + + Cada accion es un dict con {kind, uid, etag, parsed, ...}. kind in + {create, update, link, skip, skip_empty}. + """ + actions = [] + counts = {"total": len(resources), "create": 0, "update": 0, + "link": 0, "skip": 0, "skip_empty": 0} + planned_slugs = set() + + for r in resources: + parsed = parse_vcard(r["data"]) + uid = parsed.get("uid") or os.path.splitext( + os.path.basename(r["href"]))[0] + etag = r.get("etag") + + # vCard sin nada util (ni nombre ni tel ni email) -> ignorar. + if not parsed.get("fn") and not parsed["tels"] and not parsed["emails"]: + counts["skip_empty"] += 1 + actions.append({"kind": "skip_empty", "uid": uid, "etag": etag, + "href": r["href"]}) + continue + + st = state.get(uid) + ficha, match_src = match_vault(parsed, index) + + if st is None: + # UID nuevo en el estado. + if ficha is None: + # No casa con nada del vault -> contacto nuevo del movil. + slug = _unique_slug(_slug_for(parsed), index, planned_slugs) + planned_slugs.add(slug) + counts["create"] += 1 + actions.append({ + "kind": "create", "uid": uid, "etag": etag, + "href": r["href"], "parsed": parsed, "slug": slug, + "frontmatter": build_new_frontmatter(parsed, slug), + }) + else: + # Ya hay ficha que casa -> el push ya lo subio o es el mismo + # contacto. Es el LINK BASELINE: registramos el mapeo + # UID -> ficha en el estado SIN tocar el vault. NO aplicamos + # agenda aqui: un match por telefono/email es ambiguo (varios + # contactos del movil comparten numero, o el vault ya dedupeo) + # y el vault es autoritativo en su nombre curado. Los cambios de + # agenda solo fluyen cuando, ya con el UID en el estado, su etag + # CAMBIA en un sync posterior (rama UPDATE de abajo). diff vacio. + counts["link"] += 1 + actions.append({ + "kind": "link", "uid": uid, "etag": etag, + "href": r["href"], "parsed": parsed, + "ficha": ficha, "match_src": match_src, + "diff": {"changes": {}, "set_frontmatter": {}}, + }) + else: + # UID ya conocido. Comparar etag. + if st.get("etag") == etag and etag is not None: + counts["skip"] += 1 + actions.append({"kind": "skip", "uid": uid, "etag": etag, + "href": r["href"]}) + continue + # etag cambio (o no teniamos etag) -> el movil edito. Update agenda. + if ficha is None: + # Conociamos el UID pero la ficha ya no casa por tel/email + # (¿cambio el numero?). Intentar por slug guardado en estado. + saved_slug = st.get("vault_slug") + if saved_slug and saved_slug in index["by_slug"]: + ficha = index["by_slug"][saved_slug] + if ficha is None: + # No encontramos la ficha -> tratar como create (raro). + slug = _unique_slug(_slug_for(parsed), index, planned_slugs) + planned_slugs.add(slug) + counts["create"] += 1 + actions.append({ + "kind": "create", "uid": uid, "etag": etag, + "href": r["href"], "parsed": parsed, "slug": slug, + "frontmatter": build_new_frontmatter(parsed, slug), + }) + else: + diff = agenda_update_from_vcard(parsed, ficha) + counts["update"] += 1 + actions.append({ + "kind": "update", "uid": uid, "etag": etag, + "href": r["href"], "parsed": parsed, + "ficha": ficha, "diff": diff, + }) + return {"actions": actions, "counts": counts} + + +# -------------------------------------------------------------------------- +# Aplicar (solo --apply) +# -------------------------------------------------------------------------- + +def apply_pull(plan: dict, state: dict, index: dict) -> dict: + """Ejecuta el plan: crea/actualiza fichas y reescribe el estado.""" + res = {"created": 0, "updated": 0, "linked": 0, "errors": []} + now = int(time.time()) + + for a in plan["actions"]: + kind = a["kind"] + uid = a["uid"] + try: + if kind == "create": + rel = f"personas/{a['slug']}.md" + path = create_obsidian_note( + OSINT, rel, body="\n## Notas\n", + frontmatter=a["frontmatter"], overwrite=False) + res["created"] += 1 + state[uid] = {"etag": a["etag"], "vault_slug": a["slug"], + "vault_path": path, + "vault_mtime": os.path.getmtime(path), + "last_sync": now} + + elif kind in ("update", "link"): + ficha = a["ficha"] + diff = a["diff"] + if diff["set_frontmatter"]: + update_obsidian_note( + ficha["path"], set_frontmatter=diff["set_frontmatter"]) + if kind == "update": + res["updated"] += 1 + else: + res["linked"] += 1 + else: + if kind == "link": + res["linked"] += 1 + mtime = (os.path.getmtime(ficha["path"]) + if os.path.exists(ficha["path"]) else now) + state[uid] = {"etag": a["etag"], "vault_slug": ficha["slug"], + "vault_path": ficha["path"], + "vault_mtime": mtime, "last_sync": now} + + elif kind in ("skip", "skip_empty"): + # Mantener/actualizar el etag conocido sin tocar el vault. + if kind == "skip": + prev = state.get(uid, {}) + prev["etag"] = a["etag"] + prev["last_sync"] = now + state[uid] = prev + except Exception as e: # noqa: BLE001 + res["errors"].append({"uid": uid, "kind": kind, "error": str(e)}) + return res + + +# -------------------------------------------------------------------------- +# Reporte +# -------------------------------------------------------------------------- + +def report(plan: dict, index: dict, sample_n: int = 8): + c = plan["counts"] + print("=" * 72) + print("DRY-RUN — sync_dav_to_osint.py (Xandikos CardDAV -> vault OSINT)") + print("=" * 72) + print(f"servidor ........................ {DAV_BASE}") + print(f"coleccion ....................... {DAV_COLLECTION}") + print(f"fichas en el vault .............. {index['count']}") + print("-" * 72) + print(f"vCards en Xandikos .............. {c['total']}") + print(f" CREATE (contacto nuevo movil) {c['create']}") + print(f" UPDATE (movil edito agenda) .. {c['update']}") + print(f" LINK (ya en vault, enlazar) {c['link']}") + print(f" SKIP (sin cambios) ......... {c['skip']}") + print(f" SKIP (vCard vacio) ......... {c['skip_empty']}") + print("=" * 72) + + # Muestra de CREATE. + creates = [a for a in plan["actions"] if a["kind"] == "create"] + if creates: + print(f"NUEVAS fichas a crear (muestra {min(sample_n, len(creates))} " + f"de {len(creates)}):") + print("-" * 72) + for a in creates[:sample_n]: + fm = a["frontmatter"] + print(f" + personas/{a['slug']}.md | UID {a['uid']}") + print(f" nombre={fm.get('nombre')!r} tel={fm.get('telefono')!r} " + f"email={fm.get('email')!r} contexto={fm.get('contexto')!r}") + print("=" * 72) + + # Muestra de UPDATE con changes reales. + updates = [a for a in plan["actions"] + if a["kind"] in ("update", "link") and a["diff"]["changes"]] + if updates: + print(f"UPDATES de agenda (movil edito) (muestra " + f"{min(sample_n, len(updates))} de {len(updates)}):") + print("-" * 72) + for a in updates[:sample_n]: + f = a["ficha"] + print(f" ~ {f['path'].split('/osint/')[-1]} | UID {a['uid']} " + f"[{a['kind']}]") + for campo, (old, new) in a["diff"]["changes"].items(): + print(f" {campo}: {old!r} -> {new!r} (OSINT preservado)") + print("=" * 72) + elif c["update"] or c["link"]: + print("(UPDATE/LINK detectados pero sin cambios de agenda netos)") + print("=" * 72) + + +# -------------------------------------------------------------------------- +# main +# -------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser( + description="Sincroniza Xandikos CardDAV -> vault OSINT (reverse). " + "Trae contactos nuevos y ediciones de agenda del movil " + "preservando la capa OSINT del vault.") + ap.add_argument("--apply", action="store_true", + help="Escribe el vault y el estado. Por defecto: dry-run.") + ap.add_argument("--dry-run", action="store_true", + help="No escribe nada (es el comportamiento por defecto; " + "el flag existe para ser explicito).") + ap.add_argument("--sample", type=int, default=8, + help="Numero de fichas a mostrar por categoria en el dry-run.") + args = ap.parse_args() + + secret = pass_get_secret(PASS_SECRET) + if secret.get("status") != "ok": + print(f"ERROR: no se pudo leer el secreto '{PASS_SECRET}' de pass: " + f"{secret.get('error')}", file=sys.stderr) + return 1 + pwd = secret["value"] # sensible: NUNCA logear + + print("Descargando coleccion de Xandikos (1 REPORT)...") + coll = dav_get_collection(DAV_BASE, DAV_USER, pwd, DAV_COLLECTION, "vcard") + if coll.get("status") != "ok": + print(f"ERROR: no se pudo leer la coleccion CardDAV: " + f"{coll.get('error')} (http {coll.get('http_status')})", + file=sys.stderr) + return 1 + resources = coll.get("resources", []) + print(f" {len(resources)} vCards descargados.") + + print("Indexando el vault OSINT...") + index = load_vault_index() + print(f" {index['count']} fichas indexadas.") + + state = load_state() + print(f"Estado previo: {len(state)} UIDs conocidos " + f"({'existe' if os.path.exists(STATE_PATH) else 'nuevo'} .sync_state.json)") + + plan = plan_pull(resources, index, state) + report(plan, index, sample_n=args.sample) + + if args.apply: + print("\nAPLICANDO (escribiendo el vault + estado)...") + res = apply_pull(plan, state, index) + save_state(state) + print(f"APLICADO: created={res['created']} updated={res['updated']} " + f"linked={res['linked']} errors={len(res['errors'])}") + for e in res["errors"][:10]: + print(f" ERROR uid={e['uid']} [{e['kind']}]: {e['error']}") + print(f"Estado reescrito: {len(state)} UIDs en {STATE_PATH}") + else: + print("\n(dry-run: NO se toco el vault ni el estado. " + "Usa --apply para escribir.)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/sync_osint_to_dav.py b/tools/sync_osint_to_dav.py new file mode 100644 index 0000000..0fa57f8 --- /dev/null +++ b/tools/sync_osint_to_dav.py @@ -0,0 +1,843 @@ +#!/usr/bin/env python3 +"""Sincroniza las fichas del vault OSINT hacia el servidor CardDAV (Xandikos), +enriqueciendo cada vCard con la capa de informacion extra de Obsidian: alias, +relaciones, contexto, direccion, lugares, DNI, fecha de nacimiento, etc. + +Los contactos ya estan en Xandikos (vinieron de Google con tel/email). Este +sync AÑADE la capa OSINT a esos vCards (o crea los que falten). Es idempotente +por UID: re-ejecutar no duplica. + +Tool de PROYECTO (vive en projects/osint/tools/). NO es funcion del registry, +NO se indexa. + + +============================================================================= +COMO PERSONALIZAR QUE CAMPOS OSINT VIAJAN AL VCARD -> edita FIELD_MAP +============================================================================= + +FIELD_MAP es la unica fuente de verdad del mapeo frontmatter -> vCard. Cada +entrada es una tupla: + + (campo_frontmatter, sensible, generador) + + - campo_frontmatter : str. Clave del frontmatter de la ficha (p.ej. "dni"). + Hay claves SINTETICAS calculadas en build_record() + ("lugares", "nombre_completo") que tambien se mapean + aqui: no salen tal cual del YAML pero se exponen al + generador como si fueran un campo mas. + - sensible : bool. True marca el campo como dato sensible que viaja + al movil (DNI, direccion, lugares). El dry-run los lista + EXPLICITAMENTE para que confirmes antes de --apply. + - generador : callable(valor) -> list[str]. Recibe el valor del campo + y devuelve 0..N lineas vCard ya formateadas (sin CRLF). + Devuelve [] para omitir el campo (valor vacio/null). + +PARA ACTIVAR / DESACTIVAR UN CAMPO: + - Desactivar: comenta (o borra) su linea en FIELD_MAP. Esa propiedad dejara + de generarse y de viajar al servidor. + - Activar uno nuevo: añade una tupla. El generador recibe el valor crudo del + frontmatter; usa las propiedades estandar vCard 3.0 (FN, N, TEL, EMAIL, + ADR, BDAY, NICKNAME, NOTE, CATEGORIES) o una extension X- propia + (X-OSINT-*) para datos que no tienen propiedad estandar. + +REGLAS DE FORMATO QUE EL GENERADOR DEBE RESPETAR: + - Una propiedad NOTE por vCard como maximo es lo limpio: por eso TODOS los + textos largos (relaciones, contexto, notas-body, lugares) se acumulan en + NOTE_PARTS (ver helper note()) y se emiten al final como UNA sola linea + NOTE con saltos escapados (\\n). No emitas varias lineas NOTE sueltas. + - Escapa SIEMPRE los valores con vcard_escape() (\\ , ; , : de mas, saltos de + linea). Los generadores de abajo ya lo hacen. + - El orden de FIELD_MAP es el orden en que aparecen las propiedades en el + vCard resultante (salvo NOTE, que siempre va al final). + +EJEMPLO — añadir el pais como propiedad X-: + ("pais", False, lambda v: [f"X-OSINT-PAIS:{vcard_escape(v)}"] if v else []), + +EJEMPLO — desactivar el envio del DNI al movil: + # ("dni", True, lambda v: [f"X-OSINT-DNI:{vcard_escape(v)}"] if v else []), +============================================================================= +""" +import sys +import os +import re +import argparse + +sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions") + +from infra.pass_get_secret import pass_get_secret # noqa: E402 +from infra.dav_list_resources import dav_list_resources # noqa: E402 +from infra.dav_get_resource import dav_get_resource # noqa: E402 +from infra.dav_get_collection import dav_get_collection # noqa: E402 +from infra.carddav_put_vcard import carddav_put_vcard # noqa: E402 +from obsidian import ( # noqa: E402 + list_obsidian_notes, + read_obsidian_note, +) + +# -------------------------------------------------------------------------- +# Configuracion +# -------------------------------------------------------------------------- + +OSINT = "/home/enmanuel/Obsidian/osint" +DAV_BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com" +DAV_USER = "enmanuel" +DAV_COLLECTION = "/enmanuel/contacts/addressbook/" +PASS_SECRET = "dav/xandikos-enmanuel" + +# Carpetas del vault a sincronizar y el tipo que representan. +SUBFOLDERS = (("personas", "persona"), ("organizaciones", "organizacion")) + +# Prefijo del UID para fichas que NO casan con un contacto existente de Google. +OSINT_UID_PREFIX = "osint-" + + +# -------------------------------------------------------------------------- +# Helpers de formato vCard +# -------------------------------------------------------------------------- + +def vcard_escape(value) -> str: + """Escapa un valor para una propiedad vCard 3.0 (RFC 2426). + + Escapa backslash, coma, punto y coma y normaliza los saltos de linea a la + secuencia escapada \\n (un NOTE multilinea valido va en UNA sola linea + logica con \\n literales). + """ + if value is None: + return "" + s = str(value) + s = s.replace("\\", "\\\\") + s = s.replace("\n", "\\n").replace("\r", "") + s = s.replace(",", "\\,").replace(";", "\\;") + return s + + +def _date_to_bday(value) -> str: + """Convierte una fecha ISO (YYYY-MM-DD) o un date a BDAY vCard (YYYYMMDD). + + Devuelve "" si no parsea. vCard 3.0 acepta tanto YYYY-MM-DD como YYYYMMDD; + usamos el formato basico sin guiones por compatibilidad amplia. + """ + if not value: + return "" + s = str(value).strip() + m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", s) + if m: + return f"{m.group(1)}{m.group(2)}{m.group(3)}" + m = re.match(r"^(\d{4})(\d{2})(\d{2})$", s) + if m: + return s + return "" + + +def _clean_relacion(rel: str) -> str: + """Normaliza una entrada de `relaciones` para mostrarla legible en NOTE. + + Las relaciones del vault son del estilo '[[isagri]] — contacto' o + '[[personas/x|Nombre]] — hermano'. Quitamos los corchetes wikilink y la + barra de alias, dejando texto plano legible en el movil. + """ + if not rel: + return "" + s = str(rel) + # [[target|alias]] -> alias ; [[target]] -> target + def _wl(m): + inner = m.group(1) + return inner.split("|", 1)[1] if "|" in inner else inner.split("/")[-1] + s = re.sub(r"\[\[([^\]]+)\]\]", _wl, s) + return s.strip() + + +# -------------------------------------------------------------------------- +# NOTE acumulado: las propiedades de texto largo se juntan en UNA sola NOTE. +# Cada build_vcard() arranca con esta lista vacia (ver build_vcard()). +# -------------------------------------------------------------------------- + +_NOTE_PARTS: list = [] + + +def note(text: str) -> list: + """Acumula un fragmento en la NOTE global y devuelve [] (no emite linea). + + Los generadores de FIELD_MAP que producen texto descriptivo (relaciones, + contexto, notas del body, lugares) llaman a note() en vez de emitir su + propia linea NOTE: asi el vCard final lleva una unica propiedad NOTE bien + formada con todos los fragmentos separados por \\n. + """ + if text and str(text).strip(): + _NOTE_PARTS.append(str(text).strip()) + return [] + + +# -------------------------------------------------------------------------- +# FIELD_MAP — EDITA AQUI para activar/desactivar campos (ver cabecera). +# Cada tupla: (campo_frontmatter, sensible, generador(valor) -> list[str]) +# -------------------------------------------------------------------------- + +FIELD_MAP = [ + # --- Identidad basica --- + # FN es obligatorio en vCard 3.0; el nombre completo de la ficha. + ("nombre_completo", False, lambda v: [f"FN:{vcard_escape(v)}"] if v else []), + # N estructurado (apellidos;nombre;;;) — derivado del nombre completo. + ("n_struct", False, lambda v: [f"N:{v}"] if v else []), + # Alias / otros nombres por los que aparece -> NICKNAME (CSV escapado). + ("aliases", False, lambda v: [ + "NICKNAME:" + ",".join(vcard_escape(a) for a in v) + ] if v else []), + + # --- Contacto (ya suelen estar en el vCard de Google; los reafirmamos) --- + ("telefono", False, lambda v: [f"TEL;TYPE=CELL:{vcard_escape(v)}"] if v else []), + ("email", False, lambda v: [f"EMAIL;TYPE=INTERNET:{vcard_escape(v)}"] if v else []), + + # --- Localizacion --- + # ADR estructurado vCard: ;;;;;;. Metemos la + # direccion completa en el campo street (componente 3) por simplicidad. + ("direccion", True, lambda v: [f"ADR;TYPE=HOME:;;{vcard_escape(v)};;;;"] if v else []), + ("pais", False, lambda v: [f"X-OSINT-PAIS:{vcard_escape(v)}"] if v else []), + + # --- Datos OSINT sin propiedad estandar -> extensiones X- --- + ("dni", True, lambda v: [f"X-OSINT-DNI:{vcard_escape(v)}"] if v else []), + ("fecha_nacimiento", False, lambda v: ( + [f"BDAY:{_date_to_bday(v)}"] if _date_to_bday(v) else [] + )), + ("sexo", False, lambda v: [f"X-OSINT-SEXO:{vcard_escape(v)}"] if v else []), + ("contexto", False, lambda v: note(f"Contexto: {v}") if v else []), + ("fuente", False, lambda v: note(f"Fuente: {v}") if v else []), + + # --- Texto descriptivo -> se acumula en la NOTE unica --- + ("relaciones", False, lambda v: note( + "Relaciones: " + "; ".join(_clean_relacion(r) for r in v if _clean_relacion(r)) + ) if v else []), + # Lugares: sintetico (extraido de la seccion ## Lugares del body). Sensible. + ("lugares", True, lambda v: note( + "Lugares: " + "; ".join(v) + ) if v else []), + # Notas libres del body (seccion ## Notas), si las hay. + ("notas_body", False, lambda v: note(v) if v else []), + + # --- Categoria para agruparlos en la libreta del movil --- + ("tags", False, lambda v: [ + "CATEGORIES:" + ",".join(vcard_escape(t) for t in v) + ] if v else []), +] + +# Conjunto de campos marcados sensibles (para el reporte de privacidad). +SENSITIVE_FIELDS = sorted({campo for campo, sensible, _ in FIELD_MAP if sensible}) + + +# -------------------------------------------------------------------------- +# Extraccion de datos sinteticos del body de la ficha +# -------------------------------------------------------------------------- + +def _section(body: str, header: str) -> str: + """Devuelve el texto crudo de una seccion `##
` del body (hasta la + siguiente cabecera `## ` o el final). "" si la seccion no existe o vacia. + """ + if not body: + return "" + pat = re.compile(rf"^##\s+{re.escape(header)}\s*$(.*?)(?=^##\s|\Z)", + re.MULTILINE | re.DOTALL) + m = pat.search(body) + return m.group(1).strip() if m else "" + + +def _extract_lugares(body: str) -> list: + """Extrae los lugares de la seccion `## Lugares` como texto plano legible. + + Las lineas son del estilo: + - [[lugares/calle-x|Calle X 1 Almachar Malaga]] + Devolvemos el alias legible (lo de despues del |) o el target slug. + """ + sec = _section(body, "Lugares") + out = [] + for line in sec.splitlines(): + line = line.strip().lstrip("-").strip() + if not line: + continue + m = re.search(r"\[\[([^\]]+)\]\]", line) + if m: + inner = m.group(1) + disp = inner.split("|", 1)[1] if "|" in inner else inner.split("/")[-1] + out.append(disp.strip()) + elif line: + out.append(line) + return out + + +def _extract_notas(body: str) -> str: + """Extrae el texto libre de la seccion `## Notas` (si tiene contenido).""" + sec = _section(body, "Notas") + # Limpiar wikilinks/embeds del texto de notas para que sea legible plano. + sec = re.sub(r"!?\[\[([^\]]+)\]\]", + lambda m: (m.group(1).split("|", 1)[1] if "|" in m.group(1) + else m.group(1).split("/")[-1]), + sec) + return sec.strip() + + +def _n_struct_from_nombre(nombre: str) -> str: + """Construye el campo N (apellidos;nombre;;;) heuristico desde el nombre. + + vCard N = Family;Given;Additional;Prefix;Suffix. Heuristica simple para + nombres espanoles: primer token = given, resto = family. No es perfecto + (apellidos compuestos) pero es suficiente para ordenar en el movil; el FN + sigue siendo el nombre completo canonico. + """ + if not nombre: + return "" + parts = [p for p in str(nombre).split() if p] + if not parts: + return "" + given = vcard_escape(parts[0]) + family = vcard_escape(" ".join(parts[1:])) if len(parts) > 1 else "" + return f"{family};{given};;;" + + +# -------------------------------------------------------------------------- +# Construccion del "record" enriquecido por ficha + del vCard +# -------------------------------------------------------------------------- + +def build_record(note_data: dict, tipo: str) -> dict: + """Aplana una ficha (frontmatter + body) en un dict de campos para FIELD_MAP. + + Incluye los campos del frontmatter mas los sinteticos: + - nombre_completo : el nombre canonico (FN). + - n_struct : el campo N estructurado. + - lugares : lista extraida de ## Lugares. + - notas_body : texto libre de ## Notas. + Normaliza los 'null' string/None a None y deja las listas vacias como []. + """ + fm = dict(note_data.get("frontmatter") or {}) + body = note_data.get("body") or "" + + def _norm(v): + if v is None: + return None + if isinstance(v, str) and v.strip().lower() in ("null", "none", ""): + return None + return v + + nombre = _norm(fm.get("nombre")) or os.path.splitext( + os.path.basename(note_data["path"]))[0].replace("-", " ") + + rec = {k: _norm(v) for k, v in fm.items()} + # Asegurar listas para los campos lista (evita None en los generadores). + for list_field in ("aliases", "relaciones", "tags"): + val = rec.get(list_field) + if val is None: + rec[list_field] = [] + elif not isinstance(val, list): + rec[list_field] = [val] + + rec["nombre_completo"] = nombre + rec["n_struct"] = _n_struct_from_nombre(nombre) + rec["lugares"] = _extract_lugares(body) + rec["notas_body"] = _extract_notas(body) + rec["_tipo"] = tipo + rec["_slug"] = _norm(fm.get("slug")) or os.path.splitext( + os.path.basename(note_data["path"]))[0] + rec["_path"] = note_data["path"] + return rec + + +def build_vcard(rec: dict, uid: str) -> dict: + """Genera el texto vCard 3.0 de un record aplicando FIELD_MAP. + + Devuelve {text, fields_emitted} donde fields_emitted es el conjunto de + campos de FIELD_MAP que produjeron alguna linea (incluye los sensibles que + viajaron). NOTE se emite UNA sola vez al final con todos los fragmentos. + """ + global _NOTE_PARTS + _NOTE_PARTS = [] # reset del acumulador de NOTE para este vCard + + lines = ["BEGIN:VCARD", "VERSION:3.0"] + fields_emitted = set() + + for campo, _sensible, gen in FIELD_MAP: + value = rec.get(campo) + try: + produced = gen(value) + except Exception: # noqa: BLE001 — un generador no debe tumbar el sync + produced = [] + if produced: + lines.extend(produced) + fields_emitted.add(campo) + + # Emitir la NOTE acumulada (relaciones + contexto + fuente + lugares + notas). + if _NOTE_PARTS: + joined = "\\n".join(vcard_escape(p) for p in _NOTE_PARTS) + lines.append(f"NOTE:{joined}") + fields_emitted.add("NOTE") + + lines.append(f"UID:{uid}") + lines.append("END:VCARD") + text = "\r\n".join(lines) + "\r\n" + return {"text": text, "fields_emitted": fields_emitted} + + +# -------------------------------------------------------------------------- +# Indice de dedup: telefono/email -> UID de un vCard ya existente en Xandikos +# -------------------------------------------------------------------------- + +def _norm_phone(p) -> str: + """Normaliza un telefono a sus ultimos 9 digitos (numero nacional ES). + + Quita espacios, prefijos +34/0034 y separadores. Dos formatos distintos del + mismo numero ('+34 680 43 88 89' y '680438889') colapsan a la misma clave. + """ + if not p: + return "" + d = re.sub(r"\D", "", str(p)) + return d[-9:] if len(d) >= 9 else d + + +def _vcard_prop_values(text: str, prop: str) -> list: + """Extrae los valores de una propiedad (TEL, EMAIL, UID...) de un vCard.""" + out = [] + for line in text.splitlines(): + m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE) + if m: + v = m.group(1).strip() + if v: + out.append(v) + return out + + +def build_existing_index(base, user, pwd, collection, *, verbose=True) -> dict: + """Descarga los vCards existentes de Xandikos y construye los indices de dedup. + + Devuelve {phone: {key->uid}, email: {key->uid}, total: int, fetched: int, + errors: int}. Las claves son telefono normalizado (9 digitos) y email en + minusculas. Cada clave apunta al UID del PRIMER vCard que la declara. + + Usa dav_get_collection (1 sola peticion REPORT con el contenido inline) en + vez del patron N+1 (PROPFIND + un GET por recurso): para ~1064 contactos baja + de ~9s a ~1s, lo que mantiene el step PUSH del DAG dentro de su timeout. + """ + coll = dav_get_collection(base, user, pwd, collection, "vcard") + if coll.get("status") != "ok": + return {"phone": {}, "email": {}, "total": 0, "fetched": 0, + "errors": 1, "error": coll.get("error")} + resources = coll.get("resources", []) + phone_idx, email_idx = {}, {} + fetched = errors = 0 + for r in resources: + text = r.get("data") or "" + if not text: + errors += 1 + continue + fetched += 1 + uid = (_vcard_prop_values(text, "UID") or [""])[0] + if not uid: + # UID derivado del nombre del recurso si el vCard no lo declara. + uid = os.path.splitext(os.path.basename(r["href"]))[0] + for tel in _vcard_prop_values(text, "TEL"): + k = _norm_phone(tel) + if k: + phone_idx.setdefault(k, uid) + for em in _vcard_prop_values(text, "EMAIL"): + k = em.strip().lower() + if k: + email_idx.setdefault(k, uid) + if verbose: + print(f" indice Xandikos: {fetched}/{len(resources)} vCards leidos, " + f"{len(phone_idx)} telefonos, {len(email_idx)} emails, " + f"{errors} errores de lectura") + return {"phone": phone_idx, "email": email_idx, + "total": len(resources), "fetched": fetched, "errors": errors} + + +def resolve_uid(rec: dict, index: dict) -> dict: + """Resuelve el UID a usar para un record: reusa el existente o crea osint-. + + Devuelve {uid, source} donde source es 'phone', 'email' o 'new'. Match por + telefono primero (mas fiable que email para estos contactos), luego email. + """ + tel = rec.get("telefono") + em = rec.get("email") + if tel: + hit = index["phone"].get(_norm_phone(tel)) + if hit: + return {"uid": hit, "source": "phone"} + if em: + hit = index["email"].get(str(em).strip().lower()) + if hit: + return {"uid": hit, "source": "email"} + return {"uid": OSINT_UID_PREFIX + rec["_slug"], "source": "new"} + + +# -------------------------------------------------------------------------- +# Orquestacion: planificar todas las fichas +# -------------------------------------------------------------------------- + +def load_fichas() -> list: + """Carga todas las fichas de las carpetas configuradas como (note_data, tipo).""" + out = [] + for subfolder, tipo in SUBFOLDERS: + for p in list_obsidian_notes(OSINT, subfolder=subfolder): + # Solo fichas de nivel-1 (personas/.md). Excluye las sub-notas de + # documentos (personas//dni.md, fotos.md, ...) que no son contactos. + if os.path.basename(os.path.dirname(p)) != subfolder: + continue + base = os.path.splitext(os.path.basename(p))[0] + if base.startswith("_"): + continue + try: + nd = read_obsidian_note(p) + except Exception: # noqa: BLE001 + continue + out.append((nd, tipo)) + return out + + +def plan_sync(index: dict) -> dict: + """Construye el plan completo: un vCard enriquecido por ficha + su UID. + + Devuelve {records: [...], counts: {...}} donde cada record del plan lleva + {rec, uid, uid_source, vcard, fields_emitted, sensitive_emitted}. + """ + fichas = load_fichas() + plan = [] + counts = { + "fichas_total": len(fichas), + "persona": 0, "organizacion": 0, + "uid_reused_phone": 0, "uid_reused_email": 0, "uid_new": 0, + } + sensitive_carrying = {f: 0 for f in SENSITIVE_FIELDS} + + for note_data, tipo in fichas: + rec = build_record(note_data, tipo) + counts[tipo] = counts.get(tipo, 0) + 1 + resolved = resolve_uid(rec, index) + uid = resolved["uid"] + built = build_vcard(rec, uid) + emitted = built["fields_emitted"] + sens_here = sorted(f for f in SENSITIVE_FIELDS if f in emitted) + for f in sens_here: + sensitive_carrying[f] += 1 + counts_key = { + "phone": "uid_reused_phone", + "email": "uid_reused_email", + "new": "uid_new", + }[resolved["source"]] + counts[counts_key] += 1 + plan.append({ + "rec": rec, + "uid": uid, + "uid_source": resolved["source"], + "vcard": built["text"], + "fields_emitted": emitted, + "sensitive_emitted": sens_here, + }) + return {"records": plan, "counts": counts, + "sensitive_carrying": sensitive_carrying} + + +# -------------------------------------------------------------------------- +# Aplicar (solo --apply) +# -------------------------------------------------------------------------- + +def apply_sync(plan, base, user, pwd, collection) -> dict: + """Sube cada vCard del plan a Xandikos via carddav_put_vcard. Idempotente.""" + ok = fail = 0 + errors = [] + for item in plan["records"]: + res = carddav_put_vcard(base, user, pwd, collection, + item["uid"], item["vcard"]) + if res.get("status") == "ok": + ok += 1 + else: + fail += 1 + errors.append({"uid": item["uid"], "error": res.get("error"), + "http_status": res.get("http_status")}) + return {"ok": ok, "fail": fail, "errors": errors} + + +# -------------------------------------------------------------------------- +# Reporte dry-run +# -------------------------------------------------------------------------- + +def report(plan, index, sample_n=5): + c = plan["counts"] + print("=" * 70) + print("DRY-RUN — sync_osint_to_dav.py (vault OSINT -> Xandikos CardDAV)") + print("=" * 70) + print(f"servidor ........................ {DAV_BASE}") + print(f"coleccion ....................... {DAV_COLLECTION}") + print("-" * 70) + print(f"vCards ya en Xandikos ........... {index['total']} " + f"(leidos {index['fetched']}, errores {index['errors']})") + print(f"fichas OSINT a sincronizar ...... {c['fichas_total']}") + print(f" personas ...................... {c.get('persona', 0)}") + print(f" organizaciones ................ {c.get('organizacion', 0)}") + print("-" * 70) + print("Resolucion de UID (dedup contra Xandikos):") + print(f" reusa UID existente (telefono) {c['uid_reused_phone']}") + print(f" reusa UID existente (email) ... {c['uid_reused_email']}") + print(f" UID nuevo (osint-) ...... {c['uid_new']}") + reused = c['uid_reused_phone'] + c['uid_reused_email'] + print(f" => {reused} fichas ENRIQUECEN un contacto existente, " + f"{c['uid_new']} CREAN uno nuevo") + print("=" * 70) + + # ---- Privacidad: que campos sensibles viajarian al movil ---- + print("PRIVACIDAD — campos SENSIBLES que viajarian al movil con --apply:") + print("-" * 70) + any_sensitive = False + for f in SENSITIVE_FIELDS: + n = plan["sensitive_carrying"].get(f, 0) + if n: + any_sensitive = True + print(f" [SENSIBLE] {f:<12} -> presente en {n} vCard(s)") + if not any_sensitive: + print(" (ningun campo sensible viajaria con el FIELD_MAP actual)") + else: + print("") + print(" Estos datos se escribirian en la libreta de contactos del movil") + print(" (que se sincroniza/respalda fuera de tu control). Revisa el") + print(" FIELD_MAP y comenta los campos que NO quieras enviar antes de") + print(" ejecutar --apply.") + print("=" * 70) + + # ---- Campos OSINT no sensibles que tambien viajan ---- + emitted_counter = {} + for item in plan["records"]: + for f in item["fields_emitted"]: + emitted_counter[f] = emitted_counter.get(f, 0) + 1 + print("Cobertura de campos (cuantos vCards llevan cada propiedad):") + print("-" * 70) + for f in sorted(emitted_counter, key=lambda k: -emitted_counter[k]): + mark = " [SENSIBLE]" if f in SENSITIVE_FIELDS else "" + print(f" {f:<16} {emitted_counter[f]:>4}{mark}") + print("=" * 70) + + # ---- Muestra de N vCards completos ---- + print(f"MUESTRA de {sample_n} vCards completos (asi se veria el mapeo):") + print("=" * 70) + # Elegir muestras variadas: prioriza las que llevan mas campos OSINT. + enriched = sorted(plan["records"], + key=lambda it: -len(it["fields_emitted"])) + shown = 0 + for item in enriched: + rec = item["rec"] + src = {"phone": "reusa UID (tel)", "email": "reusa UID (email)", + "new": "UID nuevo"}[item["uid_source"]] + print(f"--- {rec['nombre_completo']} [{rec['_tipo']}] | {src}: {item['uid']}") + print(item["vcard"].replace("\r\n", "\n").rstrip()) + print("") + shown += 1 + if shown >= sample_n: + break + print("=" * 70) + + +# -------------------------------------------------------------------------- +# Audit de consistencia (--check) — read-only, vault <-> Xandikos +# -------------------------------------------------------------------------- + +def _norm_email(e) -> str: + return str(e).strip().lower() if e else "" + + +def audit_consistency(base, user, pwd, collection) -> dict: + """Compara vault OSINT <-> Xandikos y devuelve el reporte de drift. + + Read-only: descarga la coleccion en 1 REPORT (dav_get_collection) y la cruza + con las fichas del vault. Reporta: + - vault_sin_vcard : fichas del vault sin contraparte en Xandikos + (por telefono/email normalizado, ni por UID osint-). + - vcard_huerfano : vCards del servidor sin ficha en el vault (anadidos + en el movil que aun no se han traido / contactos no-OSINT). + - difiere : contactos que casan pero donde tel/email/alias difieren + entre vault y vCard. + Devuelve {vault_total, vcard_total, vault_sin_vcard:[...], vcard_huerfano:[...], + difiere:[...]}. + """ + coll = dav_get_collection(base, user, pwd, collection, "vcard") + if coll.get("status") != "ok": + return {"error": coll.get("error"), "http_status": coll.get("http_status")} + resources = coll.get("resources", []) + + # Indice del servidor: telefono/email -> {uid, fn, tels, emails, nicks, href} + srv_by_phone, srv_by_email, srv_by_uid = {}, {}, {} + srv_records = [] + for r in resources: + txt = re.sub(r"\r?\n[ \t]", "", r["data"]) # unfold + uid = (_vcard_prop_values(txt, "UID") or [""])[0] or \ + os.path.splitext(os.path.basename(r["href"]))[0] + fn = (_vcard_prop_values(txt, "FN") or [""])[0] + tels = _vcard_prop_values(txt, "TEL") + emails = _vcard_prop_values(txt, "EMAIL") + nicks = [] + for nk in _vcard_prop_values(txt, "NICKNAME"): + nicks.extend([a.strip() for a in nk.split(",") if a.strip()]) + rec = {"uid": uid, "fn": fn, "tels": tels, "emails": emails, + "nicks": nicks, "href": r["href"], "matched": False} + srv_records.append(rec) + srv_by_uid[uid] = rec + for t in tels: + srv_by_phone.setdefault(_norm_phone(t), rec) + for e in emails: + srv_by_email.setdefault(_norm_email(e), rec) + + # Recorrer el vault y cruzar. + vault_total = 0 + vault_sin_vcard, difiere = [], [] + for note_data, _tipo in load_fichas(): + vault_total += 1 + fm = note_data.get("frontmatter") or {} + slug = fm.get("slug") or os.path.splitext( + os.path.basename(note_data["path"]))[0] + nombre = fm.get("nombre") or slug.replace("-", " ") + tel = fm.get("telefono") + em = fm.get("email") + aliases = fm.get("aliases") or [] + if not isinstance(aliases, list): + aliases = [aliases] + if isinstance(tel, str) and tel.strip().lower() in ("null", "none", ""): + tel = None + if isinstance(em, str) and em.strip().lower() in ("null", "none", ""): + em = None + + rec = None + if tel and _norm_phone(tel) in srv_by_phone: + rec = srv_by_phone[_norm_phone(tel)] + elif em and _norm_email(em) in srv_by_email: + rec = srv_by_email[_norm_email(em)] + elif ("osint-" + slug) in srv_by_uid: + rec = srv_by_uid["osint-" + slug] + + if rec is None: + vault_sin_vcard.append({"slug": slug, "nombre": nombre, + "telefono": tel, "email": em}) + continue + rec["matched"] = True + + # Comparar campos de agenda. + diffs = [] + if tel and _norm_phone(tel) not in {_norm_phone(t) for t in rec["tels"]}: + diffs.append(("telefono", tel, ", ".join(rec["tels"]) or "-")) + if em and _norm_email(em) not in {_norm_email(e) for e in rec["emails"]}: + diffs.append(("email", em, ", ".join(rec["emails"]) or "-")) + srv_nick_set = {n.lower() for n in rec["nicks"]} + missing_alias = [a for a in aliases if a and a.lower() not in srv_nick_set] + if missing_alias and rec["nicks"]: + diffs.append(("aliases", "; ".join(aliases), + "; ".join(rec["nicks"]) or "-")) + if diffs: + difiere.append({"slug": slug, "nombre": nombre, "uid": rec["uid"], + "diffs": diffs}) + + vcard_huerfano = [ + {"uid": r["uid"], "fn": r["fn"], + "telefono": ", ".join(r["tels"]) or None, + "email": ", ".join(r["emails"]) or None} + for r in srv_records if not r["matched"] + ] + + return { + "vault_total": vault_total, + "vcard_total": len(srv_records), + "vault_sin_vcard": vault_sin_vcard, + "vcard_huerfano": vcard_huerfano, + "difiere": difiere, + } + + +def report_audit(a: dict, sample_n: int = 15): + if a.get("error"): + print(f"ERROR audit: {a['error']} (http {a.get('http_status')})", + file=sys.stderr) + return + print("=" * 72) + print("AUDIT de consistencia — vault OSINT <-> Xandikos CardDAV (read-only)") + print("=" * 72) + print(f"fichas en el vault .............. {a['vault_total']}") + print(f"vCards en Xandikos .............. {a['vcard_total']}") + print("-" * 72) + print(f"fichas del vault SIN vCard ...... {len(a['vault_sin_vcard'])}") + print(f"vCards SIN ficha (huerfanos) .... {len(a['vcard_huerfano'])}") + print(f"contactos con AGENDA divergente . {len(a['difiere'])}") + print("=" * 72) + + if a["vault_sin_vcard"]: + print(f"FICHAS DEL VAULT SIN vCard (muestra " + f"{min(sample_n, len(a['vault_sin_vcard']))}):") + print("-" * 72) + for f in a["vault_sin_vcard"][:sample_n]: + print(f" - {f['slug']:<36} tel={f['telefono']!r} email={f['email']!r}") + print("=" * 72) + + if a["vcard_huerfano"]: + print(f"vCards HUERFANOS (en el movil, sin ficha) (muestra " + f"{min(sample_n, len(a['vcard_huerfano']))}):") + print("-" * 72) + for r in a["vcard_huerfano"][:sample_n]: + print(f" - {r['fn'][:34]:<34} | UID {r['uid']} tel={r['telefono']!r}") + print("=" * 72) + + if a["difiere"]: + print(f"AGENDA DIVERGENTE vault vs movil (muestra " + f"{min(sample_n, len(a['difiere']))}):") + print("-" * 72) + for d in a["difiere"][:sample_n]: + print(f" ~ {d['slug']} (UID {d['uid']})") + for campo, vault_v, srv_v in d["diffs"]: + print(f" {campo}: vault={vault_v!r} movil={srv_v!r}") + print("=" * 72) + + +# -------------------------------------------------------------------------- +# main +# -------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser( + description="Sincroniza fichas OSINT (Obsidian) -> Xandikos CardDAV, " + "enriqueciendo los vCards con la capa OSINT.") + ap.add_argument("--apply", action="store_true", + help="Sube los vCards al servidor. Por defecto: dry-run " + "(no toca el servidor).") + ap.add_argument("--check", action="store_true", + help="Audit de consistencia READ-ONLY: compara vault <-> " + "Xandikos y reporta fichas sin vCard, vCards huerfanos " + "y agendas divergentes. No sube nada.") + ap.add_argument("--sample", type=int, default=5, + help="Numero de vCards completos a mostrar en el dry-run " + "(o de filas por categoria en --check).") + args = ap.parse_args() + + secret = pass_get_secret(PASS_SECRET) + if secret.get("status") != "ok": + print(f"ERROR: no se pudo leer el secreto '{PASS_SECRET}' de pass: " + f"{secret.get('error')}", file=sys.stderr) + return 1 + pwd = secret["value"] # sensible: NUNCA logear + + if args.check: + a = audit_consistency(DAV_BASE, DAV_USER, pwd, DAV_COLLECTION) + report_audit(a, sample_n=max(args.sample, 15)) + return 1 if a.get("error") else 0 + + print("Construyendo indice de contactos ya existentes en Xandikos...") + index = build_existing_index(DAV_BASE, DAV_USER, pwd, DAV_COLLECTION) + if index.get("errors") and index["fetched"] == 0: + print(f"ERROR: no se pudo listar/leer la coleccion CardDAV: " + f"{index.get('error')}", file=sys.stderr) + return 1 + + plan = plan_sync(index) + report(plan, index, sample_n=args.sample) + + if args.apply: + print("\nAPLICANDO (PUT a Xandikos)...") + res = apply_sync(plan, DAV_BASE, DAV_USER, pwd, DAV_COLLECTION) + print(f"APLICADO: ok={res['ok']} fail={res['fail']}") + for e in res["errors"][:10]: + print(f" FALLO uid={e['uid']}: {e['error']} (http {e['http_status']})") + else: + print("\n(dry-run: NO se toco el servidor. Usa --apply para subir los vCards.)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())