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:
2026-06-13 00:33:12 +02:00
parent 1c8a86594f
commit 1c4a4b9259
17 changed files with 1773 additions and 0 deletions
+81
View File
@@ -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.
+131
View File
@@ -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"
+72
View File
@@ -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"})