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:
2026-06-13 00:47:38 +02:00
parent 71e4d95e64
commit 9cbea2d036
6 changed files with 1248 additions and 90 deletions
+55 -3
View File
@@ -108,7 +108,12 @@ export interface Contact {
emails: ContactPhone[];
telefonos: string[];
correos: string[];
// Direcciones multi-valor (varias ADR del vCard, o X-OSINT-DIRECCION legacy).
direcciones?: string[];
osint: Record<string, string>;
// Libreta (addressbook) a la que pertenece el contacto, cuando el backend la
// expone (camino osint_db). Sirve para filtrar la lista por libreta.
collection?: string | null;
href?: string;
etag?: string;
}
@@ -223,14 +228,24 @@ export interface ContactInput {
tipo: "persona" | "organizacion";
nombre: string;
aliases: string[];
telefono: string | null;
email: string | null;
// Multi-valor: listas completas de teléfonos, emails y direcciones. El backend
// reconcilia con los campos singulares (compat) — el primer elemento de cada
// lista se conserva en telefono/email/direccion para los lectores viejos.
telefonos: string[];
emails: string[];
direcciones: string[];
// Singulares (compat). Opcionales: el frontend nuevo envía solo las listas; el
// backend rellena el singular con lista[0].
telefono?: string | null;
email?: string | null;
direccion?: string | null;
dni: string | null;
direccion: string | null;
pais: string | null;
contexto: string | null;
relaciones: string[];
notas: string | null;
// Libreta (addressbook) destino. Solo se honra con el flag OSINT_DB_BACKEND ON.
collection?: string | null;
}
export interface ContactWriteResult {
@@ -280,6 +295,43 @@ export const deleteContact = (slug: string) =>
"DELETE",
);
// --- Libretas (addressbooks) de contactos ---------------------------------
export interface Addressbook {
slug: string;
display_name: string;
collection_path: string;
color: string | null;
}
export interface AddressbooksPayload {
status: string;
count?: number;
addressbooks?: Addressbook[];
default?: string;
error?: string;
}
// Cuerpo de POST /addressbooks para crear una libreta nueva.
export interface AddressbookInput {
slug: string;
name: string;
color?: string | null;
}
export interface AddressbookWriteResult {
status: string;
slug?: string;
existed?: boolean;
error?: string;
}
export const fetchAddressbooks = () =>
getJSON<AddressbooksPayload>("/addressbooks");
export const createAddressbook = (data: AddressbookInput) =>
sendJSON<AddressbookWriteResult>("/addressbooks", "POST", data);
export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
export const createCalendar = (data: CalendarInput) =>
+295 -52
View File
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
ColorInput,
Divider,
Group,
Loader,
@@ -19,12 +21,15 @@ import {
Textarea,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconAddressBook,
IconAt,
IconEdit,
IconMapPin,
IconNote,
IconPhone,
IconPlus,
@@ -33,19 +38,23 @@ import {
IconUser,
} from "@tabler/icons-react";
import {
createAddressbook,
createContact,
deleteContact,
fetchAddressbooks,
fetchContacts,
updateContact,
type Addressbook,
type Contact,
type ContactInput,
} from "../api";
import { slugify } from "../format";
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
// 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).
// buscador, selector de libreta y filtro por libreta) y la ficha del contacto
// seleccionado a la derecha. Soporta contactos multi-valor (varios teléfonos,
// emails y direcciones) y la creación de libretas nuevas (análogo al patrón de
// "Nuevo calendario" en CalendarView).
type FormTipo = "persona" | "organizacion";
@@ -53,61 +62,73 @@ interface FormState {
tipo: FormTipo;
nombre: string;
aliases: string[];
telefono: string;
email: string;
// Multi-valor: listas completas (antes eran campos singulares).
telefonos: string[];
emails: string[];
direcciones: string[];
dni: string;
direccion: string;
pais: string;
contexto: string;
notas: string;
// Libreta destino (slug). "" → libreta por defecto.
collection: string;
}
const EMPTY_FORM: FormState = {
tipo: "persona",
nombre: "",
aliases: [],
telefono: "",
email: "",
telefonos: [],
emails: [],
direcciones: [],
dni: "",
direccion: "",
pais: "",
contexto: "",
notas: "",
collection: "",
};
// 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.
// editar). Carga TODOS los valores multi-valor (antes solo cargaba el primero).
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] ?? "",
telefonos: c.telefonos ?? [],
emails: c.correos ?? [],
// Direcciones desde el campo multi-valor; cae a osint.direccion (legacy).
direcciones:
c.direcciones && c.direcciones.length > 0
? c.direcciones
: osint.direccion
? [osint.direccion]
: [],
dni: osint.dni ?? "",
direccion: osint.direccion ?? "",
pais: osint.pais ?? "",
contexto: osint.contexto ?? "",
notas: c.nota ?? "",
collection: c.collection ?? "",
};
}
function formToInput(f: FormState): ContactInput {
const t = (v: string) => (v.trim() ? v.trim() : null);
const t = (vals: string[]) => vals.map((s) => s.trim()).filter(Boolean);
const s = (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),
aliases: t(f.aliases),
telefonos: t(f.telefonos),
emails: t(f.emails),
direcciones: t(f.direcciones),
dni: s(f.dni),
pais: s(f.pais),
contexto: s(f.contexto),
relaciones: [],
notas: t(f.notas),
notas: s(f.notas),
collection: f.collection.trim() ? f.collection.trim() : null,
};
}
@@ -119,6 +140,10 @@ export function ContactsView() {
const [query, setQuery] = useState("");
const [debQuery] = useDebouncedValue(query, 200);
// Libretas (addressbooks) + filtro por libreta de la lista.
const [addressbooks, setAddressbooks] = useState<Addressbook[]>([]);
const [filterBook, setFilterBook] = useState<string>(""); // "" = todas
// 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);
@@ -126,6 +151,13 @@ export function ContactsView() {
const [editSlug, setEditSlug] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Modal "nueva libreta" (mismo patrón que "Nuevo calendario").
const [newBookOpen, setNewBookOpen] = useState(false);
const [newBookName, setNewBookName] = useState("");
const [newBookColor, setNewBookColor] = useState("");
const [newBookErr, setNewBookErr] = useState<string | null>(null);
const [newBookSaving, setNewBookSaving] = useState(false);
function reload() {
setLoading(true);
fetchContacts()
@@ -141,15 +173,35 @@ export function ContactsView() {
.finally(() => setLoading(false));
}
// Carga de libretas. `selectSlug` selecciona una recién creada en el form.
const loadAddressbooks = useCallback((selectSlug?: string) => {
return fetchAddressbooks()
.then((d) => {
if (d.status === "ok" && d.addressbooks) {
setAddressbooks(d.addressbooks);
if (selectSlug) {
setForm((f) => ({ ...f, collection: selectSlug }));
}
}
return d;
})
.catch(() => {
/* el selector degrada a la libreta por defecto; no es fatal */
return null;
});
}, []);
useEffect(() => {
reload();
loadAddressbooks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() => {
const q = debQuery.trim().toLowerCase();
if (!q) return contacts;
return contacts.filter((c) => {
if (filterBook && (c.collection ?? "") !== filterBook) return false;
if (!q) return true;
const hay = [
c.nombre,
c.alias,
@@ -162,7 +214,14 @@ export function ContactsView() {
.toLowerCase();
return hay.includes(q);
});
}, [contacts, debQuery]);
}, [contacts, debQuery, filterBook]);
// Opciones del selector de libreta del formulario y del filtro.
const bookOptions = useMemo(
() =>
addressbooks.map((b) => ({ value: b.slug, label: b.display_name })),
[addressbooks],
);
function openNew() {
setForm(EMPTY_FORM);
@@ -218,6 +277,47 @@ export function ContactsView() {
}
}
// Crea una libreta nueva (mismo flujo que createNewCalendar): deriva el slug
// del nombre, refresca la lista y selecciona la nueva en el formulario.
const createNewBook = useCallback(async () => {
const name = newBookName.trim();
if (!name) {
setNewBookErr("El nombre es obligatorio.");
return;
}
const slug = slugify(name);
if (!slug) {
setNewBookErr("El nombre no produce un identificador válido.");
return;
}
setNewBookSaving(true);
setNewBookErr(null);
try {
const res = await createAddressbook({
slug,
name,
color: newBookColor || null,
});
if (res.status !== "ok") {
setNewBookErr(res.error || "No se pudo crear la libreta.");
return;
}
await loadAddressbooks(res.slug || slug);
notifications.show({
color: "teal",
title: "Libreta creada",
message: name,
});
setNewBookOpen(false);
setNewBookName("");
setNewBookColor("");
} catch (e) {
setNewBookErr(String(e));
} finally {
setNewBookSaving(false);
}
}, [newBookName, newBookColor, loadAddressbooks]);
return (
<>
<ContactForm
@@ -225,11 +325,67 @@ export function ContactsView() {
editing={editSlug !== null}
form={form}
saving={saving}
bookOptions={bookOptions}
onChange={setForm}
onNewBook={() => {
setNewBookErr(null);
setNewBookName("");
setNewBookColor("");
setNewBookOpen(true);
}}
onClose={() => setFormOpen(false)}
onSave={onSave}
/>
<Modal
opened={newBookOpen}
onClose={() => setNewBookOpen(false)}
title="Nueva libreta"
size="sm"
centered
>
<Stack gap="sm">
<TextInput
label="Nombre"
placeholder="Trabajo, Familia, OSINT…"
value={newBookName}
onChange={(e) => setNewBookName(e.currentTarget.value)}
data-autofocus
required
/>
<ColorInput
label="Color"
placeholder="Color de la libreta (opcional)"
value={newBookColor}
onChange={setNewBookColor}
format="hex"
swatches={[
"#23bdfe",
"#16a34a",
"#dc2626",
"#f59e0b",
"#8b5cf6",
"#ec4899",
"#0891b2",
"#64748b",
]}
/>
{newBookErr && (
<Text c="red" size="sm">
{newBookErr}
</Text>
)}
<Group justify="flex-end" gap="xs" mt="xs">
<Button variant="default" onClick={() => setNewBookOpen(false)}>
Cancelar
</Button>
<Button onClick={createNewBook} loading={newBookSaving}>
Crear
</Button>
</Group>
</Stack>
</Modal>
{error ? (
<Center h="100%" p="xl">
<Alert color="orange" title="Agenda no disponible" maw={500}>
@@ -258,6 +414,34 @@ export function ContactsView() {
>
Nuevo contacto
</Button>
<Group align="flex-end" gap="xs" wrap="nowrap" mb="sm">
<Select
label="Libreta"
placeholder="Todas"
data={bookOptions}
value={filterBook || null}
onChange={(v) => setFilterBook(v || "")}
leftSection={<IconAddressBook size={14} />}
clearable
comboboxProps={{ withinPortal: true }}
style={{ flex: 1, minWidth: 0 }}
/>
<Tooltip label="Nueva libreta">
<ActionIcon
variant="default"
size="lg"
aria-label="Nueva libreta"
onClick={() => {
setNewBookErr(null);
setNewBookName("");
setNewBookColor("");
setNewBookOpen(true);
}}
>
<IconPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="Buscar contacto…"
leftSection={<IconSearch size={16} />}
@@ -339,7 +523,9 @@ function ContactForm({
editing,
form,
saving,
bookOptions,
onChange,
onNewBook,
onClose,
onSave,
}: {
@@ -347,7 +533,9 @@ function ContactForm({
editing: boolean;
form: FormState;
saving: boolean;
bookOptions: { value: string; label: string }[];
onChange: (f: FormState) => void;
onNewBook: () => void;
onClose: () => void;
onSave: () => void;
}) {
@@ -362,17 +550,42 @@ function ContactForm({
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}
/>
<Group grow align="flex-start">
<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}
/>
<Group align="flex-end" gap="xs" wrap="nowrap">
<Select
label="Libreta"
placeholder="Por defecto"
data={bookOptions}
value={form.collection || null}
onChange={(v) => set("collection", v || "")}
leftSection={<IconAddressBook size={14} />}
clearable
comboboxProps={{ withinPortal: true }}
style={{ flex: 1, minWidth: 0 }}
/>
<Tooltip label="Nueva libreta">
<ActionIcon
variant="default"
size="lg"
aria-label="Nueva libreta"
onClick={onNewBook}
>
<IconPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<TextInput
label="Nombre"
required
@@ -386,18 +599,29 @@ function ContactForm({
value={form.aliases}
onChange={(v) => set("aliases", v)}
/>
<Group grow>
<TextInput
label="Teléfono"
value={form.telefono}
onChange={(e) => set("telefono", e.currentTarget.value)}
<Group grow align="flex-start">
<TagsInput
label="Teléfonos"
placeholder="añade un teléfono y pulsa Enter"
value={form.telefonos}
onChange={(v) => set("telefonos", v)}
leftSection={<IconPhone size={14} />}
/>
<TextInput
label="Email"
value={form.email}
onChange={(e) => set("email", e.currentTarget.value)}
<TagsInput
label="Emails"
placeholder="añade un email y pulsa Enter"
value={form.emails}
onChange={(v) => set("emails", v)}
leftSection={<IconAt size={14} />}
/>
</Group>
<TagsInput
label="Direcciones"
placeholder="añade una dirección y pulsa Enter"
value={form.direcciones}
onChange={(v) => set("direcciones", v)}
leftSection={<IconMapPin size={14} />}
/>
{form.tipo === "persona" && (
<Group grow>
<TextInput
@@ -419,11 +643,6 @@ function ContactForm({
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"
@@ -469,8 +688,16 @@ function ContactDetail({
onDelete: () => void;
}) {
const osintEntries = Object.entries(contact.osint ?? {}).filter(
([, v]) => v != null && v !== "",
// direccion se muestra en su propio bloque multi-valor; no se duplica en OSINT.
([k, v]) => v != null && v !== "" && k !== "direccion",
);
// Direcciones multi-valor: campo direcciones[] o, en su defecto, osint.direccion.
const direcciones =
contact.direcciones && contact.direcciones.length > 0
? contact.direcciones
: contact.osint?.direccion
? [contact.osint.direccion]
: [];
return (
<Stack p="xl" gap="lg" maw={720}>
<Group justify="space-between" align="flex-start">
@@ -553,6 +780,22 @@ function ContactDetail({
</Stack>
)}
{direcciones.length > 0 && (
<Stack gap={4}>
<Group gap="xs">
<IconMapPin size={16} />
<Text fw={600} size="sm">
Direcciones
</Text>
</Group>
{direcciones.map((d, i) => (
<Text key={i} size="sm" pl="lg">
{d}
</Text>
))}
</Stack>
)}
{osintEntries.length > 0 && (
<Paper withBorder p="md" radius="md">
<Text fw={600} size="sm" mb="xs" c="brand">