feat(duckdb,dav): primitivas de escritura DuckDB + libretas CardDAV + vCard multi-valor
Cinco funciones nuevas para soportar DuckDB como fuente de verdad del project osint:
Grupo duckdb (escritura, complementan a duckdb_query_readonly):
- duckdb_execute_py_infra (impure): ejecuta INSERT/UPDATE/DELETE/DDL en read-write, commit, {status,rowcount}. 6 tests.
- duckdb_upsert_py_infra (impure): UPSERT ON CONFLICT actualizando solo update_cols → ownership selectivo (un re-upsert no pisa columnas excluidas). 7 tests.
Grupo dav (libretas de contactos + vCard multi-valor):
- dav_make_addressbook_py_infra (impure): crea una libreta CardDAV nueva via extended MKCOL (RFC 5689). Idempotente. 12 tests.
- dav_list_addressbooks_py_infra (impure): lista las libretas del contacts-home (PROPFIND Depth:1). 7 tests.
- build_vcard_py_core (pure): serializa un contacto a vCard 3.0 multi-valor (N TEL/EMAIL/ADR + X-OSINT-*). 5 tests.
Paginas de capacidad duckdb.md y dav.md actualizadas.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: build_vcard
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_vcard(contact: dict) -> str"
|
||||
description: "Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor: varias lineas TEL, EMAIL y ADR. Pura, solo compone texto. Acepta claves en espanol e ingles. Generaliza el _build_vcard inline de osint_web."
|
||||
tags: [dav, vcard, carddav, contact, serialize, osint]
|
||||
params:
|
||||
- name: contact
|
||||
desc: "dict del contacto. Claves opcionales (acepta nombre ES o EN): uid/slug (identificador, uno obligatorio), fn/nombre (FN), aliases (list -> NICKNAME CSV), org (ORG), tels/telefonos (list -> N lineas TEL;TYPE=CELL), emails/correos (list -> N lineas EMAIL;TYPE=INTERNET), adrs/direcciones (list -> N lineas ADR;TYPE=HOME con la direccion en el componente street), osint (dict con dni/pais/contexto/sexo/fecha_nacimiento -> lineas X-OSINT-*), note/notas (NOTE). Una lista que venga como string suelto se envuelve en [valor]."
|
||||
output: "Texto VCARD 3.0 con lineas separadas por CRLF, empezando en BEGIN:VCARD / VERSION:3.0 y terminando en END:VCARD\\r\\n. Valores escapados segun RFC 6350; el ADR es un valor estructurado de 7 componentes cuyos separadores ';' NO se escapan."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_multivalor_tels_emails_adr", "test_escape_en_fn", "test_campos_osint", "test_claves_ingles_y_espanol_equivalentes", "test_falta_uid_y_slug_lanza_valueerror"]
|
||||
test_file_path: "python/functions/core/build_vcard_test.py"
|
||||
file_path: "python/functions/core/build_vcard.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from core.build_vcard import build_vcard
|
||||
|
||||
vcard = build_vcard({
|
||||
"uid": "ada-lovelace",
|
||||
"fn": "Ada Lovelace",
|
||||
"org": "Analytical Engine Co.",
|
||||
"tels": ["+34600111222", "+34600333444"], # 2 telefonos -> 2 lineas TEL
|
||||
"emails": ["ada@example.com"],
|
||||
"adrs": ["Calle Mayor 1, Madrid"],
|
||||
"osint": {"dni": "12345678Z", "pais": "ES"},
|
||||
"note": "Contacto de prueba",
|
||||
})
|
||||
print(vcard)
|
||||
# BEGIN:VCARD
|
||||
# VERSION:3.0
|
||||
# UID:ada-lovelace
|
||||
# FN:Ada Lovelace
|
||||
# ORG:Analytical Engine Co.
|
||||
# TEL;TYPE=CELL:+34600111222
|
||||
# TEL;TYPE=CELL:+34600333444
|
||||
# EMAIL;TYPE=INTERNET:ada@example.com
|
||||
# ADR;TYPE=HOME:;;Calle Mayor 1\, Madrid;;;;
|
||||
# X-OSINT-DNI:12345678Z
|
||||
# X-OSINT-PAIS:ES
|
||||
# NOTE:Contacto de prueba
|
||||
# END:VCARD
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando hay que materializar un contacto multi-valor (varios telefonos, emails o
|
||||
direcciones) a vCard para subirlo a CardDAV. Es el paso "componer el texto vCard"
|
||||
previo a `carddav_put_vcard_py_infra`. La reusan el service `osint_db` (push
|
||||
DB -> Xandikos) y `osint_web`. Usa el UID como identificador del recurso
|
||||
`<uid>.vcf`, asi re-subir el mismo UID sobrescribe (idempotente).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Pura salvo `ValueError`**: es determinista y sin efectos (no red ni disco).
|
||||
La unica excepcion posible es `ValueError` cuando faltan a la vez `uid` y
|
||||
`slug` (no hay identificador) — validacion de entrada aceptable en una pura.
|
||||
- **ADR estructurado de 7 campos**: el ADR del vCard es un valor estructurado
|
||||
`po-box;extended;street;locality;region;postal-code;country`. La direccion se
|
||||
coloca en el 3er componente (street) y el resto van vacios:
|
||||
`ADR;TYPE=HOME:;;<street>;;;;`. Los `;` que separan los 7 componentes NO se
|
||||
escapan; solo se escapa el contenido de cada componente (RFC 6350).
|
||||
- **Claves ES/EN**: para cada lista/campo acepta el nombre espanol o el ingles
|
||||
(`tels`/`telefonos`, `emails`/`correos`, `adrs`/`direcciones`, `note`/`notas`,
|
||||
`fn`/`nombre`). Si vienen ambos, gana el primero presente segun el orden
|
||||
documentado.
|
||||
- **Lista como string suelto**: si una clave de lista llega como string en vez
|
||||
de lista, se envuelve en `[valor]` y produce una sola linea.
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor.
|
||||
|
||||
Generaliza el ``_build_vcard`` inline de ``osint_web/server/main.py`` (que solo
|
||||
emitia un TEL y un EMAIL): aqui acepta listas de telefonos, emails y direcciones
|
||||
y emite una linea por elemento. Es una funcion pura — solo compone texto, sin red
|
||||
ni disco. La unica excepcion posible es ``ValueError`` por validacion de entrada
|
||||
(falta de identificador), lo cual es aceptable para una funcion pura.
|
||||
"""
|
||||
|
||||
# Orden + nombre de propiedad X-OSINT para cada clave del bloque osint.
|
||||
_OSINT_FIELDS = (
|
||||
("dni", "X-OSINT-DNI"),
|
||||
("pais", "X-OSINT-PAIS"),
|
||||
("contexto", "X-OSINT-CONTEXTO"),
|
||||
("sexo", "X-OSINT-SEXO"),
|
||||
("fecha_nacimiento", "X-OSINT-FECHA-NACIMIENTO"),
|
||||
)
|
||||
|
||||
|
||||
def _vcard_escape(value: str) -> str:
|
||||
"""Escapa un valor de texto para una linea vCard (RFC 6350).
|
||||
|
||||
Reglas: ``\\`` -> ``\\\\``, salto de linea -> ``\\n``, ``,`` -> ``\\,``,
|
||||
``;`` -> ``\\;``. Se aplica al contenido de cada propiedad, NO a los
|
||||
separadores estructurales del ADR.
|
||||
"""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace("\n", "\\n")
|
||||
.replace(",", "\\,")
|
||||
.replace(";", "\\;")
|
||||
)
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
"""Normaliza un valor a lista: ``None`` -> ``[]``, string suelto -> ``[s]``.
|
||||
|
||||
Tolera que una clave que deberia ser lista venga como string suelto.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _pick(contact: dict, *keys):
|
||||
"""Devuelve el primer valor no vacio entre ``keys`` (acepta ES/EN)."""
|
||||
for key in keys:
|
||||
val = contact.get(key)
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def build_vcard(contact: dict) -> str:
|
||||
"""Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor.
|
||||
|
||||
Args:
|
||||
contact: dict con claves opcionales (acepta nombre ES o EN):
|
||||
- ``uid`` / ``slug``: identificador del vCard. Uno obligatorio.
|
||||
- ``fn`` / ``nombre``: nombre completo (FN).
|
||||
- ``aliases``: lista -> NICKNAME (CSV escapado).
|
||||
- ``org``: organizacion -> ORG.
|
||||
- ``tels`` / ``telefonos``: lista -> una linea TEL;TYPE=CELL por item.
|
||||
- ``emails`` / ``correos``: lista -> una linea EMAIL;TYPE=INTERNET por item.
|
||||
- ``adrs`` / ``direcciones``: lista -> una linea ADR;TYPE=HOME por item
|
||||
(la direccion va en el componente street del ADR estructurado).
|
||||
- ``osint``: dict con ``dni, pais, contexto, sexo, fecha_nacimiento``
|
||||
-> lineas X-OSINT-* (solo las presentes/no vacias).
|
||||
- ``note`` / ``notas``: texto -> NOTE.
|
||||
|
||||
Returns:
|
||||
Texto VCARD 3.0 con lineas separadas por CRLF, terminando en
|
||||
``END:VCARD\\r\\n``.
|
||||
|
||||
Raises:
|
||||
ValueError: si faltan ``uid`` y ``slug`` (no hay identificador).
|
||||
"""
|
||||
uid = contact.get("uid") or contact.get("slug")
|
||||
if not uid:
|
||||
raise ValueError("build_vcard: falta identificador (uid o slug)")
|
||||
uid = str(uid).strip()
|
||||
|
||||
nombre = _pick(contact, "fn", "nombre")
|
||||
nombre = str(nombre).strip() if nombre else uid
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"UID:%s" % _vcard_escape(uid),
|
||||
"FN:%s" % _vcard_escape(nombre),
|
||||
]
|
||||
|
||||
aliases = _as_list(contact.get("aliases"))
|
||||
if aliases:
|
||||
joined = ",".join(_vcard_escape(str(a)) for a in aliases)
|
||||
lines.append("NICKNAME:%s" % joined)
|
||||
|
||||
org = contact.get("org")
|
||||
if org:
|
||||
lines.append("ORG:%s" % _vcard_escape(str(org)))
|
||||
|
||||
for tel in _as_list(_pick(contact, "tels", "telefonos")):
|
||||
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel)))
|
||||
|
||||
for email in _as_list(_pick(contact, "emails", "correos")):
|
||||
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(email)))
|
||||
|
||||
for adr in _as_list(_pick(contact, "adrs", "direcciones")):
|
||||
# ADR estructurado: 7 componentes separados por ';' SIN escapar los
|
||||
# separadores. La direccion va en el 3er componente (street); el resto
|
||||
# vacios: po-box;extended;street;locality;region;postal-code;country.
|
||||
street = _vcard_escape(str(adr))
|
||||
lines.append("ADR;TYPE=HOME:;;%s;;;;" % street)
|
||||
|
||||
osint = contact.get("osint")
|
||||
if isinstance(osint, dict):
|
||||
for key, x_name in _OSINT_FIELDS:
|
||||
val = osint.get(key)
|
||||
if val:
|
||||
lines.append("%s:%s" % (x_name, _vcard_escape(str(val))))
|
||||
|
||||
note = _pick(contact, "note", "notas")
|
||||
if note:
|
||||
lines.append("NOTE:%s" % _vcard_escape(str(note)))
|
||||
|
||||
lines.append("END:VCARD")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests para build_vcard."""
|
||||
|
||||
import pytest
|
||||
|
||||
from build_vcard import build_vcard
|
||||
|
||||
|
||||
def _count_lines(vcard: str, prefix: str) -> int:
|
||||
return sum(1 for ln in vcard.split("\r\n") if ln.startswith(prefix))
|
||||
|
||||
|
||||
def test_multivalor_tels_emails_adr():
|
||||
vcard = build_vcard(
|
||||
{
|
||||
"uid": "ada-lovelace",
|
||||
"fn": "Ada Lovelace",
|
||||
"tels": ["+34600111222", "+34600333444"],
|
||||
"emails": ["ada@example.com", "lovelace@example.org"],
|
||||
"adrs": ["Calle Mayor 1, Madrid"],
|
||||
}
|
||||
)
|
||||
assert _count_lines(vcard, "TEL") == 2
|
||||
assert _count_lines(vcard, "EMAIL") == 2
|
||||
assert _count_lines(vcard, "ADR") == 1
|
||||
assert vcard.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
||||
assert vcard.endswith("END:VCARD\r\n")
|
||||
|
||||
|
||||
def test_escape_en_fn():
|
||||
vcard = build_vcard({"uid": "x", "fn": "Doe, John; Jr"})
|
||||
# ',' -> '\,' y ';' -> '\;' en el valor del FN.
|
||||
assert "FN:Doe\\, John\\; Jr" in vcard.split("\r\n")
|
||||
|
||||
|
||||
def test_campos_osint():
|
||||
vcard = build_vcard(
|
||||
{
|
||||
"uid": "target-1",
|
||||
"fn": "Target One",
|
||||
"osint": {
|
||||
"dni": "12345678Z",
|
||||
"pais": "ES",
|
||||
"contexto": "investigacion",
|
||||
"sexo": "M",
|
||||
"fecha_nacimiento": "1990-01-01",
|
||||
"vacio": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
lines = vcard.split("\r\n")
|
||||
assert "X-OSINT-DNI:12345678Z" in lines
|
||||
assert "X-OSINT-PAIS:ES" in lines
|
||||
assert "X-OSINT-CONTEXTO:investigacion" in lines
|
||||
assert "X-OSINT-SEXO:M" in lines
|
||||
assert "X-OSINT-FECHA-NACIMIENTO:1990-01-01" in lines
|
||||
# Una clave vacia o desconocida no emite linea.
|
||||
assert _count_lines(vcard, "X-OSINT-") == 5
|
||||
|
||||
|
||||
def test_claves_ingles_y_espanol_equivalentes():
|
||||
ingles = build_vcard(
|
||||
{"uid": "a", "fn": "A", "tels": ["+1"], "emails": ["a@b.c"]}
|
||||
)
|
||||
espanol = build_vcard(
|
||||
{"uid": "a", "fn": "A", "telefonos": ["+1"], "correos": ["a@b.c"]}
|
||||
)
|
||||
assert ingles == espanol
|
||||
|
||||
|
||||
def test_falta_uid_y_slug_lanza_valueerror():
|
||||
with pytest.raises(ValueError):
|
||||
build_vcard({"fn": "Sin identificador"})
|
||||
Reference in New Issue
Block a user