merge: contactos multi-valor + libretas + backend osint_db (flag OSINT_DB_BACKEND)
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[];
|
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) =>
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user