feat(contacts): CRUD de contactos (vault .md fuente de verdad + reflejo vCard)

Añade alta, edición y borrado de contactos (personas y organizaciones) a la
app osint_web. La fuente de verdad es la ficha .md del vault Obsidian
(CONVENTIONS.md §3b/§6); Xandikos es el retransmisor al móvil.

Backend (server/main.py):
- POST /api/contact: genera slug, escribe la ficha .md con el frontmatter
  canónico + PUT del vCard a Xandikos. 409 si el slug ya existe.
- PUT /api/contact/{slug}: merge del frontmatter (preserva campos heredados)
  + re-PUT del vCard. 404 si no existe.
- DELETE /api/contact/{slug}: borra la ficha .md + DELETE del vCard. 404 si
  no existe.
Cada escritura invalida la caché DAV para que el cambio se vea ya en la app.
Registry-first: orquesta create/update/delete_obsidian_note del grupo obsidian
y carddav_put_vcard/dav_delete_resource del grupo dav (sin reimplementar
parseo ni HTTP). Mapea los campos OSINT a propiedades X-OSINT-* del vCard.

Frontend (ContactsView.tsx + api.ts + format.ts):
- Botón "Nuevo contacto" → modal con formulario Mantine (TextInput,
  TagsInput aliases, Select contexto, Textarea notas).
- Detalle: botones "Editar" (formulario precargado) y "Borrar" (con
  confirmación). Tras guardar refresca la lista.
- Helper slugify (replica slugify_obsidian_name) para resolver la ficha.

