diff --git a/server/main.py b/server/main.py index 24b27ae..6dcf170 100644 --- a/server/main.py +++ b/server/main.py @@ -399,6 +399,12 @@ def create_app(cfg: Config) -> FastAPI: # personas de contacto con teléfono y rol (desde derived.org_contacts). return _guard(lambda: writes.render_all_org_contacts(cfg)) + @app.post("/api/org/sync-contact-cards") + def org_sync_contact_cards() -> dict: + # Crea/actualiza un contacto de agenda por organización (uid org-); + # el push compone su vCard con los teléfonos de sus personas etiquetados. + return _guard(lambda: writes.sync_org_contact_cards(cfg)) + return app diff --git a/server/writes.py b/server/writes.py index 14bae1f..cc3dfe4 100644 --- a/server/writes.py +++ b/server/writes.py @@ -31,6 +31,7 @@ from .registry_bridge import ( build_vcard, caldav_put_event, carddav_put_vcard, + contact_import_key, create_obsidian_note, dav_delete_resource, dav_get_resource, @@ -356,6 +357,12 @@ def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str: Returns: Texto vCard 3.0 listo para PUT a Xandikos. """ + # Contacto-empresa (uid 'org-'): el vCard lleva los teléfonos de las + # personas de contacto de la organización, cada uno etiquetado con el nombre + # de la persona (item.X-ABLabel). Lo gestiona _org_contact_vcard. + if str(uid).startswith("org-"): + return _org_contact_vcard(cfg, uid, fields.get("fn") or fields.get("nombre")) + 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") @@ -381,6 +388,107 @@ def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str: return build_vcard(contact) +def _vc_escape(value) -> str: + """Escapa un valor de texto para una línea vCard (sin \\r; \\n,;, escapados).""" + return ( + str(value) + .replace("\\", "\\\\") + .replace("\r", "") + .replace("\n", "\\n") + .replace(",", "\\,") + .replace(";", "\\;") + ) + + +def _org_contact_vcard(cfg: Config, uid: str, fn) -> str: + """vCard de una EMPRESA con los teléfonos de sus personas de contacto. + + Cada teléfono va en una propiedad agrupada ``item.TEL`` etiquetada con + ``item.X-ABLabel`` = " ()", leídos de + ``derived.org_contacts``. Así, al abrir la empresa en el móvil, se ven todos + los teléfonos identificados por la persona de contacto. Sin ningún campo OSINT + (mismo criterio de privacidad que _compose_agenda_vcard). + """ + slug = uid[len("org-"):] if str(uid).startswith("org-") else str(uid) + name = str(fn).strip() if fn else slug + res = duckdb_query_readonly( + cfg.db_path, + "SELECT persona, rol, telefono FROM derived.org_contacts " + "WHERE org_slug = ? ORDER BY persona, telefono", + [slug], + 1000, + ) + rows = res.get("rows") or [] + lines = [ + "BEGIN:VCARD", + "VERSION:3.0", + "UID:%s" % _vc_escape(uid), + "FN:%s" % _vc_escape(name), + "ORG:%s" % _vc_escape(name), + ] + seen = set() + i = 0 + for r in rows: + tel = str(r.get("telefono") or "").strip() + if not tel or tel in seen: + continue + seen.add(tel) + i += 1 + persona = (r.get("persona") or "contacto").strip() + rol = (r.get("rol") or "").strip() + label = "%s (%s)" % (persona, rol) if rol and rol != "contacto" else persona + lines.append("item%d.TEL;TYPE=CELL:%s" % (i, _vc_escape(tel))) + lines.append("item%d.X-ABLabel:%s" % (i, _vc_escape(label))) + lines.append("END:VCARD") + return "\r\n".join(lines) + "\r\n" + + +def sync_org_contact_cards(cfg: Config) -> dict: + """Crea/actualiza un contacto de agenda por organización con contactos. + + Por cada organización con filas en ``derived.org_contacts`` inserta (o + refresca) una fila en ``contacts`` con uid ``org-`` y el nombre de la + empresa. El vCard real (con los teléfonos etiquetados por persona) lo compone + el push via ``_org_contact_vcard``. Idempotente y no destructivo. Devuelve el + número de tarjetas-empresa sincronizadas. + """ + orgs = duckdb_query_readonly( + cfg.db_path, + "SELECT DISTINCT d.org_slug, d.org_nombre FROM derived.org_contacts d " + "ORDER BY d.org_slug", + [], + 100000, + ) + rows = orgs.get("rows") or [] + upserted = 0 + for o in rows: + slug = o.get("org_slug") + nombre = o.get("org_nombre") or slug + uid = "org-%s" % slug + row = { + "uid": uid, + "collection": cfg.dav_contacts_collection, + "etag": None, + "fn": nombre, + "tels": "[]", + "emails": "[]", + "raw": "", + "note_path": None, + "updated_at": _now(), + "import_key": contact_import_key(nombre, [], []), + } + res = duckdb_upsert( + cfg.db_path, + "contacts", + [row], + key_cols=["uid"], + update_cols=["collection", "fn", "updated_at", "import_key"], + ) + if res.get("status") == "ok": + upserted += 1 + return {"status": "ok", "org_cards": upserted} + + def _dedup_keep_order(items: list) -> list: """Deduplica una lista de strings preservando orden (case-insensitive).""" seen, out = set(), []