feat(contacts): multi-valor (varios tel/email/direccion) + libretas + backend osint_db (flag)
- ContactIn + frontmatter + vCard multi-valor: emite N TEL, N EMAIL, N ADR; _vcard_to_json parsea ADR -> direcciones[] (y sigue leyendo X-OSINT-DIRECCION legacy). Los singulares telefono/email/direccion se mantienen por compat (= primer elemento de cada lista). - Libretas de contactos (addressbooks): endpoints GET/POST /api/addressbooks; en ContactsView un selector de libreta + boton 'Nueva libreta' (replica del patron de crear calendario) + filtro por libreta en la lista. - Frontend ContactsView: TagsInput para telefonos/emails/direcciones, cargando TODOS los valores al editar (antes solo el primero). - Feature flag OSINT_DB_BACKEND (dev/feature_flags.json, default off): con ON, osint_web lee/escribe contra el service osint_db (DuckDB = fuente de verdad) via server/osintdb_client.py; con OFF, el comportamiento historico (vault .md + vCard Xandikos) queda intacto byte a byte. Verificado: 52 tests backend (40 + 12 nuevos), tsc --noEmit limpio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+442
-35
@@ -31,8 +31,10 @@ Endpoints (JSON salvo /api/attachment):
|
||||
GET /api/node/<slug> ficha: frontmatter + body + attachments
|
||||
GET /api/attachment?path=.. binario del attachment (path relativo al vault)
|
||||
GET /api/search?q=... nodos cuyo contenido matchea la query
|
||||
GET /api/contacts contactos del addressbook Xandikos (CardDAV)
|
||||
GET /api/contacts contactos (Xandikos por defecto; osint_db si flag)
|
||||
GET /api/contact/<uid> un vCard concreto a JSON
|
||||
GET /api/addressbooks libretas de contactos (selector del frontend)
|
||||
POST /api/addressbooks crea una libreta nueva (requiere OSINT_DB_BACKEND)
|
||||
GET /api/calendars colecciones de calendario bajo /enmanuel/calendars/
|
||||
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV)
|
||||
POST /api/event crea un VEVENT en una colección de calendario
|
||||
@@ -170,6 +172,13 @@ dav_make_calendar = _load_infra_fn("dav_make_calendar", "dav_make_calendar")
|
||||
# Expandir una RRULE a las fechas de cada ocurrencia dentro de un rango (pura).
|
||||
expand_rrule = _load_infra_fn("expand_rrule", "expand_rrule")
|
||||
|
||||
# Cliente del service osint_db (DuckDB), usado SOLO cuando el feature flag
|
||||
# OSINT_DB_BACKEND está ON. El módulo vive junto a este archivo en server/, que
|
||||
# está en sys.path tanto al ejecutar `python server/main.py` como al importarlo
|
||||
# desde los tests. Se importa siempre (es barato: solo stdlib) pero no se usa a
|
||||
# menos que el flag esté activo.
|
||||
import osintdb_client # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuración Xandikos (CardDAV / CalDAV)
|
||||
@@ -179,6 +188,11 @@ XANDIKOS_BASE_URL = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||
XANDIKOS_USERNAME = "enmanuel"
|
||||
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
|
||||
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
|
||||
# Libreta (addressbook) por defecto. Cuando el flag OSINT_DB_BACKEND está OFF,
|
||||
# todos los contactos viven en esta única libreta; el frontend la muestra como
|
||||
# opción por defecto del selector.
|
||||
DEFAULT_ADDRESSBOOK_SLUG = "addressbook"
|
||||
DEFAULT_ADDRESSBOOK_NAME = "Contactos"
|
||||
# Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
|
||||
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
|
||||
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
|
||||
@@ -299,6 +313,115 @@ def _write_disk_cache(path: str, ctag: str, items: list) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature flag OSINT_DB_BACKEND: fuente de verdad de los contactos
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# OFF (default): los contactos se escriben como ficha .md en el vault + reflejo
|
||||
# del vCard en Xandikos (comportamiento histórico de la app).
|
||||
# ON: la app lee/escribe contra el service osint_db (DuckDB, 127.0.0.1:8771),
|
||||
# que pasa a ser la fuente de verdad y empuja él mismo el cambio a Xandikos.
|
||||
#
|
||||
# El flag vive en dev/feature_flags.json (raíz de la app), patrón TBD del
|
||||
# registry (.claude/rules/feature_flags.md). Se lee en cada acceso (no se cachea)
|
||||
# para que cambiarlo no requiera reiniciar el server.
|
||||
|
||||
_FLAGS_FILE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"dev",
|
||||
"feature_flags.json",
|
||||
)
|
||||
|
||||
# Service osint_db (DuckDB) — fuente de verdad cuando el flag está ON.
|
||||
OSINT_DB_BASE_URL = "http://127.0.0.1:8771"
|
||||
|
||||
|
||||
def _osint_db_backend_enabled() -> bool:
|
||||
"""True si el flag ``OSINT_DB_BACKEND`` está activo en dev/feature_flags.json.
|
||||
|
||||
Lee el archivo en cada llamada (sin caché) para que el flip se note sin
|
||||
reiniciar. Tolerante a fallos: archivo ausente, JSON corrupto o clave faltante
|
||||
→ False (comportamiento histórico vault+Xandikos), nunca lanza.
|
||||
"""
|
||||
try:
|
||||
with open(_FLAGS_FILE, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
flag = (data.get("flags") or {}).get("OSINT_DB_BACKEND") or {}
|
||||
return bool(flag.get("enabled"))
|
||||
|
||||
|
||||
def _contacts_from_osint_db() -> list:
|
||||
"""Lee los contactos del osint_db y los adapta al shape JSON del frontend.
|
||||
|
||||
El osint_db devuelve filas ``{uid, collection, fn, tels, emails, note_path}``
|
||||
(``tels``/``emails`` como JSON array). Esta función las mapea al mismo dict que
|
||||
produce ``_vcard_to_json`` (``uid, nombre, telefonos, correos, phones,
|
||||
emails, osint, ...``), para que ``/api/contacts`` y la vista no distingan la
|
||||
fuente. ``collection`` se expone para poder filtrar por libreta.
|
||||
|
||||
Raises:
|
||||
osintdb_client.OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
rows = osintdb_client.list_contacts()
|
||||
out: list = []
|
||||
for row in rows:
|
||||
tels = osintdb_client._parse_json_array(row.get("tels"))
|
||||
mails = osintdb_client._parse_json_array(row.get("emails"))
|
||||
fn = row.get("fn")
|
||||
out.append(
|
||||
{
|
||||
"uid": row.get("uid"),
|
||||
"fn": fn,
|
||||
"nombre": fn,
|
||||
"nickname": None,
|
||||
"alias": None,
|
||||
"org": None,
|
||||
"note": None,
|
||||
"nota": None,
|
||||
"collection": row.get("collection"),
|
||||
"phones": [{"value": t, "type": ""} for t in tels],
|
||||
"emails": [{"value": e, "type": ""} for e in mails],
|
||||
"telefonos": tels,
|
||||
"correos": mails,
|
||||
"direcciones": [],
|
||||
"osint": {},
|
||||
"note_path": row.get("note_path"),
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower())
|
||||
return out
|
||||
|
||||
|
||||
def _osint_db_contact_payload(data: "ContactIn", uid: Optional[str] = None) -> dict:
|
||||
"""Construye el cuerpo JSON de un contacto para el service osint_db.
|
||||
|
||||
Mapea el ``ContactIn`` (ya reconciliado multi-valor) al contrato del osint_db:
|
||||
``{uid?, collection, fn, telefonos, emails, direcciones, nombre, aliases, dni,
|
||||
pais, contexto, notas}``. ``collection`` se deriva del campo ``contexto`` si
|
||||
apunta a una libreta; por defecto la libreta canónica. ``uid`` solo se incluye
|
||||
al crear (el PUT lo lleva en la ruta, no en el cuerpo).
|
||||
"""
|
||||
nombre = data.nombre.strip()
|
||||
payload: dict = {
|
||||
"collection": _norm_str(data.collection) or DEFAULT_ADDRESSBOOK_SLUG,
|
||||
"fn": nombre,
|
||||
"nombre": nombre,
|
||||
"telefonos": _norm_list(data.telefonos),
|
||||
"emails": _norm_list(data.emails),
|
||||
"direcciones": _norm_list(data.direcciones),
|
||||
"aliases": _norm_list(data.aliases),
|
||||
"dni": _norm_str(data.dni),
|
||||
"pais": _norm_str(data.pais),
|
||||
"contexto": _norm_str(data.contexto),
|
||||
"notas": _norm_str(data.notas),
|
||||
}
|
||||
if uid:
|
||||
payload["uid"] = uid
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Estado del servidor: caché del vault + password Xandikos
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -637,17 +760,26 @@ class VaultState:
|
||||
return list(items)
|
||||
|
||||
def contacts(self) -> list:
|
||||
"""Contactos del addressbook Xandikos, parseados y cacheados.
|
||||
"""Contactos, desde Xandikos (flag OFF) o desde osint_db (flag ON).
|
||||
|
||||
Caché en dos niveles: memoria (mientras vive el proceso) y disco
|
||||
(``.cache/contacts.json``, validada por ctag para arranque instantáneo).
|
||||
Al primer acceso descarga TODO en UNA petición REPORT
|
||||
(``dav_get_collection``) en vez de un GET por ``.vcf``.
|
||||
Con el flag ``OSINT_DB_BACKEND`` activo, la fuente de verdad es el service
|
||||
osint_db (DuckDB): se consultan sus contactos y se devuelven con el mismo
|
||||
shape JSON que produce el parseo del vCard, para que el frontend no note la
|
||||
diferencia. Con el flag OFF (default), camino histórico: addressbook
|
||||
Xandikos parseado y cacheado.
|
||||
|
||||
Caché en dos niveles para el camino DAV: memoria (mientras vive el
|
||||
proceso) y disco (``.cache/contacts.json``, validada por ctag para
|
||||
arranque instantáneo). Al primer acceso descarga TODO en UNA petición
|
||||
REPORT (``dav_get_collection``) en vez de un GET por ``.vcf``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: si no se puede leer la password de ``pass``.
|
||||
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
||||
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
return _contacts_from_osint_db()
|
||||
with self._dav_lock:
|
||||
if self._contacts_cache is not None and not self._force_reload:
|
||||
return self._contacts_cache
|
||||
@@ -662,6 +794,63 @@ class VaultState:
|
||||
self._maybe_clear_force_reload()
|
||||
return contacts
|
||||
|
||||
def list_addressbooks(self) -> list:
|
||||
"""Libretas (addressbooks) disponibles para los contactos.
|
||||
|
||||
Con el flag ``OSINT_DB_BACKEND`` ON, consulta las libretas del osint_db
|
||||
(``{slug, display_name, collection_path, color}``). Con el flag OFF, hoy
|
||||
solo existe la libreta por defecto en el vault; se devuelve esa única
|
||||
entrada para que el selector del frontend tenga algo que mostrar.
|
||||
|
||||
Raises:
|
||||
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
return osintdb_client.list_addressbooks()
|
||||
return [
|
||||
{
|
||||
"slug": DEFAULT_ADDRESSBOOK_SLUG,
|
||||
"display_name": DEFAULT_ADDRESSBOOK_NAME,
|
||||
"collection_path": XANDIKOS_CONTACTS_COLLECTION,
|
||||
"color": None,
|
||||
}
|
||||
]
|
||||
|
||||
def create_addressbook(self, data: "AddressbookIn") -> dict:
|
||||
"""Crea una libreta de contactos nueva.
|
||||
|
||||
Solo soportado con el flag ``OSINT_DB_BACKEND`` ON: el osint_db crea la
|
||||
colección CardDAV en Xandikos y la registra en la DuckDB. Con el flag OFF
|
||||
no hay forma de crear libretas todavía (no existe ``dav_make_addressbook``
|
||||
en el registry) → 501 claro indicando que requiere el flag.
|
||||
|
||||
Returns:
|
||||
dict ``{status, slug, ...}`` del osint_db.
|
||||
|
||||
Raises:
|
||||
HTTPException(400): si el slug/nombre queda vacío.
|
||||
HTTPException(501): si el flag está OFF.
|
||||
osintdb_client.OsintDbUnavailable: si el osint_db no responde.
|
||||
"""
|
||||
slug = (data.slug or data.name or "").strip()
|
||||
if not slug:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="el nombre de la libreta es obligatorio"
|
||||
)
|
||||
if not _osint_db_backend_enabled():
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail=(
|
||||
"crear libretas requiere el backend OSINT_DB_BACKEND activo "
|
||||
"(hoy solo existe la libreta por defecto en el vault)"
|
||||
),
|
||||
)
|
||||
res = osintdb_client.create_addressbook(
|
||||
slug, data.name or slug, data.color or None
|
||||
)
|
||||
self.invalidate_dav()
|
||||
return res
|
||||
|
||||
def _resolve_calendar(self, cal: str = "") -> str:
|
||||
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
|
||||
|
||||
@@ -973,6 +1162,21 @@ class VaultState:
|
||||
HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
|
||||
nombre está vacío.
|
||||
"""
|
||||
if not data.nombre.strip():
|
||||
raise HTTPException(status_code=400, detail="el nombre es obligatorio")
|
||||
if _osint_db_backend_enabled():
|
||||
# Flag ON: el osint_db es la fuente de verdad. Genera el slug igual que
|
||||
# el camino vault (mismo UID), envía el payload y deja que el service
|
||||
# escriba la DuckDB + empuje a Xandikos.
|
||||
slug = slugify_obsidian_name(data.nombre)
|
||||
if not slug:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="el nombre no produce un slug válido"
|
||||
)
|
||||
res = osintdb_client.create_contact(_osint_db_contact_payload(data, slug))
|
||||
self.invalidate_dav()
|
||||
uid = res.get("uid") or slug
|
||||
return {"slug": uid, "uid": uid, "path": None, "osint_db": res}
|
||||
tipo = (data.tipo or "persona").strip()
|
||||
if tipo not in _TIPO_FOLDER:
|
||||
raise HTTPException(
|
||||
@@ -1024,6 +1228,13 @@ class VaultState:
|
||||
Raises:
|
||||
HTTPException(404): si no existe la ficha del contacto.
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
# Flag ON: delega la edición en el osint_db (PUT por UID).
|
||||
res = osintdb_client.update_contact(
|
||||
slug, _osint_db_contact_payload(data)
|
||||
)
|
||||
self.invalidate_dav()
|
||||
return {"slug": slug, "uid": slug, "path": None, "osint_db": res}
|
||||
path = self._find_contact_note(slug)
|
||||
if path is None:
|
||||
raise HTTPException(
|
||||
@@ -1031,13 +1242,21 @@ class VaultState:
|
||||
)
|
||||
note = read_obsidian_note(path)
|
||||
current = dict(note.get("frontmatter") or {})
|
||||
# Merge de los campos editables (preserva los heredados no tocados).
|
||||
# Listas multi-valor (ya reconciladas con los singulares en ContactIn).
|
||||
telefonos = _norm_list(data.telefonos)
|
||||
emails = _norm_list(data.emails)
|
||||
direcciones = _norm_list(data.direcciones)
|
||||
# Merge de los campos editables (preserva los heredados no tocados). El
|
||||
# singular se conserva = primer elemento para los lectores viejos.
|
||||
merged = {
|
||||
"nombre": data.nombre.strip() or current.get("nombre") or slug,
|
||||
"aliases": _norm_list(data.aliases),
|
||||
"telefono": _norm_str(data.telefono),
|
||||
"email": _norm_str(data.email),
|
||||
"direccion": _norm_str(data.direccion),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"telefonos": telefonos,
|
||||
"email": emails[0] if emails else None,
|
||||
"emails": emails,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"direcciones": direcciones,
|
||||
"pais": _norm_str(data.pais),
|
||||
"relaciones": _norm_list(data.relaciones),
|
||||
"contexto": _norm_str(data.contexto),
|
||||
@@ -1067,6 +1286,11 @@ class VaultState:
|
||||
Raises:
|
||||
HTTPException(404): si no existe la ficha del contacto.
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
# Flag ON: delega el borrado en el osint_db (DELETE por UID).
|
||||
res = osintdb_client.delete_contact(slug)
|
||||
self.invalidate_dav()
|
||||
return {"slug": slug, "deleted": True, "osint_db": res}
|
||||
path = self._find_contact_note(slug)
|
||||
if path is None:
|
||||
raise HTTPException(
|
||||
@@ -1172,6 +1396,7 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
"note": None,
|
||||
"phones": [],
|
||||
"emails": [],
|
||||
"direcciones": [],
|
||||
"osint": {},
|
||||
}
|
||||
for line in _unfold_lines(vcard_text):
|
||||
@@ -1179,6 +1404,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
if not parsed:
|
||||
continue
|
||||
name, params, value = parsed
|
||||
# ADR es estructurado (7 componentes separados por ';'): NO se des-escapa
|
||||
# antes de partir, para no confundir separadores con contenido escapado.
|
||||
if name == "ADR":
|
||||
adr = _parse_adr_value(value)
|
||||
if adr:
|
||||
out["direcciones"].append(adr)
|
||||
continue
|
||||
value = _unescape_ical(value.strip())
|
||||
if name == "UID":
|
||||
out["uid"] = value
|
||||
@@ -1205,6 +1437,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
out["fn"] = ("%s %s" % (comps[1], comps[0])).strip()
|
||||
elif comps:
|
||||
out["fn"] = comps[0]
|
||||
# Compat con vCards antiguos: la dirección iba en X-OSINT-DIRECCION (un solo
|
||||
# valor) en vez de ADR. Si vino por ahí y no hay ADR, súbela a direcciones[]
|
||||
# para que el frontend la vea como multi-valor; deja también osint.direccion
|
||||
# por si algún lector viejo lo consulta.
|
||||
legacy_dir = out["osint"].get("direccion")
|
||||
if legacy_dir and legacy_dir not in out["direcciones"]:
|
||||
out["direcciones"].append(legacy_dir)
|
||||
# Alias en español que consume el frontend del task (mismo dato, otra clave).
|
||||
out["nombre"] = out["fn"]
|
||||
out["alias"] = out["nickname"]
|
||||
@@ -1214,6 +1453,29 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _parse_adr_value(raw: str) -> Optional[str]:
|
||||
"""Extrae la dirección legible de un valor ADR estructurado (RFC 6350).
|
||||
|
||||
El ADR tiene 7 componentes separados por ``;``:
|
||||
``po-box;extended;street;locality;region;postal-code;country``. Esta función
|
||||
une los componentes no vacíos (des-escapados) en una sola línea legible, con
|
||||
preferencia por ``street``; si solo hay un campo, lo devuelve. Devuelve
|
||||
``None`` si el ADR queda vacío.
|
||||
"""
|
||||
parts = raw.split(";")
|
||||
# Des-escapa cada componente por separado (el ';' ya se usó para partir).
|
||||
comps = [_unescape_ical(p.strip()) for p in parts]
|
||||
nonempty = [c for c in comps if c]
|
||||
if not nonempty:
|
||||
return None
|
||||
# street es el 3er componente (índice 2). Si está, suele bastar; si hay más
|
||||
# (locality, region, etc.) se concatenan con coma para una línea legible.
|
||||
if len(comps) >= 3 and comps[2]:
|
||||
tail = [c for c in comps[3:] if c]
|
||||
return ", ".join([comps[2]] + tail) if tail else comps[2]
|
||||
return ", ".join(nonempty)
|
||||
|
||||
|
||||
_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
|
||||
_ICAL_DT_RE = re.compile(
|
||||
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$"
|
||||
@@ -1497,19 +1759,59 @@ class ContactIn(BaseModel):
|
||||
Refleja el esquema canónico del frontmatter (CONVENTIONS.md §3b/§6). Los
|
||||
campos vacíos se normalizan a ``null``/``[]`` al escribir la ficha, nunca se
|
||||
omiten, para que el score de completitud sea consistente.
|
||||
|
||||
Multi-valor: un contacto puede tener VARIOS teléfonos, emails y direcciones
|
||||
(``telefonos``/``emails``/``direcciones``). Los campos singulares
|
||||
``telefono``/``email``/``direccion`` se conservan por compatibilidad con
|
||||
clientes y lectores viejos: el validador ``model_post_init`` los reconcilia
|
||||
con las listas (singular → ``[valor]`` si la lista está vacía; y el singular
|
||||
se rellena con ``lista[0]`` para que los lectores que solo miran el singular
|
||||
sigan funcionando).
|
||||
"""
|
||||
|
||||
tipo: str = Field(default="persona")
|
||||
nombre: str
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
# Singulares (compat) — el primer elemento de cada lista multi-valor.
|
||||
telefono: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
dni: Optional[str] = None
|
||||
direccion: Optional[str] = None
|
||||
# Multi-valor: listas completas de teléfonos, emails y direcciones.
|
||||
telefonos: list[str] = Field(default_factory=list)
|
||||
emails: list[str] = Field(default_factory=list)
|
||||
direcciones: list[str] = Field(default_factory=list)
|
||||
dni: Optional[str] = None
|
||||
pais: Optional[str] = None
|
||||
contexto: Optional[str] = None
|
||||
relaciones: list[str] = Field(default_factory=list)
|
||||
notas: Optional[str] = None
|
||||
# Libreta (addressbook) destino. Solo se consume con el flag OSINT_DB_BACKEND
|
||||
# ON (el osint_db enruta el contacto a esa colección). Con el flag OFF se
|
||||
# ignora: hoy solo existe la libreta por defecto en el vault. None → libreta
|
||||
# por defecto.
|
||||
collection: Optional[str] = None
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
"""Reconcilia los campos singulares con las listas multi-valor.
|
||||
|
||||
Para cada par (singular, lista): si la lista llega vacía pero el singular
|
||||
trae valor, la lista se siembra con ``[singular]`` (cliente viejo que solo
|
||||
envía el campo singular); y siempre se rellena el singular con el primer
|
||||
elemento normalizado de la lista, para que los lectores que solo miran el
|
||||
singular (frontmatter compat, vCard heredado) sigan funcionando.
|
||||
"""
|
||||
for singular, plural in (
|
||||
("telefono", "telefonos"),
|
||||
("email", "emails"),
|
||||
("direccion", "direcciones"),
|
||||
):
|
||||
lista = _norm_list(getattr(self, plural))
|
||||
if not lista:
|
||||
single = _norm_str(getattr(self, singular))
|
||||
if single:
|
||||
lista = [single]
|
||||
object.__setattr__(self, plural, lista)
|
||||
object.__setattr__(self, singular, lista[0] if lista else None)
|
||||
|
||||
|
||||
def _norm_str(value: Optional[str]) -> Optional[str]:
|
||||
@@ -1543,15 +1845,23 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
|
||||
nombre = data.nombre.strip()
|
||||
aliases = _norm_list(data.aliases)
|
||||
relaciones = _norm_list(data.relaciones)
|
||||
# Listas multi-valor ya reconciladas en ContactIn.model_post_init; el campo
|
||||
# singular = primer elemento (o None) para los lectores viejos.
|
||||
telefonos = _norm_list(data.telefonos)
|
||||
emails = _norm_list(data.emails)
|
||||
direcciones = _norm_list(data.direcciones)
|
||||
if data.tipo == "organizacion":
|
||||
return {
|
||||
"tipo": "organizacion",
|
||||
"nombre": nombre,
|
||||
"slug": slug,
|
||||
"aliases": aliases,
|
||||
"telefono": _norm_str(data.telefono),
|
||||
"email": _norm_str(data.email),
|
||||
"direccion": _norm_str(data.direccion),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"telefonos": telefonos,
|
||||
"email": emails[0] if emails else None,
|
||||
"emails": emails,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"direcciones": direcciones,
|
||||
"pais": _norm_str(data.pais),
|
||||
"relaciones": relaciones,
|
||||
"contexto": _norm_str(data.contexto),
|
||||
@@ -1567,9 +1877,12 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
|
||||
"sexo": None,
|
||||
"fecha_nacimiento": None,
|
||||
"dni": _norm_str(data.dni),
|
||||
"telefono": _norm_str(data.telefono),
|
||||
"email": _norm_str(data.email),
|
||||
"direccion": _norm_str(data.direccion),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"telefonos": telefonos,
|
||||
"email": emails[0] if emails else None,
|
||||
"emails": emails,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"direcciones": direcciones,
|
||||
"pais": _norm_str(data.pais),
|
||||
"relaciones": relaciones,
|
||||
"contexto": _norm_str(data.contexto),
|
||||
@@ -1596,11 +1909,28 @@ def _vcard_escape(value: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _vcard_value_list(frontmatter: dict, plural: str, singular: str) -> list:
|
||||
"""Lista de valores de un campo multi-valor del frontmatter de contacto.
|
||||
|
||||
Prefiere la clave plural (``telefonos``/``emails``/``direcciones``); si está
|
||||
vacía cae al singular (``telefono``/...) por compatibilidad con fichas
|
||||
antiguas. Normaliza (trim + descarta vacíos) y devuelve una lista de strings.
|
||||
"""
|
||||
values = frontmatter.get(plural)
|
||||
if not values:
|
||||
single = frontmatter.get(singular)
|
||||
values = [single] if single else []
|
||||
return _norm_list([str(v) for v in values])
|
||||
|
||||
|
||||
def _build_vcard(frontmatter: dict, slug: str) -> str:
|
||||
"""Serializa un frontmatter de contacto a un VCARD 3.0 con el UID = slug.
|
||||
|
||||
Mapea: nombre→FN, aliases→NICKNAME, telefono→TEL, email→EMAIL, notas→NOTE,
|
||||
organización→ORG; y los campos OSINT (dni, direccion, pais, contexto, sexo,
|
||||
Soporta multi-valor: emite una línea ``TEL`` por teléfono, una ``EMAIL`` por
|
||||
email y una ``ADR`` por dirección (campos ``telefonos``/``emails``/
|
||||
``direcciones`` del frontmatter; cae a los singulares ``telefono``/... por
|
||||
compat). Mapea además: nombre→FN, aliases→NICKNAME, notas→NOTE,
|
||||
organización→ORG; y los campos OSINT (dni, pais, contexto, sexo,
|
||||
fecha_nacimiento) a propiedades ``X-OSINT-*`` que el parser ``_vcard_to_json``
|
||||
ya entiende. El UID es el slug → idempotente: re-subir el mismo slug
|
||||
sobrescribe el recurso ``<slug>.vcf``.
|
||||
@@ -1617,16 +1947,19 @@ def _build_vcard(frontmatter: dict, slug: str) -> str:
|
||||
lines.append("NICKNAME:%s" % _vcard_escape(",".join(str(a) for a in aliases)))
|
||||
if frontmatter.get("tipo") == "organizacion":
|
||||
lines.append("ORG:%s" % _vcard_escape(nombre))
|
||||
tel = frontmatter.get("telefono")
|
||||
if tel:
|
||||
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel)))
|
||||
email = frontmatter.get("email")
|
||||
if email:
|
||||
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(email)))
|
||||
# Multi-valor: una línea TEL/EMAIL por elemento.
|
||||
for tel in _vcard_value_list(frontmatter, "telefonos", "telefono"):
|
||||
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(tel))
|
||||
for email in _vcard_value_list(frontmatter, "emails", "email"):
|
||||
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(email))
|
||||
# Direcciones → ADR estructurado (la dirección va en el componente street;
|
||||
# los separadores ';' del ADR NO se escapan, solo el contenido). Una línea
|
||||
# ADR por dirección. El parser _vcard_to_json reconstruye la lista desde ADR.
|
||||
for adr in _vcard_value_list(frontmatter, "direcciones", "direccion"):
|
||||
lines.append("ADR;TYPE=HOME:;;%s;;;;" % _vcard_escape(adr))
|
||||
# Campos OSINT → X-OSINT-* (los recoge _vcard_to_json en el bloque osint).
|
||||
for fm_key, x_name in (
|
||||
("dni", "X-OSINT-DNI"),
|
||||
("direccion", "X-OSINT-DIRECCION"),
|
||||
("pais", "X-OSINT-PAIS"),
|
||||
("contexto", "X-OSINT-CONTEXTO"),
|
||||
("sexo", "X-OSINT-SEXO"),
|
||||
@@ -1702,6 +2035,19 @@ class CalendarIn(BaseModel):
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AddressbookIn(BaseModel):
|
||||
"""Cuerpo de POST /api/addressbooks: crea una libreta de contactos nueva.
|
||||
|
||||
``slug`` es el segmento de URL de la colección CardDAV; ``name`` el nombre
|
||||
visible; ``color`` un hex ``#rrggbb`` opcional. Solo se procesa con el flag
|
||||
``OSINT_DB_BACKEND`` activo (el osint_db crea la colección en Xandikos).
|
||||
"""
|
||||
|
||||
slug: str
|
||||
name: Optional[str] = ""
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
def _parse_iso_input(value: str) -> Optional[dict]:
|
||||
"""Parsea una fecha de entrada ISO a ``{year,month,day,hour,minute,second,
|
||||
offset,date_only}`` o ``None``.
|
||||
@@ -1970,12 +2316,12 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
|
||||
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
|
||||
La lista se cachea en memoria al primer acceso (``POST /api/refresh`` la
|
||||
invalida). Si Xandikos no responde o falta la password → 503 con un JSON
|
||||
de error claro, nunca un crash.
|
||||
invalida). Si Xandikos / el osint_db no responde o falta la password →
|
||||
503 con un JSON de error claro, nunca un crash.
|
||||
"""
|
||||
try:
|
||||
contacts = state.contacts()
|
||||
except (RuntimeError, DavUnavailable) as exc:
|
||||
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
@@ -1993,7 +2339,7 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
"""
|
||||
try:
|
||||
contacts = state.contacts()
|
||||
except (RuntimeError, DavUnavailable) as exc:
|
||||
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
@@ -2024,8 +2370,15 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
La ficha del vault es la fuente de verdad (CONVENTIONS.md §3b/§6);
|
||||
Xandikos se actualiza de inmediato para que el contacto se vea ya en la
|
||||
app y en el móvil. 409 si el slug ya existe. Devuelve ``{slug, uid}``.
|
||||
Con el flag ``OSINT_DB_BACKEND`` ON el alta va al osint_db; 503 si no
|
||||
responde.
|
||||
"""
|
||||
result = state.create_contact(data)
|
||||
try:
|
||||
result = state.create_contact(data)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(status_code=201, content={"status": "ok", **result})
|
||||
|
||||
@app.put("/api/contact/{slug}")
|
||||
@@ -2033,20 +2386,74 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
"""Edita la ficha de un contacto (merge frontmatter) + re-sube el vCard.
|
||||
|
||||
404 si no existe la ficha. Preserva campos heredados no editables
|
||||
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``.
|
||||
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``. Con el
|
||||
flag ``OSINT_DB_BACKEND`` ON la edición va al osint_db; 503 si no responde.
|
||||
"""
|
||||
result = state.update_contact(slug, data)
|
||||
try:
|
||||
result = state.update_contact(slug, data)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(content={"status": "ok", **result})
|
||||
|
||||
@app.delete("/api/contact/{slug}")
|
||||
def api_delete_contact(slug: str) -> JSONResponse:
|
||||
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos.
|
||||
|
||||
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``.
|
||||
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``. Con
|
||||
el flag ``OSINT_DB_BACKEND`` ON el borrado va al osint_db; 503 si no
|
||||
responde.
|
||||
"""
|
||||
result = state.delete_contact(slug)
|
||||
try:
|
||||
result = state.delete_contact(slug)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(content={"status": "ok", **result})
|
||||
|
||||
# -- Libretas (addressbooks) de contactos --
|
||||
|
||||
@app.get("/api/addressbooks")
|
||||
def api_addressbooks() -> JSONResponse:
|
||||
"""Libretas de contactos disponibles para el selector del frontend.
|
||||
|
||||
Cada una: ``{slug, display_name, collection_path, color}``. Con el flag
|
||||
``OSINT_DB_BACKEND`` ON vienen del osint_db; con el flag OFF se devuelve
|
||||
solo la libreta por defecto del vault. 503 si el osint_db no responde.
|
||||
"""
|
||||
try:
|
||||
books = state.list_addressbooks()
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "ok",
|
||||
"count": len(books),
|
||||
"addressbooks": books,
|
||||
"default": DEFAULT_ADDRESSBOOK_SLUG,
|
||||
}
|
||||
)
|
||||
|
||||
@app.post("/api/addressbooks")
|
||||
def api_create_addressbook(data: AddressbookIn = Body(...)) -> JSONResponse:
|
||||
"""Crea una libreta de contactos nueva.
|
||||
|
||||
Body: ``{slug, name?, color?}``. Requiere el flag ``OSINT_DB_BACKEND``
|
||||
activo (el osint_db crea la colección CardDAV en Xandikos); con el flag
|
||||
OFF devuelve 501 claro. 503 si el osint_db no responde.
|
||||
"""
|
||||
try:
|
||||
res = state.create_addressbook(data)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(status_code=201, content={"status": "ok", **res})
|
||||
|
||||
# -- Xandikos: calendario (CalDAV) --
|
||||
|
||||
@app.get("/api/calendars")
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Cliente HTTP fino al service osint_db (DuckDB, 127.0.0.1:8771).
|
||||
|
||||
Fuente de verdad de los contactos cuando el feature flag ``OSINT_DB_BACKEND``
|
||||
está activo. El osint_db es quien escribe la DuckDB y empuja el cambio a
|
||||
Xandikos; esta app solo le habla por HTTP. Todas las respuestas del service son
|
||||
``200 + {status: "ok"|"error", ...}`` (los errores de dominio viajan en el cuerpo,
|
||||
no en el código HTTP).
|
||||
|
||||
Solo stdlib (urllib, json) para no añadir dependencias de runtime: el cliente es
|
||||
un wrapper de transporte, no reimplementa lógica del osint_db. Errores de red
|
||||
(timeout, conexión rechazada, host caído) se traducen a la excepción
|
||||
``OsintDbUnavailable`` para que los endpoints degraden con un 503 claro, igual que
|
||||
el camino DAV, en vez de tumbar el server.
|
||||
|
||||
Contrato (cuerpo JSON):
|
||||
POST /api/query {sql, params?, max_rows?} → {status, columns, rows}
|
||||
POST /api/contact {collection, fn, telefonos, emails, direcciones, ...}
|
||||
PUT /api/contact/{uid} (mismo cuerpo, sin uid en el body)
|
||||
DELETE /api/contact/{uid}
|
||||
POST /api/addressbook {slug, display_name, color?}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
# URL base del service. Se mantiene como módulo-global para poder monkeypatchearla
|
||||
# en tests sin tocar cada llamada.
|
||||
BASE_URL = "http://127.0.0.1:8771"
|
||||
|
||||
# Timeout por petición. El osint_db es local (loopback): si tarda más que esto,
|
||||
# algo va mal y es mejor degradar que colgar el endpoint.
|
||||
_TIMEOUT_S = 20.0
|
||||
|
||||
|
||||
class OsintDbUnavailable(Exception):
|
||||
"""El service osint_db no responde (no arrancado, timeout, conexión caída).
|
||||
|
||||
Los endpoints la capturan y devuelven un 503 JSON claro, en paralelo a
|
||||
``DavUnavailable`` del camino DAV.
|
||||
"""
|
||||
|
||||
|
||||
def _request(method: str, path: str, body: Optional[dict] = None) -> dict:
|
||||
"""Hace una petición HTTP al osint_db y devuelve el JSON de respuesta.
|
||||
|
||||
Args:
|
||||
method: verbo HTTP (``GET``/``POST``/``PUT``/``DELETE``).
|
||||
path: ruta absoluta del endpoint (``/api/query``, ...).
|
||||
body: cuerpo JSON opcional (se serializa con ``ensure_ascii=False``).
|
||||
|
||||
Returns:
|
||||
El cuerpo de respuesta ya deserializado a dict.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde o la respuesta no es JSON.
|
||||
"""
|
||||
url = BASE_URL.rstrip("/") + path
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
# El contrato dice 200 siempre; un HTTPError es anómalo. Intenta leer el
|
||||
# cuerpo (puede traer {status:error,...}); si no, degrada.
|
||||
try:
|
||||
return json.loads(exc.read().decode("utf-8"))
|
||||
except (ValueError, OSError):
|
||||
raise OsintDbUnavailable(
|
||||
"osint_db respondió HTTP %s en %s" % (exc.code, path)
|
||||
) from exc
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
||||
raise OsintDbUnavailable(
|
||||
"osint_db no responde en %s: %s" % (BASE_URL, exc)
|
||||
) from exc
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise OsintDbUnavailable(
|
||||
"osint_db devolvió una respuesta no-JSON en %s" % path
|
||||
) from exc
|
||||
|
||||
|
||||
def query(sql: str, params: Optional[list] = None, max_rows: int = 2000) -> dict:
|
||||
"""Ejecuta una SELECT contra la DuckDB del osint_db.
|
||||
|
||||
Args:
|
||||
sql: la consulta SQL (de solo lectura; el service la valida).
|
||||
params: parámetros posicionales opcionales.
|
||||
max_rows: tope de filas devueltas.
|
||||
|
||||
Returns:
|
||||
dict ``{status, columns, rows}`` tal cual lo devuelve el service.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
body: dict[str, Any] = {"sql": sql, "max_rows": max_rows}
|
||||
if params:
|
||||
body["params"] = params
|
||||
return _request("POST", "/api/query", body)
|
||||
|
||||
|
||||
def list_addressbooks() -> list:
|
||||
"""Lista las libretas (addressbooks) del osint_db.
|
||||
|
||||
Devuelve una lista de dicts ``{slug, display_name, collection_path, color}``
|
||||
ordenados por ``display_name``. Si la consulta falla a nivel de dominio
|
||||
(``status != ok``) devuelve lista vacía, no lanza.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
res = query(
|
||||
"SELECT slug, display_name, collection_path, color "
|
||||
"FROM addressbooks ORDER BY display_name",
|
||||
max_rows=1000,
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return []
|
||||
cols = res.get("columns") or []
|
||||
rows = res.get("rows") or []
|
||||
out: list = []
|
||||
for row in rows:
|
||||
# El service puede devolver filas como lista posicional o como dict.
|
||||
if isinstance(row, dict):
|
||||
out.append(row)
|
||||
else:
|
||||
out.append({cols[i]: row[i] for i in range(min(len(cols), len(row)))})
|
||||
return out
|
||||
|
||||
|
||||
def list_contacts() -> list:
|
||||
"""Lista los contactos del osint_db, con los campos que consume el frontend.
|
||||
|
||||
Devuelve filas ``{uid, collection, fn, tels, emails, note_path}``; ``tels`` y
|
||||
``emails`` llegan como JSON array (string JSON o lista) y se parsean a lista de
|
||||
strings.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
res = query(
|
||||
"SELECT uid, collection, fn, tels, emails, note_path "
|
||||
"FROM contacts ORDER BY fn",
|
||||
max_rows=5000,
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return []
|
||||
cols = res.get("columns") or []
|
||||
rows = res.get("rows") or []
|
||||
out: list = []
|
||||
for row in rows:
|
||||
rec = row if isinstance(row, dict) else {
|
||||
cols[i]: row[i] for i in range(min(len(cols), len(row)))
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_json_array(value: Any) -> list:
|
||||
"""Normaliza un valor que puede venir como lista o como string JSON a lista.
|
||||
|
||||
El osint_db devuelve ``tels``/``emails`` como JSON array; según el driver,
|
||||
puede llegar ya como lista Python o como string JSON. Tolera ambos y los
|
||||
valores nulos/vacíos.
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value if v not in (None, "")]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError:
|
||||
return [value]
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) for v in parsed if v not in (None, "")]
|
||||
return [str(parsed)]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def create_contact(payload: dict) -> dict:
|
||||
"""Crea un contacto en el osint_db (POST /api/contact).
|
||||
|
||||
Args:
|
||||
payload: cuerpo JSON del contacto (``collection, fn, telefonos, emails,
|
||||
direcciones, nombre?, aliases?, dni?, pais?, contexto?, notas?``).
|
||||
|
||||
Returns:
|
||||
El cuerpo de respuesta del service (``{status, uid, ...}``).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
return _request("POST", "/api/contact", payload)
|
||||
|
||||
|
||||
def update_contact(uid: str, payload: dict) -> dict:
|
||||
"""Edita un contacto del osint_db (PUT /api/contact/{uid}).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
return _request("PUT", "/api/contact/%s" % urllib.parse.quote(uid), payload)
|
||||
|
||||
|
||||
def delete_contact(uid: str) -> dict:
|
||||
"""Borra un contacto del osint_db (DELETE /api/contact/{uid}).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
return _request("DELETE", "/api/contact/%s" % urllib.parse.quote(uid))
|
||||
|
||||
|
||||
def create_addressbook(slug: str, name: str, color: Optional[str] = None) -> dict:
|
||||
"""Crea una libreta (addressbook) en el osint_db (POST /api/addressbook).
|
||||
|
||||
El osint_db crea la colección CardDAV en Xandikos y la registra en la DuckDB.
|
||||
|
||||
Returns:
|
||||
El cuerpo de respuesta del service (``{status, slug, ...}``).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
body: dict[str, Any] = {"slug": slug, "display_name": name}
|
||||
if color:
|
||||
body["color"] = color
|
||||
return _request("POST", "/api/addressbook", body)
|
||||
Reference in New Issue
Block a user