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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-3
@@ -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) =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+442
-35
@@ -31,8 +31,10 @@ Endpoints (JSON salvo /api/attachment):
|
||||
GET /api/node/<slug> ficha: frontmatter + body + attachments
|
||||
GET /api/attachment?path=.. binario del attachment (path relativo al vault)
|
||||
GET /api/search?q=... nodos cuyo contenido matchea la query
|
||||
GET /api/contacts contactos del addressbook Xandikos (CardDAV)
|
||||
GET /api/contacts contactos (Xandikos por defecto; osint_db si flag)
|
||||
GET /api/contact/<uid> un vCard concreto a JSON
|
||||
GET /api/addressbooks libretas de contactos (selector del frontend)
|
||||
POST /api/addressbooks crea una libreta nueva (requiere OSINT_DB_BACKEND)
|
||||
GET /api/calendars colecciones de calendario bajo /enmanuel/calendars/
|
||||
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV)
|
||||
POST /api/event crea un VEVENT en una colección de calendario
|
||||
@@ -170,6 +172,13 @@ dav_make_calendar = _load_infra_fn("dav_make_calendar", "dav_make_calendar")
|
||||
# Expandir una RRULE a las fechas de cada ocurrencia dentro de un rango (pura).
|
||||
expand_rrule = _load_infra_fn("expand_rrule", "expand_rrule")
|
||||
|
||||
# Cliente del service osint_db (DuckDB), usado SOLO cuando el feature flag
|
||||
# OSINT_DB_BACKEND está ON. El módulo vive junto a este archivo en server/, que
|
||||
# está en sys.path tanto al ejecutar `python server/main.py` como al importarlo
|
||||
# desde los tests. Se importa siempre (es barato: solo stdlib) pero no se usa a
|
||||
# menos que el flag esté activo.
|
||||
import osintdb_client # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuración Xandikos (CardDAV / CalDAV)
|
||||
@@ -179,6 +188,11 @@ XANDIKOS_BASE_URL = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||
XANDIKOS_USERNAME = "enmanuel"
|
||||
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
|
||||
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
|
||||
# Libreta (addressbook) por defecto. Cuando el flag OSINT_DB_BACKEND está OFF,
|
||||
# todos los contactos viven en esta única libreta; el frontend la muestra como
|
||||
# opción por defecto del selector.
|
||||
DEFAULT_ADDRESSBOOK_SLUG = "addressbook"
|
||||
DEFAULT_ADDRESSBOOK_NAME = "Contactos"
|
||||
# Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
|
||||
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
|
||||
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
|
||||
@@ -299,6 +313,115 @@ def _write_disk_cache(path: str, ctag: str, items: list) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature flag OSINT_DB_BACKEND: fuente de verdad de los contactos
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# OFF (default): los contactos se escriben como ficha .md en el vault + reflejo
|
||||
# del vCard en Xandikos (comportamiento histórico de la app).
|
||||
# ON: la app lee/escribe contra el service osint_db (DuckDB, 127.0.0.1:8771),
|
||||
# que pasa a ser la fuente de verdad y empuja él mismo el cambio a Xandikos.
|
||||
#
|
||||
# El flag vive en dev/feature_flags.json (raíz de la app), patrón TBD del
|
||||
# registry (.claude/rules/feature_flags.md). Se lee en cada acceso (no se cachea)
|
||||
# para que cambiarlo no requiera reiniciar el server.
|
||||
|
||||
_FLAGS_FILE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"dev",
|
||||
"feature_flags.json",
|
||||
)
|
||||
|
||||
# Service osint_db (DuckDB) — fuente de verdad cuando el flag está ON.
|
||||
OSINT_DB_BASE_URL = "http://127.0.0.1:8771"
|
||||
|
||||
|
||||
def _osint_db_backend_enabled() -> bool:
|
||||
"""True si el flag ``OSINT_DB_BACKEND`` está activo en dev/feature_flags.json.
|
||||
|
||||
Lee el archivo en cada llamada (sin caché) para que el flip se note sin
|
||||
reiniciar. Tolerante a fallos: archivo ausente, JSON corrupto o clave faltante
|
||||
→ False (comportamiento histórico vault+Xandikos), nunca lanza.
|
||||
"""
|
||||
try:
|
||||
with open(_FLAGS_FILE, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
flag = (data.get("flags") or {}).get("OSINT_DB_BACKEND") or {}
|
||||
return bool(flag.get("enabled"))
|
||||
|
||||
|
||||
def _contacts_from_osint_db() -> list:
|
||||
"""Lee los contactos del osint_db y los adapta al shape JSON del frontend.
|
||||
|
||||
El osint_db devuelve filas ``{uid, collection, fn, tels, emails, note_path}``
|
||||
(``tels``/``emails`` como JSON array). Esta función las mapea al mismo dict que
|
||||
produce ``_vcard_to_json`` (``uid, nombre, telefonos, correos, phones,
|
||||
emails, osint, ...``), para que ``/api/contacts`` y la vista no distingan la
|
||||
fuente. ``collection`` se expone para poder filtrar por libreta.
|
||||
|
||||
Raises:
|
||||
osintdb_client.OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
rows = osintdb_client.list_contacts()
|
||||
out: list = []
|
||||
for row in rows:
|
||||
tels = osintdb_client._parse_json_array(row.get("tels"))
|
||||
mails = osintdb_client._parse_json_array(row.get("emails"))
|
||||
fn = row.get("fn")
|
||||
out.append(
|
||||
{
|
||||
"uid": row.get("uid"),
|
||||
"fn": fn,
|
||||
"nombre": fn,
|
||||
"nickname": None,
|
||||
"alias": None,
|
||||
"org": None,
|
||||
"note": None,
|
||||
"nota": None,
|
||||
"collection": row.get("collection"),
|
||||
"phones": [{"value": t, "type": ""} for t in tels],
|
||||
"emails": [{"value": e, "type": ""} for e in mails],
|
||||
"telefonos": tels,
|
||||
"correos": mails,
|
||||
"direcciones": [],
|
||||
"osint": {},
|
||||
"note_path": row.get("note_path"),
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower())
|
||||
return out
|
||||
|
||||
|
||||
def _osint_db_contact_payload(data: "ContactIn", uid: Optional[str] = None) -> dict:
|
||||
"""Construye el cuerpo JSON de un contacto para el service osint_db.
|
||||
|
||||
Mapea el ``ContactIn`` (ya reconciliado multi-valor) al contrato del osint_db:
|
||||
``{uid?, collection, fn, telefonos, emails, direcciones, nombre, aliases, dni,
|
||||
pais, contexto, notas}``. ``collection`` se deriva del campo ``contexto`` si
|
||||
apunta a una libreta; por defecto la libreta canónica. ``uid`` solo se incluye
|
||||
al crear (el PUT lo lleva en la ruta, no en el cuerpo).
|
||||
"""
|
||||
nombre = data.nombre.strip()
|
||||
payload: dict = {
|
||||
"collection": _norm_str(data.collection) or DEFAULT_ADDRESSBOOK_SLUG,
|
||||
"fn": nombre,
|
||||
"nombre": nombre,
|
||||
"telefonos": _norm_list(data.telefonos),
|
||||
"emails": _norm_list(data.emails),
|
||||
"direcciones": _norm_list(data.direcciones),
|
||||
"aliases": _norm_list(data.aliases),
|
||||
"dni": _norm_str(data.dni),
|
||||
"pais": _norm_str(data.pais),
|
||||
"contexto": _norm_str(data.contexto),
|
||||
"notas": _norm_str(data.notas),
|
||||
}
|
||||
if uid:
|
||||
payload["uid"] = uid
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Estado del servidor: caché del vault + password Xandikos
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -637,17 +760,26 @@ class VaultState:
|
||||
return list(items)
|
||||
|
||||
def contacts(self) -> list:
|
||||
"""Contactos del addressbook Xandikos, parseados y cacheados.
|
||||
"""Contactos, desde Xandikos (flag OFF) o desde osint_db (flag ON).
|
||||
|
||||
Caché en dos niveles: memoria (mientras vive el proceso) y disco
|
||||
(``.cache/contacts.json``, validada por ctag para arranque instantáneo).
|
||||
Al primer acceso descarga TODO en UNA petición REPORT
|
||||
(``dav_get_collection``) en vez de un GET por ``.vcf``.
|
||||
Con el flag ``OSINT_DB_BACKEND`` activo, la fuente de verdad es el service
|
||||
osint_db (DuckDB): se consultan sus contactos y se devuelven con el mismo
|
||||
shape JSON que produce el parseo del vCard, para que el frontend no note la
|
||||
diferencia. Con el flag OFF (default), camino histórico: addressbook
|
||||
Xandikos parseado y cacheado.
|
||||
|
||||
Caché en dos niveles para el camino DAV: memoria (mientras vive el
|
||||
proceso) y disco (``.cache/contacts.json``, validada por ctag para
|
||||
arranque instantáneo). Al primer acceso descarga TODO en UNA petición
|
||||
REPORT (``dav_get_collection``) en vez de un GET por ``.vcf``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: si no se puede leer la password de ``pass``.
|
||||
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
||||
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
return _contacts_from_osint_db()
|
||||
with self._dav_lock:
|
||||
if self._contacts_cache is not None and not self._force_reload:
|
||||
return self._contacts_cache
|
||||
@@ -662,6 +794,63 @@ class VaultState:
|
||||
self._maybe_clear_force_reload()
|
||||
return contacts
|
||||
|
||||
def list_addressbooks(self) -> list:
|
||||
"""Libretas (addressbooks) disponibles para los contactos.
|
||||
|
||||
Con el flag ``OSINT_DB_BACKEND`` ON, consulta las libretas del osint_db
|
||||
(``{slug, display_name, collection_path, color}``). Con el flag OFF, hoy
|
||||
solo existe la libreta por defecto en el vault; se devuelve esa única
|
||||
entrada para que el selector del frontend tenga algo que mostrar.
|
||||
|
||||
Raises:
|
||||
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
return osintdb_client.list_addressbooks()
|
||||
return [
|
||||
{
|
||||
"slug": DEFAULT_ADDRESSBOOK_SLUG,
|
||||
"display_name": DEFAULT_ADDRESSBOOK_NAME,
|
||||
"collection_path": XANDIKOS_CONTACTS_COLLECTION,
|
||||
"color": None,
|
||||
}
|
||||
]
|
||||
|
||||
def create_addressbook(self, data: "AddressbookIn") -> dict:
|
||||
"""Crea una libreta de contactos nueva.
|
||||
|
||||
Solo soportado con el flag ``OSINT_DB_BACKEND`` ON: el osint_db crea la
|
||||
colección CardDAV en Xandikos y la registra en la DuckDB. Con el flag OFF
|
||||
no hay forma de crear libretas todavía (no existe ``dav_make_addressbook``
|
||||
en el registry) → 501 claro indicando que requiere el flag.
|
||||
|
||||
Returns:
|
||||
dict ``{status, slug, ...}`` del osint_db.
|
||||
|
||||
Raises:
|
||||
HTTPException(400): si el slug/nombre queda vacío.
|
||||
HTTPException(501): si el flag está OFF.
|
||||
osintdb_client.OsintDbUnavailable: si el osint_db no responde.
|
||||
"""
|
||||
slug = (data.slug or data.name or "").strip()
|
||||
if not slug:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="el nombre de la libreta es obligatorio"
|
||||
)
|
||||
if not _osint_db_backend_enabled():
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail=(
|
||||
"crear libretas requiere el backend OSINT_DB_BACKEND activo "
|
||||
"(hoy solo existe la libreta por defecto en el vault)"
|
||||
),
|
||||
)
|
||||
res = osintdb_client.create_addressbook(
|
||||
slug, data.name or slug, data.color or None
|
||||
)
|
||||
self.invalidate_dav()
|
||||
return res
|
||||
|
||||
def _resolve_calendar(self, cal: str = "") -> str:
|
||||
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
|
||||
|
||||
@@ -973,6 +1162,21 @@ class VaultState:
|
||||
HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
|
||||
nombre está vacío.
|
||||
"""
|
||||
if not data.nombre.strip():
|
||||
raise HTTPException(status_code=400, detail="el nombre es obligatorio")
|
||||
if _osint_db_backend_enabled():
|
||||
# Flag ON: el osint_db es la fuente de verdad. Genera el slug igual que
|
||||
# el camino vault (mismo UID), envía el payload y deja que el service
|
||||
# escriba la DuckDB + empuje a Xandikos.
|
||||
slug = slugify_obsidian_name(data.nombre)
|
||||
if not slug:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="el nombre no produce un slug válido"
|
||||
)
|
||||
res = osintdb_client.create_contact(_osint_db_contact_payload(data, slug))
|
||||
self.invalidate_dav()
|
||||
uid = res.get("uid") or slug
|
||||
return {"slug": uid, "uid": uid, "path": None, "osint_db": res}
|
||||
tipo = (data.tipo or "persona").strip()
|
||||
if tipo not in _TIPO_FOLDER:
|
||||
raise HTTPException(
|
||||
@@ -1024,6 +1228,13 @@ class VaultState:
|
||||
Raises:
|
||||
HTTPException(404): si no existe la ficha del contacto.
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
# Flag ON: delega la edición en el osint_db (PUT por UID).
|
||||
res = osintdb_client.update_contact(
|
||||
slug, _osint_db_contact_payload(data)
|
||||
)
|
||||
self.invalidate_dav()
|
||||
return {"slug": slug, "uid": slug, "path": None, "osint_db": res}
|
||||
path = self._find_contact_note(slug)
|
||||
if path is None:
|
||||
raise HTTPException(
|
||||
@@ -1031,13 +1242,21 @@ class VaultState:
|
||||
)
|
||||
note = read_obsidian_note(path)
|
||||
current = dict(note.get("frontmatter") or {})
|
||||
# Merge de los campos editables (preserva los heredados no tocados).
|
||||
# Listas multi-valor (ya reconciladas con los singulares en ContactIn).
|
||||
telefonos = _norm_list(data.telefonos)
|
||||
emails = _norm_list(data.emails)
|
||||
direcciones = _norm_list(data.direcciones)
|
||||
# Merge de los campos editables (preserva los heredados no tocados). El
|
||||
# singular se conserva = primer elemento para los lectores viejos.
|
||||
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),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"telefonos": telefonos,
|
||||
"email": emails[0] if emails else None,
|
||||
"emails": emails,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"direcciones": direcciones,
|
||||
"pais": _norm_str(data.pais),
|
||||
"relaciones": _norm_list(data.relaciones),
|
||||
"contexto": _norm_str(data.contexto),
|
||||
@@ -1067,6 +1286,11 @@ class VaultState:
|
||||
Raises:
|
||||
HTTPException(404): si no existe la ficha del contacto.
|
||||
"""
|
||||
if _osint_db_backend_enabled():
|
||||
# Flag ON: delega el borrado en el osint_db (DELETE por UID).
|
||||
res = osintdb_client.delete_contact(slug)
|
||||
self.invalidate_dav()
|
||||
return {"slug": slug, "deleted": True, "osint_db": res}
|
||||
path = self._find_contact_note(slug)
|
||||
if path is None:
|
||||
raise HTTPException(
|
||||
@@ -1172,6 +1396,7 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
"note": None,
|
||||
"phones": [],
|
||||
"emails": [],
|
||||
"direcciones": [],
|
||||
"osint": {},
|
||||
}
|
||||
for line in _unfold_lines(vcard_text):
|
||||
@@ -1179,6 +1404,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
if not parsed:
|
||||
continue
|
||||
name, params, value = parsed
|
||||
# ADR es estructurado (7 componentes separados por ';'): NO se des-escapa
|
||||
# antes de partir, para no confundir separadores con contenido escapado.
|
||||
if name == "ADR":
|
||||
adr = _parse_adr_value(value)
|
||||
if adr:
|
||||
out["direcciones"].append(adr)
|
||||
continue
|
||||
value = _unescape_ical(value.strip())
|
||||
if name == "UID":
|
||||
out["uid"] = value
|
||||
@@ -1205,6 +1437,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
out["fn"] = ("%s %s" % (comps[1], comps[0])).strip()
|
||||
elif comps:
|
||||
out["fn"] = comps[0]
|
||||
# Compat con vCards antiguos: la dirección iba en X-OSINT-DIRECCION (un solo
|
||||
# valor) en vez de ADR. Si vino por ahí y no hay ADR, súbela a direcciones[]
|
||||
# para que el frontend la vea como multi-valor; deja también osint.direccion
|
||||
# por si algún lector viejo lo consulta.
|
||||
legacy_dir = out["osint"].get("direccion")
|
||||
if legacy_dir and legacy_dir not in out["direcciones"]:
|
||||
out["direcciones"].append(legacy_dir)
|
||||
# Alias en español que consume el frontend del task (mismo dato, otra clave).
|
||||
out["nombre"] = out["fn"]
|
||||
out["alias"] = out["nickname"]
|
||||
@@ -1214,6 +1453,29 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _parse_adr_value(raw: str) -> Optional[str]:
|
||||
"""Extrae la dirección legible de un valor ADR estructurado (RFC 6350).
|
||||
|
||||
El ADR tiene 7 componentes separados por ``;``:
|
||||
``po-box;extended;street;locality;region;postal-code;country``. Esta función
|
||||
une los componentes no vacíos (des-escapados) en una sola línea legible, con
|
||||
preferencia por ``street``; si solo hay un campo, lo devuelve. Devuelve
|
||||
``None`` si el ADR queda vacío.
|
||||
"""
|
||||
parts = raw.split(";")
|
||||
# Des-escapa cada componente por separado (el ';' ya se usó para partir).
|
||||
comps = [_unescape_ical(p.strip()) for p in parts]
|
||||
nonempty = [c for c in comps if c]
|
||||
if not nonempty:
|
||||
return None
|
||||
# street es el 3er componente (índice 2). Si está, suele bastar; si hay más
|
||||
# (locality, region, etc.) se concatenan con coma para una línea legible.
|
||||
if len(comps) >= 3 and comps[2]:
|
||||
tail = [c for c in comps[3:] if c]
|
||||
return ", ".join([comps[2]] + tail) if tail else comps[2]
|
||||
return ", ".join(nonempty)
|
||||
|
||||
|
||||
_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
|
||||
_ICAL_DT_RE = re.compile(
|
||||
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$"
|
||||
@@ -1497,19 +1759,59 @@ class ContactIn(BaseModel):
|
||||
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.
|
||||
|
||||
Multi-valor: un contacto puede tener VARIOS teléfonos, emails y direcciones
|
||||
(``telefonos``/``emails``/``direcciones``). Los campos singulares
|
||||
``telefono``/``email``/``direccion`` se conservan por compatibilidad con
|
||||
clientes y lectores viejos: el validador ``model_post_init`` los reconcilia
|
||||
con las listas (singular → ``[valor]`` si la lista está vacía; y el singular
|
||||
se rellena con ``lista[0]`` para que los lectores que solo miran el singular
|
||||
sigan funcionando).
|
||||
"""
|
||||
|
||||
tipo: str = Field(default="persona")
|
||||
nombre: str
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
# Singulares (compat) — el primer elemento de cada lista multi-valor.
|
||||
telefono: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
dni: Optional[str] = None
|
||||
direccion: Optional[str] = None
|
||||
# Multi-valor: listas completas de teléfonos, emails y direcciones.
|
||||
telefonos: list[str] = Field(default_factory=list)
|
||||
emails: list[str] = Field(default_factory=list)
|
||||
direcciones: list[str] = Field(default_factory=list)
|
||||
dni: Optional[str] = None
|
||||
pais: Optional[str] = None
|
||||
contexto: Optional[str] = None
|
||||
relaciones: list[str] = Field(default_factory=list)
|
||||
notas: Optional[str] = None
|
||||
# Libreta (addressbook) destino. Solo se consume con el flag OSINT_DB_BACKEND
|
||||
# ON (el osint_db enruta el contacto a esa colección). Con el flag OFF se
|
||||
# ignora: hoy solo existe la libreta por defecto en el vault. None → libreta
|
||||
# por defecto.
|
||||
collection: Optional[str] = None
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
"""Reconcilia los campos singulares con las listas multi-valor.
|
||||
|
||||
Para cada par (singular, lista): si la lista llega vacía pero el singular
|
||||
trae valor, la lista se siembra con ``[singular]`` (cliente viejo que solo
|
||||
envía el campo singular); y siempre se rellena el singular con el primer
|
||||
elemento normalizado de la lista, para que los lectores que solo miran el
|
||||
singular (frontmatter compat, vCard heredado) sigan funcionando.
|
||||
"""
|
||||
for singular, plural in (
|
||||
("telefono", "telefonos"),
|
||||
("email", "emails"),
|
||||
("direccion", "direcciones"),
|
||||
):
|
||||
lista = _norm_list(getattr(self, plural))
|
||||
if not lista:
|
||||
single = _norm_str(getattr(self, singular))
|
||||
if single:
|
||||
lista = [single]
|
||||
object.__setattr__(self, plural, lista)
|
||||
object.__setattr__(self, singular, lista[0] if lista else None)
|
||||
|
||||
|
||||
def _norm_str(value: Optional[str]) -> Optional[str]:
|
||||
@@ -1543,15 +1845,23 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
|
||||
nombre = data.nombre.strip()
|
||||
aliases = _norm_list(data.aliases)
|
||||
relaciones = _norm_list(data.relaciones)
|
||||
# Listas multi-valor ya reconciladas en ContactIn.model_post_init; el campo
|
||||
# singular = primer elemento (o None) para los lectores viejos.
|
||||
telefonos = _norm_list(data.telefonos)
|
||||
emails = _norm_list(data.emails)
|
||||
direcciones = _norm_list(data.direcciones)
|
||||
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),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"telefonos": telefonos,
|
||||
"email": emails[0] if emails else None,
|
||||
"emails": emails,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"direcciones": direcciones,
|
||||
"pais": _norm_str(data.pais),
|
||||
"relaciones": relaciones,
|
||||
"contexto": _norm_str(data.contexto),
|
||||
@@ -1567,9 +1877,12 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
|
||||
"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),
|
||||
"telefono": telefonos[0] if telefonos else None,
|
||||
"telefonos": telefonos,
|
||||
"email": emails[0] if emails else None,
|
||||
"emails": emails,
|
||||
"direccion": direcciones[0] if direcciones else None,
|
||||
"direcciones": direcciones,
|
||||
"pais": _norm_str(data.pais),
|
||||
"relaciones": relaciones,
|
||||
"contexto": _norm_str(data.contexto),
|
||||
@@ -1596,11 +1909,28 @@ def _vcard_escape(value: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _vcard_value_list(frontmatter: dict, plural: str, singular: str) -> list:
|
||||
"""Lista de valores de un campo multi-valor del frontmatter de contacto.
|
||||
|
||||
Prefiere la clave plural (``telefonos``/``emails``/``direcciones``); si está
|
||||
vacía cae al singular (``telefono``/...) por compatibilidad con fichas
|
||||
antiguas. Normaliza (trim + descarta vacíos) y devuelve una lista de strings.
|
||||
"""
|
||||
values = frontmatter.get(plural)
|
||||
if not values:
|
||||
single = frontmatter.get(singular)
|
||||
values = [single] if single else []
|
||||
return _norm_list([str(v) for v in values])
|
||||
|
||||
|
||||
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,
|
||||
Soporta multi-valor: emite una línea ``TEL`` por teléfono, una ``EMAIL`` por
|
||||
email y una ``ADR`` por dirección (campos ``telefonos``/``emails``/
|
||||
``direcciones`` del frontmatter; cae a los singulares ``telefono``/... por
|
||||
compat). Mapea además: nombre→FN, aliases→NICKNAME, notas→NOTE,
|
||||
organización→ORG; y los campos OSINT (dni, 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``.
|
||||
@@ -1617,16 +1947,19 @@ def _build_vcard(frontmatter: dict, slug: str) -> str:
|
||||
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)))
|
||||
# Multi-valor: una línea TEL/EMAIL por elemento.
|
||||
for tel in _vcard_value_list(frontmatter, "telefonos", "telefono"):
|
||||
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(tel))
|
||||
for email in _vcard_value_list(frontmatter, "emails", "email"):
|
||||
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(email))
|
||||
# Direcciones → ADR estructurado (la dirección va en el componente street;
|
||||
# los separadores ';' del ADR NO se escapan, solo el contenido). Una línea
|
||||
# ADR por dirección. El parser _vcard_to_json reconstruye la lista desde ADR.
|
||||
for adr in _vcard_value_list(frontmatter, "direcciones", "direccion"):
|
||||
lines.append("ADR;TYPE=HOME:;;%s;;;;" % _vcard_escape(adr))
|
||||
# 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"),
|
||||
@@ -1702,6 +2035,19 @@ class CalendarIn(BaseModel):
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AddressbookIn(BaseModel):
|
||||
"""Cuerpo de POST /api/addressbooks: crea una libreta de contactos nueva.
|
||||
|
||||
``slug`` es el segmento de URL de la colección CardDAV; ``name`` el nombre
|
||||
visible; ``color`` un hex ``#rrggbb`` opcional. Solo se procesa con el flag
|
||||
``OSINT_DB_BACKEND`` activo (el osint_db crea la colección en Xandikos).
|
||||
"""
|
||||
|
||||
slug: str
|
||||
name: Optional[str] = ""
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
def _parse_iso_input(value: str) -> Optional[dict]:
|
||||
"""Parsea una fecha de entrada ISO a ``{year,month,day,hour,minute,second,
|
||||
offset,date_only}`` o ``None``.
|
||||
@@ -1970,12 +2316,12 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
|
||||
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
|
||||
La lista se cachea en memoria al primer acceso (``POST /api/refresh`` la
|
||||
invalida). Si Xandikos no responde o falta la password → 503 con un JSON
|
||||
de error claro, nunca un crash.
|
||||
invalida). Si Xandikos / el osint_db no responde o falta la password →
|
||||
503 con un JSON de error claro, nunca un crash.
|
||||
"""
|
||||
try:
|
||||
contacts = state.contacts()
|
||||
except (RuntimeError, DavUnavailable) as exc:
|
||||
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
@@ -1993,7 +2339,7 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
"""
|
||||
try:
|
||||
contacts = state.contacts()
|
||||
except (RuntimeError, DavUnavailable) as exc:
|
||||
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
@@ -2024,8 +2370,15 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
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}``.
|
||||
Con el flag ``OSINT_DB_BACKEND`` ON el alta va al osint_db; 503 si no
|
||||
responde.
|
||||
"""
|
||||
result = state.create_contact(data)
|
||||
try:
|
||||
result = state.create_contact(data)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(status_code=201, content={"status": "ok", **result})
|
||||
|
||||
@app.put("/api/contact/{slug}")
|
||||
@@ -2033,20 +2386,74 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
"""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}``.
|
||||
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``. Con el
|
||||
flag ``OSINT_DB_BACKEND`` ON la edición va al osint_db; 503 si no responde.
|
||||
"""
|
||||
result = state.update_contact(slug, data)
|
||||
try:
|
||||
result = state.update_contact(slug, data)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
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}``.
|
||||
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``. Con
|
||||
el flag ``OSINT_DB_BACKEND`` ON el borrado va al osint_db; 503 si no
|
||||
responde.
|
||||
"""
|
||||
result = state.delete_contact(slug)
|
||||
try:
|
||||
result = state.delete_contact(slug)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(content={"status": "ok", **result})
|
||||
|
||||
# -- Libretas (addressbooks) de contactos --
|
||||
|
||||
@app.get("/api/addressbooks")
|
||||
def api_addressbooks() -> JSONResponse:
|
||||
"""Libretas de contactos disponibles para el selector del frontend.
|
||||
|
||||
Cada una: ``{slug, display_name, collection_path, color}``. Con el flag
|
||||
``OSINT_DB_BACKEND`` ON vienen del osint_db; con el flag OFF se devuelve
|
||||
solo la libreta por defecto del vault. 503 si el osint_db no responde.
|
||||
"""
|
||||
try:
|
||||
books = state.list_addressbooks()
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "ok",
|
||||
"count": len(books),
|
||||
"addressbooks": books,
|
||||
"default": DEFAULT_ADDRESSBOOK_SLUG,
|
||||
}
|
||||
)
|
||||
|
||||
@app.post("/api/addressbooks")
|
||||
def api_create_addressbook(data: AddressbookIn = Body(...)) -> JSONResponse:
|
||||
"""Crea una libreta de contactos nueva.
|
||||
|
||||
Body: ``{slug, name?, color?}``. Requiere el flag ``OSINT_DB_BACKEND``
|
||||
activo (el osint_db crea la colección CardDAV en Xandikos); con el flag
|
||||
OFF devuelve 501 claro. 503 si el osint_db no responde.
|
||||
"""
|
||||
try:
|
||||
res = state.create_addressbook(data)
|
||||
except osintdb_client.OsintDbUnavailable as exc:
|
||||
return JSONResponse(
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
return JSONResponse(status_code=201, content={"status": "ok", **res})
|
||||
|
||||
# -- Xandikos: calendario (CalDAV) --
|
||||
|
||||
@app.get("/api/calendars")
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Cliente HTTP fino al service osint_db (DuckDB, 127.0.0.1:8771).
|
||||
|
||||
Fuente de verdad de los contactos cuando el feature flag ``OSINT_DB_BACKEND``
|
||||
está activo. El osint_db es quien escribe la DuckDB y empuja el cambio a
|
||||
Xandikos; esta app solo le habla por HTTP. Todas las respuestas del service son
|
||||
``200 + {status: "ok"|"error", ...}`` (los errores de dominio viajan en el cuerpo,
|
||||
no en el código HTTP).
|
||||
|
||||
Solo stdlib (urllib, json) para no añadir dependencias de runtime: el cliente es
|
||||
un wrapper de transporte, no reimplementa lógica del osint_db. Errores de red
|
||||
(timeout, conexión rechazada, host caído) se traducen a la excepción
|
||||
``OsintDbUnavailable`` para que los endpoints degraden con un 503 claro, igual que
|
||||
el camino DAV, en vez de tumbar el server.
|
||||
|
||||
Contrato (cuerpo JSON):
|
||||
POST /api/query {sql, params?, max_rows?} → {status, columns, rows}
|
||||
POST /api/contact {collection, fn, telefonos, emails, direcciones, ...}
|
||||
PUT /api/contact/{uid} (mismo cuerpo, sin uid en el body)
|
||||
DELETE /api/contact/{uid}
|
||||
POST /api/addressbook {slug, display_name, color?}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
# URL base del service. Se mantiene como módulo-global para poder monkeypatchearla
|
||||
# en tests sin tocar cada llamada.
|
||||
BASE_URL = "http://127.0.0.1:8771"
|
||||
|
||||
# Timeout por petición. El osint_db es local (loopback): si tarda más que esto,
|
||||
# algo va mal y es mejor degradar que colgar el endpoint.
|
||||
_TIMEOUT_S = 20.0
|
||||
|
||||
|
||||
class OsintDbUnavailable(Exception):
|
||||
"""El service osint_db no responde (no arrancado, timeout, conexión caída).
|
||||
|
||||
Los endpoints la capturan y devuelven un 503 JSON claro, en paralelo a
|
||||
``DavUnavailable`` del camino DAV.
|
||||
"""
|
||||
|
||||
|
||||
def _request(method: str, path: str, body: Optional[dict] = None) -> dict:
|
||||
"""Hace una petición HTTP al osint_db y devuelve el JSON de respuesta.
|
||||
|
||||
Args:
|
||||
method: verbo HTTP (``GET``/``POST``/``PUT``/``DELETE``).
|
||||
path: ruta absoluta del endpoint (``/api/query``, ...).
|
||||
body: cuerpo JSON opcional (se serializa con ``ensure_ascii=False``).
|
||||
|
||||
Returns:
|
||||
El cuerpo de respuesta ya deserializado a dict.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde o la respuesta no es JSON.
|
||||
"""
|
||||
url = BASE_URL.rstrip("/") + path
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
# El contrato dice 200 siempre; un HTTPError es anómalo. Intenta leer el
|
||||
# cuerpo (puede traer {status:error,...}); si no, degrada.
|
||||
try:
|
||||
return json.loads(exc.read().decode("utf-8"))
|
||||
except (ValueError, OSError):
|
||||
raise OsintDbUnavailable(
|
||||
"osint_db respondió HTTP %s en %s" % (exc.code, path)
|
||||
) from exc
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
||||
raise OsintDbUnavailable(
|
||||
"osint_db no responde en %s: %s" % (BASE_URL, exc)
|
||||
) from exc
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise OsintDbUnavailable(
|
||||
"osint_db devolvió una respuesta no-JSON en %s" % path
|
||||
) from exc
|
||||
|
||||
|
||||
def query(sql: str, params: Optional[list] = None, max_rows: int = 2000) -> dict:
|
||||
"""Ejecuta una SELECT contra la DuckDB del osint_db.
|
||||
|
||||
Args:
|
||||
sql: la consulta SQL (de solo lectura; el service la valida).
|
||||
params: parámetros posicionales opcionales.
|
||||
max_rows: tope de filas devueltas.
|
||||
|
||||
Returns:
|
||||
dict ``{status, columns, rows}`` tal cual lo devuelve el service.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
body: dict[str, Any] = {"sql": sql, "max_rows": max_rows}
|
||||
if params:
|
||||
body["params"] = params
|
||||
return _request("POST", "/api/query", body)
|
||||
|
||||
|
||||
def list_addressbooks() -> list:
|
||||
"""Lista las libretas (addressbooks) del osint_db.
|
||||
|
||||
Devuelve una lista de dicts ``{slug, display_name, collection_path, color}``
|
||||
ordenados por ``display_name``. Si la consulta falla a nivel de dominio
|
||||
(``status != ok``) devuelve lista vacía, no lanza.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
res = query(
|
||||
"SELECT slug, display_name, collection_path, color "
|
||||
"FROM addressbooks ORDER BY display_name",
|
||||
max_rows=1000,
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return []
|
||||
cols = res.get("columns") or []
|
||||
rows = res.get("rows") or []
|
||||
out: list = []
|
||||
for row in rows:
|
||||
# El service puede devolver filas como lista posicional o como dict.
|
||||
if isinstance(row, dict):
|
||||
out.append(row)
|
||||
else:
|
||||
out.append({cols[i]: row[i] for i in range(min(len(cols), len(row)))})
|
||||
return out
|
||||
|
||||
|
||||
def list_contacts() -> list:
|
||||
"""Lista los contactos del osint_db, con los campos que consume el frontend.
|
||||
|
||||
Devuelve filas ``{uid, collection, fn, tels, emails, note_path}``; ``tels`` y
|
||||
``emails`` llegan como JSON array (string JSON o lista) y se parsean a lista de
|
||||
strings.
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
res = query(
|
||||
"SELECT uid, collection, fn, tels, emails, note_path "
|
||||
"FROM contacts ORDER BY fn",
|
||||
max_rows=5000,
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return []
|
||||
cols = res.get("columns") or []
|
||||
rows = res.get("rows") or []
|
||||
out: list = []
|
||||
for row in rows:
|
||||
rec = row if isinstance(row, dict) else {
|
||||
cols[i]: row[i] for i in range(min(len(cols), len(row)))
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_json_array(value: Any) -> list:
|
||||
"""Normaliza un valor que puede venir como lista o como string JSON a lista.
|
||||
|
||||
El osint_db devuelve ``tels``/``emails`` como JSON array; según el driver,
|
||||
puede llegar ya como lista Python o como string JSON. Tolera ambos y los
|
||||
valores nulos/vacíos.
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value if v not in (None, "")]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError:
|
||||
return [value]
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) for v in parsed if v not in (None, "")]
|
||||
return [str(parsed)]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def create_contact(payload: dict) -> dict:
|
||||
"""Crea un contacto en el osint_db (POST /api/contact).
|
||||
|
||||
Args:
|
||||
payload: cuerpo JSON del contacto (``collection, fn, telefonos, emails,
|
||||
direcciones, nombre?, aliases?, dni?, pais?, contexto?, notas?``).
|
||||
|
||||
Returns:
|
||||
El cuerpo de respuesta del service (``{status, uid, ...}``).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
return _request("POST", "/api/contact", payload)
|
||||
|
||||
|
||||
def update_contact(uid: str, payload: dict) -> dict:
|
||||
"""Edita un contacto del osint_db (PUT /api/contact/{uid}).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
return _request("PUT", "/api/contact/%s" % urllib.parse.quote(uid), payload)
|
||||
|
||||
|
||||
def delete_contact(uid: str) -> dict:
|
||||
"""Borra un contacto del osint_db (DELETE /api/contact/{uid}).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
return _request("DELETE", "/api/contact/%s" % urllib.parse.quote(uid))
|
||||
|
||||
|
||||
def create_addressbook(slug: str, name: str, color: Optional[str] = None) -> dict:
|
||||
"""Crea una libreta (addressbook) en el osint_db (POST /api/addressbook).
|
||||
|
||||
El osint_db crea la colección CardDAV en Xandikos y la registra en la DuckDB.
|
||||
|
||||
Returns:
|
||||
El cuerpo de respuesta del service (``{status, slug, ...}``).
|
||||
|
||||
Raises:
|
||||
OsintDbUnavailable: si el service no responde.
|
||||
"""
|
||||
body: dict[str, Any] = {"slug": slug, "display_name": name}
|
||||
if color:
|
||||
body["color"] = color
|
||||
return _request("POST", "/api/addressbook", body)
|
||||
@@ -609,6 +609,212 @@ def test_crud_update_preserves_inherited_fields(crud_client, vault):
|
||||
crud_client.delete("/api/contact/%s" % slug)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contactos multi-valor: varias TEL/EMAIL/ADR + compat singular/lista
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_contactin_reconcilia_singular_y_lista():
|
||||
"""ContactIn reconcilia singular↔lista: cliente viejo (singular) y nuevo (lista)."""
|
||||
# Cliente viejo: solo el campo singular → se siembra la lista.
|
||||
viejo = srv.ContactIn(nombre="X", telefono="111", email="a@x.com", direccion="C1")
|
||||
assert viejo.telefonos == ["111"]
|
||||
assert viejo.emails == ["a@x.com"]
|
||||
assert viejo.direcciones == ["C1"]
|
||||
# El singular se conserva = primer elemento.
|
||||
assert viejo.telefono == "111"
|
||||
|
||||
# Cliente nuevo: listas → el singular se rellena con lista[0].
|
||||
nuevo = srv.ContactIn(
|
||||
nombre="X",
|
||||
telefonos=["111", "222"],
|
||||
emails=["a@x.com", "b@x.com"],
|
||||
direcciones=["C1", "C2"],
|
||||
)
|
||||
assert nuevo.telefonos == ["111", "222"]
|
||||
assert nuevo.telefono == "111"
|
||||
assert nuevo.email == "a@x.com"
|
||||
assert nuevo.direccion == "C1"
|
||||
|
||||
|
||||
def test_build_vcard_multivalor_emite_n_lineas():
|
||||
"""_build_vcard emite una TEL/EMAIL/ADR por elemento de cada lista."""
|
||||
fm = {
|
||||
"tipo": "persona",
|
||||
"nombre": "Multi Persona",
|
||||
"telefonos": ["111", "222"],
|
||||
"emails": ["a@x.com", "b@x.com"],
|
||||
"direcciones": ["Calle 1", "Calle 2"],
|
||||
"dni": "X",
|
||||
}
|
||||
vc = srv._build_vcard(fm, "multi-persona")
|
||||
assert vc.count("TEL;TYPE=CELL:") == 2
|
||||
assert "TEL;TYPE=CELL:111" in vc and "TEL;TYPE=CELL:222" in vc
|
||||
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
|
||||
assert vc.count("ADR;TYPE=HOME:") == 2
|
||||
assert "ADR;TYPE=HOME:;;Calle 1;;;;" in vc
|
||||
assert "X-OSINT-DNI:X" in vc
|
||||
|
||||
|
||||
def test_vcard_to_json_lee_adr_multivalor():
|
||||
"""_vcard_to_json reconstruye la lista de direcciones desde las líneas ADR."""
|
||||
vcard = (
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:adr-1\r\nFN:Con Direcciones\r\n"
|
||||
"ADR;TYPE=HOME:;;Calle Uno 1;;;;\r\n"
|
||||
"ADR;TYPE=HOME:;;Calle Dos 2;Madrid;;28001;España\r\n"
|
||||
"END:VCARD\r\n"
|
||||
)
|
||||
out = srv._vcard_to_json(vcard)
|
||||
assert out["direcciones"][0] == "Calle Uno 1"
|
||||
# El 2º ADR concatena street + locality/region/postal/country legibles.
|
||||
assert "Calle Dos 2" in out["direcciones"][1]
|
||||
assert "Madrid" in out["direcciones"][1]
|
||||
|
||||
|
||||
def test_vcard_to_json_legacy_x_osint_direccion():
|
||||
"""Compat: una dirección antigua en X-OSINT-DIRECCION sube a direcciones[]."""
|
||||
vcard = (
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:legacy-1\r\nFN:Legacy\r\n"
|
||||
"X-OSINT-DIRECCION:Calle Antigua 7\r\nEND:VCARD\r\n"
|
||||
)
|
||||
out = srv._vcard_to_json(vcard)
|
||||
assert "Calle Antigua 7" in out["direcciones"]
|
||||
# Se mantiene también en osint.direccion por si un lector viejo lo consulta.
|
||||
assert out["osint"]["direccion"] == "Calle Antigua 7"
|
||||
|
||||
|
||||
def test_crud_multivalor_round_trip(crud_client, vault):
|
||||
"""Golden multi-valor: crear con 2 teléfonos/emails/direcciones y verlos todos."""
|
||||
calls = crud_client._crud_calls
|
||||
body = {
|
||||
"tipo": "persona",
|
||||
"nombre": "Poli Valor",
|
||||
"telefonos": ["+34600000001", "+34600000002"],
|
||||
"emails": ["uno@x.com", "dos@x.com"],
|
||||
"direcciones": ["Calle A 1", "Calle B 2"],
|
||||
}
|
||||
r = crud_client.post("/api/contact", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
slug = r.json()["slug"]
|
||||
md = os.path.join(vault, "personas", slug + ".md")
|
||||
content = open(md, encoding="utf-8").read()
|
||||
# El frontmatter escribe las listas multi-valor + el singular compat.
|
||||
assert "+34600000001" in content and "+34600000002" in content
|
||||
assert "uno@x.com" in content and "dos@x.com" in content
|
||||
# El vCard emitió las dos líneas TEL/EMAIL/ADR.
|
||||
vc = calls["put"][-1]["vcard"]
|
||||
assert vc.count("TEL;TYPE=CELL:") == 2
|
||||
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
|
||||
assert vc.count("ADR;TYPE=HOME:") == 2
|
||||
crud_client.delete("/api/contact/%s" % slug)
|
||||
|
||||
|
||||
def test_crud_singular_compat_sigue_funcionando(crud_client, vault):
|
||||
"""Edge: un cliente viejo que envía solo el singular sigue funcionando."""
|
||||
calls = crud_client._crud_calls
|
||||
body = {"tipo": "persona", "nombre": "Solo Singular", "telefono": "+34611111111"}
|
||||
r = crud_client.post("/api/contact", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
slug = r.json()["slug"]
|
||||
vc = calls["put"][-1]["vcard"]
|
||||
assert "TEL;TYPE=CELL:+34611111111" in vc
|
||||
assert vc.count("TEL;TYPE=CELL:") == 1
|
||||
crud_client.delete("/api/contact/%s" % slug)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Libretas (addressbooks) + feature flag OSINT_DB_BACKEND
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_feature_flag_off_por_defecto(monkeypatch, tmp_path):
|
||||
"""El flag OSINT_DB_BACKEND está OFF por defecto (archivo ausente → False)."""
|
||||
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
|
||||
assert srv._osint_db_backend_enabled() is False
|
||||
|
||||
|
||||
def test_feature_flag_lee_archivo(monkeypatch, tmp_path):
|
||||
"""_osint_db_backend_enabled refleja el archivo dev/feature_flags.json."""
|
||||
flags = tmp_path / "feature_flags.json"
|
||||
flags.write_text(
|
||||
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
|
||||
assert srv._osint_db_backend_enabled() is True
|
||||
flags.write_text(
|
||||
'{"flags":{"OSINT_DB_BACKEND":{"enabled":false}}}', encoding="utf-8"
|
||||
)
|
||||
assert srv._osint_db_backend_enabled() is False
|
||||
|
||||
|
||||
def test_addressbooks_off_devuelve_libreta_por_defecto(crud_client):
|
||||
"""Con el flag OFF, /api/addressbooks devuelve solo la libreta por defecto."""
|
||||
r = crud_client.get("/api/addressbooks")
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["count"] == 1
|
||||
assert data["addressbooks"][0]["slug"] == srv.DEFAULT_ADDRESSBOOK_SLUG
|
||||
assert data["default"] == srv.DEFAULT_ADDRESSBOOK_SLUG
|
||||
|
||||
|
||||
def test_create_addressbook_off_devuelve_501(crud_client, monkeypatch, tmp_path):
|
||||
"""Error: crear libreta con el flag OFF → 501 claro (requiere OSINT_DB_BACKEND)."""
|
||||
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
|
||||
r = crud_client.post("/api/addressbooks", json={"slug": "trabajo", "name": "Trabajo"})
|
||||
assert r.status_code == 501
|
||||
assert "OSINT_DB_BACKEND" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_contacts_flag_on_usa_osint_db(crud_client, monkeypatch, tmp_path):
|
||||
"""Con el flag ON, /api/contacts lee del osint_db (mockeado), no de Xandikos."""
|
||||
flags = tmp_path / "feature_flags.json"
|
||||
flags.write_text(
|
||||
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
|
||||
monkeypatch.setattr(
|
||||
srv.osintdb_client,
|
||||
"list_contacts",
|
||||
lambda: [
|
||||
{
|
||||
"uid": "u1",
|
||||
"collection": "addressbook",
|
||||
"fn": "Desde DuckDB",
|
||||
"tels": '["111", "222"]',
|
||||
"emails": '["a@x.com"]',
|
||||
"note_path": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
r = crud_client.get("/api/contacts")
|
||||
assert r.status_code == 200, r.text
|
||||
contacts = r.json()["contacts"]
|
||||
assert len(contacts) == 1
|
||||
c = contacts[0]
|
||||
assert c["nombre"] == "Desde DuckDB"
|
||||
# Los JSON array de tels/emails se parsean a lista de strings.
|
||||
assert c["telefonos"] == ["111", "222"]
|
||||
assert c["correos"] == ["a@x.com"]
|
||||
|
||||
|
||||
def test_contacts_flag_on_osint_db_caido_503(crud_client, monkeypatch, tmp_path):
|
||||
"""Error: con el flag ON y el osint_db caído, /api/contacts degrada a 503."""
|
||||
flags = tmp_path / "feature_flags.json"
|
||||
flags.write_text(
|
||||
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
|
||||
|
||||
def _down():
|
||||
raise srv.osintdb_client.OsintDbUnavailable("no arrancado")
|
||||
|
||||
monkeypatch.setattr(srv.osintdb_client, "list_contacts", _down)
|
||||
r = crud_client.get("/api/contacts")
|
||||
assert r.status_code == 503
|
||||
assert r.json()["status"] == "error"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user