Compare commits

..

11 Commits

Author SHA1 Message Date
egutierrez 8cacc7dacf fix(calendar): vista lista sin recurrentes repetidos + secciones próximos/pasados
La vista de lista pedía el rango 2000-2100, así que el backend expandía cada
cumpleaños anual ~75 veces (hasta 2099). Ahora: (1) el rango llega solo a +13
meses, suficiente para la próxima ocurrencia de cada serie; (2) AgendaView
deduplica las series recurrentes a una sola entrada (su próxima ocurrencia); y
(3) separa la agenda en 'Próximos' (>= hoy, ascendente) arriba y 'Pasados'
(< hoy, descendente) debajo, estos atenuados (opacity 0.5) en gris claro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 20:46:09 +02:00
egutierrez f5d15a9f7b fix(security): CR-injection vCard/iCal, guard anti-CSRF y permisos 0600 de la cache PII
- _vcard_escape elimina el retorno de carro crudo: cubre tanto el vCard como
  SUMMARY/LOCATION del VEVENT (que reusan este escape), cerrando la inyeccion de
  propiedades iCal/vCard via un \r sin \n.
- Middleware que rechaza las peticiones mutantes marcadas cross-site por el
  navegador (Sec-Fetch-Site), cerrando el CSRF residual de los POST simples sin
  preflight (p.ej. /api/refresh) que el TrustedHost no filtra.