Tests: 6 nuevos casos (ciclo crear→editar→borrar con .md real + reflejo vCard
mockeado, organización, 404s, tipo inválido, preserva campos heredados). Suite
27 passed. Ciclo e2e real verificado contra Xandikos + vault (vCard creado,
editado y borrado; slug zz-test-crud limpiado). pnpm build verde (React 19 +
Mantine v9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:18:55 +02:00
parent 44a696c12e
commit 43889bfc07
6 changed files with 1079 additions and 114 deletions
+387 -1
View File
@@ -91,20 +91,24 @@ def _registry_functions_dir() -> str:
_FUNCTIONS_DIR = _registry_functions_dir()
sys.path.insert(0, _FUNCTIONS_DIR)
from fastapi import FastAPI, HTTPException, Query # noqa: E402
from fastapi import Body, FastAPI, HTTPException, Query # noqa: E402
from fastapi.responses import FileResponse, JSONResponse # noqa: E402
from pydantic import BaseModel, Field # noqa: E402
# --- Grupo de capacidad obsidian (parseo del vault) ---
# El paquete obsidian tiene un __init__ ligero (sin dependencias pesadas), así
# que se importa directamente.
from obsidian import ( # noqa: E402 (sys.path debe resolverse antes)
build_obsidian_graph,
create_obsidian_note,
delete_obsidian_note,
extract_obsidian_embeds,
list_obsidian_notes,
read_obsidian_note,
resolve_obsidian_embed,
search_obsidian_notes,
slugify_obsidian_name,
update_obsidian_note,
)
@@ -138,6 +142,11 @@ dav_get_collection = _load_infra_fn("dav_get_collection", "dav_get_collection")
dav_collection_ctag = _load_infra_fn("dav_collection_ctag", "dav_collection_ctag")
split_vcards = _load_infra_fn("split_vcards", "split_vcards")
pass_get_secret = _load_infra_fn("pass_get_secret", "pass_get_secret")
# Escritura CardDAV: PUT (crear/editar) y DELETE (borrar) un vCard. El cambio en
# el vault .md es la fuente de verdad; estas reflejan el cambio en Xandikos de
# inmediato para que la app y el móvil lo vean ya, sin esperar al sync periódico.
carddav_put_vcard = _load_infra_fn("carddav_put_vcard", "carddav_put_vcard")
dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource")
# ---------------------------------------------------------------------------
@@ -656,6 +665,183 @@ class VaultState:
self._calendar_cache = None
self._force_reload = True
# --- Escritura de contactos: ficha .md (verdad) + reflejo en Xandikos ----
def _contact_note_path(self, tipo: str, slug: str) -> str:
"""Path absoluto de la ficha ``.md`` de un contacto en el vault.
``personas/<slug>.md`` o ``organizaciones/<slug>.md`` según el tipo.
"""
folder = _TIPO_FOLDER.get(tipo, "personas")
return os.path.join(self._vault_real, folder, slug + ".md")
def _put_vcard(self, slug: str, vcard_text: str) -> dict:
"""Sube (PUT) un vCard a Xandikos por su UID=slug. No lanza por sí sola.
Reflejo inmediato del cambio en la ficha del vault, para que el contacto
se vea ya en la app y en el móvil sin esperar al sync periódico. Devuelve
el dict ``{status, http_status|error}`` de ``carddav_put_vcard``.
"""
password = self.xandikos_password()
return carddav_put_vcard(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
XANDIKOS_CONTACTS_COLLECTION,
slug,
vcard_text,
)
def _delete_vcard(self, slug: str) -> dict:
"""Borra (DELETE) el vCard ``<slug>.vcf`` de Xandikos. No lanza.
Compone ``dav_delete_resource`` con el href del recurso (mismo nombre que
usó el PUT: ``<slug>.vcf``). Trata 404 como idempotente (ya no existía →
objetivo cumplido), igual que un borrado repetido.
"""
password = self.xandikos_password()
resource_path = XANDIKOS_CONTACTS_COLLECTION + slug + ".vcf"
res = dav_delete_resource(
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, resource_path
)
if res.get("status") != "ok" and res.get("http_status") == 404:
return {"status": "ok", "http_status": 404, "idempotent": True}
return res
def create_contact(self, data: "ContactIn") -> dict:
"""Crea un contacto: ficha ``.md`` (verdad) + reflejo del vCard.
1. Genera el slug del nombre. 409 si ya existe la ficha.
2. Escribe la ficha ``.md`` con el frontmatter canónico (acción primaria).
3. Hace PUT del vCard a Xandikos (reflejo inmediato; un fallo NO revierte
la ficha — el sync periódico reconciliará).
4. Invalida las cachés DAV para que el contacto aparezca ya en la app.
Returns:
dict ``{slug, uid, path, dav}``.
Raises:
HTTPException(409): si ya existe una ficha con ese slug.
HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
nombre está vacío.
"""
tipo = (data.tipo or "persona").strip()
if tipo not in _TIPO_FOLDER:
raise HTTPException(
status_code=400,
detail="tipo inválido '%s' (persona|organizacion)" % tipo,
)
if not data.nombre.strip():
raise HTTPException(status_code=400, detail="el nombre es obligatorio")
slug = slugify_obsidian_name(data.nombre)
if not slug:
raise HTTPException(
status_code=400, detail="el nombre no produce un slug válido"
)
note_path = self._contact_note_path(tipo, slug)
if os.path.exists(note_path):
raise HTTPException(
status_code=409, detail="ya existe un contacto con slug '%s'" % slug
)
frontmatter = _contact_frontmatter(data, slug)
body = _contact_body(data.notas)
folder = _TIPO_FOLDER[tipo]
# Acción primaria: la ficha del vault es la fuente de verdad.
create_obsidian_note(
self.vault_dir,
os.path.join(folder, slug),
body=body,
frontmatter=frontmatter,
)
# Reflejo inmediato en Xandikos (no rompe el alta si Xandikos cae).
vcard_fm = dict(frontmatter)
vcard_fm["_notas"] = _norm_str(data.notas)
dav = self._put_vcard(slug, _build_vcard(vcard_fm, slug))
self.refresh()
self.invalidate_dav()
return {"slug": slug, "uid": slug, "path": note_path, "dav": dav}
def update_contact(self, slug: str, data: "ContactIn") -> dict:
"""Edita un contacto existente: merge del frontmatter + re-PUT del vCard.
Localiza la ficha por slug (en personas/ u organizaciones/ según el tipo
de la ficha actual). 404 si no existe. Hace merge de los campos editables
sobre el frontmatter actual (preserva campos heredados no tocados como
``sexo``, ``fecha_nacimiento``, ``horoscopo``), reescribe el body
``## Notas`` y re-sube el vCard. Invalida las cachés DAV.
Returns:
dict ``{slug, uid, path, dav}``.
Raises:
HTTPException(404): si no existe la ficha del contacto.
"""
path = self._find_contact_note(slug)
if path is None:
raise HTTPException(
status_code=404, detail="contacto '%s' no encontrado" % slug
)
note = read_obsidian_note(path)
current = dict(note.get("frontmatter") or {})
# Merge de los campos editables (preserva los heredados no tocados).
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),
"pais": _norm_str(data.pais),
"relaciones": _norm_list(data.relaciones),
"contexto": _norm_str(data.contexto),
}
if current.get("tipo") != "organizacion":
merged["dni"] = _norm_str(data.dni)
current.update(merged)
update_obsidian_note(
path, body=_contact_body(data.notas), set_frontmatter=current
)
vcard_fm = dict(current)
vcard_fm["_notas"] = _norm_str(data.notas)
dav = self._put_vcard(slug, _build_vcard(vcard_fm, slug))
self.refresh()
self.invalidate_dav()
return {"slug": slug, "uid": slug, "path": path, "dav": dav}
def delete_contact(self, slug: str) -> dict:
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos.
404 si la ficha no existe. Borra el archivo ``.md`` (acción primaria) y
el recurso ``<slug>.vcf`` de Xandikos (reflejo). Invalida las cachés DAV.
Returns:
dict ``{slug, deleted: True, dav}``.
Raises:
HTTPException(404): si no existe la ficha del contacto.
"""
path = self._find_contact_note(slug)
if path is None:
raise HTTPException(
status_code=404, detail="contacto '%s' no encontrado" % slug
)
delete_obsidian_note(path)
dav = self._delete_vcard(slug)
self.refresh()
self.invalidate_dav()
return {"slug": slug, "deleted": True, "dav": dav}
def _find_contact_note(self, slug: str):
"""Localiza la ficha ``.md`` de un contacto por slug, o None.
Busca ``personas/<slug>.md`` y ``organizaciones/<slug>.md`` (los dos
tipos de contacto). Devuelve el primer path existente o None.
"""
for folder in ("personas", "organizaciones"):
candidate = os.path.join(self._vault_real, folder, slug + ".md")
if os.path.isfile(candidate):
return candidate
return None
# ---------------------------------------------------------------------------
# Helpers DAV: parseo ligero de vCard / iCalendar a JSON
@@ -846,6 +1032,174 @@ def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
return True
# ---------------------------------------------------------------------------
# Escritura de contactos: ficha .md del vault (fuente de verdad) + vCard
# ---------------------------------------------------------------------------
# Subcarpeta del vault por tipo de contacto. La fuente de verdad de un contacto
# es su ficha en el vault (CONVENTIONS.md §3b para persona, §6 para organización);
# Xandikos es solo el retransmisor al móvil.
_TIPO_FOLDER = {"persona": "personas", "organizacion": "organizaciones"}
# Tags por defecto de cada tipo (CONVENTIONS.md). Se preservan si la ficha ya
# trae otros tags al editar.
_TIPO_TAGS = {
"persona": ["persona", "osint"],
"organizacion": ["organizacion", "osint"],
}
class ContactIn(BaseModel):
"""Cuerpo de POST/PUT de un contacto (persona u organización).
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.
"""
tipo: str = Field(default="persona")
nombre: str
aliases: list[str] = Field(default_factory=list)
telefono: Optional[str] = None
email: Optional[str] = None
dni: Optional[str] = None
direccion: Optional[str] = None
pais: Optional[str] = None
contexto: Optional[str] = None
relaciones: list[str] = Field(default_factory=list)
notas: Optional[str] = None
def _norm_str(value: Optional[str]) -> Optional[str]:
"""Normaliza un string opcional: trim; cadena vacía → None."""
if value is None:
return None
value = value.strip()
return value or None
def _norm_list(values: Optional[list]) -> list:
"""Normaliza una lista de strings: trim cada item y descarta los vacíos."""
if not values:
return []
out = []
for v in values:
s = (v or "").strip()
if s:
out.append(s)
return out
def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
"""Construye el frontmatter canónico de la ficha de un contacto.
Para ``tipo: persona`` sigue el esquema completo de CONVENTIONS.md §3b
(todos los campos presentes, ``null``/``[]`` si vacíos). Para
``tipo: organizacion`` usa el subconjunto de §6. El orden de claves se
preserva (``yaml.safe_dump(sort_keys=False)`` en ``format_obsidian_note``).
"""
nombre = data.nombre.strip()
aliases = _norm_list(data.aliases)
relaciones = _norm_list(data.relaciones)
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),
"pais": _norm_str(data.pais),
"relaciones": relaciones,
"contexto": _norm_str(data.contexto),
"fuente": "osint_web (alta manual)",
"tags": list(_TIPO_TAGS["organizacion"]),
}
# Persona: esquema canónico §3b.
return {
"tipo": "persona",
"nombre": nombre,
"slug": slug,
"aliases": aliases,
"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),
"pais": _norm_str(data.pais),
"relaciones": relaciones,
"contexto": _norm_str(data.contexto),
"fuente": "osint_web (alta manual)",
"tags": list(_TIPO_TAGS["persona"]),
}
def _contact_body(notas: Optional[str]) -> str:
"""Cuerpo Markdown de la ficha: sección ``## Notas`` con el texto libre."""
notas = _norm_str(notas)
if notas:
return "## Notas\n\n%s\n" % notas
return "## Notas\n"
def _vcard_escape(value: str) -> str:
"""Escapa un valor de texto para una línea vCard (RFC 6350)."""
return (
value.replace("\\", "\\\\")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
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,
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``.
"""
nombre = (frontmatter.get("nombre") or slug).strip()
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"UID:%s" % slug,
"FN:%s" % _vcard_escape(nombre),
]
aliases = frontmatter.get("aliases") or []
if aliases:
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)))
# 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"),
("fecha_nacimiento", "X-OSINT-FECHA-NACIMIENTO"),
):
val = frontmatter.get(fm_key)
if val:
lines.append("%s:%s" % (x_name, _vcard_escape(str(val))))
notas = frontmatter.get("_notas")
if notas:
lines.append("NOTE:%s" % _vcard_escape(str(notas)))
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
# ---------------------------------------------------------------------------
# Construcción de la app FastAPI
# ---------------------------------------------------------------------------
@@ -981,6 +1335,38 @@ def create_app(vault_dir: str) -> FastAPI:
)
return JSONResponse(content={"status": "ok", "contact": match})
# -- Contactos: CRUD (ficha .md del vault = verdad, vCard = reflejo) --
@app.post("/api/contact")
def api_create_contact(data: ContactIn = Body(...)) -> JSONResponse:
"""Crea un contacto: escribe la ficha ``.md`` del vault + el vCard.
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}``.
"""
result = state.create_contact(data)
return JSONResponse(status_code=201, content={"status": "ok", **result})
@app.put("/api/contact/{slug}")
def api_update_contact(slug: str, data: ContactIn = Body(...)) -> JSONResponse:
"""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}``.
"""
result = state.update_contact(slug, data)
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}``.
"""
result = state.delete_contact(slug)
return JSONResponse(content={"status": "ok", **result})
# -- Xandikos: calendario (CalDAV) --
@app.get("/api/calendar")