From 43889bfc07b41abba4c44416ee2a52dfdf2f1b08 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 12 Jun 2026 00:18:55 +0200 Subject: [PATCH] feat(contacts): CRUD de contactos (vault .md fuente de verdad + reflejo vCard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app.md | 26 +- frontend/src/api.ts | 63 ++++ frontend/src/format.ts | 18 + frontend/src/views/ContactsView.tsx | 536 ++++++++++++++++++++++------ server/main.py | 388 +++++++++++++++++++- tests/test_server.py | 162 +++++++++ 6 files changed, 1079 insertions(+), 114 deletions(-) diff --git a/app.md b/app.md index c63c3e6..d5f65ce 100644 --- a/app.md +++ b/app.md @@ -9,12 +9,17 @@ uses_functions: - build_obsidian_graph_py_obsidian - list_obsidian_notes_py_obsidian - read_obsidian_note_py_obsidian + - create_obsidian_note_py_obsidian + - update_obsidian_note_py_obsidian + - delete_obsidian_note_py_obsidian - extract_obsidian_embeds_py_obsidian - resolve_obsidian_embed_py_obsidian - slugify_obsidian_name_py_obsidian - search_obsidian_notes_py_obsidian - - dav_list_resources_py_infra - - dav_get_resource_py_infra + - dav_get_collection_py_infra + - dav_collection_ctag_py_infra + - carddav_put_vcard_py_infra + - dav_delete_resource_py_infra - split_vcards_py_infra - pass_get_secret_py_infra uses_types: [] @@ -43,11 +48,22 @@ web local: 2. **El servidor Xandikos** (CardDAV/CalDAV): agenda de contactos y calendario de eventos. +Edición de contactos (CRUD): la app permite crear, editar y borrar contactos +(personas y organizaciones). La **fuente de verdad es la ficha `.md` del vault** +(esquema canónico de `CONVENTIONS.md` §3b/§6); Xandikos es el retransmisor al +móvil. Cada alta/edición/borrado escribe primero la ficha del vault (acción +primaria con `create_obsidian_note` / `update_obsidian_note` / +`delete_obsidian_note`) y a continuación refleja el cambio en Xandikos de +inmediato (`carddav_put_vcard` / `dav_delete_resource`) para que se vea ya en la +app y en el móvil sin esperar al sync periódico del dag_engine. + Registry-first: el backend NO parsea el vault ni habla DAV a mano — orquesta las funciones del grupo `obsidian` (`build_obsidian_graph`, `read_obsidian_note`, -`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_list_resources`, -`dav_get_resource`, `split_vcards`) más `pass_get_secret` para la credencial, -todas declaradas en `uses_functions`. +`create_obsidian_note`, `update_obsidian_note`, `delete_obsidian_note`, +`resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_get_collection`, +`dav_collection_ctag`, `carddav_put_vcard`, `dav_delete_resource`, +`split_vcards`) más `pass_get_secret` para la credencial, todas declaradas en +`uses_functions`. ## Stack diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 38503c9..0ee59e6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -153,6 +153,69 @@ export const fetchSearch = (q: string) => export const fetchContacts = () => getJSON("/contacts"); +// --- CRUD de contactos: ficha .md del vault (verdad) + reflejo del vCard ---- + +export interface ContactInput { + tipo: "persona" | "organizacion"; + nombre: string; + aliases: string[]; + telefono: string | null; + email: string | null; + dni: string | null; + direccion: string | null; + pais: string | null; + contexto: string | null; + relaciones: string[]; + notas: string | null; +} + +export interface ContactWriteResult { + status: string; + slug: string; + uid: string; + deleted?: boolean; + dav?: { status?: string; http_status?: number; error?: string }; +} + +async function sendJSON( + path: string, + method: "POST" | "PUT" | "DELETE", + body?: unknown, +): Promise { + const res = await fetch(BASE + path, { + method, + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + let detail = ""; + try { + const j = await res.json(); + detail = j?.detail || j?.error || ""; + } catch { + /* respuesta no JSON */ + } + throw new Error(`HTTP ${res.status}${detail ? ` — ${detail}` : ""}`); + } + return res.json() as Promise; +} + +export const createContact = (data: ContactInput) => + sendJSON("/contact", "POST", data); + +export const updateContact = (slug: string, data: ContactInput) => + sendJSON( + `/contact/${encodeURIComponent(slug)}`, + "PUT", + data, + ); + +export const deleteContact = (slug: string) => + sendJSON( + `/contact/${encodeURIComponent(slug)}`, + "DELETE", + ); + export const fetchCalendar = (from = "", to = "") => { const qs = new URLSearchParams(); if (from) qs.set("from", from); diff --git a/frontend/src/format.ts b/frontend/src/format.ts index 886af2d..ab19973 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -19,6 +19,24 @@ const MESES = [ "dic", ]; +/** + * Slug kebab-case estable a partir de un nombre, replicando el + * `slugify_obsidian_name` del registry (transliteración Unicode + minúsculas + + * colapsar no-[a-z0-9] a un guion). Se usa para resolver la ficha .md de un + * contacto al editar/borrar (el archivo se llama `.md`). El backend valida + * la existencia, esto solo predice el nombre del recurso. + */ +export function slugify(name: string): string { + return name + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") // quita marcas diacríticas combinantes + .replace(/ñ/g, "n") + .replace(/Ñ/g, "n") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + /** "2026-06-07" → "07/06/2026". Devuelve el original si no matchea. */ export function formatISODate(value: string): string { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value); diff --git a/frontend/src/views/ContactsView.tsx b/frontend/src/views/ContactsView.tsx index 55634f2..0399d04 100644 --- a/frontend/src/views/ContactsView.tsx +++ b/frontend/src/views/ContactsView.tsx @@ -3,31 +3,113 @@ import { Alert, Badge, Box, + Button, Center, Divider, Group, Loader, + Modal, Paper, ScrollArea, + Select, Stack, + TagsInput, Table, Text, + Textarea, TextInput, Title, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; import { IconAt, + IconEdit, IconNote, IconPhone, + IconPlus, IconSearch, + IconTrash, IconUser, } from "@tabler/icons-react"; -import { fetchContacts, type Contact } from "../api"; +import { + createContact, + deleteContact, + fetchContacts, + updateContact, + type Contact, + type ContactInput, +} from "../api"; +import { slugify } from "../format"; // Agenda: lista de contactos del addressbook Xandikos a la izquierda (con -// buscador por nombre / alias / teléfono / email) y la ficha del contacto -// seleccionado a la derecha (todos los campos, incluido el bloque osint y nota). +// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la +// vista de lectura y añade los controles de edición: alta ("Nuevo contacto"), +// edición y borrado de la ficha del vault (con reflejo inmediato en Xandikos). + +type FormTipo = "persona" | "organizacion"; + +interface FormState { + tipo: FormTipo; + nombre: string; + aliases: string[]; + telefono: string; + email: string; + dni: string; + direccion: string; + pais: string; + contexto: string; + notas: string; +} + +const EMPTY_FORM: FormState = { + tipo: "persona", + nombre: "", + aliases: [], + telefono: "", + email: "", + dni: "", + direccion: "", + pais: "", + contexto: "", + notas: "", +}; + +// Construye el estado del formulario a partir de un contacto existente (para +// editar). Toma los campos del bloque osint (dni/direccion/pais/contexto) que el +// backend expone tras parsear el vCard. +function formFromContact(c: Contact): FormState { + const osint = c.osint ?? {}; + return { + tipo: "persona", + nombre: c.nombre ?? "", + aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [], + telefono: c.telefonos?.[0] ?? "", + email: c.correos?.[0] ?? "", + dni: osint.dni ?? "", + direccion: osint.direccion ?? "", + pais: osint.pais ?? "", + contexto: osint.contexto ?? "", + notas: c.nota ?? "", + }; +} + +function formToInput(f: FormState): ContactInput { + const t = (v: string) => (v.trim() ? v.trim() : null); + return { + tipo: f.tipo, + nombre: f.nombre.trim(), + aliases: f.aliases.map((s) => s.trim()).filter(Boolean), + telefono: t(f.telefono), + email: t(f.email), + dni: t(f.dni), + direccion: t(f.direccion), + pais: t(f.pais), + contexto: t(f.contexto), + relaciones: [], + notas: t(f.notas), + }; +} export function ContactsView() { const [contacts, setContacts] = useState([]); @@ -37,23 +119,31 @@ export function ContactsView() { const [query, setQuery] = useState(""); const [debQuery] = useDebouncedValue(query, 200); - useEffect(() => { - let alive = true; + // Estado del formulario (alta/edición). `editSlug` distingue alta (null) de + // edición (slug del contacto). `saving` deshabilita el botón mientras escribe. + const [formOpen, setFormOpen] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [editSlug, setEditSlug] = useState(null); + const [saving, setSaving] = useState(false); + + function reload() { setLoading(true); fetchContacts() .then((d) => { - if (!alive) return; if (d.status !== "ok") { setError(d.error || "Xandikos no respondió"); return; } + setError(null); setContacts(d.contacts ?? []); }) - .catch((e) => alive && setError(String(e))) - .finally(() => alive && setLoading(false)); - return () => { - alive = false; - }; + .catch((e) => setError(String(e))) + .finally(() => setLoading(false)); + } + + useEffect(() => { + reload(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const filtered = useMemo(() => { @@ -74,115 +164,345 @@ export function ContactsView() { }); }, [contacts, debQuery]); - if (error) { - return ( -
- - {error} - - El calendario y los contactos vienen del servidor Xandikos. El resto - de la app (grafo, tablas) funciona sin él. - - -
- ); + function openNew() { + setForm(EMPTY_FORM); + setEditSlug(null); + setFormOpen(true); + } + + function openEdit(c: Contact) { + setForm(formFromContact(c)); + setEditSlug(c.uid || slugify(c.nombre ?? "")); + setFormOpen(true); + } + + async function onSave() { + if (!form.nombre.trim()) { + notifications.show({ color: "red", message: "El nombre es obligatorio" }); + return; + } + setSaving(true); + try { + const input = formToInput(form); + if (editSlug) { + await updateContact(editSlug, input); + notifications.show({ color: "teal", message: "Contacto actualizado" }); + } else { + const res = await createContact(input); + notifications.show({ + color: "teal", + message: `Contacto creado (${res.slug})`, + }); + } + setFormOpen(false); + setSelected(null); + reload(); + } catch (e) { + notifications.show({ color: "red", title: "No se pudo guardar", message: String(e) }); + } finally { + setSaving(false); + } + } + + async function onDelete(c: Contact) { + const slug = c.uid || slugify(c.nombre ?? ""); + if (!confirm(`¿Borrar el contacto "${c.nombre || slug}"? Se elimina la ficha y el contacto del móvil.`)) + return; + try { + await deleteContact(slug); + notifications.show({ color: "teal", message: "Contacto borrado" }); + setSelected(null); + reload(); + } catch (e) { + notifications.show({ color: "red", title: "No se pudo borrar", message: String(e) }); + } } return ( - - - - - } - value={query} - onChange={(e) => setQuery(e.currentTarget.value)} - /> - - {filtered.length} de {contacts.length} contactos - - - - {loading ? ( -
- -
- ) : ( - - - {filtered.map((c, i) => { - const key = c.uid || c.href || String(i); - const isSel = selected === c; - return ( - setSelected(c)} - style={{ - cursor: "pointer", - background: isSel - ? "var(--mantine-color-brand-light)" - : undefined, - borderBottom: "1px solid var(--mantine-color-dark-5)", - }} - > - - {c.nombre || c.alias || c.uid || "(sin nombre)"} - - {(c.telefonos?.[0] || c.correos?.[0]) && ( - - {c.telefonos?.[0] || c.correos?.[0]} - - )} - - ); - })} - - - )} -
-
+ <> + setFormOpen(false)} + onSave={onSave} + /> - - - {selected ? ( - - ) : ( -
- - - Selecciona un contacto - -
- )} -
-
-
+ {error ? ( +
+ + {error} + + El calendario y los contactos vienen del servidor Xandikos. El + resto de la app (grafo, tablas) funciona sin él. + + +
+ ) : ( + + + + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + /> + + {filtered.length} de {contacts.length} contactos + + + + {loading ? ( +
+ +
+ ) : ( + + + {filtered.map((c, i) => { + const key = c.uid || c.href || String(i); + const isSel = selected === c; + return ( + setSelected(c)} + style={{ + cursor: "pointer", + background: isSel + ? "var(--mantine-color-brand-light)" + : undefined, + borderBottom: "1px solid var(--mantine-color-dark-5)", + }} + > + + {c.nombre || c.alias || c.uid || "(sin nombre)"} + + {(c.telefonos?.[0] || c.correos?.[0]) && ( + + {c.telefonos?.[0] || c.correos?.[0]} + + )} + + ); + })} + + + )} +
+
+ + + + {selected ? ( + openEdit(selected)} + onDelete={() => onDelete(selected)} + /> + ) : ( +
+ + + Selecciona un contacto + +
+ )} +
+
+
+ )} + ); } -function ContactDetail({ contact }: { contact: Contact }) { +function ContactForm({ + opened, + editing, + form, + saving, + onChange, + onClose, + onSave, +}: { + opened: boolean; + editing: boolean; + form: FormState; + saving: boolean; + onChange: (f: FormState) => void; + onClose: () => void; + onSave: () => void; +}) { + const set = (key: K, value: FormState[K]) => + onChange({ ...form, [key]: value }); + + return ( + + + set("contexto", v || "")} + searchable + clearable + /> +