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
+63
View File
@@ -153,6 +153,69 @@ export const fetchSearch = (q: string) =>
export const fetchContacts = () => getJSON<ContactsPayload>("/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<T>(
path: string,
method: "POST" | "PUT" | "DELETE",
body?: unknown,
): Promise<T> {
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<T>;
}
export const createContact = (data: ContactInput) =>
sendJSON<ContactWriteResult>("/contact", "POST", data);
export const updateContact = (slug: string, data: ContactInput) =>
sendJSON<ContactWriteResult>(
`/contact/${encodeURIComponent(slug)}`,
"PUT",
data,
);
export const deleteContact = (slug: string) =>
sendJSON<ContactWriteResult>(
`/contact/${encodeURIComponent(slug)}`,
"DELETE",
);
export const fetchCalendar = (from = "", to = "") => {
const qs = new URLSearchParams();
if (from) qs.set("from", from);
+18
View File
@@ -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 `<slug>.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);
+428 -108
View File
@@ -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<Contact[]>([]);
@@ -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<FormState>(EMPTY_FORM);
const [editSlug, setEditSlug] = useState<string | null>(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 (
<Center h="100%" p="xl">
<Alert color="orange" title="Agenda no disponible" maw={500}>
{error}
<Text size="sm" mt="xs" c="dimmed">
El calendario y los contactos vienen del servidor Xandikos. El resto
de la app (grafo, tablas) funciona sin él.
</Text>
</Alert>
</Center>
);
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 (
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
<Paper
w={360}
radius={0}
withBorder
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
>
<Stack gap={0} w="100%">
<Box p="md" pb="xs">
<TextInput
placeholder="Buscar contacto…"
leftSection={<IconSearch size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed" mt={6}>
{filtered.length} de {contacts.length} contactos
</Text>
</Box>
<Divider />
{loading ? (
<Center style={{ flex: 1 }}>
<Loader />
</Center>
) : (
<ScrollArea style={{ flex: 1 }}>
<Stack gap={0}>
{filtered.map((c, i) => {
const key = c.uid || c.href || String(i);
const isSel = selected === c;
return (
<Box
key={key}
px="md"
py="xs"
onClick={() => setSelected(c)}
style={{
cursor: "pointer",
background: isSel
? "var(--mantine-color-brand-light)"
: undefined,
borderBottom: "1px solid var(--mantine-color-dark-5)",
}}
>
<Text size="sm" fw={isSel ? 600 : 400} truncate>
{c.nombre || c.alias || c.uid || "(sin nombre)"}
</Text>
{(c.telefonos?.[0] || c.correos?.[0]) && (
<Text size="xs" c="dimmed" truncate>
{c.telefonos?.[0] || c.correos?.[0]}
</Text>
)}
</Box>
);
})}
</Stack>
</ScrollArea>
)}
</Stack>
</Paper>
<>
<ContactForm
opened={formOpen}
editing={editSlug !== null}
form={form}
saving={saving}
onChange={setForm}
onClose={() => setFormOpen(false)}
onSave={onSave}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<ScrollArea h="100%">
{selected ? (
<ContactDetail contact={selected} />
) : (
<Center h="60vh">
<Stack align="center" gap="xs">
<IconUser size={48} opacity={0.3} />
<Text c="dimmed">Selecciona un contacto</Text>
</Stack>
</Center>
)}
</ScrollArea>
</Box>
</Group>
{error ? (
<Center h="100%" p="xl">
<Alert color="orange" title="Agenda no disponible" maw={500}>
{error}
<Text size="sm" mt="xs" c="dimmed">
El calendario y los contactos vienen del servidor Xandikos. El
resto de la app (grafo, tablas) funciona sin él.
</Text>
</Alert>
</Center>
) : (
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
<Paper
w={360}
radius={0}
withBorder
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
>
<Stack gap={0} w="100%">
<Box p="md" pb="xs">
<Button
fullWidth
leftSection={<IconPlus size={16} />}
onClick={openNew}
mb="sm"
>
Nuevo contacto
</Button>
<TextInput
placeholder="Buscar contacto…"
leftSection={<IconSearch size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed" mt={6}>
{filtered.length} de {contacts.length} contactos
</Text>
</Box>
<Divider />
{loading ? (
<Center style={{ flex: 1 }}>
<Loader />
</Center>
) : (
<ScrollArea style={{ flex: 1 }}>
<Stack gap={0}>
{filtered.map((c, i) => {
const key = c.uid || c.href || String(i);
const isSel = selected === c;
return (
<Box
key={key}
px="md"
py="xs"
onClick={() => setSelected(c)}
style={{
cursor: "pointer",
background: isSel
? "var(--mantine-color-brand-light)"
: undefined,
borderBottom: "1px solid var(--mantine-color-dark-5)",
}}
>
<Text size="sm" fw={isSel ? 600 : 400} truncate>
{c.nombre || c.alias || c.uid || "(sin nombre)"}
</Text>
{(c.telefonos?.[0] || c.correos?.[0]) && (
<Text size="xs" c="dimmed" truncate>
{c.telefonos?.[0] || c.correos?.[0]}
</Text>
)}
</Box>
);
})}
</Stack>
</ScrollArea>
)}
</Stack>
</Paper>
<Box style={{ flex: 1, minWidth: 0 }}>
<ScrollArea h="100%">
{selected ? (
<ContactDetail
contact={selected}
onEdit={() => openEdit(selected)}
onDelete={() => onDelete(selected)}
/>
) : (
<Center h="60vh">
<Stack align="center" gap="xs">
<IconUser size={48} opacity={0.3} />
<Text c="dimmed">Selecciona un contacto</Text>
</Stack>
</Center>
)}
</ScrollArea>
</Box>
</Group>
)}
</>
);
}
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 = <K extends keyof FormState>(key: K, value: FormState[K]) =>
onChange({ ...form, [key]: value });
return (
<Modal
opened={opened}
onClose={onClose}
title={editing ? "Editar contacto" : "Nuevo contacto"}
size="lg"
>
<Stack gap="sm">
<Select
label="Tipo"
data={[
{ value: "persona", label: "Persona" },
{ value: "organizacion", label: "Organización" },
]}
value={form.tipo}
onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
disabled={editing}
allowDeselect={false}
/>
<TextInput
label="Nombre"
required
value={form.nombre}
onChange={(e) => set("nombre", e.currentTarget.value)}
data-autofocus
/>
<TagsInput
label="Aliases"
placeholder="otros nombres"
value={form.aliases}
onChange={(v) => set("aliases", v)}
/>
<Group grow>
<TextInput
label="Teléfono"
value={form.telefono}
onChange={(e) => set("telefono", e.currentTarget.value)}
/>
<TextInput
label="Email"
value={form.email}
onChange={(e) => set("email", e.currentTarget.value)}
/>
</Group>
{form.tipo === "persona" && (
<Group grow>
<TextInput
label="DNI"
value={form.dni}
onChange={(e) => set("dni", e.currentTarget.value)}
/>
<TextInput
label="País"
value={form.pais}
onChange={(e) => set("pais", e.currentTarget.value)}
/>
</Group>
)}
{form.tipo === "organizacion" && (
<TextInput
label="País"
value={form.pais}
onChange={(e) => set("pais", e.currentTarget.value)}
/>
)}
<TextInput
label="Dirección"
value={form.direccion}
onChange={(e) => set("direccion", e.currentTarget.value)}
/>
<Select
label="Contexto"
placeholder="origen / círculo"
data={[
"familia",
"aurgiobsidian",
"google-contacts",
"trabajo",
"prueba",
]}
value={form.contexto || null}
onChange={(v) => set("contexto", v || "")}
searchable
clearable
/>
<Textarea
label="Notas"
autosize
minRows={2}
value={form.notas}
onChange={(e) => set("notas", e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>
Cancelar
</Button>
<Button onClick={onSave} loading={saving}>
{editing ? "Guardar cambios" : "Crear contacto"}
</Button>
</Group>
</Stack>
</Modal>
);
}
function ContactDetail({
contact,
onEdit,
onDelete,
}: {
contact: Contact;
onEdit: () => void;
onDelete: () => void;
}) {
const osintEntries = Object.entries(contact.osint ?? {}).filter(
([, v]) => v != null && v !== "",
);
return (
<Stack p="xl" gap="lg" maw={720}>
<Group gap="sm">
<Title order={3}>
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
</Title>
{contact.alias && contact.alias !== contact.nombre && (
<Badge variant="light" color="gray">
{contact.alias}
</Badge>
)}
<Group justify="space-between" align="flex-start">
<Group gap="sm">
<Title order={3}>
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
</Title>
{contact.alias && contact.alias !== contact.nombre && (
<Badge variant="light" color="gray">
{contact.alias}
</Badge>
)}
</Group>
<Group gap="xs">
<Button
size="xs"
variant="light"
leftSection={<IconEdit size={14} />}
onClick={onEdit}
>
Editar
</Button>
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={14} />}
onClick={onDelete}
>
Borrar
</Button>
</Group>
</Group>
{contact.org && (