feat(org): contacto-empresa en la agenda con los telefonos de sus personas etiquetados
sync_org_contact_cards crea un contacto de agenda por organizacion (uid org-<slug>); el push compone su vCard via _org_contact_vcard con un item.TEL + item.X-ABLabel por persona de contacto (nombre + rol) desde derived.org_contacts. Asi, al abrir la empresa en el movil, se ven todos los telefonos identificados por persona. Sin campos OSINT (misma privacidad que el resto de la agenda). Nuevo endpoint POST /api/org/sync-contact-cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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-<slug>);
|
||||
# el push compone su vCard con los teléfonos de sus personas etiquetados.
|
||||
return _guard(lambda: writes.sync_org_contact_cards(cfg))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -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-<slug>'): 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<N>.TEL`` etiquetada con
|
||||
``item<N>.X-ABLabel`` = "<persona> (<rol>)", 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-<slug>`` 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(), []
|
||||
|
||||
Reference in New Issue
Block a user