From 167a7e5eb7198c1c3a739fe4874f804ce8aa8882 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 11:47:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20contact=5Fimport=5Fkey=20?= =?UTF-8?q?=E2=80=94=20clave=20de=20importacion=20determinista=20de=20cont?= =?UTF-8?q?actos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hash estable (tel normalizado > email > nombre normalizado) para importaciones idempotentes: re-importar el mismo .vcf matchea la fila existente sin depender de UIDs opacos ni de nombres que el pipeline de import transforma. Prefijo v1- para versionar el algoritmo. Funcion pura + 5 tests. Co-Authored-By: Claude Fable 5 --- python/functions/core/contact_import_key.md | 76 ++++++++++++++++++ python/functions/core/contact_import_key.py | 77 +++++++++++++++++++ .../functions/core/contact_import_key_test.py | 38 +++++++++ 3 files changed, 191 insertions(+) create mode 100644 python/functions/core/contact_import_key.md create mode 100644 python/functions/core/contact_import_key.py create mode 100644 python/functions/core/contact_import_key_test.py diff --git a/python/functions/core/contact_import_key.md b/python/functions/core/contact_import_key.md new file mode 100644 index 00000000..e597559f --- /dev/null +++ b/python/functions/core/contact_import_key.md @@ -0,0 +1,76 @@ +--- +name: contact_import_key +kind: function +lang: py +domain: core +version: "1.0.0" +purity: pure +signature: "def contact_import_key(name: str = \"\", phones: list = None, emails: list = None) -> str" +description: "Genera una clave de importacion determinista de contactos para imports idempotentes. La identidad prioriza el telefono normalizado (mas estable entre exports de Google), luego el email, y por ultimo el nombre normalizado sin acentos. Re-importar el mismo .vcf matchea la fila existente sin depender de UIDs opacos. Pura, sin I/O." +tags: [contactos, import, hash, dav, dedup, core] +params: + - name: name + desc: "Nombre del contacto (FN). Solo se usa como ultimo recurso cuando no hay telefono ni email. Se normaliza con NFKD (quita acentos), lowercase, espacios colapsados, y se filtra a [a-z0-9 ]." + - name: phones + desc: "Lista de telefonos en cualquier formato (con prefijos, espacios, guiones). Para cada uno se extraen solo los digitos (re.sub r'\\D') y se toman los ultimos 9 si len>=9 (numero nacional estable), o todos los digitos si son menos. Vacios descartados, dedup + sort ascendente. None equivale a lista vacia." + - name: emails + desc: "Lista de emails. Cada uno se pasa a strip+lowercase; se descartan los vacios y los que no contienen '@'. Dedup + sort. None equivale a lista vacia." +output: "Clave determinista con formato 'v1-' + los primeros 16 caracteres hex del SHA-1 de la identity string. Longitud total fija de 19 caracteres. La identity string es 'tel:' si hay telefonos, 'email:' si no hay telefonos pero si emails, o 'name:' en otro caso." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["test_determinismo_misma_entrada_misma_clave", "test_estabilidad_ante_formato_y_nombre_distinto", "test_fallback_a_email_cuando_no_hay_telefono", "test_fallback_a_nombre_insensible_a_acentos_y_mayusculas", "test_prefijo_v1_y_longitud"] +test_file_path: "python/functions/core/contact_import_key_test.py" +file_path: "python/functions/core/contact_import_key.py" +--- + +## Ejemplo + +```python +from core.contact_import_key import contact_import_key + +# El telefono manda y es robusto al formato: estos dos producen la MISMA clave. +k1 = contact_import_key("Bob", ["+34 600 11 22 33"]) +k2 = contact_import_key("BOB DISTINTO", ["600112233"]) +print(k1, k1 == k2) +# v1-8d3f... True (mismo telefono normalizado -> misma clave) + +# Sin telefono, cae a email; sin email, cae al nombre normalizado. +print(contact_import_key("Carol", [], ["CAROL@Example.com"])) # v1-... (identity = email:carol@example.com) +print(contact_import_key("José Pérez")) # == contact_import_key("jose perez") +``` + +## Cuando usarla + +Usala en el nucleo de un import idempotente de contactos: antes de insertar una +fila de un `.vcf` de Google, calcula la clave y busca esa clave en la tabla +destino. Si existe, actualizas la fila; si no, la insertas. Asi re-importar el +mismo export no duplica contactos y no depende de UIDs opacos (que Google rota) +ni del nombre (que el pipeline de import transforma: quita sufijos de lugar, +reordena). Es la primitiva de matching del sistema de import idempotente del +grupo `dav`. + +## Gotchas + +- **Pura y determinista**: misma entrada -> misma salida, sin red ni disco. No + lanza excepciones (entradas `None`/vacias se tratan como lista/cadena vacia). +- **Prioridad telefono > email > nombre por estabilidad**: el telefono + normalizado es lo mas estable entre exports de Google, por eso es la identidad + primaria; el email es fallback; el nombre es el ultimo recurso porque el + pipeline de import lo transforma (quita sufijos de lugar, reordena) y no es + fiable como identidad. +- **Cambiar los telefonos cambia la clave**: si los telefonos de un contacto + varian entre dos importaciones, la clave cambia y se trata como contacto + distinto. Es una limitacion aceptada: la idempotencia es robusta solo para + contactos cuyos telefonos no varian. Lo mismo aplica al fallback de email y al + de nombre. +- **Ultimos 9 digitos**: solo se conservan los ultimos 9 digitos del telefono + para ignorar prefijos de pais inconsistentes (`+34`, `0034`, sin prefijo + producen la misma clave). Numeros con menos de 9 digitos se usan completos. +- **Prefijo `v1-`**: versiona el algoritmo. Si en el futuro cambia la + normalizacion o el esquema de identidad, se sube a `v2-` y las claves nuevas no + colisionan con las viejas; el consumidor puede migrar de forma controlada. diff --git a/python/functions/core/contact_import_key.py b/python/functions/core/contact_import_key.py new file mode 100644 index 00000000..525e1a4d --- /dev/null +++ b/python/functions/core/contact_import_key.py @@ -0,0 +1,77 @@ +"""Clave de importacion determinista de contactos para imports idempotentes. + +Genera una clave estable a partir de los datos de un contacto (telefono, email, +nombre) para que re-importar el mismo .vcf de Google matchee la fila existente +sin depender de UIDs opacos ni de nombres que el pipeline de import transforma. +""" + +import hashlib +import re +import unicodedata + + +def contact_import_key( + name: str = "", + phones: list = None, + emails: list = None, +) -> str: + """Calcula una clave de importacion determinista para un contacto. + + La identidad se construye priorizando lo mas estable entre exports de + Google: telefono normalizado > email normalizado > nombre normalizado. + La funcion es pura: dada la misma entrada devuelve siempre la misma clave, + sin I/O ni estado mutable. + + Args: + name: nombre del contacto (FN). Solo se usa como ultimo recurso. + phones: lista de telefonos en cualquier formato. Cada uno se reduce a + sus digitos y se queda con los ultimos 9 (numero nacional estable). + emails: lista de emails. Se pasan a minusculas y se filtran los que no + contienen "@". + + Returns: + Clave determinista con el formato "v1-" + 16 hex chars del SHA-1 de la + identity string. Longitud total fija de 19 caracteres. + """ + phones = phones or [] + emails = emails or [] + + # 1. Normalizar phones: solo digitos, ultimos 9 si len>=9, dedup + sort. + phones_norm = set() + for p in phones: + digits = re.sub(r"\D", "", str(p)) + if not digits: + continue + if len(digits) >= 9: + digits = digits[-9:] + phones_norm.add(digits) + phones_sorted = sorted(phones_norm) + + # 2. Normalizar emails: lowercase + strip, descartar vacios y sin "@". + emails_norm = set() + for e in emails: + e = str(e).strip().lower() + if not e or "@" not in e: + continue + emails_norm.add(e) + emails_sorted = sorted(emails_norm) + + # 3. Normalizar name: NFKD sin acentos, lowercase, colapsar espacios, + # quedarse solo con [a-z0-9 ]. + decomposed = unicodedata.normalize("NFKD", name or "") + without_marks = "".join(c for c in decomposed if not unicodedata.combining(c)) + lowered = without_marks.lower() + collapsed = re.sub(r"\s+", " ", lowered).strip() + name_norm = re.sub(r"[^a-z0-9 ]", "", collapsed) + + # 4. Identity string priorizando lo mas estable. + if phones_sorted: + identity = "tel:" + ",".join(phones_sorted) + elif emails_sorted: + identity = "email:" + ",".join(emails_sorted) + else: + identity = "name:" + name_norm + + # 5. Clave versionada. + digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:16] + return "v1-" + digest diff --git a/python/functions/core/contact_import_key_test.py b/python/functions/core/contact_import_key_test.py new file mode 100644 index 00000000..dca294ce --- /dev/null +++ b/python/functions/core/contact_import_key_test.py @@ -0,0 +1,38 @@ +"""Tests para contact_import_key.""" + +from contact_import_key import contact_import_key + + +def test_determinismo_misma_entrada_misma_clave(): + a = contact_import_key("Ada Lovelace", ["+34600112233"], ["ada@example.com"]) + b = contact_import_key("Ada Lovelace", ["+34600112233"], ["ada@example.com"]) + assert a == b + + +def test_estabilidad_ante_formato_y_nombre_distinto(): + # Mismo telefono normalizado (ultimos 9 digitos), distinto formato y nombre. + # El telefono manda -> misma clave. + con_formato = contact_import_key("Bob", ["+34 600 11 22 33"]) + sin_formato = contact_import_key("BOB DISTINTO", ["600112233"]) + assert con_formato == sin_formato + + +def test_fallback_a_email_cuando_no_hay_telefono(): + solo_email = contact_import_key("Carol", [], ["CAROL@Example.com"]) + mismo_email_otro_nombre = contact_import_key("nombre cambiado", [], ["carol@example.com"]) + assert solo_email == mismo_email_otro_nombre + # Y distinto de la clave por nombre puro (prefijo de identity distinto). + por_nombre = contact_import_key("Carol") + assert solo_email != por_nombre + + +def test_fallback_a_nombre_insensible_a_acentos_y_mayusculas(): + con_acentos = contact_import_key("José Pérez") + sin_acentos = contact_import_key("jose perez") + assert con_acentos == sin_acentos + + +def test_prefijo_v1_y_longitud(): + k = contact_import_key("Dave", ["600999888"]) + assert k.startswith("v1-") + assert len(k) == 19 # 3 ("v1-") + 16 hex chars