Migrar contactos a Mailbox

Hoy toca migrar los contactos a Mailbox.org.

Como siempre he tenido lo que yo llamo “mi orden caótico”, un orden desordenado que solo yo entiendo, los contactos no podían ser menos y estaban esperriados. algunos en Google, otros en Apple… Y curiosamente, y eso que le he dado al botón de fusionar duplicados del iPhone, algunos todavía estaban duplicados.

Así que he decidido dos cosas. Sacarlos de las BigTech y ordenarlos.

Paso 1: exportar

  • Gmail: en contacts.google.com, el botón Exportar permite elegir “Contactos” y el formato vCard. Ojo con no exportar “Otros contactos”: ahí Google guarda solo cualquier dirección a la que hayas escrito alguna vez, y normalmente es basura. La web de Google Contacts se maneja bien con NVDA.
  • iCloud: desde iOS 16, en la app Contactos puedes ir a Listas, abrir las acciones de “Todos los contactos de iCloud” y elegir Exportar. Te genera un .vcf que guardas en Archivos. También se puede hacer desde icloud.com, pero Apple no sabe hacer webs accesibles, algún día aprenderán, todavía les tengo un poquito de fe.

Paso 2: fusionar con un script

En vez de fiarme del buscador de duplicados de Apple, preparé un pequeño script en Python que cruza los contactos por teléfono y por correo, no solo por nombre:

  • Si dos contactos comparten un teléfono o un correo, los fusiona en una sola tarjeta, con todos los teléfonos, correos, direcciones y notas…
  • Los teléfonos se comparan normalizados.
  • Si dos contactos solo coinciden en el nombre, no los toca. Los apunta en un informe como dudosos para que decidas tú, porque dos “María López” pueden ser dos personas distintas.
  • Genera un informe en texto plano (txt).

Solo requiere Pyhthon y funciona sin internet. Fusiona todos los archivos que le pases como argumento.

python fusionar_vcf.py google.vcf icloud.vcf -o contactos.vcf

El resultado en mi caso: 324 contactos de entrada y 214 de salida, con 110 fusiones y ningún dudoso.

Un consejo antes de importar: revisa el informe buscando grupos de tres o más tarjetas fusionadas, o con nombres que no tengan nada que ver. Un teléfono fijo compartido o un correo de familia pueden juntar a personas distintas. En mi caso estaba todo bien.

Ver el script completo
#!/usr/bin/env python3
"""
Fusiona varios ficheros vCard (.vcf) en uno solo, quitando duplicados.

Criterios:
  - Fusiona automáticamente los contactos que comparten un teléfono o un correo.
  - Los que solo coinciden en el nombre NO se fusionan por defecto: salen en el
    informe como dudosos. Con --fusionar-nombres también se fusionan.

Al fusionar se juntan todos los teléfonos, correos, direcciones, notas, etc.,
sin repetir teléfonos (comparados por número normalizado) ni correos.

Uso:
  python fusionar_vcf.py gmail.vcf icloud.vcf -o contactos.vcf
  python fusionar_vcf.py gmail.vcf icloud.vcf -o contactos.vcf --fusionar-nombres

Genera también un informe de texto (contactos_informe.txt) junto al fichero de
salida. Solo usa Python 3.8+. No se conecta a nada.
"""

import argparse
import sys
import unicodedata
import uuid
from collections import defaultdict
from pathlib import Path

# Propiedades que se regeneran o no se copian al fusionar
OMITIR = {"BEGIN", "END", "VERSION", "PRODID", "REV", "UID", "FN", "N"}


# ---------- Lectura y análisis ----------

def leer_texto(ruta):
    datos = Path(ruta).read_bytes()
    for codificacion in ("utf-8-sig", "cp1252"):
        try:
            return datos.decode(codificacion)
        except UnicodeDecodeError:
            continue
    sys.exit(f"No se pudo leer {ruta}: codificación desconocida")


def desplegar(texto):
    """Une las líneas plegadas (las que empiezan por espacio o tabulador)."""
    lineas = []
    for linea in texto.splitlines():
        if linea[:1] in (" ", "\t") and lineas:
            lineas[-1] += linea[1:]
        elif linea.strip():
            lineas.append(linea)
    return lineas


