diff --git a/dev/feature_flags.json b/dev/feature_flags.json new file mode 100644 index 0000000..5a2e126 --- /dev/null +++ b/dev/feature_flags.json @@ -0,0 +1,10 @@ +{ + "flags": { + "OSINT_DB_BACKEND": { + "enabled": false, + "description": "osint_web lee/escribe contra osint_db (DuckDB) en vez de vault+Xandikos", + "added": "2026-06-13", + "enabled_at": null + } + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2602b5c..143f93a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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; + // 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("/addressbooks"); + +export const createAddressbook = (data: AddressbookInput) => + sendJSON("/addressbooks", "POST", data); + export const fetchCalendars = () => getJSON("/calendars"); export const createCalendar = (data: CalendarInput) => diff --git a/frontend/src/views/ContactsView.tsx b/frontend/src/views/ContactsView.tsx index 0399d04..8372371 100644 --- a/frontend/src/views/ContactsView.tsx +++ b/frontend/src/views/ContactsView.tsx @@ -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([]); + const [filterBook, setFilterBook] = useState(""); // "" = 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(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(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 ( <> { + setNewBookErr(null); + setNewBookName(""); + setNewBookColor(""); + setNewBookOpen(true); + }} onClose={() => setFormOpen(false)} onSave={onSave} /> + setNewBookOpen(false)} + title="Nueva libreta" + size="sm" + centered + > + + setNewBookName(e.currentTarget.value)} + data-autofocus + required + /> + + {newBookErr && ( + + {newBookErr} + + )} + + + + + + + {error ? (
@@ -258,6 +414,34 @@ export function ContactsView() { > Nuevo contacto + + set("tipo", (v as FormTipo) || "persona")} - disabled={editing} - allowDeselect={false} - /> + + set("collection", v || "")} + leftSection={} + clearable + comboboxProps={{ withinPortal: true }} + style={{ flex: 1, minWidth: 0 }} + /> + + + + + + + set("aliases", v)} /> - - set("telefono", e.currentTarget.value)} + + set("telefonos", v)} + leftSection={} /> - set("email", e.currentTarget.value)} + set("emails", v)} + leftSection={} /> + set("direcciones", v)} + leftSection={} + /> {form.tipo === "persona" && ( set("pais", e.currentTarget.value)} /> )} - set("direccion", e.currentTarget.value)} - />