merge: contactos multi-valor + libretas + backend osint_db (flag OSINT_DB_BACKEND)

This commit is contained in:
2026-06-13 00:47:38 +02:00
6 changed files with 1248 additions and 90 deletions
+10
View File
@@ -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
View File
@@ -108,7 +108,12 @@ export interface Contact {
emails: ContactPhone[]; emails: ContactPhone[];
telefonos: string[]; telefonos: string[];
correos: string[]; correos: string[];
// Direcciones multi-valor (varias ADR del vCard, o X-OSINT-DIRECCION legacy).
direcciones?: string[];
osint: Record<string, 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; href?: string;
etag?: string; etag?: string;
} }
@@ -223,14 +228,24 @@ export interface ContactInput {
tipo: "persona" | "organizacion"; tipo: "persona" | "organizacion";
nombre: string; nombre: string;
aliases: string[]; aliases: string[];
telefono: string | null; // Multi-valor: listas completas de teléfonos, emails y direcciones. El backend
email: string | null; // 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; dni: string | null;
direccion: string | null;
pais: string | null; pais: string | null;
contexto: string | null; contexto: string | null;
relaciones: string[]; relaciones: string[];
notas: string | null; notas: string | null;
// Libreta (addressbook) destino. Solo se honra con el flag OSINT_DB_BACKEND ON.
collection?: string | null;
} }
export interface ContactWriteResult { export interface ContactWriteResult {
@@ -280,6 +295,43 @@ export const deleteContact = (slug: string) =>
"DELETE", "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 fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
export const createCalendar = (data: CalendarInput) => 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 { import {
ActionIcon,
Alert, Alert,
Badge, Badge,
Box, Box,
Button, Button,
Center, Center,
ColorInput,
Divider, Divider,
Group, Group,
Loader, Loader,
@@ -19,12 +21,15 @@ import {
Textarea, Textarea,
TextInput, TextInput,
Title, Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { import {
IconAddressBook,
IconAt, IconAt,
IconEdit, IconEdit,
IconMapPin,
IconNote, IconNote,
IconPhone, IconPhone,
IconPlus, IconPlus,
@@ -33,19 +38,23 @@ import {
IconUser, IconUser,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
createAddressbook,
createContact, createContact,
deleteContact, deleteContact,
fetchAddressbooks,
fetchContacts, fetchContacts,
updateContact, updateContact,
type Addressbook,
type Contact, type Contact,
type ContactInput, type ContactInput,
} from "../api"; } from "../api";
import { slugify } from "../format"; import { slugify } from "../format";
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con // Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la // buscador, selector de libreta y filtro por libreta) y la ficha del contacto
// vista de lectura y añade los controles de edición: alta ("Nuevo contacto"), // seleccionado a la derecha. Soporta contactos multi-valor (varios teléfonos,
// edición y borrado de la ficha del vault (con reflejo inmediato en Xandikos). // emails y direcciones) y la creación de libretas nuevas (análogo al patrón de
// "Nuevo calendario" en CalendarView).
type FormTipo = "persona" | "organizacion"; type FormTipo = "persona" | "organizacion";
@@ -53,61 +62,73 @@ interface FormState {
tipo: FormTipo; tipo: FormTipo;
nombre: string; nombre: string;
aliases: string[]; aliases: string[];
telefono: string; // Multi-valor: listas completas (antes eran campos singulares).
email: string; telefonos: string[];
emails: string[];
direcciones: string[];
dni: string; dni: string;
direccion: string;
pais: string; pais: string;
contexto: string; contexto: string;
notas: string; notas: string;
// Libreta destino (slug). "" → libreta por defecto.
collection: string;
} }
const EMPTY_FORM: FormState = { const EMPTY_FORM: FormState = {
tipo: "persona", tipo: "persona",
nombre: "", nombre: "",
aliases: [], aliases: [],
telefono: "", telefonos: [],
email: "", emails: [],
direcciones: [],
dni: "", dni: "",
direccion: "",
pais: "", pais: "",
contexto: "", contexto: "",
notas: "", notas: "",
collection: "",
}; };
// Construye el estado del formulario a partir de un contacto existente (para // 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 // editar). Carga TODOS los valores multi-valor (antes solo cargaba el primero).
// backend expone tras parsear el vCard.
function formFromContact(c: Contact): FormState { function formFromContact(c: Contact): FormState {
const osint = c.osint ?? {}; const osint = c.osint ?? {};
return { return {
tipo: "persona", tipo: "persona",
nombre: c.nombre ?? "", nombre: c.nombre ?? "",
aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [], aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [],
telefono: c.telefonos?.[0] ?? "", telefonos: c.telefonos ?? [],
email: c.correos?.[0] ?? "", 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 ?? "", dni: osint.dni ?? "",
direccion: osint.direccion ?? "",
pais: osint.pais ?? "", pais: osint.pais ?? "",
contexto: osint.contexto ?? "", contexto: osint.contexto ?? "",
notas: c.nota ?? "", notas: c.nota ?? "",
collection: c.collection ?? "",
}; };
} }
function formToInput(f: FormState): ContactInput { 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 { return {
tipo: f.tipo, tipo: f.tipo,
nombre: f.nombre.trim(), nombre: f.nombre.trim(),
aliases: f.aliases.map((s) => s.trim()).filter(Boolean), aliases: t(f.aliases),
telefono: t(f.telefono), telefonos: t(f.telefonos),
email: t(f.email), emails: t(f.emails),
dni: t(f.dni), direcciones: t(f.direcciones),
direccion: t(f.direccion), dni: s(f.dni),
pais: t(f.pais), pais: s(f.pais),
contexto: t(f.contexto), contexto: s(f.contexto),
relaciones: [], 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 [query, setQuery] = useState("");
const [debQuery] = useDebouncedValue(query, 200); 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 // Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
// edición (slug del contacto). `saving` deshabilita el botón mientras escribe. // edición (slug del contacto). `saving` deshabilita el botón mientras escribe.
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
@@ -126,6 +151,13 @@ export function ContactsView() {
const [editSlug, setEditSlug] = useState<string | null>(null); const [editSlug, setEditSlug] = useState<string | null>(null);
const [saving, setSaving] = useState(false); 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() { function reload() {
setLoading(true); setLoading(true);
fetchContacts() fetchContacts()
@@ -141,15 +173,35 @@ export function ContactsView() {
.finally(() => setLoading(false)); .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(() => { useEffect(() => {
reload(); reload();
loadAddressbooks();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = debQuery.trim().toLowerCase(); const q = debQuery.trim().toLowerCase();
if (!q) return contacts;
return contacts.filter((c) => { return contacts.filter((c) => {
if (filterBook && (c.collection ?? "") !== filterBook) return false;
if (!q) return true;
const hay = [ const hay = [
c.nombre, c.nombre,
c.alias, c.alias,
@@ -162,7 +214,14 @@ export function ContactsView() {
.toLowerCase(); .toLowerCase();
return hay.includes(q); 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() { function openNew() {
setForm(EMPTY_FORM); 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 ( return (
<> <>
<ContactForm <ContactForm
@@ -225,11 +325,67 @@ export function ContactsView() {
editing={editSlug !== null} editing={editSlug !== null}
form={form} form={form}
saving={saving} saving={saving}
bookOptions={bookOptions}
onChange={setForm} onChange={setForm}
onNewBook={() => {
setNewBookErr(null);
setNewBookName("");
setNewBookColor("");
setNewBookOpen(true);
}}
onClose={() => setFormOpen(false)} onClose={() => setFormOpen(false)}
onSave={onSave} 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 ? ( {error ? (
<Center h="100%" p="xl"> <Center h="100%" p="xl">
<Alert color="orange" title="Agenda no disponible" maw={500}> <Alert color="orange" title="Agenda no disponible" maw={500}>
@@ -258,6 +414,34 @@ export function ContactsView() {
> >
Nuevo contacto Nuevo contacto
</Button> </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 <TextInput
placeholder="Buscar contacto…" placeholder="Buscar contacto…"
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
@@ -339,7 +523,9 @@ function ContactForm({
editing, editing,
form, form,
saving, saving,
bookOptions,
onChange, onChange,
onNewBook,
onClose, onClose,
onSave, onSave,
}: { }: {
@@ -347,7 +533,9 @@ function ContactForm({
editing: boolean; editing: boolean;
form: FormState; form: FormState;
saving: boolean; saving: boolean;
bookOptions: { value: string; label: string }[];
onChange: (f: FormState) => void; onChange: (f: FormState) => void;
onNewBook: () => void;
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
}) { }) {
@@ -362,17 +550,42 @@ function ContactForm({
size="lg" size="lg"
> >
<Stack gap="sm"> <Stack gap="sm">
<Select <Group grow align="flex-start">
label="Tipo" <Select
data={[ label="Tipo"
{ value: "persona", label: "Persona" }, data={[
{ value: "organizacion", label: "Organización" }, { value: "persona", label: "Persona" },
]} { value: "organizacion", label: "Organización" },
value={form.tipo} ]}
onChange={(v) => set("tipo", (v as FormTipo) || "persona")} value={form.tipo}
disabled={editing} onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
allowDeselect={false} 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 <TextInput
label="Nombre" label="Nombre"
required required
@@ -386,18 +599,29 @@ function ContactForm({
value={form.aliases} value={form.aliases}
onChange={(v) => set("aliases", v)} onChange={(v) => set("aliases", v)}
/> />
<Group grow> <Group grow align="flex-start">
<TextInput <TagsInput
label="Teléfono" label="Teléfonos"
value={form.telefono} placeholder="añade un teléfono y pulsa Enter"
onChange={(e) => set("telefono", e.currentTarget.value)} value={form.telefonos}
onChange={(v) => set("telefonos", v)}
leftSection={<IconPhone size={14} />}
/> />
<TextInput <TagsInput
label="Email" label="Emails"
value={form.email} placeholder="añade un email y pulsa Enter"
onChange={(e) => set("email", e.currentTarget.value)} value={form.emails}
onChange={(v) => set("emails", v)}
leftSection={<IconAt size={14} />}
/> />
</Group> </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" && ( {form.tipo === "persona" && (
<Group grow> <Group grow>
<TextInput <TextInput
@@ -419,11 +643,6 @@ function ContactForm({
onChange={(e) => set("pais", e.currentTarget.value)} onChange={(e) => set("pais", e.currentTarget.value)}
/> />
)} )}
<TextInput
label="Dirección"
value={form.direccion}
onChange={(e) => set("direccion", e.currentTarget.value)}
/>
<Select <Select
label="Contexto" label="Contexto"
placeholder="origen / círculo" placeholder="origen / círculo"
@@ -469,8 +688,16 @@ function ContactDetail({
onDelete: () => void; onDelete: () => void;
}) { }) {
const osintEntries = Object.entries(contact.osint ?? {}).filter( 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 ( return (
<Stack p="xl" gap="lg" maw={720}> <Stack p="xl" gap="lg" maw={720}>
<Group justify="space-between" align="flex-start"> <Group justify="space-between" align="flex-start">
@@ -553,6 +780,22 @@ function ContactDetail({
</Stack> </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 && ( {osintEntries.length > 0 && (
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
<Text fw={600} size="sm" mb="xs" c="brand"> <Text fw={600} size="sm" mb="xs" c="brand">
+442 -35
View File
@@ -31,8 +31,10 @@ Endpoints (JSON salvo /api/attachment):
GET /api/node/<slug> ficha: frontmatter + body + attachments GET /api/node/<slug> ficha: frontmatter + body + attachments
GET /api/attachment?path=.. binario del attachment (path relativo al vault) GET /api/attachment?path=.. binario del attachment (path relativo al vault)
GET /api/search?q=... nodos cuyo contenido matchea la query 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/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/calendars colecciones de calendario bajo /enmanuel/calendars/
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV) 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 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). # Expandir una RRULE a las fechas de cada ocurrencia dentro de un rango (pura).
expand_rrule = _load_infra_fn("expand_rrule", "expand_rrule") 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) # Configuración Xandikos (CardDAV / CalDAV)
@@ -179,6 +188,11 @@ XANDIKOS_BASE_URL = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
XANDIKOS_USERNAME = "enmanuel" XANDIKOS_USERNAME = "enmanuel"
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel" XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/" 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 # Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1). # selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/" XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
@@ -299,6 +313,115 @@ def _write_disk_cache(path: str, ctag: str, items: list) -> None:
pass 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 # Estado del servidor: caché del vault + password Xandikos
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -637,17 +760,26 @@ class VaultState:
return list(items) return list(items)
def contacts(self) -> list: 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 Con el flag ``OSINT_DB_BACKEND`` activo, la fuente de verdad es el service
(``.cache/contacts.json``, validada por ctag para arranque instantáneo). osint_db (DuckDB): se consultan sus contactos y se devuelven con el mismo
Al primer acceso descarga TODO en UNA petición REPORT shape JSON que produce el parseo del vCard, para que el frontend no note la
(``dav_get_collection``) en vez de un GET por ``.vcf``. 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: Raises:
RuntimeError: si no se puede leer la password de ``pass``. RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde (sin red, timeout, auth). 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: with self._dav_lock:
if self._contacts_cache is not None and not self._force_reload: if self._contacts_cache is not None and not self._force_reload:
return self._contacts_cache return self._contacts_cache
@@ -662,6 +794,63 @@ class VaultState:
self._maybe_clear_force_reload() self._maybe_clear_force_reload()
return contacts 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: def _resolve_calendar(self, cal: str = "") -> str:
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario. """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 HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
nombre está vacío. 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() tipo = (data.tipo or "persona").strip()
if tipo not in _TIPO_FOLDER: if tipo not in _TIPO_FOLDER:
raise HTTPException( raise HTTPException(
@@ -1024,6 +1228,13 @@ class VaultState:
Raises: Raises:
HTTPException(404): si no existe la ficha del contacto. 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) path = self._find_contact_note(slug)
if path is None: if path is None:
raise HTTPException( raise HTTPException(
@@ -1031,13 +1242,21 @@ class VaultState:
) )
note = read_obsidian_note(path) note = read_obsidian_note(path)
current = dict(note.get("frontmatter") or {}) 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 = { merged = {
"nombre": data.nombre.strip() or current.get("nombre") or slug, "nombre": data.nombre.strip() or current.get("nombre") or slug,
"aliases": _norm_list(data.aliases), "aliases": _norm_list(data.aliases),
"telefono": _norm_str(data.telefono), "telefono": telefonos[0] if telefonos else None,
"email": _norm_str(data.email), "telefonos": telefonos,
"direccion": _norm_str(data.direccion), "email": emails[0] if emails else None,
"emails": emails,
"direccion": direcciones[0] if direcciones else None,
"direcciones": direcciones,
"pais": _norm_str(data.pais), "pais": _norm_str(data.pais),
"relaciones": _norm_list(data.relaciones), "relaciones": _norm_list(data.relaciones),
"contexto": _norm_str(data.contexto), "contexto": _norm_str(data.contexto),
@@ -1067,6 +1286,11 @@ class VaultState:
Raises: Raises:
HTTPException(404): si no existe la ficha del contacto. 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) path = self._find_contact_note(slug)
if path is None: if path is None:
raise HTTPException( raise HTTPException(
@@ -1172,6 +1396,7 @@ def _vcard_to_json(vcard_text: str) -> dict:
"note": None, "note": None,
"phones": [], "phones": [],
"emails": [], "emails": [],
"direcciones": [],
"osint": {}, "osint": {},
} }
for line in _unfold_lines(vcard_text): for line in _unfold_lines(vcard_text):
@@ -1179,6 +1404,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
if not parsed: if not parsed:
continue continue
name, params, value = parsed 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()) value = _unescape_ical(value.strip())
if name == "UID": if name == "UID":
out["uid"] = value out["uid"] = value
@@ -1205,6 +1437,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
out["fn"] = ("%s %s" % (comps[1], comps[0])).strip() out["fn"] = ("%s %s" % (comps[1], comps[0])).strip()
elif comps: elif comps:
out["fn"] = comps[0] 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). # Alias en español que consume el frontend del task (mismo dato, otra clave).
out["nombre"] = out["fn"] out["nombre"] = out["fn"]
out["alias"] = out["nickname"] out["alias"] = out["nickname"]
@@ -1214,6 +1453,29 @@ def _vcard_to_json(vcard_text: str) -> dict:
return out 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) _VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
_ICAL_DT_RE = re.compile( _ICAL_DT_RE = re.compile(
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$" 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 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 campos vacíos se normalizan a ``null``/``[]`` al escribir la ficha, nunca se
omiten, para que el score de completitud sea consistente. 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") tipo: str = Field(default="persona")
nombre: str nombre: str
aliases: list[str] = Field(default_factory=list) aliases: list[str] = Field(default_factory=list)
# Singulares (compat) — el primer elemento de cada lista multi-valor.
telefono: Optional[str] = None telefono: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
dni: Optional[str] = None
direccion: 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 pais: Optional[str] = None
contexto: Optional[str] = None contexto: Optional[str] = None
relaciones: list[str] = Field(default_factory=list) relaciones: list[str] = Field(default_factory=list)
notas: Optional[str] = None 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]: def _norm_str(value: Optional[str]) -> Optional[str]:
@@ -1543,15 +1845,23 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
nombre = data.nombre.strip() nombre = data.nombre.strip()
aliases = _norm_list(data.aliases) aliases = _norm_list(data.aliases)
relaciones = _norm_list(data.relaciones) 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": if data.tipo == "organizacion":
return { return {
"tipo": "organizacion", "tipo": "organizacion",
"nombre": nombre, "nombre": nombre,
"slug": slug, "slug": slug,
"aliases": aliases, "aliases": aliases,
"telefono": _norm_str(data.telefono), "telefono": telefonos[0] if telefonos else None,
"email": _norm_str(data.email), "telefonos": telefonos,
"direccion": _norm_str(data.direccion), "email": emails[0] if emails else None,
"emails": emails,
"direccion": direcciones[0] if direcciones else None,
"direcciones": direcciones,
"pais": _norm_str(data.pais), "pais": _norm_str(data.pais),
"relaciones": relaciones, "relaciones": relaciones,
"contexto": _norm_str(data.contexto), "contexto": _norm_str(data.contexto),
@@ -1567,9 +1877,12 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
"sexo": None, "sexo": None,
"fecha_nacimiento": None, "fecha_nacimiento": None,
"dni": _norm_str(data.dni), "dni": _norm_str(data.dni),
"telefono": _norm_str(data.telefono), "telefono": telefonos[0] if telefonos else None,
"email": _norm_str(data.email), "telefonos": telefonos,
"direccion": _norm_str(data.direccion), "email": emails[0] if emails else None,
"emails": emails,
"direccion": direcciones[0] if direcciones else None,
"direcciones": direcciones,
"pais": _norm_str(data.pais), "pais": _norm_str(data.pais),
"relaciones": relaciones, "relaciones": relaciones,
"contexto": _norm_str(data.contexto), "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: def _build_vcard(frontmatter: dict, slug: str) -> str:
"""Serializa un frontmatter de contacto a un VCARD 3.0 con el UID = slug. """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, Soporta multi-valor: emite una línea ``TEL`` por teléfono, una ``EMAIL`` por
organización→ORG; y los campos OSINT (dni, direccion, pais, contexto, sexo, 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`` 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 ya entiende. El UID es el slug → idempotente: re-subir el mismo slug
sobrescribe el recurso ``<slug>.vcf``. 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))) lines.append("NICKNAME:%s" % _vcard_escape(",".join(str(a) for a in aliases)))
if frontmatter.get("tipo") == "organizacion": if frontmatter.get("tipo") == "organizacion":
lines.append("ORG:%s" % _vcard_escape(nombre)) lines.append("ORG:%s" % _vcard_escape(nombre))
tel = frontmatter.get("telefono") # Multi-valor: una línea TEL/EMAIL por elemento.
if tel: for tel in _vcard_value_list(frontmatter, "telefonos", "telefono"):
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel))) lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(tel))
email = frontmatter.get("email") for email in _vcard_value_list(frontmatter, "emails", "email"):
if email: lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(email))
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(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). # Campos OSINT → X-OSINT-* (los recoge _vcard_to_json en el bloque osint).
for fm_key, x_name in ( for fm_key, x_name in (
("dni", "X-OSINT-DNI"), ("dni", "X-OSINT-DNI"),
("direccion", "X-OSINT-DIRECCION"),
("pais", "X-OSINT-PAIS"), ("pais", "X-OSINT-PAIS"),
("contexto", "X-OSINT-CONTEXTO"), ("contexto", "X-OSINT-CONTEXTO"),
("sexo", "X-OSINT-SEXO"), ("sexo", "X-OSINT-SEXO"),
@@ -1702,6 +2035,19 @@ class CalendarIn(BaseModel):
description: Optional[str] = None 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]: def _parse_iso_input(value: str) -> Optional[dict]:
"""Parsea una fecha de entrada ISO a ``{year,month,day,hour,minute,second, """Parsea una fecha de entrada ISO a ``{year,month,day,hour,minute,second,
offset,date_only}`` o ``None``. 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[], Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``). osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
La lista se cachea en memoria al primer acceso (``POST /api/refresh`` la 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 invalida). Si Xandikos / el osint_db no responde o falta la password →
de error claro, nunca un crash. 503 con un JSON de error claro, nunca un crash.
""" """
try: try:
contacts = state.contacts() contacts = state.contacts()
except (RuntimeError, DavUnavailable) as exc: except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
return JSONResponse( return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)} status_code=503, content={"status": "error", "error": str(exc)}
) )
@@ -1993,7 +2339,7 @@ def create_app(vault_dir: str) -> FastAPI:
""" """
try: try:
contacts = state.contacts() contacts = state.contacts()
except (RuntimeError, DavUnavailable) as exc: except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
return JSONResponse( return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)} 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); 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 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}``. 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}) return JSONResponse(status_code=201, content={"status": "ok", **result})
@app.put("/api/contact/{slug}") @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. """Edita la ficha de un contacto (merge frontmatter) + re-sube el vCard.
404 si no existe la ficha. Preserva campos heredados no editables 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}) return JSONResponse(content={"status": "ok", **result})
@app.delete("/api/contact/{slug}") @app.delete("/api/contact/{slug}")
def api_delete_contact(slug: str) -> JSONResponse: def api_delete_contact(slug: str) -> JSONResponse:
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos. """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}) 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) -- # -- Xandikos: calendario (CalDAV) --
@app.get("/api/calendars") @app.get("/api/calendars")
+240
View File
@@ -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)
+206
View File
@@ -609,6 +609,212 @@ def test_crud_update_preserves_inherited_fields(crud_client, vault):
crud_client.delete("/api/contact/%s" % slug) 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 # Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------