- La cache DAV en disco (.cache/*.json, contiene PII) se crea con permisos 0600
  via O_CREAT 0600 + os.chmod, sin depender del umask del proceso.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:19:55 +02:00
egutierrez 6b7fa621d6 merge: hardening seguridad osint_web (TrustedHost + escape iCal + tests del flag) 2026-06-13 01:22:36 +02:00
egutierrez 3716b3f22a fix(security): TrustedHostMiddleware + escape UID/RRULE iCal + tests deterministas del flag
- TrustedHostMiddleware (127.0.0.1/localhost/testserver): anti DNS-rebinding, cierra el
  vector por el que una web maliciosa alcanza el service local desde el navegador.
- _build_vcalendar: sanitiza UID y RRULE (quita saltos de línea) para evitar iCal injection
  (summary/location/description ya escapaban con _vcard_escape).
- tests: fixture autouse que fuerza OSINT_DB_BACKEND OFF por defecto, así la suite es
  determinista sin depender del estado real de dev/feature_flags.json (los tests del camino
  ON sobrescriben _FLAGS_FILE). Corrige 14 fallos que aparecían con el flag activado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:22:36 +02:00
egutierrez 83c672c072 merge: lista completa de eventos + eventos en Tablas 2026-06-13 01:12:33 +02:00
egutierrez ef23c8aee1 feat(calendar,tables): vista Lista con todos los eventos + tabla de Eventos en Tablas
- CalendarView vista Lista: rango ampliado a todos los eventos (pasados y futuros),
  agrupados por dia con cabecera DD/MM/AAAA.
- TablesView: pestana Eventos nueva (fecha, hora, titulo, calendario, ubicacion,
  indicador de recurrencia), mismo patron de tabla ordenable/filtrable que el resto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:12:33 +02:00
egutierrez fb3956e8eb chore: activar OSINT_DB_BACKEND — osint_web usa DuckDB como fuente de verdad
Verificado end-to-end: lectura de contactos (1065) y libretas desde osint_db; crear un
contacto con 2 telefonos escribe DuckDB + empuja a Xandikos; borrarlo limpia ambos (DB + 404
en Xandikos). El multi-valor real se sirve desde DuckDB (p.ej. un contacto con dos TEL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:50:11 +02:00
egutierrez 9a256be2bb merge: contactos multi-valor + libretas + backend osint_db (flag OSINT_DB_BACKEND) 2026-06-13 00:47:38 +02:00
egutierrez 9cbea2d036 feat(contacts): multi-valor (varios tel/email/direccion) + libretas + backend osint_db (flag)
- ContactIn + frontmatter + vCard multi-valor: emite N TEL, N EMAIL, N ADR; _vcard_to_json
  parsea ADR -> direcciones[] (y sigue leyendo X-OSINT-DIRECCION legacy). Los singulares
  telefono/email/direccion se mantienen por compat (= primer elemento de cada lista).
- Libretas de contactos (addressbooks): endpoints GET/POST /api/addressbooks; en
  ContactsView un selector de libreta + boton 'Nueva libreta' (replica del patron de crear
  calendario) + filtro por libreta en la lista.
- Frontend ContactsView: TagsInput para telefonos/emails/direcciones, cargando TODOS los
  valores al editar (antes solo el primero).
- Feature flag OSINT_DB_BACKEND (dev/feature_flags.json, default off): con ON, osint_web
  lee/escribe contra el service osint_db (DuckDB = fuente de verdad) via
  server/osintdb_client.py; con OFF, el comportamiento historico (vault .md + vCard
  Xandikos) queda intacto byte a byte.

Verificado: 52 tests backend (40 + 12 nuevos), tsc --noEmit limpio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:47:38 +02:00
egutierrez 71e4d95e64 merge: calendario con recurrencia, multi-agenda, vista lista y linea de ahora 2026-06-12 23:30:14 +02:00
egutierrez 5d5ce65e88 feat(calendar): recurrencia (RRULE), multi-agenda, vista lista y linea de ahora
Backend (server/main.py):
- EventIn.rrule + emision/parseo de RRULE en el VCALENDAR.
- calendar() expande las series recurrentes a sus ocurrencias dentro de [from,to]
  (compone expand_rrule del registry); helpers _expand_event_occurrences /
  _occurrence_clone preservan hora local, offset y duracion por ocurrencia.
- POST /api/calendars: crea una coleccion de calendario nueva (compone
  dav_make_calendar); invalida la cache de colecciones.

Frontend:
- EventModal: controles de repeticion (frecuencia, intervalo, BYDAY para semanal,
  fin por N veces / hasta fecha); parseRrule/buildRrule; aviso 'afecta a la serie'.
- CalendarView: vista Lista/Agenda (eventos por dia, click para editar, nuevo
  evento), linea roja de hora actual (refresco cada 60s, solo columna de hoy),
  boton Nuevo calendario (modal nombre/color), indicador de recurrencia (IconRepeat).
- api.ts/calendar.ts: rrule/recurring/occurrence en los tipos, createCalendar,
  helpers nowLinePct/slugifyCalendar.

Verificado: tsc -b + vite build limpios; smoke backend (FREQ=WEEKLY;COUNT=3 -> 3
ocurrencias con hora/offset/duracion correctas); render en navegador (vista Lista,
Nuevo calendario, Nuevo evento, selectores presentes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:30:14 +02:00
10 changed files with 2489 additions and 136 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"flags": {
"OSINT_DB_BACKEND": {
"enabled": true,
"description": "osint_web lee/escribe contra osint_db (DuckDB) en vez de vault+Xandikos",
"added": "2026-06-13",
"enabled_at": "2026-06-13"
}
}
}
+82 -3
View File
@@ -108,7 +108,12 @@ export interface Contact {
emails: ContactPhone[];
telefonos: string[];
correos: string[];
// Direcciones multi-valor (varias ADR del vCard, o X-OSINT-DIRECCION legacy).
direcciones?: string[];
osint: Record<string, string>;
// Libreta (addressbook) a la que pertenece el contacto, cuando el backend la
// expone (camino osint_db). Sirve para filtrar la lista por libreta.
collection?: string | null;
href?: string;
etag?: string;
}
@@ -132,6 +137,12 @@ export interface CalendarEvent {
location: string | null;
description: string | null;
color?: string | null;
// Regla RRULE cruda del evento maestro (sin prefijo "RRULE:"), p.ej.
// "FREQ=WEEKLY;INTERVAL=1;COUNT=10". `recurring` true si el evento repite;
// `occurrence` true si esta entrada es una ocurrencia expandida de una serie.
rrule?: string | null;
recurring?: boolean;
occurrence?: boolean;
href?: string;
etag?: string;
}
@@ -157,6 +168,21 @@ export interface CalendarsPayload {
error?: string;
}
// Cuerpo de POST /calendars para crear una colección de calendario nueva.
export interface CalendarInput {
slug: string;
name: string;
color?: string | null;
description?: string | null;
}
export interface CalendarWriteResult {
status: string;
href?: string;
existed?: boolean;
error?: string;
}
// Cuerpo de POST/PUT de un evento del calendario (VEVENT).
export interface EventInput {
cal?: string | null;
@@ -168,6 +194,9 @@ export interface EventInput {
location?: string | null;
description?: string | null;
color?: string | null;
// Cuerpo RRULE SIN el prefijo "RRULE:" (o null si no repite). Editar un evento
// recurrente reescribe toda la serie.
rrule?: string | null;
}
export interface EventWriteResult {
@@ -199,14 +228,24 @@ export interface ContactInput {
tipo: "persona" | "organizacion";
nombre: string;
aliases: string[];
telefono: string | null;
email: string | null;
// Multi-valor: listas completas de teléfonos, emails y direcciones. El backend
// reconcilia con los campos singulares (compat) — el primer elemento de cada
// lista se conserva en telefono/email/direccion para los lectores viejos.
telefonos: string[];
emails: string[];
direcciones: string[];
// Singulares (compat). Opcionales: el frontend nuevo envía solo las listas; el
// backend rellena el singular con lista[0].
telefono?: string | null;
email?: string | null;
direccion?: string | null;
dni: string | null;
direccion: string | null;
pais: string | null;
contexto: string | null;
relaciones: string[];
notas: string | null;
// Libreta (addressbook) destino. Solo se honra con el flag OSINT_DB_BACKEND ON.
collection?: string | null;
}
export interface ContactWriteResult {
@@ -256,8 +295,48 @@ export const deleteContact = (slug: string) =>
"DELETE",
);
// --- Libretas (addressbooks) de contactos ---------------------------------
export interface Addressbook {
slug: string;
display_name: string;
collection_path: string;
color: string | null;
}
export interface AddressbooksPayload {
status: string;
count?: number;
addressbooks?: Addressbook[];
default?: string;
error?: string;
}
// Cuerpo de POST /addressbooks para crear una libreta nueva.
export interface AddressbookInput {
slug: string;
name: string;
color?: string | null;
}
export interface AddressbookWriteResult {
status: string;
slug?: string;
existed?: boolean;
error?: string;
}
export const fetchAddressbooks = () =>
getJSON<AddressbooksPayload>("/addressbooks");
export const createAddressbook = (data: AddressbookInput) =>
sendJSON<AddressbookWriteResult>("/addressbooks", "POST", data);
export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
export const createCalendar = (data: CalendarInput) =>
sendJSON<CalendarWriteResult>("/calendars", "POST", data);
export const fetchCalendar = (cal = "", from = "", to = "") => {
const qs = new URLSearchParams();
if (cal) qs.set("cal", cal);
+123
View File
@@ -139,6 +139,129 @@ export function monthMatrix(date: dayjs.Dayjs): dayjs.Dayjs[][] {
export const HOURS = Array.from({ length: 24 }, (_, h) => h);
export const WEEKDAY_LABELS = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
// --- Recurrencia (RRULE) ---------------------------------------------------
export type RruleFreq = "none" | "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
export type RruleEndMode = "never" | "count" | "until";
// Días de la semana en orden L-M-X-J-V-S-D con su código BYDAY de iCalendar.
export const BYDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
export const BYDAY_LABELS = ["L", "M", "X", "J", "V", "S", "D"];
export interface RruleParts {
freq: RruleFreq;
interval: number;
endMode: RruleEndMode;
count: number;
until: string; // input date "YYYY-MM-DD" (vacío si no aplica)
byday: string[]; // códigos BYDAY ("MO", "TU", ...)
}
/**
* Construye el cuerpo RRULE (sin prefijo "RRULE:") a partir de los controles
* del modal. Devuelve "" si freq==="none". El `until` ("YYYY-MM-DD") se serializa
* como "UNTIL=YYYYMMDD" (sin guiones). Si endMode no es count/until, no añade
* cláusula de fin (recurrencia infinita).
*/
export function buildRrule(
freq: RruleFreq,
interval: number,
endMode: RruleEndMode,
count: number,
until: string,
byday?: string[],
): string {
if (freq === "none") return "";
const parts: string[] = [`FREQ=${freq}`];
const iv = Math.max(1, Math.floor(interval || 1));
parts.push(`INTERVAL=${iv}`);
if (freq === "WEEKLY" && byday && byday.length > 0) {
parts.push(`BYDAY=${byday.join(",")}`);
}
if (endMode === "count") {
parts.push(`COUNT=${Math.max(1, Math.floor(count || 1))}`);
} else if (endMode === "until" && until) {
parts.push(`UNTIL=${until.replace(/-/g, "")}`);
}
return parts.join(";");
}
/**
* Parsea un cuerpo RRULE crudo a los controles del modal. Tolerante: cadena
* vacía/indefinida → freq "none". Acepta un prefijo "RRULE:" residual por si el
* backend lo deja. UNTIL admite "YYYYMMDD" o "YYYYMMDDTHHMMSSZ".
*/
export function parseRrule(rrule: string | null | undefined): RruleParts {
const base: RruleParts = {
freq: "none",
interval: 1,
endMode: "never",
count: 10,
until: "",
byday: [],
};
if (!rrule) return base;
const body = rrule.replace(/^RRULE:/i, "").trim();
if (!body) return base;
const map = new Map<string, string>();
for (const seg of body.split(";")) {
const [k, v] = seg.split("=");
if (k && v !== undefined) map.set(k.toUpperCase(), v);
}
const freq = (map.get("FREQ") || "").toUpperCase();
if (
freq === "DAILY" ||
freq === "WEEKLY" ||
freq === "MONTHLY" ||
freq === "YEARLY"
) {
base.freq = freq as RruleFreq;
}
const iv = parseInt(map.get("INTERVAL") || "1", 10);
if (!Number.isNaN(iv) && iv > 0) base.interval = iv;
if (map.has("COUNT")) {
const c = parseInt(map.get("COUNT") || "", 10);
if (!Number.isNaN(c) && c > 0) {
base.endMode = "count";
base.count = c;
}
} else if (map.has("UNTIL")) {
const raw = map.get("UNTIL") || "";
const ymd = raw.slice(0, 8);
if (/^\d{8}$/.test(ymd)) {
base.endMode = "until";
base.until = `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
}
}
if (map.has("BYDAY")) {
base.byday = (map.get("BYDAY") || "")
.split(",")
.map((d) => d.trim().toUpperCase())
.filter((d) => BYDAY_CODES.includes(d));
}
return base;
}
/** Posición vertical (0..100) de la línea "ahora" dentro de la rejilla 024h. */
export function nowLinePct(now: dayjs.Dayjs): number {
return ((now.hour() * 60 + now.minute()) / (24 * 60)) * 100;
}
/**
* Deriva un slug de calendario de un nombre: minúsculas, espacios y caracteres
* fuera de [a-z0-9_-] → "-", colapsa guiones y recorta los de los extremos.
*/
export function slugifyCalendar(name: string): string {
return name
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // quita acentos (combining marks)
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/** Color efectivo de un evento: el propio del VEVENT o el del calendario. */
export function eventColor(
ev: CalendarEvent,
+460 -39
View File
@@ -6,16 +6,19 @@ import {
Box,
Button,
Center,
ColorInput,
ColorSwatch,
Group,
Indicator,
Loader,
Modal,
Paper,
ScrollArea,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
@@ -25,8 +28,10 @@ import {
IconChevronLeft,
IconChevronRight,
IconPlus,
IconRepeat,
} from "@tabler/icons-react";
import {
createCalendar,
deleteEvent,
fetchCalendar,
fetchCalendars,
@@ -36,10 +41,13 @@ import {
import {
dayjs,
eventColor,
eventEnd,
eventStart,
HOURS,
monthMatrix,
nowLinePct,
positionEventsForDay,
slugifyCalendar,
TIMEZONES,
WEEKDAY_LABELS,
weekDays,
@@ -47,7 +55,7 @@ import {
} from "../calendar";
import { EventModal, type EventDraft } from "./EventModal";
type ViewMode = "mes" | "semana" | "dia";
type ViewMode = "mes" | "semana" | "dia" | "lista";
// Calendario: vista Mes/Semana/Día con eventos posicionados por hora, selector
// de calendario (con color), selector de zona horaria, colores por evento y CRUD
@@ -66,34 +74,93 @@ export function CalendarView() {
const [cursor, setCursor] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [modal, setModal] = useState<EventDraft | null>(null);
const [reloadKey, setReloadKey] = useState(0);
// Instante "ahora" en la TZ del visor, refrescado cada minuto para la línea roja.
const [now, setNow] = useState(() => dayjs().tz(tz));
// Modal "nuevo calendario".
const [newCalOpen, setNewCalOpen] = useState(false);
const [newCalName, setNewCalName] = useState("");
const [newCalColor, setNewCalColor] = useState("");
const [newCalErr, setNewCalErr] = useState<string | null>(null);
const [newCalSaving, setNewCalSaving] = useState(false);
// Carga de calendarios (selector) una vez.
// Rango visible [from, to] (YYYY-MM-DD) según vista+cursor. El backend expande
// las series recurrentes dentro de este rango (una entrada por ocurrencia).
const range = useMemo(() => {
const c = dayjs(cursor);
if (view === "mes") {
const weeks = monthMatrix(c);
return {
from: weeks[0][0].format("YYYY-MM-DD"),
to: weeks[weeks.length - 1][6].add(1, "day").format("YYYY-MM-DD"),
};
}
if (view === "semana") {
const wd = weekDays(c);
return {
from: wd[0].format("YYYY-MM-DD"),
to: wd[6].add(1, "day").format("YYYY-MM-DD"),
};
}
if (view === "dia") {
return {
from: c.format("YYYY-MM-DD"),
to: c.add(1, "day").format("YYYY-MM-DD"),
};
}
// lista: histórico completo de eventos puntuales (desde 2000) + las series
// recurrentes expandidas solo hasta ~13 meses vista. Ese tope evita que un
// cumpleaños anual se expanda hasta 2099; AgendaView deduplica además cada
// serie a una sola entrada (su próxima ocurrencia).
return {
from: "2000-01-01",
to: dayjs().add(13, "month").format("YYYY-MM-DD"),
};
}, [cursor, view]);
// Refresca el instante "ahora" cada minuto (línea roja en semana/día).
useEffect(() => {
let alive = true;
fetchCalendars()
setNow(dayjs().tz(tz));
const id = window.setInterval(() => setNow(dayjs().tz(tz)), 60000);
return () => window.clearInterval(id);
}, [tz]);
// Carga de calendarios (selector). `selectHref` permite seleccionar uno recién
// creado por su href cuando la lista se refresca tras crearlo.
const loadCalendars = useCallback((selectHref?: string) => {
return fetchCalendars()
.then((d) => {
if (!alive) return;
if (d.status === "ok" && d.calendars) {
setCalendars(d.calendars);
if (!activeCal && d.default) setActiveCal(d.default);
else if (!activeCal && d.calendars[0]) setActiveCal(d.calendars[0].href);
if (selectHref) {
setActiveCal(selectHref);
} else {
setActiveCal((cur) => {
if (cur) return cur;
if (d.default) return d.default;
if (d.calendars && d.calendars[0]) return d.calendars[0].href;
return cur;
});
}
}
return d;
})
.catch(() => {
/* el selector degrada a "calendario" implícito; no es fatal */
return null;
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Carga de eventos de la colección activa.
useEffect(() => {
loadCalendars();
}, [loadCalendars]);
// Carga de eventos de la colección activa, dentro del rango visible. Pasar
// from/to hace que el backend expanda las series recurrentes por ocurrencia.
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
fetchCalendar(activeCal)
fetchCalendar(activeCal, range.from, range.to)
.then((d) => {
if (!alive) return;
if (d.status !== "ok") {
@@ -107,7 +174,7 @@ export function CalendarView() {
return () => {
alive = false;
};
}, [activeCal, reloadKey]);
}, [activeCal, reloadKey, range.from, range.to]);
const calColor = useMemo(() => {
const c = calendars.find((c) => c.href === activeCal);
@@ -180,6 +247,7 @@ export function CalendarView() {
description: "",
color: "",
cal: activeCal,
rrule: "",
});
},
[tz, activeCal],
@@ -206,11 +274,53 @@ export function CalendarView() {
description: ev.description ?? "",
color: ev.color ?? "",
cal: activeCal,
rrule: ev.rrule ?? "",
});
},
[tz, activeCal],
);
// Crea un calendario nuevo derivando el slug del nombre, refresca la lista y
// selecciona el nuevo por su href.
const createNewCalendar = useCallback(async () => {
const name = newCalName.trim();
if (!name) {
setNewCalErr("El nombre es obligatorio.");
return;
}
const slug = slugifyCalendar(name);
if (!slug) {
setNewCalErr("El nombre no produce un identificador válido.");
return;
}
setNewCalSaving(true);
setNewCalErr(null);
try {
const res = await createCalendar({
slug,
name,
color: newCalColor || null,
});
if (res.status !== "ok") {
setNewCalErr(res.error || "No se pudo crear el calendario.");
return;
}
await loadCalendars(res.href);
notifications.show({
color: "teal",
title: "Calendario creado",
message: name,
});
setNewCalOpen(false);
setNewCalName("");
setNewCalColor("");
} catch (e) {
setNewCalErr(String(e));
} finally {
setNewCalSaving(false);
}
}, [newCalName, newCalColor, loadCalendars]);
function navigate(dir: -1 | 1) {
const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day";
setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD"));
@@ -233,12 +343,14 @@ export function CalendarView() {
const headerTitle =
view === "mes"
? dayjs(cursor).format("MMMM YYYY")
: view === "semana"
? (() => {
const wd = weekDays(dayjs(cursor));
return `${wd[0].format("D MMM")} ${wd[6].format("D MMM YYYY")}`;
})()
: dayjs(cursor).format("dddd D [de] MMMM YYYY");
: view === "lista"
? "Agenda"
: view === "semana"
? (() => {
const wd = weekDays(dayjs(cursor));
return `${wd[0].format("D MMM")} ${wd[6].format("D MMM YYYY")}`;
})()
: dayjs(cursor).format("dddd D [de] MMMM YYYY");
return (
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
@@ -279,20 +391,38 @@ export function CalendarView() {
}}
/>
<Select
label="Calendario"
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
value={activeCal || null}
onChange={(v) => v && setActiveCal(v)}
leftSection={
<ColorSwatch
size={14}
color={eventColor({} as CalendarEvent, calColor)}
/>
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
<Group align="flex-end" gap="xs" wrap="nowrap">
<Select
label="Calendario"
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
value={activeCal || null}
onChange={(v) => v && setActiveCal(v)}
leftSection={
<ColorSwatch
size={14}
color={eventColor({} as CalendarEvent, calColor)}
/>
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
style={{ flex: 1, minWidth: 0 }}
/>
<Tooltip label="Nuevo calendario">
<ActionIcon
variant="default"
size="lg"
aria-label="Nuevo calendario"
onClick={() => {
setNewCalErr(null);
setNewCalName("");
setNewCalColor("");
setNewCalOpen(true);
}}
>
<IconPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Select
label="Zona horaria"
@@ -341,6 +471,7 @@ export function CalendarView() {
{ value: "mes", label: "Mes" },
{ value: "semana", label: "Semana" },
{ value: "dia", label: "Día" },
{ value: "lista", label: "Lista" },
]}
/>
</Group>
@@ -362,12 +493,21 @@ export function CalendarView() {
}}
onEvent={openEdit}
/>
) : view === "lista" ? (
<AgendaView
events={events}
tz={tz}
calColor={calColor}
onNew={() => openNew(dayjs().format("YYYY-MM-DD"))}
onEvent={openEdit}
/>
) : view === "semana" ? (
<TimeGrid
days={weekDays(dayjs(cursor))}
events={events}
tz={tz}
calColor={calColor}
now={now}
onSlot={openNew}
onEvent={openEdit}
/>
@@ -377,6 +517,7 @@ export function CalendarView() {
events={events}
tz={tz}
calColor={calColor}
now={now}
onSlot={openNew}
onEvent={openEdit}
/>
@@ -391,6 +532,55 @@ export function CalendarView() {
onSaved={onSaved}
onDelete={onDelete}
/>
<Modal
opened={newCalOpen}
onClose={() => setNewCalOpen(false)}
title="Nuevo calendario"
size="sm"
centered
>
<Stack gap="sm">
<TextInput
label="Nombre"
placeholder="Trabajo, Personal, OSINT…"
value={newCalName}
onChange={(e) => setNewCalName(e.currentTarget.value)}
data-autofocus
required
/>
<ColorInput
label="Color"
placeholder="Color del calendario (opcional)"
value={newCalColor}
onChange={setNewCalColor}
format="hex"
swatches={[
"#23bdfe",
"#16a34a",
"#dc2626",
"#f59e0b",
"#8b5cf6",
"#ec4899",
"#0891b2",
"#64748b",
]}
/>
{newCalErr && (
<Text c="red" size="sm">
{newCalErr}
</Text>
)}
<Group justify="flex-end" gap="xs" mt="xs">
<Button variant="default" onClick={() => setNewCalOpen(false)}>
Cancelar
</Button>
<Button onClick={createNewCalendar} loading={newCalSaving}>
Crear
</Button>
</Group>
</Stack>
</Modal>
</Group>
);
}
@@ -402,6 +592,7 @@ function TimeGrid({
events,
tz,
calColor,
now,
onSlot,
onEvent,
}: {
@@ -409,11 +600,14 @@ function TimeGrid({
events: CalendarEvent[];
tz: string;
calColor: string | null;
now: dayjs.Dayjs;
onSlot: (day: string, hour: number) => void;
onEvent: (ev: CalendarEvent) => void;
}) {
const HOUR_PX = 44;
const today = dayjs().format("YYYY-MM-DD");
// "Hoy" según la TZ del visor (no la del navegador) para alinear con la línea.
const today = now.format("YYYY-MM-DD");
const nowPct = nowLinePct(now);
return (
<ScrollArea h="100%" type="auto">
<Box style={{ display: "flex", minWidth: days.length > 1 ? 640 : 0 }}>
@@ -523,12 +717,45 @@ function TimeGrid({
cursor: "pointer",
}}
>
<Text size="xs" fw={600} truncate c="#fff">
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
{p.ev.summary || "(sin título)"}
</Text>
<Group gap={3} wrap="nowrap" align="center">
{p.ev.recurring && (
<IconRepeat size={11} style={{ flexShrink: 0 }} />
)}
<Text size="xs" fw={600} truncate c="#fff">
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
{p.ev.summary || "(sin título)"}
</Text>
</Group>
</Box>
))}
{/* Línea roja "ahora" — solo en la columna del día de hoy */}
{isToday && (
<Box
style={{
position: "absolute",
top: `${nowPct}%`,
left: 0,
right: 0,
height: 0,
borderTop: "2px solid var(--mantine-color-red-6)",
zIndex: 5,
pointerEvents: "none",
}}
>
<Box
style={{
position: "absolute",
left: -3,
top: -4,
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--mantine-color-red-6)",
}}
/>
</Box>
)}
</Box>
</Box>
);
@@ -679,6 +906,9 @@ function EventChip({
variant="filled"
radius="sm"
fullWidth
leftSection={
ev.recurring ? <IconRepeat size={10} style={{ display: "block" }} /> : undefined
}
style={{
background: color,
cursor: "pointer",
@@ -692,3 +922,194 @@ function EventChip({
</Badge>
);
}
// --- Vista Lista / Agenda: eventos del rango, agrupados por día ------------
function AgendaView({
events,
tz,
calColor,
onNew,
onEvent,
}: {
events: CalendarEvent[];
tz: string;
calColor: string | null;
onNew: () => void;
onEvent: (ev: CalendarEvent) => void;
}) {
const startOfToday = useMemo(() => dayjs().tz(tz).startOf("day"), [tz]);
const todayKey = startOfToday.format("YYYY-MM-DD");
// 1) Deduplica las series recurrentes: una sola entrada por uid (su próxima
// ocurrencia desde hoy; si todas son pasadas, la más reciente). Así un
// cumpleaños anual aparece una vez, no una por año. Los puntuales se dejan.
const deduped = useMemo(() => {
const recurring = new Map<string, CalendarEvent[]>();
const singles: CalendarEvent[] = [];
for (const ev of events) {
if (ev.recurring && ev.uid) {
const l = recurring.get(ev.uid) ?? [];
l.push(ev);
recurring.set(ev.uid, l);
} else {
singles.push(ev);
}
}
const out = [...singles];
for (const occ of recurring.values()) {
const sorted = occ
.slice()
.sort(
(a, b) =>
(eventStart(a, tz)?.valueOf() ?? 0) -
(eventStart(b, tz)?.valueOf() ?? 0),
);
const next = sorted.find((e) => {
const s = eventStart(e, tz);
return s && !s.isBefore(startOfToday);
});
out.push(next ?? sorted[sorted.length - 1]);
}
return out;
}, [events, tz, startOfToday]);
// 2) Separa en próximos (>= hoy, ascendente) y pasados (< hoy, descendente),
// cada uno agrupado por día.
const groupByDay = (list: CalendarEvent[]) => {
const map = new Map<string, CalendarEvent[]>();
for (const ev of list) {
const s = eventStart(ev, tz);
if (!s) continue;
const k = s.format("YYYY-MM-DD");
const arr = map.get(k) ?? [];
arr.push(ev);
map.set(k, arr);
}
return Array.from(map.entries());
};
const { upcomingGroups, pastGroups } = useMemo(() => {
const upcoming: CalendarEvent[] = [];
const past: CalendarEvent[] = [];
for (const ev of deduped) {
const s = eventStart(ev, tz);
if (s && s.isBefore(startOfToday)) past.push(ev);
else upcoming.push(ev);
}
upcoming.sort(
(a, b) =>
(eventStart(a, tz)?.valueOf() ?? Infinity) -
(eventStart(b, tz)?.valueOf() ?? Infinity),
);
past.sort(
(a, b) =>
(eventStart(b, tz)?.valueOf() ?? 0) -
(eventStart(a, tz)?.valueOf() ?? 0),
);
return { upcomingGroups: groupByDay(upcoming), pastGroups: groupByDay(past) };
}, [deduped, tz, startOfToday]);
const renderGroup = ([dayKey, list]: [string, CalendarEvent[]]) => {
const d = dayjs(dayKey);
const isToday = dayKey === todayKey;
return (
<Box key={dayKey}>
<Text
size="sm"
fw={600}
c={isToday ? "brand" : undefined}
mb={6}
style={{ textTransform: "capitalize" }}
>
{d.format("dddd")} · {d.format("DD/MM/YYYY")}
</Text>
<Stack gap={4}>
{list.map((ev, i) => {
const s = eventStart(ev, tz);
const e = eventEnd(ev, tz);
const timeLabel = ev.all_day
? "Todo el día"
: `${s?.format("HH:mm") ?? "--:--"}${
e ? ` ${e.format("HH:mm")}` : ""
}`;
return (
<Group
key={(ev.uid ?? "") + i}
gap="sm"
wrap="nowrap"
onClick={() => onEvent(ev)}
style={{
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
borderLeft: `3px solid ${eventColor(ev, calColor)}`,
background: "var(--mantine-color-default-hover)",
}}
>
<Text size="xs" c="dimmed" style={{ width: 96, flexShrink: 0 }}>
{timeLabel}
</Text>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{ev.recurring && (
<IconRepeat size={13} style={{ flexShrink: 0 }} />
)}
<Text size="sm" truncate>
{ev.summary || "(sin título)"}
</Text>
</Group>
</Group>
);
})}
</Stack>
</Box>
);
};
const isEmpty = upcomingGroups.length === 0 && pastGroups.length === 0;
return (
<ScrollArea h="100%" type="auto">
<Box p="md">
<Group justify="space-between" mb="sm" wrap="nowrap">
<Text fw={600}>Agenda</Text>
<Button size="xs" leftSection={<IconPlus size={14} />} onClick={onNew}>
Nuevo evento
</Button>
</Group>
{isEmpty ? (
<Text c="dimmed" size="sm">
No hay eventos en este calendario.
</Text>
) : (
<Stack gap="xl">
{/* Próximos */}
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb="sm">
Próximos
</Text>
{upcomingGroups.length === 0 ? (
<Text c="dimmed" size="sm">
No hay eventos próximos.
</Text>
) : (
<Stack gap="lg">{upcomingGroups.map(renderGroup)}</Stack>
)}
</Box>
{/* Pasados — atenuados en gris claro */}
{pastGroups.length > 0 && (
<Box style={{ opacity: 0.5 }}>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb="sm">
Pasados
</Text>
<Stack gap="lg">{pastGroups.map(renderGroup)}</Stack>
</Box>
)}
</Stack>
)}
</Box>
</ScrollArea>
);
}
+295 -52
View File
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
ColorInput,
Divider,
Group,
Loader,
@@ -19,12 +21,15 @@ import {
Textarea,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconAddressBook,
IconAt,
IconEdit,
IconMapPin,
IconNote,
IconPhone,
IconPlus,
@@ -33,19 +38,23 @@ import {
IconUser,
} from "@tabler/icons-react";
import {
createAddressbook,
createContact,
deleteContact,
fetchAddressbooks,
fetchContacts,
updateContact,
type Addressbook,
type Contact,
type ContactInput,
} from "../api";
import { slugify } from "../format";
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la
// vista de lectura y añade los controles de edición: alta ("Nuevo contacto"),
// edición y borrado de la ficha del vault (con reflejo inmediato en Xandikos).
// buscador, selector de libreta y filtro por libreta) y la ficha del contacto
// seleccionado a la derecha. Soporta contactos multi-valor (varios teléfonos,
// emails y direcciones) y la creación de libretas nuevas (análogo al patrón de
// "Nuevo calendario" en CalendarView).
type FormTipo = "persona" | "organizacion";
@@ -53,61 +62,73 @@ interface FormState {
tipo: FormTipo;
nombre: string;
aliases: string[];
telefono: string;
email: string;
// Multi-valor: listas completas (antes eran campos singulares).
telefonos: string[];
emails: string[];
direcciones: string[];
dni: string;
direccion: string;
pais: string;
contexto: string;
notas: string;
// Libreta destino (slug). "" → libreta por defecto.
collection: string;
}
const EMPTY_FORM: FormState = {
tipo: "persona",
nombre: "",
aliases: [],
telefono: "",
email: "",
telefonos: [],
emails: [],
direcciones: [],
dni: "",
direccion: "",
pais: "",
contexto: "",
notas: "",
collection: "",
};
// Construye el estado del formulario a partir de un contacto existente (para
// editar). Toma los campos del bloque osint (dni/direccion/pais/contexto) que el
// backend expone tras parsear el vCard.
// editar). Carga TODOS los valores multi-valor (antes solo cargaba el primero).
function formFromContact(c: Contact): FormState {
const osint = c.osint ?? {};
return {
tipo: "persona",
nombre: c.nombre ?? "",
aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [],
telefono: c.telefonos?.[0] ?? "",
email: c.correos?.[0] ?? "",
telefonos: c.telefonos ?? [],
emails: c.correos ?? [],
// Direcciones desde el campo multi-valor; cae a osint.direccion (legacy).
direcciones:
c.direcciones && c.direcciones.length > 0
? c.direcciones
: osint.direccion
? [osint.direccion]
: [],
dni: osint.dni ?? "",
direccion: osint.direccion ?? "",
pais: osint.pais ?? "",
contexto: osint.contexto ?? "",
notas: c.nota ?? "",
collection: c.collection ?? "",
};
}
function formToInput(f: FormState): ContactInput {
const t = (v: string) => (v.trim() ? v.trim() : null);
const t = (vals: string[]) => vals.map((s) => s.trim()).filter(Boolean);
const s = (v: string) => (v.trim() ? v.trim() : null);
return {
tipo: f.tipo,
nombre: f.nombre.trim(),
aliases: f.aliases.map((s) => s.trim()).filter(Boolean),
telefono: t(f.telefono),
email: t(f.email),
dni: t(f.dni),
direccion: t(f.direccion),
pais: t(f.pais),
contexto: t(f.contexto),
aliases: t(f.aliases),
telefonos: t(f.telefonos),
emails: t(f.emails),
direcciones: t(f.direcciones),
dni: s(f.dni),
pais: s(f.pais),
contexto: s(f.contexto),
relaciones: [],
notas: t(f.notas),
notas: s(f.notas),
collection: f.collection.trim() ? f.collection.trim() : null,
};
}
@@ -119,6 +140,10 @@ export function ContactsView() {
const [query, setQuery] = useState("");
const [debQuery] = useDebouncedValue(query, 200);
// Libretas (addressbooks) + filtro por libreta de la lista.
const [addressbooks, setAddressbooks] = useState<Addressbook[]>([]);
const [filterBook, setFilterBook] = useState<string>(""); // "" = todas
// Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
// edición (slug del contacto). `saving` deshabilita el botón mientras escribe.
const [formOpen, setFormOpen] = useState(false);
@@ -126,6 +151,13 @@ export function ContactsView() {
const [editSlug, setEditSlug] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Modal "nueva libreta" (mismo patrón que "Nuevo calendario").
const [newBookOpen, setNewBookOpen] = useState(false);
const [newBookName, setNewBookName] = useState("");
const [newBookColor, setNewBookColor] = useState("");
const [newBookErr, setNewBookErr] = useState<string | null>(null);
const [newBookSaving, setNewBookSaving] = useState(false);
function reload() {
setLoading(true);
fetchContacts()
@@ -141,15 +173,35 @@ export function ContactsView() {
.finally(() => setLoading(false));
}
// Carga de libretas. `selectSlug` selecciona una recién creada en el form.
const loadAddressbooks = useCallback((selectSlug?: string) => {
return fetchAddressbooks()
.then((d) => {
if (d.status === "ok" && d.addressbooks) {
setAddressbooks(d.addressbooks);
if (selectSlug) {
setForm((f) => ({ ...f, collection: selectSlug }));
}
}
return d;
})
.catch(() => {
/* el selector degrada a la libreta por defecto; no es fatal */
return null;
});
}, []);
useEffect(() => {
reload();
loadAddressbooks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() => {
const q = debQuery.trim().toLowerCase();
if (!q) return contacts;
return contacts.filter((c) => {
if (filterBook && (c.collection ?? "") !== filterBook) return false;
if (!q) return true;
const hay = [
c.nombre,
c.alias,
@@ -162,7 +214,14 @@ export function ContactsView() {
.toLowerCase();
return hay.includes(q);
});
}, [contacts, debQuery]);
}, [contacts, debQuery, filterBook]);
// Opciones del selector de libreta del formulario y del filtro.
const bookOptions = useMemo(
() =>
addressbooks.map((b) => ({ value: b.slug, label: b.display_name })),
[addressbooks],
);
function openNew() {
setForm(EMPTY_FORM);
@@ -218,6 +277,47 @@ export function ContactsView() {
}
}
// Crea una libreta nueva (mismo flujo que createNewCalendar): deriva el slug
// del nombre, refresca la lista y selecciona la nueva en el formulario.
const createNewBook = useCallback(async () => {
const name = newBookName.trim();
if (!name) {
setNewBookErr("El nombre es obligatorio.");
return;
}
const slug = slugify(name);
if (!slug) {
setNewBookErr("El nombre no produce un identificador válido.");
return;
}
setNewBookSaving(true);
setNewBookErr(null);
try {
const res = await createAddressbook({
slug,
name,
color: newBookColor || null,
});
if (res.status !== "ok") {
setNewBookErr(res.error || "No se pudo crear la libreta.");
return;
}
await loadAddressbooks(res.slug || slug);
notifications.show({
color: "teal",
title: "Libreta creada",
message: name,
});
setNewBookOpen(false);
setNewBookName("");
setNewBookColor("");
} catch (e) {
setNewBookErr(String(e));
} finally {
setNewBookSaving(false);
}
}, [newBookName, newBookColor, loadAddressbooks]);
return (
<>
<ContactForm
@@ -225,11 +325,67 @@ export function ContactsView() {
editing={editSlug !== null}
form={form}
saving={saving}
bookOptions={bookOptions}
onChange={setForm}
onNewBook={() => {
setNewBookErr(null);
setNewBookName("");
setNewBookColor("");
setNewBookOpen(true);
}}
onClose={() => setFormOpen(false)}
onSave={onSave}
/>
<Modal
opened={newBookOpen}
onClose={() => setNewBookOpen(false)}
title="Nueva libreta"
size="sm"
centered
>
<Stack gap="sm">
<TextInput
label="Nombre"
placeholder="Trabajo, Familia, OSINT…"
value={newBookName}
onChange={(e) => setNewBookName(e.currentTarget.value)}
data-autofocus
required
/>
<ColorInput
label="Color"
placeholder="Color de la libreta (opcional)"
value={newBookColor}
onChange={setNewBookColor}
format="hex"
swatches={[
"#23bdfe",
"#16a34a",
"#dc2626",
"#f59e0b",
"#8b5cf6",
"#ec4899",
"#0891b2",
"#64748b",
]}
/>
{newBookErr && (
<Text c="red" size="sm">
{newBookErr}
</Text>
)}
<Group justify="flex-end" gap="xs" mt="xs">
<Button variant="default" onClick={() => setNewBookOpen(false)}>
Cancelar
</Button>
<Button onClick={createNewBook} loading={newBookSaving}>
Crear
</Button>
</Group>
</Stack>
</Modal>
{error ? (
<Center h="100%" p="xl">
<Alert color="orange" title="Agenda no disponible" maw={500}>
@@ -258,6 +414,34 @@ export function ContactsView() {
>
Nuevo contacto
</Button>
<Group align="flex-end" gap="xs" wrap="nowrap" mb="sm">
<Select
label="Libreta"
placeholder="Todas"
data={bookOptions}
value={filterBook || null}
onChange={(v) => setFilterBook(v || "")}
leftSection={<IconAddressBook size={14} />}
clearable
comboboxProps={{ withinPortal: true }}
style={{ flex: 1, minWidth: 0 }}
/>
<Tooltip label="Nueva libreta">
<ActionIcon
variant="default"
size="lg"
aria-label="Nueva libreta"
onClick={() => {
setNewBookErr(null);
setNewBookName("");
setNewBookColor("");
setNewBookOpen(true);
}}
>
<IconPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
<TextInput
placeholder="Buscar contacto…"
leftSection={<IconSearch size={16} />}
@@ -339,7 +523,9 @@ function ContactForm({
editing,
form,
saving,
bookOptions,
onChange,
onNewBook,
onClose,
onSave,
}: {
@@ -347,7 +533,9 @@ function ContactForm({
editing: boolean;
form: FormState;
saving: boolean;
bookOptions: { value: string; label: string }[];
onChange: (f: FormState) => void;
onNewBook: () => void;
onClose: () => void;
onSave: () => void;
}) {
@@ -362,17 +550,42 @@ function ContactForm({
size="lg"
>
<Stack gap="sm">
<Select
label="Tipo"
data={[
{ value: "persona", label: "Persona" },
{ value: "organizacion", label: "Organización" },
]}
value={form.tipo}
onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
disabled={editing}
allowDeselect={false}
/>
<Group grow align="flex-start">
<Select
label="Tipo"
data={[
{ value: "persona", label: "Persona" },
{ value: "organizacion", label: "Organización" },
]}
value={form.tipo}
onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
disabled={editing}
allowDeselect={false}
/>
<Group align="flex-end" gap="xs" wrap="nowrap">
<Select
label="Libreta"
placeholder="Por defecto"
data={bookOptions}
value={form.collection || null}
onChange={(v) => set("collection", v || "")}
leftSection={<IconAddressBook size={14} />}
clearable
comboboxProps={{ withinPortal: true }}
style={{ flex: 1, minWidth: 0 }}
/>
<Tooltip label="Nueva libreta">
<ActionIcon
variant="default"
size="lg"
aria-label="Nueva libreta"
onClick={onNewBook}
>
<IconPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<TextInput
label="Nombre"
required
@@ -386,18 +599,29 @@ function ContactForm({
value={form.aliases}
onChange={(v) => set("aliases", v)}
/>
<Group grow>
<TextInput
label="Teléfono"
value={form.telefono}
onChange={(e) => set("telefono", e.currentTarget.value)}
<Group grow align="flex-start">
<TagsInput
label="Teléfonos"
placeholder="añade un teléfono y pulsa Enter"
value={form.telefonos}
onChange={(v) => set("telefonos", v)}
leftSection={<IconPhone size={14} />}
/>
<TextInput
label="Email"
value={form.email}
onChange={(e) => set("email", e.currentTarget.value)}
<TagsInput
label="Emails"
placeholder="añade un email y pulsa Enter"
value={form.emails}
onChange={(v) => set("emails", v)}
leftSection={<IconAt size={14} />}
/>
</Group>
<TagsInput
label="Direcciones"
placeholder="añade una dirección y pulsa Enter"
value={form.direcciones}
onChange={(v) => set("direcciones", v)}
leftSection={<IconMapPin size={14} />}
/>
{form.tipo === "persona" && (
<Group grow>
<TextInput
@@ -419,11 +643,6 @@ function ContactForm({
onChange={(e) => set("pais", e.currentTarget.value)}
/>
)}
<TextInput
label="Dirección"
value={form.direccion}
onChange={(e) => set("direccion", e.currentTarget.value)}
/>
<Select
label="Contexto"
placeholder="origen / círculo"
@@ -469,8 +688,16 @@ function ContactDetail({
onDelete: () => void;
}) {
const osintEntries = Object.entries(contact.osint ?? {}).filter(
([, v]) => v != null && v !== "",
// direccion se muestra en su propio bloque multi-valor; no se duplica en OSINT.
([k, v]) => v != null && v !== "" && k !== "direccion",
);
// Direcciones multi-valor: campo direcciones[] o, en su defecto, osint.direccion.
const direcciones =
contact.direcciones && contact.direcciones.length > 0
? contact.direcciones
: contact.osint?.direccion
? [contact.osint.direccion]
: [];
return (
<Stack p="xl" gap="lg" maw={720}>
<Group justify="space-between" align="flex-start">
@@ -553,6 +780,22 @@ function ContactDetail({
</Stack>
)}
{direcciones.length > 0 && (
<Stack gap={4}>
<Group gap="xs">
<IconMapPin size={16} />
<Text fw={600} size="sm">
Direcciones
</Text>
</Group>
{direcciones.map((d, i) => (
<Text key={i} size="sm" pl="lg">
{d}
</Text>
))}
</Stack>
)}
{osintEntries.length > 0 && (
<Paper withBorder p="md" radius="md">
<Text fw={600} size="sm" mb="xs" c="brand">
+136 -1
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import {
Button,
Chip,
ColorInput,
Group,
Modal,
NumberInput,
Select,
Stack,
Switch,
@@ -18,7 +20,15 @@ import {
type CalendarCollection,
type EventInput,
} from "../api";
import { TIMEZONES } from "../calendar";
import {
TIMEZONES,
buildRrule,
parseRrule,
BYDAY_CODES,
BYDAY_LABELS,
type RruleFreq,
type RruleEndMode,
} from "../calendar";
// Borrador de evento que el CalendarView pasa al modal. `mode` decide si el
// guardado hace POST (new) o PUT (edit). Las fechas van en ISO local
@@ -35,8 +45,33 @@ export interface EventDraft {
description: string;
color: string;
cal: string;
// Cuerpo RRULE crudo del maestro (sin prefijo "RRULE:"). "" = no se repite.
rrule: string;
}
// Opciones del Select de frecuencia y sufijo textual del intervalo por frecuencia.
const FREQ_OPTIONS: { value: RruleFreq; label: string }[] = [
{ value: "none", label: "No se repite" },
{ value: "DAILY", label: "Cada día" },
{ value: "WEEKLY", label: "Cada semana" },
{ value: "MONTHLY", label: "Cada mes" },
{ value: "YEARLY", label: "Cada año" },
];
const INTERVAL_SUFFIX: Record<RruleFreq, string> = {
none: "",
DAILY: "días",
WEEKLY: "semanas",
MONTHLY: "meses",
YEARLY: "años",
};
const END_OPTIONS: { value: RruleEndMode; label: string }[] = [
{ value: "never", label: "Nunca" },
{ value: "count", label: "Tras N veces" },
{ value: "until", label: "En fecha" },
];
export function EventModal({
draft,
calendars,
@@ -54,13 +89,36 @@ export function EventModal({
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Controles de recurrencia, derivados del rrule crudo al abrir el modal.
const [freq, setFreq] = useState<RruleFreq>("none");
const [interval, setInterval] = useState<number>(1);
const [endMode, setEndMode] = useState<RruleEndMode>("never");
const [count, setCount] = useState<number>(10);
const [until, setUntil] = useState<string>("");
const [byday, setByday] = useState<string[]>([]);
useEffect(() => {
setForm(draft);
setErr(null);
const p = parseRrule(draft?.rrule ?? "");
setFreq(p.freq);
setInterval(p.interval);
setEndMode(p.endMode);
setCount(p.count);
setUntil(p.until);
setByday(p.byday);
}, [draft]);
if (!form) return null;
const recurs = freq !== "none";
function toggleByday(code: string) {
setByday((days) =>
days.includes(code) ? days.filter((d) => d !== code) : [...days, code],
);
}
function set<K extends keyof EventDraft>(key: K, value: EventDraft[K]) {
setForm((f) => (f ? { ...f, [key]: value } : f));
}
@@ -108,6 +166,7 @@ export function EventModal({
location: form.location || null,
description: form.description || null,
color: form.color || null,
rrule: buildRrule(freq, interval, endMode, count, until, byday) || null,
};
try {
if (form.mode === "edit" && form.uid) {
@@ -179,6 +238,82 @@ export function EventModal({
/>
)}
{/* Repetición (RRULE) */}
<Select
label="Repetición"
data={FREQ_OPTIONS}
value={freq}
onChange={(v) => v && setFreq(v as RruleFreq)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
{recurs && (
<>
<Group align="flex-end" gap="xs" wrap="nowrap">
<NumberInput
label="Cada"
min={1}
value={interval}
onChange={(v) => setInterval(typeof v === "number" ? v : 1)}
style={{ width: 110 }}
/>
<Text size="sm" mb={8} c="dimmed">
{INTERVAL_SUFFIX[freq]}
</Text>
</Group>
{freq === "WEEKLY" && (
<Chip.Group multiple value={byday}>
<Group gap={4} wrap="wrap">
{BYDAY_CODES.map((code, i) => (
<Chip
key={code}
value={code}
size="xs"
checked={byday.includes(code)}
onClick={() => toggleByday(code)}
>
{BYDAY_LABELS[i]}
</Chip>
))}
</Group>
</Chip.Group>
)}
<Select
label="Termina"
data={END_OPTIONS}
value={endMode}
onChange={(v) => v && setEndMode(v as RruleEndMode)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
{endMode === "count" && (
<NumberInput
label="Número de repeticiones"
min={1}
value={count}
onChange={(v) => setCount(typeof v === "number" ? v : 1)}
/>
)}
{endMode === "until" && (
<TextInput
label="Fecha de fin"
type="date"
value={until}
onChange={(e) => setUntil(e.currentTarget.value)}
/>
)}
<Text size="xs" c="dimmed">
Los cambios se aplican a toda la serie.
</Text>
</>
)}
<Select
label="Calendario"
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
+246 -1
View File
@@ -13,16 +13,29 @@ import {
UnstyledButton,
} from "@mantine/core";
import {
IconCalendarEvent,
IconChevronDown,
IconChevronUp,
IconRepeat,
IconSearch,
IconSelector,
} from "@tabler/icons-react";
import { fetchGraph, fetchNodes, type NodeRow } from "../api";
import {
fetchCalendar,
fetchGraph,
fetchNodes,
type CalendarEvent,
type NodeRow,
} from "../api";
import { eventStart, eventEnd } from "../calendar";
import { formatFrontmatterValue } from "../format";
import { tipoStyle } from "../tipos";
import { useNodeCard } from "../NodeCardContext";
// Valor de pestaña sintético para la tabla de eventos del calendario. No es un
// `tipo` de nodo del vault — los eventos vienen de Xandikos via fetchCalendar.
const EVENTS_TAB = "__eventos__";
// Una pestaña por tipo de nodo real (no fantasma). Cada pestaña carga
// perezosamente sus filas de /api/nodes?tipo=<t> y las muestra en una tabla
// Mantine ordenable + filtrable. Las columnas se deducen de las claves de
@@ -110,6 +123,12 @@ export function TablesView() {
{tipoStyle(t).label}
</Tabs.Tab>
))}
<Tabs.Tab
value={EVENTS_TAB}
leftSection={<IconCalendarEvent size={14} />}
>
Eventos
</Tabs.Tab>
</Tabs.List>
{availableTipos.map((t) => (
@@ -121,6 +140,13 @@ export function TablesView() {
{active === t && <TypeTable tipo={t} />}
</Tabs.Panel>
))}
<Tabs.Panel
value={EVENTS_TAB}
style={{ flex: 1, minHeight: 0, display: "flex" }}
>
{active === EVENTS_TAB && <EventsTable />}
</Tabs.Panel>
</Tabs>
);
}
@@ -287,3 +313,222 @@ function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
<IconChevronDown size={14} />
);
}
// --- Tabla de eventos del calendario ---------------------------------------
// Columnas de la tabla de eventos. `key` identifica la columna para ordenar;
// `label` es la cabecera humana. Las fechas/horas se muestran en formato europeo.
const EVENT_COLUMNS: { key: string; label: string }[] = [
{ key: "fecha", label: "Fecha" },
{ key: "hora", label: "Hora" },
{ key: "summary", label: "Título" },
{ key: "calendar", label: "Calendario" },
{ key: "location", label: "Ubicación" },
{ key: "recurring", label: "Recurrencia" },
];
// Etiqueta de calendario derivada del href del evento (Xandikos expone el slug
// del calendario como segmento de la URL). Cae al href entero si no hay slug.
function calendarLabel(ev: CalendarEvent): string {
const href = ev.href ?? "";
if (!href) return "";
const parts = href.split("/").filter(Boolean);
// El penúltimo segmento suele ser el slug del calendario (…/<cal>/<uid>.ics).
if (parts.length >= 2) return decodeURIComponent(parts[parts.length - 2]);
return decodeURIComponent(parts[parts.length - 1] ?? href);
}
// Lee todos los eventos del calendario (sin restricción de colección) en un rango
// muy amplio y los muestra como tabla ordenable + filtrable, mismo patrón que
// TypeTable. Por defecto ordena por fecha ascendente.
function EventsTable() {
// TZ del visor para posicionar el día/hora de cada evento (la del navegador).
const tz = useMemo(() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Madrid";
} catch {
return "Europe/Madrid";
}
}, []);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [sortCol, setSortCol] = useState<string>("fecha");
const [sortDir, setSortDir] = useState<SortDir>("asc");
useEffect(() => {
let alive = true;
setLoading(true);
// Sin cal → todos los calendarios; rango muy amplio → todos los eventos.
fetchCalendar("", "2000-01-01", "2100-01-01")
.then((d) => {
if (!alive) return;
if (d.status !== "ok") {
setError(d.error || "Xandikos no respondió");
return;
}
setEvents(d.events ?? []);
})
.catch((e) => alive && setError(String(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, []);
const cellValue = (ev: CalendarEvent, col: string): string => {
const s = eventStart(ev, tz);
switch (col) {
case "fecha":
return ev.all_day
? `${s ? s.format("DD/MM/YYYY") : ""} · todo el día`
: s
? s.format("DD/MM/YYYY")
: "";
case "hora": {
if (ev.all_day) return "";
const e = eventEnd(ev, tz);
return `${s ? s.format("HH:mm") : "--:--"}${
e ? ` ${e.format("HH:mm")}` : ""
}`;
}
case "summary":
return ev.summary || "(sin título)";
case "calendar":
return calendarLabel(ev);
case "location":
return ev.location || "";
case "recurring":
return ev.recurring ? "sí" : "";
default:
return "";
}
};
// Clave de orden estable por fecha (instante real, no string formateado).
const sortKey = (ev: CalendarEvent, col: string): string | number => {
if (col === "fecha" || col === "hora") {
const s = eventStart(ev, tz);
return s ? s.valueOf() : Number.POSITIVE_INFINITY;
}
return cellValue(ev, col).toLowerCase();
};
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
let out = events;
if (q) {
out = events.filter((ev) =>
EVENT_COLUMNS.some((c) =>
cellValue(ev, c.key).toLowerCase().includes(q),
),
);
}
if (sortDir) {
out = [...out].sort((a, b) => {
const va = sortKey(a, sortCol);
const vb = sortKey(b, sortCol);
if (va < vb) return sortDir === "asc" ? -1 : 1;
if (va > vb) return sortDir === "asc" ? 1 : -1;
return 0;
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [events, filter, sortCol, sortDir, tz]);
function toggleSort(col: string) {
if (sortCol !== col) {
setSortCol(col);
setSortDir("asc");
} else {
setSortDir((d) => (d === "asc" ? "desc" : d === "desc" ? null : "asc"));
}
}
if (error) {
return (
<Alert color="orange" title="Calendario no disponible" m="md">
{error}
<Text size="sm" mt="xs" c="dimmed">
Los eventos vienen del servidor Xandikos. El resto de tablas funcionan
sin él.
</Text>
</Alert>
);
}
return (
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Group p="md" pb="xs" justify="space-between">
<TextInput
placeholder="Filtrar eventos…"
leftSection={<IconSearch size={16} />}
value={filter}
onChange={(e) => setFilter(e.currentTarget.value)}
w={300}
/>
<Text size="sm" c="dimmed">
{filtered.length} de {events.length}
</Text>
</Group>
{loading ? (
<Center style={{ flex: 1 }}>
<Loader />
</Center>
) : (
<ScrollArea style={{ flex: 1 }} px="md">
<Table striped highlightOnHover stickyHeader withTableBorder>
<Table.Thead>
<Table.Tr>
{EVENT_COLUMNS.map((c) => (
<Table.Th key={c.key}>
<UnstyledButton onClick={() => toggleSort(c.key)}>
<Group gap={4} wrap="nowrap">
<Text fw={600} size="sm">
{c.label}
</Text>
<SortIcon active={sortCol === c.key} dir={sortDir} />
</Group>
</UnstyledButton>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((ev, i) => (
<Table.Tr key={(ev.uid ?? "") + (ev.dtstart ?? "") + i}>
{EVENT_COLUMNS.map((c) => (
<Table.Td key={c.key}>
{c.key === "recurring" ? (
ev.recurring ? (
<IconRepeat size={15} />
) : null
) : (
<Text size="sm" lineClamp={2}>
{cellValue(ev, c.key)}
</Text>
)}
</Table.Td>
))}
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={EVENT_COLUMNS.length}>
<Text c="dimmed" ta="center" py="md">
Sin eventos
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Box>
);
}
+680 -40
View File
@@ -31,8 +31,10 @@ Endpoints (JSON salvo /api/attachment):
GET /api/node/<slug> ficha: frontmatter + body + attachments
GET /api/attachment?path=.. binario del attachment (path relativo al vault)
GET /api/search?q=... nodos cuyo contenido matchea la query
GET /api/contacts contactos del addressbook Xandikos (CardDAV)
GET /api/contacts contactos (Xandikos por defecto; osint_db si flag)
GET /api/contact/<uid> un vCard concreto a JSON
GET /api/addressbooks libretas de contactos (selector del frontend)
POST /api/addressbooks crea una libreta nueva (requiere OSINT_DB_BACKEND)
GET /api/calendars colecciones de calendario bajo /enmanuel/calendars/
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV)
POST /api/event crea un VEVENT en una colección de calendario
@@ -165,6 +167,17 @@ dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource
# las colecciones de calendario del usuario con su nombre y color.
caldav_put_event = _load_infra_fn("caldav_put_event", "caldav_put_event")
dav_list_calendars = _load_infra_fn("dav_list_calendars", "dav_list_calendars")
# Crear una colección de calendario nueva (MKCALENDAR + PROPPATCH nombre/color).
dav_make_calendar = _load_infra_fn("dav_make_calendar", "dav_make_calendar")
# Expandir una RRULE a las fechas de cada ocurrencia dentro de un rango (pura).
expand_rrule = _load_infra_fn("expand_rrule", "expand_rrule")
# Cliente del service osint_db (DuckDB), usado SOLO cuando el feature flag
# OSINT_DB_BACKEND está ON. El módulo vive junto a este archivo en server/, que
# está en sys.path tanto al ejecutar `python server/main.py` como al importarlo
# desde los tests. Se importa siempre (es barato: solo stdlib) pero no se usa a
# menos que el flag esté activo.
import osintdb_client # noqa: E402
# ---------------------------------------------------------------------------
@@ -175,6 +188,11 @@ XANDIKOS_BASE_URL = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
XANDIKOS_USERNAME = "enmanuel"
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
# Libreta (addressbook) por defecto. Cuando el flag OSINT_DB_BACKEND está OFF,
# todos los contactos viven en esta única libreta; el frontend la muestra como
# opción por defecto del selector.
DEFAULT_ADDRESSBOOK_SLUG = "addressbook"
DEFAULT_ADDRESSBOOK_NAME = "Contactos"
# Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
@@ -282,19 +300,133 @@ def _write_disk_cache(path: str, ctag: str, items: list) -> None:
sirviendo y el disco se reintentará en el siguiente refresco.
"""
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
# La caché contiene PII (contactos, posibles DNIs en osint{}): se crea con
# permisos 0600 para que ningún otro usuario local pueda leerla, sin
# depender del umask del proceso.
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(
{"ctag": ctag, "items": items, "saved_at": time.time()},
fh,
ensure_ascii=False,
)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except OSError:
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
# ---------------------------------------------------------------------------
@@ -633,17 +765,26 @@ class VaultState:
return list(items)
def contacts(self) -> list:
"""Contactos del addressbook Xandikos, parseados y cacheados.
"""Contactos, desde Xandikos (flag OFF) o desde osint_db (flag ON).
Caché en dos niveles: memoria (mientras vive el proceso) y disco
(``.cache/contacts.json``, validada por ctag para arranque instantáneo).
Al primer acceso descarga TODO en UNA petición REPORT
(``dav_get_collection``) en vez de un GET por ``.vcf``.
Con el flag ``OSINT_DB_BACKEND`` activo, la fuente de verdad es el service
osint_db (DuckDB): se consultan sus contactos y se devuelven con el mismo
shape JSON que produce el parseo del vCard, para que el frontend no note la
diferencia. Con el flag OFF (default), camino histórico: addressbook
Xandikos parseado y cacheado.
Caché en dos niveles para el camino DAV: memoria (mientras vive el
proceso) y disco (``.cache/contacts.json``, validada por ctag para
arranque instantáneo). Al primer acceso descarga TODO en UNA petición
REPORT (``dav_get_collection``) en vez de un GET por ``.vcf``.
Raises:
RuntimeError: si no se puede leer la password de ``pass``.
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
"""
if _osint_db_backend_enabled():
return _contacts_from_osint_db()
with self._dav_lock:
if self._contacts_cache is not None and not self._force_reload:
return self._contacts_cache
@@ -658,6 +799,63 @@ class VaultState:
self._maybe_clear_force_reload()
return contacts
def list_addressbooks(self) -> list:
"""Libretas (addressbooks) disponibles para los contactos.
Con el flag ``OSINT_DB_BACKEND`` ON, consulta las libretas del osint_db
(``{slug, display_name, collection_path, color}``). Con el flag OFF, hoy
solo existe la libreta por defecto en el vault; se devuelve esa única
entrada para que el selector del frontend tenga algo que mostrar.
Raises:
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
"""
if _osint_db_backend_enabled():
return osintdb_client.list_addressbooks()
return [
{
"slug": DEFAULT_ADDRESSBOOK_SLUG,
"display_name": DEFAULT_ADDRESSBOOK_NAME,
"collection_path": XANDIKOS_CONTACTS_COLLECTION,
"color": None,
}
]
def create_addressbook(self, data: "AddressbookIn") -> dict:
"""Crea una libreta de contactos nueva.
Solo soportado con el flag ``OSINT_DB_BACKEND`` ON: el osint_db crea la
colección CardDAV en Xandikos y la registra en la DuckDB. Con el flag OFF
no hay forma de crear libretas todavía (no existe ``dav_make_addressbook``
en el registry) → 501 claro indicando que requiere el flag.
Returns:
dict ``{status, slug, ...}`` del osint_db.
Raises:
HTTPException(400): si el slug/nombre queda vacío.
HTTPException(501): si el flag está OFF.
osintdb_client.OsintDbUnavailable: si el osint_db no responde.
"""
slug = (data.slug or data.name or "").strip()
if not slug:
raise HTTPException(
status_code=400, detail="el nombre de la libreta es obligatorio"
)
if not _osint_db_backend_enabled():
raise HTTPException(
status_code=501,
detail=(
"crear libretas requiere el backend OSINT_DB_BACKEND activo "
"(hoy solo existe la libreta por defecto en el vault)"
),
)
res = osintdb_client.create_addressbook(
slug, data.name or slug, data.color or None
)
self.invalidate_dav()
return res
def _resolve_calendar(self, cal: str = "") -> str:
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
@@ -705,6 +903,42 @@ class VaultState:
self._maybe_clear_force_reload()
return calendars
def create_calendar(self, data: "CalendarIn") -> dict:
"""Crea una colección de calendario nueva bajo el calendar-home (MKCALENDAR).
Compone la función del registry ``dav_make_calendar`` (MKCALENDAR +
PROPPATCH de nombre/color). Invalida la caché de colecciones para que el
calendario nuevo aparezca en el selector al recargar.
Returns:
dict ``{status, href, existed?}`` de la función del registry.
Raises:
HTTPException(400): si el slug/nombre queda vacío tras sanear.
DavUnavailable: si Xandikos rechaza la creación.
"""
slug = (data.slug or data.name or "").strip()
if not slug:
raise HTTPException(status_code=400, detail="el nombre del calendario es obligatorio")
password = self.xandikos_password()
res = dav_make_calendar(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
XANDIKOS_CALENDAR_HOME,
slug,
data.name or slug,
data.color or "",
data.description or "",
)
if res.get("status") != "ok":
raise DavUnavailable(
"Xandikos no pudo crear el calendario: %s" % res.get("error")
)
with self._dav_lock:
self._calendars_cache = None
return res
def calendar(self, cal: str = "", dt_from: str = "", dt_to: str = "") -> list:
"""Eventos de una colección de calendario Xandikos, cacheados y filtrados.
@@ -732,9 +966,18 @@ class VaultState:
self._maybe_clear_force_reload()
cached = events
all_events = list(cached)
# Sin rango: devolvemos los eventos maestros tal cual (no expandimos
# series infinitas). Con rango: cada serie recurrente se expande a sus
# ocurrencias dentro de [from, to]; los puntuales se filtran por fecha.
if not dt_from and not dt_to:
return all_events
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
out: list = []
for ev in all_events:
if ev.get("rrule"):
out.extend(_expand_event_occurrences(ev, dt_from, dt_to))
elif _event_in_range(ev, dt_from, dt_to):
out.append(ev)
return out
# --- Escritura de eventos del calendario (CalDAV) -----------------------
@@ -924,6 +1167,21 @@ class VaultState:
HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
nombre está vacío.
"""
if not data.nombre.strip():
raise HTTPException(status_code=400, detail="el nombre es obligatorio")
if _osint_db_backend_enabled():
# Flag ON: el osint_db es la fuente de verdad. Genera el slug igual que
# el camino vault (mismo UID), envía el payload y deja que el service
# escriba la DuckDB + empuje a Xandikos.
slug = slugify_obsidian_name(data.nombre)
if not slug:
raise HTTPException(
status_code=400, detail="el nombre no produce un slug válido"
)
res = osintdb_client.create_contact(_osint_db_contact_payload(data, slug))
self.invalidate_dav()
uid = res.get("uid") or slug
return {"slug": uid, "uid": uid, "path": None, "osint_db": res}
tipo = (data.tipo or "persona").strip()
if tipo not in _TIPO_FOLDER:
raise HTTPException(
@@ -975,6 +1233,13 @@ class VaultState:
Raises:
HTTPException(404): si no existe la ficha del contacto.
"""
if _osint_db_backend_enabled():
# Flag ON: delega la edición en el osint_db (PUT por UID).
res = osintdb_client.update_contact(
slug, _osint_db_contact_payload(data)
)
self.invalidate_dav()
return {"slug": slug, "uid": slug, "path": None, "osint_db": res}
path = self._find_contact_note(slug)
if path is None:
raise HTTPException(
@@ -982,13 +1247,21 @@ class VaultState:
)
note = read_obsidian_note(path)
current = dict(note.get("frontmatter") or {})
# Merge de los campos editables (preserva los heredados no tocados).
# Listas multi-valor (ya reconciladas con los singulares en ContactIn).
telefonos = _norm_list(data.telefonos)
emails = _norm_list(data.emails)
direcciones = _norm_list(data.direcciones)
# Merge de los campos editables (preserva los heredados no tocados). El
# singular se conserva = primer elemento para los lectores viejos.
merged = {
"nombre": data.nombre.strip() or current.get("nombre") or slug,
"aliases": _norm_list(data.aliases),
"telefono": _norm_str(data.telefono),
"email": _norm_str(data.email),
"direccion": _norm_str(data.direccion),
"telefono": telefonos[0] if telefonos else None,
"telefonos": telefonos,
"email": emails[0] if emails else None,
"emails": emails,
"direccion": direcciones[0] if direcciones else None,
"direcciones": direcciones,
"pais": _norm_str(data.pais),
"relaciones": _norm_list(data.relaciones),
"contexto": _norm_str(data.contexto),
@@ -1018,6 +1291,11 @@ class VaultState:
Raises:
HTTPException(404): si no existe la ficha del contacto.
"""
if _osint_db_backend_enabled():
# Flag ON: delega el borrado en el osint_db (DELETE por UID).
res = osintdb_client.delete_contact(slug)
self.invalidate_dav()
return {"slug": slug, "deleted": True, "osint_db": res}
path = self._find_contact_note(slug)
if path is None:
raise HTTPException(
@@ -1123,6 +1401,7 @@ def _vcard_to_json(vcard_text: str) -> dict:
"note": None,
"phones": [],
"emails": [],
"direcciones": [],
"osint": {},
}
for line in _unfold_lines(vcard_text):
@@ -1130,6 +1409,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
if not parsed:
continue
name, params, value = parsed
# ADR es estructurado (7 componentes separados por ';'): NO se des-escapa
# antes de partir, para no confundir separadores con contenido escapado.
if name == "ADR":
adr = _parse_adr_value(value)
if adr:
out["direcciones"].append(adr)
continue
value = _unescape_ical(value.strip())
if name == "UID":
out["uid"] = value
@@ -1156,6 +1442,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
out["fn"] = ("%s %s" % (comps[1], comps[0])).strip()
elif comps:
out["fn"] = comps[0]
# Compat con vCards antiguos: la dirección iba en X-OSINT-DIRECCION (un solo
# valor) en vez de ADR. Si vino por ahí y no hay ADR, súbela a direcciones[]
# para que el frontend la vea como multi-valor; deja también osint.direccion
# por si algún lector viejo lo consulta.
legacy_dir = out["osint"].get("direccion")
if legacy_dir and legacy_dir not in out["direcciones"]:
out["direcciones"].append(legacy_dir)
# Alias en español que consume el frontend del task (mismo dato, otra clave).
out["nombre"] = out["fn"]
out["alias"] = out["nickname"]
@@ -1165,6 +1458,29 @@ def _vcard_to_json(vcard_text: str) -> dict:
return out
def _parse_adr_value(raw: str) -> Optional[str]:
"""Extrae la dirección legible de un valor ADR estructurado (RFC 6350).
El ADR tiene 7 componentes separados por ``;``:
``po-box;extended;street;locality;region;postal-code;country``. Esta función
une los componentes no vacíos (des-escapados) en una sola línea legible, con
preferencia por ``street``; si solo hay un campo, lo devuelve. Devuelve
``None`` si el ADR queda vacío.
"""
parts = raw.split(";")
# Des-escapa cada componente por separado (el ';' ya se usó para partir).
comps = [_unescape_ical(p.strip()) for p in parts]
nonempty = [c for c in comps if c]
if not nonempty:
return None
# street es el 3er componente (índice 2). Si está, suele bastar; si hay más
# (locality, region, etc.) se concatenan con coma para una línea legible.
if len(comps) >= 3 and comps[2]:
tail = [c for c in comps[3:] if c]
return ", ".join([comps[2]] + tail) if tail else comps[2]
return ", ".join(nonempty)
_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
_ICAL_DT_RE = re.compile(
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$"
@@ -1262,6 +1578,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
"location": None,
"description": None,
"color": None,
"rrule": None,
"recurring": False,
"occurrence": False,
}
for line in _unfold_lines(vevent_block):
parsed = _parse_property(line)
@@ -1295,6 +1614,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
out["description"] = _unescape_ical(value)
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
out["color"] = value
elif name == "RRULE":
out["rrule"] = value
out["recurring"] = True
return out
@@ -1327,6 +1649,98 @@ def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
return True
def _default_expand_start() -> str:
"""Límite inferior por defecto al expandir una serie sin rango explícito."""
return (datetime.now(timezone.utc) - timedelta(days=366)).strftime("%Y%m%d")
def _default_expand_end() -> str:
"""Límite superior por defecto al expandir una serie sin rango explícito."""
return (datetime.now(timezone.utc) + timedelta(days=731)).strftime("%Y%m%d")
def _shift_iso_days(value: str, days: int) -> str:
"""Desplaza la parte de fecha de un ISO (``YYYY-MM-DD`` o con ``T...``).
Conserva intacta la parte horaria/offset (``T10:00:00+02:00``) y solo mueve
la fecha ``days`` días. Para una fecha pura mueve la fecha sola. Si el valor
no parsea, lo devuelve sin tocar (defensivo).
"""
if not value:
return value
date_part = value[:10]
rest = value[10:]
try:
base = datetime.strptime(date_part, "%Y-%m-%d")
except ValueError:
return value
shifted = (base + timedelta(days=days)).strftime("%Y-%m-%d")
return shifted + rest
def _occurrence_clone(event: dict, occ_ymd: str) -> dict:
"""Clona un evento maestro recurrente reubicado en la fecha ``occ_ymd``.
Mantiene la hora local / offset del maestro (solo cambia la fecha) y aplica el
mismo desplazamiento de días al ``dtend`` para preservar la duración. Marca
``occurrence=True`` cuando la fecha difiere de la del maestro (la primera
ocurrencia coincide con el maestro y queda ``occurrence=False``).
"""
master_ymd = (
event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "")
)[:8]
new_date_iso = "%s-%s-%s" % (occ_ymd[:4], occ_ymd[4:6], occ_ymd[6:8])
clone = dict(event)
ds = event.get("dtstart") or ""
if event.get("all_day") or len(ds) == 10:
clone["dtstart"] = new_date_iso
else:
clone["dtstart"] = new_date_iso + ds[10:]
clone["dtstart_ical"] = occ_ymd
try:
delta = (
datetime.strptime(occ_ymd, "%Y%m%d")
- datetime.strptime(master_ymd, "%Y%m%d")
).days
except ValueError:
delta = 0
de = event.get("dtend")
if de:
clone["dtend"] = _shift_iso_days(de, delta)
de_ical = event.get("dtend_ical")
if de_ical:
clone["dtend_ical"] = (clone["dtend"] or "").replace("-", "")[:8]
clone["occurrence"] = occ_ymd != master_ymd
return clone
def _expand_event_occurrences(event: dict, dt_from: str, dt_to: str) -> list:
"""Expande un evento recurrente a sus ocurrencias dentro de ``[from, to]``.
Compone la función pura del registry ``expand_rrule`` (solo necesita las
FECHAS de cada ocurrencia; la hora local se preserva clonando el maestro). Si
el evento no tiene ``rrule``, o algo no parsea, devuelve ``[event]`` sin
tocar — nunca pierde el evento original.
"""
rrule = event.get("rrule")
if not rrule:
return [event]
master_ymd = (
event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "")
)[:8]
if len(master_ymd) < 8:
return [event]
rs = (dt_from or "").replace("-", "")[:8] or _default_expand_start()
re_ = (dt_to or "").replace("-", "")[:8] or _default_expand_end()
try:
occ_dates = expand_rrule(master_ymd, rrule, rs, re_, all_day=True)
except Exception:
return [event]
if not occ_dates:
return []
return [_occurrence_clone(event, d) for d in occ_dates]
# ---------------------------------------------------------------------------
# Escritura de contactos: ficha .md del vault (fuente de verdad) + vCard
# ---------------------------------------------------------------------------
@@ -1350,19 +1764,59 @@ class ContactIn(BaseModel):
Refleja el esquema canónico del frontmatter (CONVENTIONS.md §3b/§6). Los
campos vacíos se normalizan a ``null``/``[]`` al escribir la ficha, nunca se
omiten, para que el score de completitud sea consistente.
Multi-valor: un contacto puede tener VARIOS teléfonos, emails y direcciones
(``telefonos``/``emails``/``direcciones``). Los campos singulares
``telefono``/``email``/``direccion`` se conservan por compatibilidad con
clientes y lectores viejos: el validador ``model_post_init`` los reconcilia
con las listas (singular → ``[valor]`` si la lista está vacía; y el singular
se rellena con ``lista[0]`` para que los lectores que solo miran el singular
sigan funcionando).
"""
tipo: str = Field(default="persona")
nombre: str
aliases: list[str] = Field(default_factory=list)
# Singulares (compat) — el primer elemento de cada lista multi-valor.
telefono: Optional[str] = None
email: Optional[str] = None
dni: Optional[str] = None
direccion: Optional[str] = None
# Multi-valor: listas completas de teléfonos, emails y direcciones.
telefonos: list[str] = Field(default_factory=list)
emails: list[str] = Field(default_factory=list)
direcciones: list[str] = Field(default_factory=list)
dni: Optional[str] = None
pais: Optional[str] = None
contexto: Optional[str] = None
relaciones: list[str] = Field(default_factory=list)
notas: Optional[str] = None
# Libreta (addressbook) destino. Solo se consume con el flag OSINT_DB_BACKEND
# ON (el osint_db enruta el contacto a esa colección). Con el flag OFF se
# ignora: hoy solo existe la libreta por defecto en el vault. None → libreta
# por defecto.
collection: Optional[str] = None
def model_post_init(self, __context: object) -> None:
"""Reconcilia los campos singulares con las listas multi-valor.
Para cada par (singular, lista): si la lista llega vacía pero el singular
trae valor, la lista se siembra con ``[singular]`` (cliente viejo que solo
envía el campo singular); y siempre se rellena el singular con el primer
elemento normalizado de la lista, para que los lectores que solo miran el
singular (frontmatter compat, vCard heredado) sigan funcionando.
"""
for singular, plural in (
("telefono", "telefonos"),
("email", "emails"),
("direccion", "direcciones"),
):
lista = _norm_list(getattr(self, plural))
if not lista:
single = _norm_str(getattr(self, singular))
if single:
lista = [single]
object.__setattr__(self, plural, lista)
object.__setattr__(self, singular, lista[0] if lista else None)
def _norm_str(value: Optional[str]) -> Optional[str]:
@@ -1396,15 +1850,23 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
nombre = data.nombre.strip()
aliases = _norm_list(data.aliases)
relaciones = _norm_list(data.relaciones)
# Listas multi-valor ya reconciladas en ContactIn.model_post_init; el campo
# singular = primer elemento (o None) para los lectores viejos.
telefonos = _norm_list(data.telefonos)
emails = _norm_list(data.emails)
direcciones = _norm_list(data.direcciones)
if data.tipo == "organizacion":
return {
"tipo": "organizacion",
"nombre": nombre,
"slug": slug,
"aliases": aliases,
"telefono": _norm_str(data.telefono),
"email": _norm_str(data.email),
"direccion": _norm_str(data.direccion),
"telefono": telefonos[0] if telefonos else None,
"telefonos": telefonos,
"email": emails[0] if emails else None,
"emails": emails,
"direccion": direcciones[0] if direcciones else None,
"direcciones": direcciones,
"pais": _norm_str(data.pais),
"relaciones": relaciones,
"contexto": _norm_str(data.contexto),
@@ -1420,9 +1882,12 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
"sexo": None,
"fecha_nacimiento": None,
"dni": _norm_str(data.dni),
"telefono": _norm_str(data.telefono),
"email": _norm_str(data.email),
"direccion": _norm_str(data.direccion),
"telefono": telefonos[0] if telefonos else None,
"telefonos": telefonos,
"email": emails[0] if emails else None,
"emails": emails,
"direccion": direcciones[0] if direcciones else None,
"direcciones": direcciones,
"pais": _norm_str(data.pais),
"relaciones": relaciones,
"contexto": _norm_str(data.contexto),
@@ -1440,20 +1905,46 @@ def _contact_body(notas: Optional[str]) -> str:
def _vcard_escape(value: str) -> str:
"""Escapa un valor de texto para una línea vCard (RFC 6350)."""
"""Escapa un valor de texto para una línea vCard (RFC 6350).
El retorno de carro ``\\r`` crudo se ELIMINA (no se escapa): solo, sin un
``\\n`` que lo siga, sobreviviría al escape de ``\\n`` y quedaría como carácter
de control. ``_unfold_lines`` normaliza ``\\r`` a ``\\n``, así que un ``\\r``
crudo en un valor permitiría inyectar propiedades nuevas (p. ej.
``X-OSINT-DNI``) en la tarjeta o, al reutilizarse esta función para SUMMARY/
LOCATION del VEVENT, en el VCALENDAR. Eliminarlo cierra ese vector.
"""
return (
value.replace("\\", "\\\\")
.replace("\r", "")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
)
def _vcard_value_list(frontmatter: dict, plural: str, singular: str) -> list:
"""Lista de valores de un campo multi-valor del frontmatter de contacto.
Prefiere la clave plural (``telefonos``/``emails``/``direcciones``); si está
vacía cae al singular (``telefono``/...) por compatibilidad con fichas
antiguas. Normaliza (trim + descarta vacíos) y devuelve una lista de strings.
"""
values = frontmatter.get(plural)
if not values:
single = frontmatter.get(singular)
values = [single] if single else []
return _norm_list([str(v) for v in values])
def _build_vcard(frontmatter: dict, slug: str) -> str:
"""Serializa un frontmatter de contacto a un VCARD 3.0 con el UID = slug.
Mapea: nombre→FN, aliases→NICKNAME, telefono→TEL, email→EMAIL, notas→NOTE,
organización→ORG; y los campos OSINT (dni, direccion, pais, contexto, sexo,
Soporta multi-valor: emite una línea ``TEL`` por teléfono, una ``EMAIL`` por
email y una ``ADR`` por dirección (campos ``telefonos``/``emails``/
``direcciones`` del frontmatter; cae a los singulares ``telefono``/... por
compat). Mapea además: nombre→FN, aliases→NICKNAME, notas→NOTE,
organización→ORG; y los campos OSINT (dni, pais, contexto, sexo,
fecha_nacimiento) a propiedades ``X-OSINT-*`` que el parser ``_vcard_to_json``
ya entiende. El UID es el slug → idempotente: re-subir el mismo slug
sobrescribe el recurso ``<slug>.vcf``.
@@ -1470,16 +1961,19 @@ def _build_vcard(frontmatter: dict, slug: str) -> str:
lines.append("NICKNAME:%s" % _vcard_escape(",".join(str(a) for a in aliases)))
if frontmatter.get("tipo") == "organizacion":
lines.append("ORG:%s" % _vcard_escape(nombre))
tel = frontmatter.get("telefono")
if tel:
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel)))
email = frontmatter.get("email")
if email:
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(email)))
# Multi-valor: una línea TEL/EMAIL por elemento.
for tel in _vcard_value_list(frontmatter, "telefonos", "telefono"):
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(tel))
for email in _vcard_value_list(frontmatter, "emails", "email"):
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(email))
# Direcciones → ADR estructurado (la dirección va en el componente street;
# los separadores ';' del ADR NO se escapan, solo el contenido). Una línea
# ADR por dirección. El parser _vcard_to_json reconstruye la lista desde ADR.
for adr in _vcard_value_list(frontmatter, "direcciones", "direccion"):
lines.append("ADR;TYPE=HOME:;;%s;;;;" % _vcard_escape(adr))
# Campos OSINT → X-OSINT-* (los recoge _vcard_to_json en el bloque osint).
for fm_key, x_name in (
("dni", "X-OSINT-DNI"),
("direccion", "X-OSINT-DIRECCION"),
("pais", "X-OSINT-PAIS"),
("contexto", "X-OSINT-CONTEXTO"),
("sexo", "X-OSINT-SEXO"),
@@ -1534,6 +2028,38 @@ class EventIn(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
# Regla de recurrencia iCalendar SIN el prefijo "RRULE:" (p.ej.
# "FREQ=WEEKLY;INTERVAL=1;COUNT=10"). None / "" → evento puntual. Editar un
# evento recurrente reescribe toda la serie (no se soporta editar una sola
# ocurrencia).
rrule: Optional[str] = None
class CalendarIn(BaseModel):
"""Cuerpo de POST /api/calendars: crea una colección de calendario nueva.
El ``slug`` es el segmento de URL de la colección (lo sanea la función del
registry ``dav_make_calendar`` a ``[a-z0-9_-]``). ``name`` es el nombre
visible; ``color`` un hex ``#rrggbb`` opcional.
"""
slug: str
name: Optional[str] = ""
color: 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]:
@@ -1687,7 +2213,9 @@ def _build_vcalendar(data: "EventIn", uid: str) -> str:
body.append(vtz)
vevent = [
"BEGIN:VEVENT",
"UID:%s" % uid,
# Sanitizamos el UID (quitamos saltos de línea) para que no pueda inyectar
# propiedades/componentes iCal nuevos en el VEVENT.
"UID:%s" % str(uid).replace("\r", "").replace("\n", ""),
"DTSTAMP:%s" % dtstamp,
_ical_dt_property("DTSTART", data.dtstart, tz, data.all_day),
]
@@ -1701,6 +2229,16 @@ def _build_vcalendar(data: "EventIn", uid: str) -> str:
if data.color and data.color.strip():
# COLOR (RFC 7986) — nombre CSS3 o, para clientes Apple, el hex va aparte.
vevent.append("X-APPLE-CALENDAR-COLOR:%s" % data.color.strip())
rrule = (data.rrule or "").strip()
if rrule:
# Acepta tanto "FREQ=..." como "RRULE:FREQ=..."; normaliza a la línea
# canónica "RRULE:<cuerpo>" que entienden Xandikos y los clientes (DAVx5).
if rrule.upper().startswith("RRULE:"):
rrule = rrule[len("RRULE:"):].strip()
# Sanitizar: quitar saltos de línea para que el valor de la RRULE no
# inyecte propiedades/componentes nuevos (los `;`/`,` son separadores
# legítimos de la regla, así que no se escapan).
vevent.append("RRULE:%s" % rrule.replace("\r", "").replace("\n", ""))
vevent.append("END:VEVENT")
body.append("\r\n".join(vevent))
body.append("END:VCALENDAR")
@@ -1721,6 +2259,31 @@ def create_app(vault_dir: str) -> FastAPI:
"""
state = VaultState(vault_dir)
app = FastAPI(title="osint_web", version="0.1.0")
# Anti DNS-rebinding: solo acepta requests cuyo Host sea localhost. Cierra el
# vector por el que una web maliciosa rebindea su dominio a 127.0.0.1 y, desde
# el navegador del usuario, alcanza este service local (sin auth) o el de DuckDB.
from starlette.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["127.0.0.1", "localhost", "testserver"],
)
# Anti-CSRF de navegador: rechaza las peticiones mutantes que el navegador
# marca como cross-site (header Sec-Fetch-Site). Cierra el hueco de las
# peticiones "simples" (POST sin preflight CORS, p.ej. /api/refresh) que el
# TrustedHost no filtra porque su Host sigue siendo 127.0.0.1. El frontend
# mismo-origen y los clientes server-to-server no envían 'cross-site'.
@app.middleware("http")
async def _reject_cross_site(request, call_next):
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
if request.headers.get("sec-fetch-site") == "cross-site":
return JSONResponse(
status_code=403,
content={"status": "error", "error": "petición cross-site rechazada"},
)
return await call_next(request)
app.state.vault = state
# -- Vault --
@@ -1797,12 +2360,12 @@ def create_app(vault_dir: str) -> FastAPI:
Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
La lista se cachea en memoria al primer acceso (``POST /api/refresh`` la
invalida). Si Xandikos no responde o falta la password → 503 con un JSON
de error claro, nunca un crash.
invalida). Si Xandikos / el osint_db no responde o falta la password →
503 con un JSON de error claro, nunca un crash.
"""
try:
contacts = state.contacts()
except (RuntimeError, DavUnavailable) as exc:
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
@@ -1820,7 +2383,7 @@ def create_app(vault_dir: str) -> FastAPI:
"""
try:
contacts = state.contacts()
except (RuntimeError, DavUnavailable) as exc:
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
@@ -1851,8 +2414,15 @@ def create_app(vault_dir: str) -> FastAPI:
La ficha del vault es la fuente de verdad (CONVENTIONS.md §3b/§6);
Xandikos se actualiza de inmediato para que el contacto se vea ya en la
app y en el móvil. 409 si el slug ya existe. Devuelve ``{slug, uid}``.
Con el flag ``OSINT_DB_BACKEND`` ON el alta va al osint_db; 503 si no
responde.
"""
result = state.create_contact(data)
try:
result = state.create_contact(data)
except osintdb_client.OsintDbUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(status_code=201, content={"status": "ok", **result})
@app.put("/api/contact/{slug}")
@@ -1860,20 +2430,74 @@ def create_app(vault_dir: str) -> FastAPI:
"""Edita la ficha de un contacto (merge frontmatter) + re-sube el vCard.
404 si no existe la ficha. Preserva campos heredados no editables
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``.
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``. Con el
flag ``OSINT_DB_BACKEND`` ON la edición va al osint_db; 503 si no responde.
"""
result = state.update_contact(slug, data)
try:
result = state.update_contact(slug, data)
except osintdb_client.OsintDbUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(content={"status": "ok", **result})
@app.delete("/api/contact/{slug}")
def api_delete_contact(slug: str) -> JSONResponse:
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos.
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``.
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``. Con
el flag ``OSINT_DB_BACKEND`` ON el borrado va al osint_db; 503 si no
responde.
"""
result = state.delete_contact(slug)
try:
result = state.delete_contact(slug)
except osintdb_client.OsintDbUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(content={"status": "ok", **result})
# -- Libretas (addressbooks) de contactos --
@app.get("/api/addressbooks")
def api_addressbooks() -> JSONResponse:
"""Libretas de contactos disponibles para el selector del frontend.
Cada una: ``{slug, display_name, collection_path, color}``. Con el flag
``OSINT_DB_BACKEND`` ON vienen del osint_db; con el flag OFF se devuelve
solo la libreta por defecto del vault. 503 si el osint_db no responde.
"""
try:
books = state.list_addressbooks()
except osintdb_client.OsintDbUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(
content={
"status": "ok",
"count": len(books),
"addressbooks": books,
"default": DEFAULT_ADDRESSBOOK_SLUG,
}
)
@app.post("/api/addressbooks")
def api_create_addressbook(data: AddressbookIn = Body(...)) -> JSONResponse:
"""Crea una libreta de contactos nueva.
Body: ``{slug, name?, color?}``. Requiere el flag ``OSINT_DB_BACKEND``
activo (el osint_db crea la colección CardDAV en Xandikos); con el flag
OFF devuelve 501 claro. 503 si el osint_db no responde.
"""
try:
res = state.create_addressbook(data)
except osintdb_client.OsintDbUnavailable as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(status_code=201, content={"status": "ok", **res})
# -- Xandikos: calendario (CalDAV) --
@app.get("/api/calendars")
@@ -1898,6 +2522,22 @@ def create_app(vault_dir: str) -> FastAPI:
}
)
@app.post("/api/calendars")
def api_create_calendar(data: CalendarIn = Body(...)) -> JSONResponse:
"""Crea una colección de calendario nueva (MKCALENDAR + nombre/color).
Body: ``{slug, name?, color?, description?}``. Idempotente si ya existe.
Devuelve ``{status, href, existed?}``. 400 si falta el nombre; 503 si
Xandikos no responde.
"""
try:
res = state.create_calendar(data)
except (RuntimeError, DavUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(status_code=201, content={"status": "ok", **res})
@app.get("/api/calendar")
def api_calendar(
cal: str = Query("", description="colección de calendario (ruta o nombre)"),
+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)
+217
View File
@@ -25,6 +25,17 @@ sys.path.insert(0, os.path.join(_HERE, "..", "server"))
import main as srv # noqa: E402
@pytest.fixture(autouse=True)
def _flag_off_por_defecto(monkeypatch, tmp_path):
"""Por defecto los tests corren con OSINT_DB_BACKEND OFF (camino histórico
vault + Xandikos), independientemente del estado real de
``dev/feature_flags.json`` en disco. Apunta ``_FLAGS_FILE`` a un archivo
inexistente (→ False). Los tests que prueban el camino ON sobrescriben
``srv._FLAGS_FILE`` dentro del propio test, ganando sobre este default.
"""
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "_no_flags.json"))
# ---------------------------------------------------------------------------
# Fixtures: vault sintético mínimo
# ---------------------------------------------------------------------------
@@ -609,6 +620,212 @@ def test_crud_update_preserves_inherited_fields(crud_client, vault):
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Contactos multi-valor: varias TEL/EMAIL/ADR + compat singular/lista
# ---------------------------------------------------------------------------
def test_contactin_reconcilia_singular_y_lista():
"""ContactIn reconcilia singular↔lista: cliente viejo (singular) y nuevo (lista)."""
# Cliente viejo: solo el campo singular → se siembra la lista.
viejo = srv.ContactIn(nombre="X", telefono="111", email="a@x.com", direccion="C1")
assert viejo.telefonos == ["111"]
assert viejo.emails == ["a@x.com"]
assert viejo.direcciones == ["C1"]
# El singular se conserva = primer elemento.
assert viejo.telefono == "111"
# Cliente nuevo: listas → el singular se rellena con lista[0].
nuevo = srv.ContactIn(
nombre="X",
telefonos=["111", "222"],
emails=["a@x.com", "b@x.com"],
direcciones=["C1", "C2"],
)
assert nuevo.telefonos == ["111", "222"]
assert nuevo.telefono == "111"
assert nuevo.email == "a@x.com"
assert nuevo.direccion == "C1"
def test_build_vcard_multivalor_emite_n_lineas():
"""_build_vcard emite una TEL/EMAIL/ADR por elemento de cada lista."""
fm = {
"tipo": "persona",
"nombre": "Multi Persona",
"telefonos": ["111", "222"],
"emails": ["a@x.com", "b@x.com"],
"direcciones": ["Calle 1", "Calle 2"],
"dni": "X",
}
vc = srv._build_vcard(fm, "multi-persona")
assert vc.count("TEL;TYPE=CELL:") == 2
assert "TEL;TYPE=CELL:111" in vc and "TEL;TYPE=CELL:222" in vc
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
assert vc.count("ADR;TYPE=HOME:") == 2
assert "ADR;TYPE=HOME:;;Calle 1;;;;" in vc
assert "X-OSINT-DNI:X" in vc
def test_vcard_to_json_lee_adr_multivalor():
"""_vcard_to_json reconstruye la lista de direcciones desde las líneas ADR."""
vcard = (
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:adr-1\r\nFN:Con Direcciones\r\n"
"ADR;TYPE=HOME:;;Calle Uno 1;;;;\r\n"
"ADR;TYPE=HOME:;;Calle Dos 2;Madrid;;28001;España\r\n"
"END:VCARD\r\n"
)
out = srv._vcard_to_json(vcard)
assert out["direcciones"][0] == "Calle Uno 1"
# El 2º ADR concatena street + locality/region/postal/country legibles.
assert "Calle Dos 2" in out["direcciones"][1]
assert "Madrid" in out["direcciones"][1]
def test_vcard_to_json_legacy_x_osint_direccion():
"""Compat: una dirección antigua en X-OSINT-DIRECCION sube a direcciones[]."""
vcard = (
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:legacy-1\r\nFN:Legacy\r\n"
"X-OSINT-DIRECCION:Calle Antigua 7\r\nEND:VCARD\r\n"
)
out = srv._vcard_to_json(vcard)
assert "Calle Antigua 7" in out["direcciones"]
# Se mantiene también en osint.direccion por si un lector viejo lo consulta.
assert out["osint"]["direccion"] == "Calle Antigua 7"
def test_crud_multivalor_round_trip(crud_client, vault):
"""Golden multi-valor: crear con 2 teléfonos/emails/direcciones y verlos todos."""
calls = crud_client._crud_calls
body = {
"tipo": "persona",
"nombre": "Poli Valor",
"telefonos": ["+34600000001", "+34600000002"],
"emails": ["uno@x.com", "dos@x.com"],
"direcciones": ["Calle A 1", "Calle B 2"],
}
r = crud_client.post("/api/contact", json=body)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
md = os.path.join(vault, "personas", slug + ".md")
content = open(md, encoding="utf-8").read()
# El frontmatter escribe las listas multi-valor + el singular compat.
assert "+34600000001" in content and "+34600000002" in content
assert "uno@x.com" in content and "dos@x.com" in content
# El vCard emitió las dos líneas TEL/EMAIL/ADR.
vc = calls["put"][-1]["vcard"]
assert vc.count("TEL;TYPE=CELL:") == 2
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
assert vc.count("ADR;TYPE=HOME:") == 2
crud_client.delete("/api/contact/%s" % slug)
def test_crud_singular_compat_sigue_funcionando(crud_client, vault):
"""Edge: un cliente viejo que envía solo el singular sigue funcionando."""
calls = crud_client._crud_calls
body = {"tipo": "persona", "nombre": "Solo Singular", "telefono": "+34611111111"}
r = crud_client.post("/api/contact", json=body)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
vc = calls["put"][-1]["vcard"]
assert "TEL;TYPE=CELL:+34611111111" in vc
assert vc.count("TEL;TYPE=CELL:") == 1
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Libretas (addressbooks) + feature flag OSINT_DB_BACKEND
# ---------------------------------------------------------------------------
def test_feature_flag_off_por_defecto(monkeypatch, tmp_path):
"""El flag OSINT_DB_BACKEND está OFF por defecto (archivo ausente → False)."""
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
assert srv._osint_db_backend_enabled() is False
def test_feature_flag_lee_archivo(monkeypatch, tmp_path):
"""_osint_db_backend_enabled refleja el archivo dev/feature_flags.json."""
flags = tmp_path / "feature_flags.json"
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
)
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
assert srv._osint_db_backend_enabled() is True
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":false}}}', encoding="utf-8"
)
assert srv._osint_db_backend_enabled() is False
def test_addressbooks_off_devuelve_libreta_por_defecto(crud_client):
"""Con el flag OFF, /api/addressbooks devuelve solo la libreta por defecto."""
r = crud_client.get("/api/addressbooks")
assert r.status_code == 200, r.text
data = r.json()
assert data["status"] == "ok"
assert data["count"] == 1
assert data["addressbooks"][0]["slug"] == srv.DEFAULT_ADDRESSBOOK_SLUG
assert data["default"] == srv.DEFAULT_ADDRESSBOOK_SLUG
def test_create_addressbook_off_devuelve_501(crud_client, monkeypatch, tmp_path):
"""Error: crear libreta con el flag OFF → 501 claro (requiere OSINT_DB_BACKEND)."""
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
r = crud_client.post("/api/addressbooks", json={"slug": "trabajo", "name": "Trabajo"})
assert r.status_code == 501
assert "OSINT_DB_BACKEND" in r.json()["detail"]
def test_contacts_flag_on_usa_osint_db(crud_client, monkeypatch, tmp_path):
"""Con el flag ON, /api/contacts lee del osint_db (mockeado), no de Xandikos."""
flags = tmp_path / "feature_flags.json"
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
)
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
monkeypatch.setattr(
srv.osintdb_client,
"list_contacts",
lambda: [
{
"uid": "u1",
"collection": "addressbook",
"fn": "Desde DuckDB",
"tels": '["111", "222"]',
"emails": '["a@x.com"]',
"note_path": None,
}
],
)
r = crud_client.get("/api/contacts")
assert r.status_code == 200, r.text
contacts = r.json()["contacts"]
assert len(contacts) == 1
c = contacts[0]
assert c["nombre"] == "Desde DuckDB"
# Los JSON array de tels/emails se parsean a lista de strings.
assert c["telefonos"] == ["111", "222"]
assert c["correos"] == ["a@x.com"]
def test_contacts_flag_on_osint_db_caido_503(crud_client, monkeypatch, tmp_path):
"""Error: con el flag ON y el osint_db caído, /api/contacts degrada a 503."""
flags = tmp_path / "feature_flags.json"
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
)
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
def _down():
raise srv.osintdb_client.OsintDbUnavailable("no arrancado")
monkeypatch.setattr(srv.osintdb_client, "list_contacts", _down)
r = crud_client.get("/api/contacts")
assert r.status_code == 503
assert r.json()["status"] == "error"
# ---------------------------------------------------------------------------
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
# ---------------------------------------------------------------------------