def partir(linea):
    """Separa 'cabecera:valor' respetando los dos puntos dentro de comillas."""
    en_comillas = False
    for i, c in enumerate(linea):
        if c == '"':
            en_comillas = not en_comillas
        elif c == ":" and not en_comillas:
            return linea[:i], linea[i + 1:]
    return linea, ""


class Propiedad:
    def __init__(self, linea):
        cabecera, self.valor = partir(linea)
        nombre_completo, _, self.parametros = cabecera.partition(";")
        if "." in nombre_completo:
            self.grupo, _, nombre = nombre_completo.partition(".")
        else:
            self.grupo, nombre = None, nombre_completo
        self.nombre = nombre.upper()

    def linea(self, grupo=None):
        cabecera = f"{grupo}.{self.nombre}" if grupo else self.nombre
        if self.parametros:
            cabecera += ";" + self.parametros
        return f"{cabecera}:{self.valor}"


class Tarjeta:
    def __init__(self, propiedades, origen):
        self.props = propiedades
        self.origen = origen

    def primera(self, nombre):
        for p in self.props:
            if p.nombre == nombre:
                return p
        return None

    def nombre_visible(self):
        fn = self.primera("FN")
        if fn and fn.valor.strip():
            return desescapar(fn.valor).strip()
        n = self.primera("N")
        if n:
            partes = [desescapar(x).strip() for x in n.valor.split(";")]
            # N = apellidos;nombre;segundos nombres;prefijo;sufijo
            orden = [partes[i] for i in (3, 1, 2, 0, 4) if i < len(partes)]
            texto = " ".join(x for x in orden if x)
            if texto:
                return texto
        return ""

    def telefonos(self):
        return {t for p in self.props if p.nombre == "TEL"
                for t in [normalizar_telefono(p.valor)] if t}

    def correos(self):
        return {normalizar_correo(p.valor) for p in self.props
                if p.nombre == "EMAIL" and p.valor.strip()}


def leer_tarjetas(ruta):
    tarjetas, actual = [], None
    for linea in desplegar(leer_texto(ruta)):
        mayus = linea.strip().upper()
        if mayus == "BEGIN:VCARD":
            actual = []
        elif mayus == "END:VCARD":
            if actual is not None:
                tarjetas.append(Tarjeta(actual, Path(ruta).name))
            actual = None
        elif actual is not None:
            actual.append(Propiedad(linea))
    return tarjetas


# ---------- Normalización ----------

def desescapar(valor):
    return (valor.replace("\\n", "\n").replace("\\N", "\n")
            .replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\"))


def normalizar_telefono(valor):
    valor = valor.strip()
    if valor.lower().startswith("tel:"):
        valor = valor[4:]
    digitos = "".join(c for c in valor if c.isdigit())
    if digitos.startswith("00"):
        digitos = digitos[2:]
    if digitos.startswith("34") and len(digitos) == 11:
        digitos = digitos[2:]
    # Los 9 últimos dígitos bastan para cruzar números españoles con o sin prefijo
    return digitos[-9:] if len(digitos) >= 9 else (digitos or None)


def normalizar_correo(valor):
    valor = valor.strip()
    if valor.lower().startswith("mailto:"):
        valor = valor[7:]
    return valor.lower()


def normalizar_nombre(texto):
    sin_tildes = "".join(c for c in unicodedata.normalize("NFKD", texto)
                         if not unicodedata.combining(c))
    return " ".join(sin_tildes.casefold().split())


# ---------- Agrupación (union-find) ----------

class Conjuntos:
    def __init__(self, n):
        self.padre = list(range(n))

    def raiz(self, x):
        while self.padre[x] != x:
            self.padre[x] = self.padre[self.padre[x]]
            x = self.padre[x]
        return x

    def unir(self, a, b):
        ra, rb = self.raiz(a), self.raiz(b)
        if ra != rb:
            self.padre[rb] = ra


def agrupar(tarjetas, fusionar_nombres):
    conj = Conjuntos(len(tarjetas))
    vistos = {}
    for i, t in enumerate(tarjetas):
        claves = [("tel", x) for x in t.telefonos()] + \
                 [("correo", x) for x in t.correos()]
        for clave in claves:
            if clave in vistos:
                conj.unir(vistos[clave], i)
            else:
                vistos[clave] = i

    # Coincidencias solo por nombre entre grupos distintos
    por_nombre = defaultdict(set)
    for i, t in enumerate(tarjetas):
        nombre = normalizar_nombre(t.nombre_visible())
        if nombre:
            por_nombre[nombre].add(conj.raiz(i))
    dudosos = []
    for nombre, raices in por_nombre.items():
        if len(raices) > 1:
            dudosos.append(sorted(raices))
            if fusionar_nombres:
                raices = list(raices)
                for r in raices[1:]:
                    conj.unir(raices[0], r)

    grupos = defaultdict(list)
    for i in range(len(tarjetas)):
        grupos[conj.raiz(i)].append(i)
    return list(grupos.values()), dudosos


# ---------- Fusión ----------

def clave_dedup(p):
    if p.nombre == "TEL":
        return ("TEL", normalizar_telefono(p.valor))
    if p.nombre == "EMAIL":
        return ("EMAIL", normalizar_correo(p.valor))
    return (p.nombre, p.valor.strip())


def fusionar(grupo_tarjetas):
    # La tarjeta más completa manda en nombre y orden
    ordenadas = sorted(grupo_tarjetas, key=lambda t: len(t.props), reverse=True)
    principal = max(ordenadas, key=lambda t: len(t.nombre_visible()))

    lineas = ["BEGIN:VCARD", "VERSION:3.0", "PRODID:-//fusionar_vcf//ES",
              f"UID:{uuid.uuid4()}"]

    fn = principal.primera("FN")
    nombre = principal.nombre_visible()
    if not nombre:
        correos = sorted(set().union(*(t.correos() for t in ordenadas)))
        telefonos = sorted(set().union(*(t.telefonos() for t in ordenadas)))
        nombre = (correos or telefonos or ["Sin nombre"])[0]
    lineas.append(fn.linea() if fn and fn.valor.strip() else f"FN:{nombre}")

    n = principal.primera("N") or next(
        (t.primera("N") for t in ordenadas if t.primera("N")), None)
    lineas.append(n.linea() if n else f"N:;{nombre};;;")

    vistas = set()
    foto_puesta = False
    contador_grupos = 0

    for t in ordenadas:
        # 1.ª pasada: qué propiedades principales sobreviven y, con ellas, sus grupos
        conservar = []
        grupos_vivos = set()
        for p in t.props:
            if p.nombre in OMITIR or p.nombre.startswith("X-AB"):
                continue
            if p.nombre == "PHOTO":
                if foto_puesta:
                    continue
                foto_puesta = True
            else:
                clave = clave_dedup(p)
                if clave in vistas:
                    continue
                vistas.add(clave)
            conservar.append(p)
            if p.grupo:
                grupos_vivos.add(p.grupo)

        # Renombrar grupos (item1, item2...) para que no choquen entre tarjetas
        renombre = {}
        for g in sorted(grupos_vivos):
            contador_grupos += 1
            renombre[g] = f"item{contador_grupos}"

        for p in t.props:
            if p in conservar:
                lineas.append(p.linea(renombre.get(p.grupo)))
            elif p.nombre.startswith("X-AB") and p.grupo in renombre:
                # Etiquetas personalizadas (X-ABLabel) de grupos que siguen vivos
                lineas.append(p.linea(renombre[p.grupo]))
            elif (p.nombre.startswith("X-AB") and not p.grupo
                  and clave_dedup(p) not in vistas):
                vistas.add(clave_dedup(p))
                lineas.append(p.linea())

    lineas.append("END:VCARD")
    return lineas, nombre


def plegar(linea, limite=75):
    """Pliega a 75 octetos sin partir caracteres UTF-8."""
    if len(linea.encode("utf-8")) <= limite:
        return linea
    trozos, actual, tam = [], "", 0
    for c in linea:
        bytes_c = len(c.encode("utf-8"))
        if tam + bytes_c > limite:
            trozos.append(actual)
            actual, tam = c, 1 + bytes_c  # la continuación empieza con un espacio
        else:
            actual += c
            tam += bytes_c
    trozos.append(actual)
    return "\r\n ".join(trozos)


# ---------- Programa principal ----------

def main():
    ap = argparse.ArgumentParser(description="Fusiona ficheros vCard sin duplicados.")
    ap.add_argument("entradas", nargs="+", help="Ficheros .vcf de entrada")
    ap.add_argument("-o", "--salida", default="contactos_fusionados.vcf",
                    help="Fichero .vcf de salida")
    ap.add_argument("--fusionar-nombres", action="store_true",
                    help="Fusionar también los que solo coinciden en el nombre")
    args = ap.parse_args()

    tarjetas, recuento = [], []
    for ruta in args.entradas:
        leidas = leer_tarjetas(ruta)
        recuento.append(f"{Path(ruta).name}: {len(leidas)}")
        tarjetas.extend(leidas)

    grupos, dudosos = agrupar(tarjetas, args.fusionar_nombres)

    salida, informe_fusion, sin_nombre = [], [], []
    for indices in sorted(grupos, key=lambda g: tarjetas[g[0]].nombre_visible().casefold()):
        grupo = [tarjetas[i] for i in indices]
        lineas, nombre = fusionar(grupo)
        salida.extend(plegar(l) for l in lineas)
        if not grupo[0].nombre_visible() and all(not t.nombre_visible() for t in grupo):
            sin_nombre.append(nombre)
        if len(grupo) > 1:
            nombres = [t.nombre_visible() or "(sin nombre)" for t in grupo]
            origenes = sorted({t.origen for t in grupo})
            informe_fusion.append(f"- {nombre}: {len(grupo)} tarjetas "
                                  f"({' | '.join(nombres)}) de {', '.join(origenes)}")

    ruta_salida = Path(args.salida)
    ruta_salida.write_text("\r\n".join(salida) + "\r\n", encoding="utf-8")

    informe = ["INFORME DE FUSIÓN DE CONTACTOS", ""]
    informe.append("Contactos leídos: " + "; ".join(recuento))
    informe.append(f"Contactos resultantes: {len(grupos)}")
    informe.append("")
    informe.append(f"Fusionados ({len(informe_fusion)} contactos):")
    informe.extend(informe_fusion or ["- Ninguno"])
    informe.append("")
    estado = "FUSIONADOS" if args.fusionar_nombres else "NO fusionados, revísalos"
    informe.append(f"Coincidencias solo por nombre, {estado} ({len(dudosos)}):")
    for raices in dudosos:
        nombres = []
        for r in raices:
            t = tarjetas[r]
            datos = sorted(t.telefonos()) + sorted(t.correos())
            nombres.append(f"{t.nombre_visible()} [{', '.join(datos) or 'sin datos'}]")
        informe.append("- " + " / ".join(nombres))
    if not dudosos:
        informe.append("- Ninguna")
    informe.append("")
    informe.append(f"Contactos sin nombre ({len(sin_nombre)}):")
    informe.extend(f"- {x}" for x in sin_nombre) if sin_nombre else informe.append("- Ninguno")

    ruta_informe = ruta_salida.with_name(ruta_salida.stem + "_informe.txt")
    ruta_informe.write_text("\n".join(informe) + "\n", encoding="utf-8")

    print(f"Leídos {len(tarjetas)} contactos, resultan {len(grupos)}.")
    print(f"Fusionados: {len(informe_fusion)}. Dudosos por nombre: {len(dudosos)}.")
    print(f"Salida: {ruta_salida}")
    print(f"Informe: {ruta_informe}")


if __name__ == "__main__":
    main()

Paso 3: importar en Mailbox.org

En la web de Mailbox.org, en Contactos, tras crear una libreta puedes importarlos. No es difícil, solo es cuestión de subir el ficherito.

Paso 4: conectar el iPhone por CardDAV

Primero desactiva los contactos de iCloud y de Gmail en el iPhone, eligiendo eliminarlos del móvil para no tener duplicados, si no vas a liar la de Dios es Cristo.

Luego ve a Ajustes, Contactos, Cuentas, Añadir cuenta, Otra, “Añadir cuenta CardDAV”:

  • Servidor: dav.mailbox.org
  • Usuario: tu dirección de correo
  • Contraseña: la de Mailbox.org, o una contraseña de aplicación si tienes activada la verificación en dos pasos

Paso 5: limpiar

Dentro de unos días, cuando esté seguro de que todo funciona, borraré los contactos de Google, incluida la papelera, y los de icloud.com. Mientras tanto guardo el .vcf como copia de seguridad.

Y ya estaría.

Webmentions

Aún no hay webmentions. ¡Envía una!