Compare commits
11 Commits
4a487b3d33
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cacc7dacf | |||
| f5d15a9f7b | |||
| 6b7fa621d6 | |||
| 3716b3f22a | |||
| 83c672c072 | |||
| ef23c8aee1 | |||
| fb3956e8eb | |||
| 9a256be2bb | |||
| 9cbea2d036 | |||
| 71e4d95e64 | |||
| 5d5ce65e88 |
@@ -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
@@ -108,7 +108,12 @@ export interface Contact {
|
|||||||
emails: ContactPhone[];
|
emails: ContactPhone[];
|
||||||
telefonos: string[];
|
telefonos: string[];
|
||||||
correos: string[];
|
correos: string[];
|
||||||
|
// Direcciones multi-valor (varias ADR del vCard, o X-OSINT-DIRECCION legacy).
|
||||||
|
direcciones?: string[];
|
||||||
osint: Record<string, string>;
|
osint: Record<string, string>;
|
||||||
|
// Libreta (addressbook) a la que pertenece el contacto, cuando el backend la
|
||||||
|
// expone (camino osint_db). Sirve para filtrar la lista por libreta.
|
||||||
|
collection?: string | null;
|
||||||
href?: string;
|
href?: string;
|
||||||
etag?: string;
|
etag?: string;
|
||||||
}
|
}
|
||||||
@@ -132,6 +137,12 @@ export interface CalendarEvent {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
color?: 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;
|
href?: string;
|
||||||
etag?: string;
|
etag?: string;
|
||||||
}
|
}
|
||||||
@@ -157,6 +168,21 @@ export interface CalendarsPayload {
|
|||||||
error?: string;
|
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).
|
// Cuerpo de POST/PUT de un evento del calendario (VEVENT).
|
||||||
export interface EventInput {
|
export interface EventInput {
|
||||||
cal?: string | null;
|
cal?: string | null;
|
||||||
@@ -168,6 +194,9 @@ export interface EventInput {
|
|||||||
location?: string | null;
|
location?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
color?: 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 {
|
export interface EventWriteResult {
|
||||||
@@ -199,14 +228,24 @@ export interface ContactInput {
|
|||||||
tipo: "persona" | "organizacion";
|
tipo: "persona" | "organizacion";
|
||||||
nombre: string;
|
nombre: string;
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
telefono: string | null;
|
// Multi-valor: listas completas de teléfonos, emails y direcciones. El backend
|
||||||
email: string | null;
|
// reconcilia con los campos singulares (compat) — el primer elemento de cada
|
||||||
|
// lista se conserva en telefono/email/direccion para los lectores viejos.
|
||||||
|
telefonos: string[];
|
||||||
|
emails: string[];
|
||||||
|
direcciones: string[];
|
||||||
|
// Singulares (compat). Opcionales: el frontend nuevo envía solo las listas; el
|
||||||
|
// backend rellena el singular con lista[0].
|
||||||
|
telefono?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
direccion?: string | null;
|
||||||
dni: string | null;
|
dni: string | null;
|
||||||
direccion: string | null;
|
|
||||||
pais: string | null;
|
pais: string | null;
|
||||||
contexto: string | null;
|
contexto: string | null;
|
||||||
relaciones: string[];
|
relaciones: string[];
|
||||||
notas: string | null;
|
notas: string | null;
|
||||||
|
// Libreta (addressbook) destino. Solo se honra con el flag OSINT_DB_BACKEND ON.
|
||||||
|
collection?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContactWriteResult {
|
export interface ContactWriteResult {
|
||||||
@@ -256,8 +295,48 @@ export const deleteContact = (slug: string) =>
|
|||||||
"DELETE",
|
"DELETE",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Libretas (addressbooks) de contactos ---------------------------------
|
||||||
|
|
||||||
|
export interface Addressbook {
|
||||||
|
slug: string;
|
||||||
|
display_name: string;
|
||||||
|
collection_path: string;
|
||||||
|
color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressbooksPayload {
|
||||||
|
status: string;
|
||||||
|
count?: number;
|
||||||
|
addressbooks?: Addressbook[];
|
||||||
|
default?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cuerpo de POST /addressbooks para crear una libreta nueva.
|
||||||
|
export interface AddressbookInput {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressbookWriteResult {
|
||||||
|
status: string;
|
||||||
|
slug?: string;
|
||||||
|
existed?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchAddressbooks = () =>
|
||||||
|
getJSON<AddressbooksPayload>("/addressbooks");
|
||||||
|
|
||||||
|
export const createAddressbook = (data: AddressbookInput) =>
|
||||||
|
sendJSON<AddressbookWriteResult>("/addressbooks", "POST", data);
|
||||||
|
|
||||||
export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
|
export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
|
||||||
|
|
||||||
|
export const createCalendar = (data: CalendarInput) =>
|
||||||
|
sendJSON<CalendarWriteResult>("/calendars", "POST", data);
|
||||||
|
|
||||||
export const fetchCalendar = (cal = "", from = "", to = "") => {
|
export const fetchCalendar = (cal = "", from = "", to = "") => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (cal) qs.set("cal", cal);
|
if (cal) qs.set("cal", cal);
|
||||||
|
|||||||
@@ -139,6 +139,129 @@ export function monthMatrix(date: dayjs.Dayjs): dayjs.Dayjs[][] {
|
|||||||
export const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
export const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||||
export const WEEKDAY_LABELS = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
|
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 0–24h. */
|
||||||
|
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. */
|
/** Color efectivo de un evento: el propio del VEVENT o el del calendario. */
|
||||||
export function eventColor(
|
export function eventColor(
|
||||||
ev: CalendarEvent,
|
ev: CalendarEvent,
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
|
ColorInput,
|
||||||
ColorSwatch,
|
ColorSwatch,
|
||||||
Group,
|
Group,
|
||||||
Indicator,
|
Indicator,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -25,8 +28,10 @@ import {
|
|||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
|
IconRepeat,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
|
createCalendar,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
fetchCalendar,
|
fetchCalendar,
|
||||||
fetchCalendars,
|
fetchCalendars,
|
||||||
@@ -36,10 +41,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
dayjs,
|
dayjs,
|
||||||
eventColor,
|
eventColor,
|
||||||
|
eventEnd,
|
||||||
eventStart,
|
eventStart,
|
||||||
HOURS,
|
HOURS,
|
||||||
monthMatrix,
|
monthMatrix,
|
||||||
|
nowLinePct,
|
||||||
positionEventsForDay,
|
positionEventsForDay,
|
||||||
|
slugifyCalendar,
|
||||||
TIMEZONES,
|
TIMEZONES,
|
||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
weekDays,
|
weekDays,
|
||||||
@@ -47,7 +55,7 @@ import {
|
|||||||
} from "../calendar";
|
} from "../calendar";
|
||||||
import { EventModal, type EventDraft } from "./EventModal";
|
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
|
// 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
|
// 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 [cursor, setCursor] = useState<string>(dayjs().format("YYYY-MM-DD"));
|
||||||
const [modal, setModal] = useState<EventDraft | null>(null);
|
const [modal, setModal] = useState<EventDraft | null>(null);
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
setNow(dayjs().tz(tz));
|
||||||
fetchCalendars()
|
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) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
|
||||||
if (d.status === "ok" && d.calendars) {
|
if (d.status === "ok" && d.calendars) {
|
||||||
setCalendars(d.calendars);
|
setCalendars(d.calendars);
|
||||||
if (!activeCal && d.default) setActiveCal(d.default);
|
if (selectHref) {
|
||||||
else if (!activeCal && d.calendars[0]) setActiveCal(d.calendars[0].href);
|
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(() => {
|
.catch(() => {
|
||||||
/* el selector degrada a "calendario" implícito; no es fatal */
|
/* 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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchCalendar(activeCal)
|
fetchCalendar(activeCal, range.from, range.to)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
if (d.status !== "ok") {
|
if (d.status !== "ok") {
|
||||||
@@ -107,7 +174,7 @@ export function CalendarView() {
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [activeCal, reloadKey]);
|
}, [activeCal, reloadKey, range.from, range.to]);
|
||||||
|
|
||||||
const calColor = useMemo(() => {
|
const calColor = useMemo(() => {
|
||||||
const c = calendars.find((c) => c.href === activeCal);
|
const c = calendars.find((c) => c.href === activeCal);
|
||||||
@@ -180,6 +247,7 @@ export function CalendarView() {
|
|||||||
description: "",
|
description: "",
|
||||||
color: "",
|
color: "",
|
||||||
cal: activeCal,
|
cal: activeCal,
|
||||||
|
rrule: "",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[tz, activeCal],
|
[tz, activeCal],
|
||||||
@@ -206,11 +274,53 @@ export function CalendarView() {
|
|||||||
description: ev.description ?? "",
|
description: ev.description ?? "",
|
||||||
color: ev.color ?? "",
|
color: ev.color ?? "",
|
||||||
cal: activeCal,
|
cal: activeCal,
|
||||||
|
rrule: ev.rrule ?? "",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[tz, activeCal],
|
[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) {
|
function navigate(dir: -1 | 1) {
|
||||||
const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day";
|
const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day";
|
||||||
setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD"));
|
setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD"));
|
||||||
@@ -233,6 +343,8 @@ export function CalendarView() {
|
|||||||
const headerTitle =
|
const headerTitle =
|
||||||
view === "mes"
|
view === "mes"
|
||||||
? dayjs(cursor).format("MMMM YYYY")
|
? dayjs(cursor).format("MMMM YYYY")
|
||||||
|
: view === "lista"
|
||||||
|
? "Agenda"
|
||||||
: view === "semana"
|
: view === "semana"
|
||||||
? (() => {
|
? (() => {
|
||||||
const wd = weekDays(dayjs(cursor));
|
const wd = weekDays(dayjs(cursor));
|
||||||
@@ -279,6 +391,7 @@ export function CalendarView() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||||
<Select
|
<Select
|
||||||
label="Calendario"
|
label="Calendario"
|
||||||
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
|
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
|
||||||
@@ -292,7 +405,24 @@ export function CalendarView() {
|
|||||||
}
|
}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
comboboxProps={{ withinPortal: true }}
|
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
|
<Select
|
||||||
label="Zona horaria"
|
label="Zona horaria"
|
||||||
@@ -341,6 +471,7 @@ export function CalendarView() {
|
|||||||
{ value: "mes", label: "Mes" },
|
{ value: "mes", label: "Mes" },
|
||||||
{ value: "semana", label: "Semana" },
|
{ value: "semana", label: "Semana" },
|
||||||
{ value: "dia", label: "Día" },
|
{ value: "dia", label: "Día" },
|
||||||
|
{ value: "lista", label: "Lista" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -362,12 +493,21 @@ export function CalendarView() {
|
|||||||
}}
|
}}
|
||||||
onEvent={openEdit}
|
onEvent={openEdit}
|
||||||
/>
|
/>
|
||||||
|
) : view === "lista" ? (
|
||||||
|
<AgendaView
|
||||||
|
events={events}
|
||||||
|
tz={tz}
|
||||||
|
calColor={calColor}
|
||||||
|
onNew={() => openNew(dayjs().format("YYYY-MM-DD"))}
|
||||||
|
onEvent={openEdit}
|
||||||
|
/>
|
||||||
) : view === "semana" ? (
|
) : view === "semana" ? (
|
||||||
<TimeGrid
|
<TimeGrid
|
||||||
days={weekDays(dayjs(cursor))}
|
days={weekDays(dayjs(cursor))}
|
||||||
events={events}
|
events={events}
|
||||||
tz={tz}
|
tz={tz}
|
||||||
calColor={calColor}
|
calColor={calColor}
|
||||||
|
now={now}
|
||||||
onSlot={openNew}
|
onSlot={openNew}
|
||||||
onEvent={openEdit}
|
onEvent={openEdit}
|
||||||
/>
|
/>
|
||||||
@@ -377,6 +517,7 @@ export function CalendarView() {
|
|||||||
events={events}
|
events={events}
|
||||||
tz={tz}
|
tz={tz}
|
||||||
calColor={calColor}
|
calColor={calColor}
|
||||||
|
now={now}
|
||||||
onSlot={openNew}
|
onSlot={openNew}
|
||||||
onEvent={openEdit}
|
onEvent={openEdit}
|
||||||
/>
|
/>
|
||||||
@@ -391,6 +532,55 @@ export function CalendarView() {
|
|||||||
onSaved={onSaved}
|
onSaved={onSaved}
|
||||||
onDelete={onDelete}
|
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>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -402,6 +592,7 @@ function TimeGrid({
|
|||||||
events,
|
events,
|
||||||
tz,
|
tz,
|
||||||
calColor,
|
calColor,
|
||||||
|
now,
|
||||||
onSlot,
|
onSlot,
|
||||||
onEvent,
|
onEvent,
|
||||||
}: {
|
}: {
|
||||||
@@ -409,11 +600,14 @@ function TimeGrid({
|
|||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
tz: string;
|
tz: string;
|
||||||
calColor: string | null;
|
calColor: string | null;
|
||||||
|
now: dayjs.Dayjs;
|
||||||
onSlot: (day: string, hour: number) => void;
|
onSlot: (day: string, hour: number) => void;
|
||||||
onEvent: (ev: CalendarEvent) => void;
|
onEvent: (ev: CalendarEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
const HOUR_PX = 44;
|
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 (
|
return (
|
||||||
<ScrollArea h="100%" type="auto">
|
<ScrollArea h="100%" type="auto">
|
||||||
<Box style={{ display: "flex", minWidth: days.length > 1 ? 640 : 0 }}>
|
<Box style={{ display: "flex", minWidth: days.length > 1 ? 640 : 0 }}>
|
||||||
@@ -523,12 +717,45 @@ function TimeGrid({
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Group gap={3} wrap="nowrap" align="center">
|
||||||
|
{p.ev.recurring && (
|
||||||
|
<IconRepeat size={11} style={{ flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
<Text size="xs" fw={600} truncate c="#fff">
|
<Text size="xs" fw={600} truncate c="#fff">
|
||||||
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
|
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
|
||||||
{p.ev.summary || "(sin título)"}
|
{p.ev.summary || "(sin título)"}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Group>
|
||||||
</Box>
|
</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>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -679,6 +906,9 @@ function EventChip({
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
leftSection={
|
||||||
|
ev.recurring ? <IconRepeat size={10} style={{ display: "block" }} /> : undefined
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
background: color,
|
background: color,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -692,3 +922,194 @@ function EventChip({
|
|||||||
</Badge>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
|
ColorInput,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -19,12 +21,15 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
|
IconAddressBook,
|
||||||
IconAt,
|
IconAt,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
|
IconMapPin,
|
||||||
IconNote,
|
IconNote,
|
||||||
IconPhone,
|
IconPhone,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -33,19 +38,23 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
|
createAddressbook,
|
||||||
createContact,
|
createContact,
|
||||||
deleteContact,
|
deleteContact,
|
||||||
|
fetchAddressbooks,
|
||||||
fetchContacts,
|
fetchContacts,
|
||||||
updateContact,
|
updateContact,
|
||||||
|
type Addressbook,
|
||||||
type Contact,
|
type Contact,
|
||||||
type ContactInput,
|
type ContactInput,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { slugify } from "../format";
|
import { slugify } from "../format";
|
||||||
|
|
||||||
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
|
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
|
||||||
// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la
|
// buscador, selector de libreta y filtro por libreta) y la ficha del contacto
|
||||||
// vista de lectura y añade los controles de edición: alta ("Nuevo contacto"),
|
// seleccionado a la derecha. Soporta contactos multi-valor (varios teléfonos,
|
||||||
// edición y borrado de la ficha del vault (con reflejo inmediato en Xandikos).
|
// emails y direcciones) y la creación de libretas nuevas (análogo al patrón de
|
||||||
|
// "Nuevo calendario" en CalendarView).
|
||||||
|
|
||||||
type FormTipo = "persona" | "organizacion";
|
type FormTipo = "persona" | "organizacion";
|
||||||
|
|
||||||
@@ -53,61 +62,73 @@ interface FormState {
|
|||||||
tipo: FormTipo;
|
tipo: FormTipo;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
telefono: string;
|
// Multi-valor: listas completas (antes eran campos singulares).
|
||||||
email: string;
|
telefonos: string[];
|
||||||
|
emails: string[];
|
||||||
|
direcciones: string[];
|
||||||
dni: string;
|
dni: string;
|
||||||
direccion: string;
|
|
||||||
pais: string;
|
pais: string;
|
||||||
contexto: string;
|
contexto: string;
|
||||||
notas: string;
|
notas: string;
|
||||||
|
// Libreta destino (slug). "" → libreta por defecto.
|
||||||
|
collection: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM: FormState = {
|
const EMPTY_FORM: FormState = {
|
||||||
tipo: "persona",
|
tipo: "persona",
|
||||||
nombre: "",
|
nombre: "",
|
||||||
aliases: [],
|
aliases: [],
|
||||||
telefono: "",
|
telefonos: [],
|
||||||
email: "",
|
emails: [],
|
||||||
|
direcciones: [],
|
||||||
dni: "",
|
dni: "",
|
||||||
direccion: "",
|
|
||||||
pais: "",
|
pais: "",
|
||||||
contexto: "",
|
contexto: "",
|
||||||
notas: "",
|
notas: "",
|
||||||
|
collection: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Construye el estado del formulario a partir de un contacto existente (para
|
// Construye el estado del formulario a partir de un contacto existente (para
|
||||||
// editar). Toma los campos del bloque osint (dni/direccion/pais/contexto) que el
|
// editar). Carga TODOS los valores multi-valor (antes solo cargaba el primero).
|
||||||
// backend expone tras parsear el vCard.
|
|
||||||
function formFromContact(c: Contact): FormState {
|
function formFromContact(c: Contact): FormState {
|
||||||
const osint = c.osint ?? {};
|
const osint = c.osint ?? {};
|
||||||
return {
|
return {
|
||||||
tipo: "persona",
|
tipo: "persona",
|
||||||
nombre: c.nombre ?? "",
|
nombre: c.nombre ?? "",
|
||||||
aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
||||||
telefono: c.telefonos?.[0] ?? "",
|
telefonos: c.telefonos ?? [],
|
||||||
email: c.correos?.[0] ?? "",
|
emails: c.correos ?? [],
|
||||||
|
// Direcciones desde el campo multi-valor; cae a osint.direccion (legacy).
|
||||||
|
direcciones:
|
||||||
|
c.direcciones && c.direcciones.length > 0
|
||||||
|
? c.direcciones
|
||||||
|
: osint.direccion
|
||||||
|
? [osint.direccion]
|
||||||
|
: [],
|
||||||
dni: osint.dni ?? "",
|
dni: osint.dni ?? "",
|
||||||
direccion: osint.direccion ?? "",
|
|
||||||
pais: osint.pais ?? "",
|
pais: osint.pais ?? "",
|
||||||
contexto: osint.contexto ?? "",
|
contexto: osint.contexto ?? "",
|
||||||
notas: c.nota ?? "",
|
notas: c.nota ?? "",
|
||||||
|
collection: c.collection ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formToInput(f: FormState): ContactInput {
|
function formToInput(f: FormState): ContactInput {
|
||||||
const t = (v: string) => (v.trim() ? v.trim() : null);
|
const t = (vals: string[]) => vals.map((s) => s.trim()).filter(Boolean);
|
||||||
|
const s = (v: string) => (v.trim() ? v.trim() : null);
|
||||||
return {
|
return {
|
||||||
tipo: f.tipo,
|
tipo: f.tipo,
|
||||||
nombre: f.nombre.trim(),
|
nombre: f.nombre.trim(),
|
||||||
aliases: f.aliases.map((s) => s.trim()).filter(Boolean),
|
aliases: t(f.aliases),
|
||||||
telefono: t(f.telefono),
|
telefonos: t(f.telefonos),
|
||||||
email: t(f.email),
|
emails: t(f.emails),
|
||||||
dni: t(f.dni),
|
direcciones: t(f.direcciones),
|
||||||
direccion: t(f.direccion),
|
dni: s(f.dni),
|
||||||
pais: t(f.pais),
|
pais: s(f.pais),
|
||||||
contexto: t(f.contexto),
|
contexto: s(f.contexto),
|
||||||
relaciones: [],
|
relaciones: [],
|
||||||
notas: t(f.notas),
|
notas: s(f.notas),
|
||||||
|
collection: f.collection.trim() ? f.collection.trim() : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +140,10 @@ export function ContactsView() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debQuery] = useDebouncedValue(query, 200);
|
const [debQuery] = useDebouncedValue(query, 200);
|
||||||
|
|
||||||
|
// Libretas (addressbooks) + filtro por libreta de la lista.
|
||||||
|
const [addressbooks, setAddressbooks] = useState<Addressbook[]>([]);
|
||||||
|
const [filterBook, setFilterBook] = useState<string>(""); // "" = todas
|
||||||
|
|
||||||
// Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
|
// Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
|
||||||
// edición (slug del contacto). `saving` deshabilita el botón mientras escribe.
|
// edición (slug del contacto). `saving` deshabilita el botón mientras escribe.
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
@@ -126,6 +151,13 @@ export function ContactsView() {
|
|||||||
const [editSlug, setEditSlug] = useState<string | null>(null);
|
const [editSlug, setEditSlug] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Modal "nueva libreta" (mismo patrón que "Nuevo calendario").
|
||||||
|
const [newBookOpen, setNewBookOpen] = useState(false);
|
||||||
|
const [newBookName, setNewBookName] = useState("");
|
||||||
|
const [newBookColor, setNewBookColor] = useState("");
|
||||||
|
const [newBookErr, setNewBookErr] = useState<string | null>(null);
|
||||||
|
const [newBookSaving, setNewBookSaving] = useState(false);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
@@ -141,15 +173,35 @@ export function ContactsView() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carga de libretas. `selectSlug` selecciona una recién creada en el form.
|
||||||
|
const loadAddressbooks = useCallback((selectSlug?: string) => {
|
||||||
|
return fetchAddressbooks()
|
||||||
|
.then((d) => {
|
||||||
|
if (d.status === "ok" && d.addressbooks) {
|
||||||
|
setAddressbooks(d.addressbooks);
|
||||||
|
if (selectSlug) {
|
||||||
|
setForm((f) => ({ ...f, collection: selectSlug }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* el selector degrada a la libreta por defecto; no es fatal */
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
|
loadAddressbooks();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = debQuery.trim().toLowerCase();
|
const q = debQuery.trim().toLowerCase();
|
||||||
if (!q) return contacts;
|
|
||||||
return contacts.filter((c) => {
|
return contacts.filter((c) => {
|
||||||
|
if (filterBook && (c.collection ?? "") !== filterBook) return false;
|
||||||
|
if (!q) return true;
|
||||||
const hay = [
|
const hay = [
|
||||||
c.nombre,
|
c.nombre,
|
||||||
c.alias,
|
c.alias,
|
||||||
@@ -162,7 +214,14 @@ export function ContactsView() {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return hay.includes(q);
|
return hay.includes(q);
|
||||||
});
|
});
|
||||||
}, [contacts, debQuery]);
|
}, [contacts, debQuery, filterBook]);
|
||||||
|
|
||||||
|
// Opciones del selector de libreta del formulario y del filtro.
|
||||||
|
const bookOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
addressbooks.map((b) => ({ value: b.slug, label: b.display_name })),
|
||||||
|
[addressbooks],
|
||||||
|
);
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
@@ -218,6 +277,47 @@ export function ContactsView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crea una libreta nueva (mismo flujo que createNewCalendar): deriva el slug
|
||||||
|
// del nombre, refresca la lista y selecciona la nueva en el formulario.
|
||||||
|
const createNewBook = useCallback(async () => {
|
||||||
|
const name = newBookName.trim();
|
||||||
|
if (!name) {
|
||||||
|
setNewBookErr("El nombre es obligatorio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slug = slugify(name);
|
||||||
|
if (!slug) {
|
||||||
|
setNewBookErr("El nombre no produce un identificador válido.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewBookSaving(true);
|
||||||
|
setNewBookErr(null);
|
||||||
|
try {
|
||||||
|
const res = await createAddressbook({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
color: newBookColor || null,
|
||||||
|
});
|
||||||
|
if (res.status !== "ok") {
|
||||||
|
setNewBookErr(res.error || "No se pudo crear la libreta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadAddressbooks(res.slug || slug);
|
||||||
|
notifications.show({
|
||||||
|
color: "teal",
|
||||||
|
title: "Libreta creada",
|
||||||
|
message: name,
|
||||||
|
});
|
||||||
|
setNewBookOpen(false);
|
||||||
|
setNewBookName("");
|
||||||
|
setNewBookColor("");
|
||||||
|
} catch (e) {
|
||||||
|
setNewBookErr(String(e));
|
||||||
|
} finally {
|
||||||
|
setNewBookSaving(false);
|
||||||
|
}
|
||||||
|
}, [newBookName, newBookColor, loadAddressbooks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContactForm
|
<ContactForm
|
||||||
@@ -225,11 +325,67 @@ export function ContactsView() {
|
|||||||
editing={editSlug !== null}
|
editing={editSlug !== null}
|
||||||
form={form}
|
form={form}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
bookOptions={bookOptions}
|
||||||
onChange={setForm}
|
onChange={setForm}
|
||||||
|
onNewBook={() => {
|
||||||
|
setNewBookErr(null);
|
||||||
|
setNewBookName("");
|
||||||
|
setNewBookColor("");
|
||||||
|
setNewBookOpen(true);
|
||||||
|
}}
|
||||||
onClose={() => setFormOpen(false)}
|
onClose={() => setFormOpen(false)}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={newBookOpen}
|
||||||
|
onClose={() => setNewBookOpen(false)}
|
||||||
|
title="Nueva libreta"
|
||||||
|
size="sm"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Nombre"
|
||||||
|
placeholder="Trabajo, Familia, OSINT…"
|
||||||
|
value={newBookName}
|
||||||
|
onChange={(e) => setNewBookName(e.currentTarget.value)}
|
||||||
|
data-autofocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<ColorInput
|
||||||
|
label="Color"
|
||||||
|
placeholder="Color de la libreta (opcional)"
|
||||||
|
value={newBookColor}
|
||||||
|
onChange={setNewBookColor}
|
||||||
|
format="hex"
|
||||||
|
swatches={[
|
||||||
|
"#23bdfe",
|
||||||
|
"#16a34a",
|
||||||
|
"#dc2626",
|
||||||
|
"#f59e0b",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#ec4899",
|
||||||
|
"#0891b2",
|
||||||
|
"#64748b",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{newBookErr && (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{newBookErr}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="xs" mt="xs">
|
||||||
|
<Button variant="default" onClick={() => setNewBookOpen(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={createNewBook} loading={newBookSaving}>
|
||||||
|
Crear
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Center h="100%" p="xl">
|
<Center h="100%" p="xl">
|
||||||
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
||||||
@@ -258,6 +414,34 @@ export function ContactsView() {
|
|||||||
>
|
>
|
||||||
Nuevo contacto
|
Nuevo contacto
|
||||||
</Button>
|
</Button>
|
||||||
|
<Group align="flex-end" gap="xs" wrap="nowrap" mb="sm">
|
||||||
|
<Select
|
||||||
|
label="Libreta"
|
||||||
|
placeholder="Todas"
|
||||||
|
data={bookOptions}
|
||||||
|
value={filterBook || null}
|
||||||
|
onChange={(v) => setFilterBook(v || "")}
|
||||||
|
leftSection={<IconAddressBook size={14} />}
|
||||||
|
clearable
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
<Tooltip label="Nueva libreta">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Nueva libreta"
|
||||||
|
onClick={() => {
|
||||||
|
setNewBookErr(null);
|
||||||
|
setNewBookName("");
|
||||||
|
setNewBookColor("");
|
||||||
|
setNewBookOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPlus size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Buscar contacto…"
|
placeholder="Buscar contacto…"
|
||||||
leftSection={<IconSearch size={16} />}
|
leftSection={<IconSearch size={16} />}
|
||||||
@@ -339,7 +523,9 @@ function ContactForm({
|
|||||||
editing,
|
editing,
|
||||||
form,
|
form,
|
||||||
saving,
|
saving,
|
||||||
|
bookOptions,
|
||||||
onChange,
|
onChange,
|
||||||
|
onNewBook,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
@@ -347,7 +533,9 @@ function ContactForm({
|
|||||||
editing: boolean;
|
editing: boolean;
|
||||||
form: FormState;
|
form: FormState;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
bookOptions: { value: string; label: string }[];
|
||||||
onChange: (f: FormState) => void;
|
onChange: (f: FormState) => void;
|
||||||
|
onNewBook: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -362,6 +550,7 @@ function ContactForm({
|
|||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
<Group grow align="flex-start">
|
||||||
<Select
|
<Select
|
||||||
label="Tipo"
|
label="Tipo"
|
||||||
data={[
|
data={[
|
||||||
@@ -373,6 +562,30 @@ function ContactForm({
|
|||||||
disabled={editing}
|
disabled={editing}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
/>
|
/>
|
||||||
|
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||||
|
<Select
|
||||||
|
label="Libreta"
|
||||||
|
placeholder="Por defecto"
|
||||||
|
data={bookOptions}
|
||||||
|
value={form.collection || null}
|
||||||
|
onChange={(v) => set("collection", v || "")}
|
||||||
|
leftSection={<IconAddressBook size={14} />}
|
||||||
|
clearable
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
<Tooltip label="Nueva libreta">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Nueva libreta"
|
||||||
|
onClick={onNewBook}
|
||||||
|
>
|
||||||
|
<IconPlus size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Nombre"
|
label="Nombre"
|
||||||
required
|
required
|
||||||
@@ -386,18 +599,29 @@ function ContactForm({
|
|||||||
value={form.aliases}
|
value={form.aliases}
|
||||||
onChange={(v) => set("aliases", v)}
|
onChange={(v) => set("aliases", v)}
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<Group grow align="flex-start">
|
||||||
<TextInput
|
<TagsInput
|
||||||
label="Teléfono"
|
label="Teléfonos"
|
||||||
value={form.telefono}
|
placeholder="añade un teléfono y pulsa Enter"
|
||||||
onChange={(e) => set("telefono", e.currentTarget.value)}
|
value={form.telefonos}
|
||||||
|
onChange={(v) => set("telefonos", v)}
|
||||||
|
leftSection={<IconPhone size={14} />}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TagsInput
|
||||||
label="Email"
|
label="Emails"
|
||||||
value={form.email}
|
placeholder="añade un email y pulsa Enter"
|
||||||
onChange={(e) => set("email", e.currentTarget.value)}
|
value={form.emails}
|
||||||
|
onChange={(v) => set("emails", v)}
|
||||||
|
leftSection={<IconAt size={14} />}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
<TagsInput
|
||||||
|
label="Direcciones"
|
||||||
|
placeholder="añade una dirección y pulsa Enter"
|
||||||
|
value={form.direcciones}
|
||||||
|
onChange={(v) => set("direcciones", v)}
|
||||||
|
leftSection={<IconMapPin size={14} />}
|
||||||
|
/>
|
||||||
{form.tipo === "persona" && (
|
{form.tipo === "persona" && (
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -419,11 +643,6 @@ function ContactForm({
|
|||||||
onChange={(e) => set("pais", e.currentTarget.value)}
|
onChange={(e) => set("pais", e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextInput
|
|
||||||
label="Dirección"
|
|
||||||
value={form.direccion}
|
|
||||||
onChange={(e) => set("direccion", e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Select
|
<Select
|
||||||
label="Contexto"
|
label="Contexto"
|
||||||
placeholder="origen / círculo"
|
placeholder="origen / círculo"
|
||||||
@@ -469,8 +688,16 @@ function ContactDetail({
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const osintEntries = Object.entries(contact.osint ?? {}).filter(
|
const osintEntries = Object.entries(contact.osint ?? {}).filter(
|
||||||
([, v]) => v != null && v !== "",
|
// direccion se muestra en su propio bloque multi-valor; no se duplica en OSINT.
|
||||||
|
([k, v]) => v != null && v !== "" && k !== "direccion",
|
||||||
);
|
);
|
||||||
|
// Direcciones multi-valor: campo direcciones[] o, en su defecto, osint.direccion.
|
||||||
|
const direcciones =
|
||||||
|
contact.direcciones && contact.direcciones.length > 0
|
||||||
|
? contact.direcciones
|
||||||
|
: contact.osint?.direccion
|
||||||
|
? [contact.osint.direccion]
|
||||||
|
: [];
|
||||||
return (
|
return (
|
||||||
<Stack p="xl" gap="lg" maw={720}>
|
<Stack p="xl" gap="lg" maw={720}>
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
@@ -553,6 +780,22 @@ function ContactDetail({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{direcciones.length > 0 && (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconMapPin size={16} />
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Direcciones
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{direcciones.map((d, i) => (
|
||||||
|
<Text key={i} size="sm" pl="lg">
|
||||||
|
{d}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{osintEntries.length > 0 && (
|
{osintEntries.length > 0 && (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
<Text fw={600} size="sm" mb="xs" c="brand">
|
<Text fw={600} size="sm" mb="xs" c="brand">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
ColorInput,
|
ColorInput,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -18,7 +20,15 @@ import {
|
|||||||
type CalendarCollection,
|
type CalendarCollection,
|
||||||
type EventInput,
|
type EventInput,
|
||||||
} from "../api";
|
} 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
|
// 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
|
// guardado hace POST (new) o PUT (edit). Las fechas van en ISO local
|
||||||
@@ -35,8 +45,33 @@ export interface EventDraft {
|
|||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
cal: 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({
|
export function EventModal({
|
||||||
draft,
|
draft,
|
||||||
calendars,
|
calendars,
|
||||||
@@ -54,13 +89,36 @@ export function EventModal({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setForm(draft);
|
setForm(draft);
|
||||||
setErr(null);
|
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]);
|
}, [draft]);
|
||||||
|
|
||||||
if (!form) return null;
|
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]) {
|
function set<K extends keyof EventDraft>(key: K, value: EventDraft[K]) {
|
||||||
setForm((f) => (f ? { ...f, [key]: value } : f));
|
setForm((f) => (f ? { ...f, [key]: value } : f));
|
||||||
}
|
}
|
||||||
@@ -108,6 +166,7 @@ export function EventModal({
|
|||||||
location: form.location || null,
|
location: form.location || null,
|
||||||
description: form.description || null,
|
description: form.description || null,
|
||||||
color: form.color || null,
|
color: form.color || null,
|
||||||
|
rrule: buildRrule(freq, interval, endMode, count, until, byday) || null,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (form.mode === "edit" && form.uid) {
|
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
|
<Select
|
||||||
label="Calendario"
|
label="Calendario"
|
||||||
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
|
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
|
||||||
|
|||||||
@@ -13,16 +13,29 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconCalendarEvent,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
|
IconRepeat,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSelector,
|
IconSelector,
|
||||||
} from "@tabler/icons-react";
|
} 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 { formatFrontmatterValue } from "../format";
|
||||||
import { tipoStyle } from "../tipos";
|
import { tipoStyle } from "../tipos";
|
||||||
import { useNodeCard } from "../NodeCardContext";
|
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
|
// 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
|
// 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
|
// Mantine ordenable + filtrable. Las columnas se deducen de las claves de
|
||||||
@@ -110,6 +123,12 @@ export function TablesView() {
|
|||||||
{tipoStyle(t).label}
|
{tipoStyle(t).label}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
))}
|
))}
|
||||||
|
<Tabs.Tab
|
||||||
|
value={EVENTS_TAB}
|
||||||
|
leftSection={<IconCalendarEvent size={14} />}
|
||||||
|
>
|
||||||
|
Eventos
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{availableTipos.map((t) => (
|
{availableTipos.map((t) => (
|
||||||
@@ -121,6 +140,13 @@ export function TablesView() {
|
|||||||
{active === t && <TypeTable tipo={t} />}
|
{active === t && <TypeTable tipo={t} />}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<Tabs.Panel
|
||||||
|
value={EVENTS_TAB}
|
||||||
|
style={{ flex: 1, minHeight: 0, display: "flex" }}
|
||||||
|
>
|
||||||
|
{active === EVENTS_TAB && <EventsTable />}
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -287,3 +313,222 @@ function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
|
|||||||
<IconChevronDown size={14} />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+677
-37
@@ -31,8 +31,10 @@ Endpoints (JSON salvo /api/attachment):
|
|||||||
GET /api/node/<slug> ficha: frontmatter + body + attachments
|
GET /api/node/<slug> ficha: frontmatter + body + attachments
|
||||||
GET /api/attachment?path=.. binario del attachment (path relativo al vault)
|
GET /api/attachment?path=.. binario del attachment (path relativo al vault)
|
||||||
GET /api/search?q=... nodos cuyo contenido matchea la query
|
GET /api/search?q=... nodos cuyo contenido matchea la query
|
||||||
GET /api/contacts contactos del addressbook Xandikos (CardDAV)
|
GET /api/contacts contactos (Xandikos por defecto; osint_db si flag)
|
||||||
GET /api/contact/<uid> un vCard concreto a JSON
|
GET /api/contact/<uid> un vCard concreto a JSON
|
||||||
|
GET /api/addressbooks libretas de contactos (selector del frontend)
|
||||||
|
POST /api/addressbooks crea una libreta nueva (requiere OSINT_DB_BACKEND)
|
||||||
GET /api/calendars colecciones de calendario bajo /enmanuel/calendars/
|
GET /api/calendars colecciones de calendario bajo /enmanuel/calendars/
|
||||||
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV)
|
GET /api/calendar?cal=&from=&to= eventos de una colección del calendario (CalDAV)
|
||||||
POST /api/event crea un VEVENT en una colección de calendario
|
POST /api/event crea un VEVENT en una colección de calendario
|
||||||
@@ -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.
|
# las colecciones de calendario del usuario con su nombre y color.
|
||||||
caldav_put_event = _load_infra_fn("caldav_put_event", "caldav_put_event")
|
caldav_put_event = _load_infra_fn("caldav_put_event", "caldav_put_event")
|
||||||
dav_list_calendars = _load_infra_fn("dav_list_calendars", "dav_list_calendars")
|
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_USERNAME = "enmanuel"
|
||||||
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
|
XANDIKOS_PASS_ENTRY = "dav/xandikos-enmanuel"
|
||||||
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
|
XANDIKOS_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
|
||||||
|
# Libreta (addressbook) por defecto. Cuando el flag OSINT_DB_BACKEND está OFF,
|
||||||
|
# todos los contactos viven en esta única libreta; el frontend la muestra como
|
||||||
|
# opción por defecto del selector.
|
||||||
|
DEFAULT_ADDRESSBOOK_SLUG = "addressbook"
|
||||||
|
DEFAULT_ADDRESSBOOK_NAME = "Contactos"
|
||||||
# Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
|
# Calendar-home del usuario: bajo él cuelgan las colecciones de calendario. El
|
||||||
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
|
# selector de calendario las descubre con dav_list_calendars (PROPFIND Depth:1).
|
||||||
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
|
XANDIKOS_CALENDAR_HOME = "/enmanuel/calendars/"
|
||||||
@@ -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.
|
sirviendo y el disco se reintentará en el siguiente refresco.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||||
tmp = path + ".tmp"
|
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(
|
json.dump(
|
||||||
{"ctag": ctag, "items": items, "saved_at": time.time()},
|
{"ctag": ctag, "items": items, "saved_at": time.time()},
|
||||||
fh,
|
fh,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature flag OSINT_DB_BACKEND: fuente de verdad de los contactos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# OFF (default): los contactos se escriben como ficha .md en el vault + reflejo
|
||||||
|
# del vCard en Xandikos (comportamiento histórico de la app).
|
||||||
|
# ON: la app lee/escribe contra el service osint_db (DuckDB, 127.0.0.1:8771),
|
||||||
|
# que pasa a ser la fuente de verdad y empuja él mismo el cambio a Xandikos.
|
||||||
|
#
|
||||||
|
# El flag vive en dev/feature_flags.json (raíz de la app), patrón TBD del
|
||||||
|
# registry (.claude/rules/feature_flags.md). Se lee en cada acceso (no se cachea)
|
||||||
|
# para que cambiarlo no requiera reiniciar el server.
|
||||||
|
|
||||||
|
_FLAGS_FILE = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"dev",
|
||||||
|
"feature_flags.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Service osint_db (DuckDB) — fuente de verdad cuando el flag está ON.
|
||||||
|
OSINT_DB_BASE_URL = "http://127.0.0.1:8771"
|
||||||
|
|
||||||
|
|
||||||
|
def _osint_db_backend_enabled() -> bool:
|
||||||
|
"""True si el flag ``OSINT_DB_BACKEND`` está activo en dev/feature_flags.json.
|
||||||
|
|
||||||
|
Lee el archivo en cada llamada (sin caché) para que el flip se note sin
|
||||||
|
reiniciar. Tolerante a fallos: archivo ausente, JSON corrupto o clave faltante
|
||||||
|
→ False (comportamiento histórico vault+Xandikos), nunca lanza.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(_FLAGS_FILE, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
flag = (data.get("flags") or {}).get("OSINT_DB_BACKEND") or {}
|
||||||
|
return bool(flag.get("enabled"))
|
||||||
|
|
||||||
|
|
||||||
|
def _contacts_from_osint_db() -> list:
|
||||||
|
"""Lee los contactos del osint_db y los adapta al shape JSON del frontend.
|
||||||
|
|
||||||
|
El osint_db devuelve filas ``{uid, collection, fn, tels, emails, note_path}``
|
||||||
|
(``tels``/``emails`` como JSON array). Esta función las mapea al mismo dict que
|
||||||
|
produce ``_vcard_to_json`` (``uid, nombre, telefonos, correos, phones,
|
||||||
|
emails, osint, ...``), para que ``/api/contacts`` y la vista no distingan la
|
||||||
|
fuente. ``collection`` se expone para poder filtrar por libreta.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
osintdb_client.OsintDbUnavailable: si el service no responde.
|
||||||
|
"""
|
||||||
|
rows = osintdb_client.list_contacts()
|
||||||
|
out: list = []
|
||||||
|
for row in rows:
|
||||||
|
tels = osintdb_client._parse_json_array(row.get("tels"))
|
||||||
|
mails = osintdb_client._parse_json_array(row.get("emails"))
|
||||||
|
fn = row.get("fn")
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"uid": row.get("uid"),
|
||||||
|
"fn": fn,
|
||||||
|
"nombre": fn,
|
||||||
|
"nickname": None,
|
||||||
|
"alias": None,
|
||||||
|
"org": None,
|
||||||
|
"note": None,
|
||||||
|
"nota": None,
|
||||||
|
"collection": row.get("collection"),
|
||||||
|
"phones": [{"value": t, "type": ""} for t in tels],
|
||||||
|
"emails": [{"value": e, "type": ""} for e in mails],
|
||||||
|
"telefonos": tels,
|
||||||
|
"correos": mails,
|
||||||
|
"direcciones": [],
|
||||||
|
"osint": {},
|
||||||
|
"note_path": row.get("note_path"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _osint_db_contact_payload(data: "ContactIn", uid: Optional[str] = None) -> dict:
|
||||||
|
"""Construye el cuerpo JSON de un contacto para el service osint_db.
|
||||||
|
|
||||||
|
Mapea el ``ContactIn`` (ya reconciliado multi-valor) al contrato del osint_db:
|
||||||
|
``{uid?, collection, fn, telefonos, emails, direcciones, nombre, aliases, dni,
|
||||||
|
pais, contexto, notas}``. ``collection`` se deriva del campo ``contexto`` si
|
||||||
|
apunta a una libreta; por defecto la libreta canónica. ``uid`` solo se incluye
|
||||||
|
al crear (el PUT lo lleva en la ruta, no en el cuerpo).
|
||||||
|
"""
|
||||||
|
nombre = data.nombre.strip()
|
||||||
|
payload: dict = {
|
||||||
|
"collection": _norm_str(data.collection) or DEFAULT_ADDRESSBOOK_SLUG,
|
||||||
|
"fn": nombre,
|
||||||
|
"nombre": nombre,
|
||||||
|
"telefonos": _norm_list(data.telefonos),
|
||||||
|
"emails": _norm_list(data.emails),
|
||||||
|
"direcciones": _norm_list(data.direcciones),
|
||||||
|
"aliases": _norm_list(data.aliases),
|
||||||
|
"dni": _norm_str(data.dni),
|
||||||
|
"pais": _norm_str(data.pais),
|
||||||
|
"contexto": _norm_str(data.contexto),
|
||||||
|
"notas": _norm_str(data.notas),
|
||||||
|
}
|
||||||
|
if uid:
|
||||||
|
payload["uid"] = uid
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Estado del servidor: caché del vault + password Xandikos
|
# Estado del servidor: caché del vault + password Xandikos
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -633,17 +765,26 @@ class VaultState:
|
|||||||
return list(items)
|
return list(items)
|
||||||
|
|
||||||
def contacts(self) -> list:
|
def contacts(self) -> list:
|
||||||
"""Contactos del addressbook Xandikos, parseados y cacheados.
|
"""Contactos, desde Xandikos (flag OFF) o desde osint_db (flag ON).
|
||||||
|
|
||||||
Caché en dos niveles: memoria (mientras vive el proceso) y disco
|
Con el flag ``OSINT_DB_BACKEND`` activo, la fuente de verdad es el service
|
||||||
(``.cache/contacts.json``, validada por ctag para arranque instantáneo).
|
osint_db (DuckDB): se consultan sus contactos y se devuelven con el mismo
|
||||||
Al primer acceso descarga TODO en UNA petición REPORT
|
shape JSON que produce el parseo del vCard, para que el frontend no note la
|
||||||
(``dav_get_collection``) en vez de un GET por ``.vcf``.
|
diferencia. Con el flag OFF (default), camino histórico: addressbook
|
||||||
|
Xandikos parseado y cacheado.
|
||||||
|
|
||||||
|
Caché en dos niveles para el camino DAV: memoria (mientras vive el
|
||||||
|
proceso) y disco (``.cache/contacts.json``, validada por ctag para
|
||||||
|
arranque instantáneo). Al primer acceso descarga TODO en UNA petición
|
||||||
|
REPORT (``dav_get_collection``) en vez de un GET por ``.vcf``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: si no se puede leer la password de ``pass``.
|
RuntimeError: si no se puede leer la password de ``pass``.
|
||||||
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
||||||
|
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
|
||||||
"""
|
"""
|
||||||
|
if _osint_db_backend_enabled():
|
||||||
|
return _contacts_from_osint_db()
|
||||||
with self._dav_lock:
|
with self._dav_lock:
|
||||||
if self._contacts_cache is not None and not self._force_reload:
|
if self._contacts_cache is not None and not self._force_reload:
|
||||||
return self._contacts_cache
|
return self._contacts_cache
|
||||||
@@ -658,6 +799,63 @@ class VaultState:
|
|||||||
self._maybe_clear_force_reload()
|
self._maybe_clear_force_reload()
|
||||||
return contacts
|
return contacts
|
||||||
|
|
||||||
|
def list_addressbooks(self) -> list:
|
||||||
|
"""Libretas (addressbooks) disponibles para los contactos.
|
||||||
|
|
||||||
|
Con el flag ``OSINT_DB_BACKEND`` ON, consulta las libretas del osint_db
|
||||||
|
(``{slug, display_name, collection_path, color}``). Con el flag OFF, hoy
|
||||||
|
solo existe la libreta por defecto en el vault; se devuelve esa única
|
||||||
|
entrada para que el selector del frontend tenga algo que mostrar.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
osintdb_client.OsintDbUnavailable: si el osint_db no responde (flag ON).
|
||||||
|
"""
|
||||||
|
if _osint_db_backend_enabled():
|
||||||
|
return osintdb_client.list_addressbooks()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slug": DEFAULT_ADDRESSBOOK_SLUG,
|
||||||
|
"display_name": DEFAULT_ADDRESSBOOK_NAME,
|
||||||
|
"collection_path": XANDIKOS_CONTACTS_COLLECTION,
|
||||||
|
"color": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_addressbook(self, data: "AddressbookIn") -> dict:
|
||||||
|
"""Crea una libreta de contactos nueva.
|
||||||
|
|
||||||
|
Solo soportado con el flag ``OSINT_DB_BACKEND`` ON: el osint_db crea la
|
||||||
|
colección CardDAV en Xandikos y la registra en la DuckDB. Con el flag OFF
|
||||||
|
no hay forma de crear libretas todavía (no existe ``dav_make_addressbook``
|
||||||
|
en el registry) → 501 claro indicando que requiere el flag.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict ``{status, slug, ...}`` del osint_db.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException(400): si el slug/nombre queda vacío.
|
||||||
|
HTTPException(501): si el flag está OFF.
|
||||||
|
osintdb_client.OsintDbUnavailable: si el osint_db no responde.
|
||||||
|
"""
|
||||||
|
slug = (data.slug or data.name or "").strip()
|
||||||
|
if not slug:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="el nombre de la libreta es obligatorio"
|
||||||
|
)
|
||||||
|
if not _osint_db_backend_enabled():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=501,
|
||||||
|
detail=(
|
||||||
|
"crear libretas requiere el backend OSINT_DB_BACKEND activo "
|
||||||
|
"(hoy solo existe la libreta por defecto en el vault)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
res = osintdb_client.create_addressbook(
|
||||||
|
slug, data.name or slug, data.color or None
|
||||||
|
)
|
||||||
|
self.invalidate_dav()
|
||||||
|
return res
|
||||||
|
|
||||||
def _resolve_calendar(self, cal: str = "") -> str:
|
def _resolve_calendar(self, cal: str = "") -> str:
|
||||||
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
|
"""Normaliza el parámetro ``cal`` a una ruta de colección de calendario.
|
||||||
|
|
||||||
@@ -705,6 +903,42 @@ class VaultState:
|
|||||||
self._maybe_clear_force_reload()
|
self._maybe_clear_force_reload()
|
||||||
return calendars
|
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:
|
def calendar(self, cal: str = "", dt_from: str = "", dt_to: str = "") -> list:
|
||||||
"""Eventos de una colección de calendario Xandikos, cacheados y filtrados.
|
"""Eventos de una colección de calendario Xandikos, cacheados y filtrados.
|
||||||
|
|
||||||
@@ -732,9 +966,18 @@ class VaultState:
|
|||||||
self._maybe_clear_force_reload()
|
self._maybe_clear_force_reload()
|
||||||
cached = events
|
cached = events
|
||||||
all_events = list(cached)
|
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:
|
if not dt_from and not dt_to:
|
||||||
return all_events
|
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) -----------------------
|
# --- Escritura de eventos del calendario (CalDAV) -----------------------
|
||||||
|
|
||||||
@@ -924,6 +1167,21 @@ class VaultState:
|
|||||||
HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
|
HTTPException(400): si el tipo no es 'persona'|'organizacion' o el
|
||||||
nombre está vacío.
|
nombre está vacío.
|
||||||
"""
|
"""
|
||||||
|
if not data.nombre.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="el nombre es obligatorio")
|
||||||
|
if _osint_db_backend_enabled():
|
||||||
|
# Flag ON: el osint_db es la fuente de verdad. Genera el slug igual que
|
||||||
|
# el camino vault (mismo UID), envía el payload y deja que el service
|
||||||
|
# escriba la DuckDB + empuje a Xandikos.
|
||||||
|
slug = slugify_obsidian_name(data.nombre)
|
||||||
|
if not slug:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="el nombre no produce un slug válido"
|
||||||
|
)
|
||||||
|
res = osintdb_client.create_contact(_osint_db_contact_payload(data, slug))
|
||||||
|
self.invalidate_dav()
|
||||||
|
uid = res.get("uid") or slug
|
||||||
|
return {"slug": uid, "uid": uid, "path": None, "osint_db": res}
|
||||||
tipo = (data.tipo or "persona").strip()
|
tipo = (data.tipo or "persona").strip()
|
||||||
if tipo not in _TIPO_FOLDER:
|
if tipo not in _TIPO_FOLDER:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -975,6 +1233,13 @@ class VaultState:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException(404): si no existe la ficha del contacto.
|
HTTPException(404): si no existe la ficha del contacto.
|
||||||
"""
|
"""
|
||||||
|
if _osint_db_backend_enabled():
|
||||||
|
# Flag ON: delega la edición en el osint_db (PUT por UID).
|
||||||
|
res = osintdb_client.update_contact(
|
||||||
|
slug, _osint_db_contact_payload(data)
|
||||||
|
)
|
||||||
|
self.invalidate_dav()
|
||||||
|
return {"slug": slug, "uid": slug, "path": None, "osint_db": res}
|
||||||
path = self._find_contact_note(slug)
|
path = self._find_contact_note(slug)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -982,13 +1247,21 @@ class VaultState:
|
|||||||
)
|
)
|
||||||
note = read_obsidian_note(path)
|
note = read_obsidian_note(path)
|
||||||
current = dict(note.get("frontmatter") or {})
|
current = dict(note.get("frontmatter") or {})
|
||||||
# Merge de los campos editables (preserva los heredados no tocados).
|
# Listas multi-valor (ya reconciladas con los singulares en ContactIn).
|
||||||
|
telefonos = _norm_list(data.telefonos)
|
||||||
|
emails = _norm_list(data.emails)
|
||||||
|
direcciones = _norm_list(data.direcciones)
|
||||||
|
# Merge de los campos editables (preserva los heredados no tocados). El
|
||||||
|
# singular se conserva = primer elemento para los lectores viejos.
|
||||||
merged = {
|
merged = {
|
||||||
"nombre": data.nombre.strip() or current.get("nombre") or slug,
|
"nombre": data.nombre.strip() or current.get("nombre") or slug,
|
||||||
"aliases": _norm_list(data.aliases),
|
"aliases": _norm_list(data.aliases),
|
||||||
"telefono": _norm_str(data.telefono),
|
"telefono": telefonos[0] if telefonos else None,
|
||||||
"email": _norm_str(data.email),
|
"telefonos": telefonos,
|
||||||
"direccion": _norm_str(data.direccion),
|
"email": emails[0] if emails else None,
|
||||||
|
"emails": emails,
|
||||||
|
"direccion": direcciones[0] if direcciones else None,
|
||||||
|
"direcciones": direcciones,
|
||||||
"pais": _norm_str(data.pais),
|
"pais": _norm_str(data.pais),
|
||||||
"relaciones": _norm_list(data.relaciones),
|
"relaciones": _norm_list(data.relaciones),
|
||||||
"contexto": _norm_str(data.contexto),
|
"contexto": _norm_str(data.contexto),
|
||||||
@@ -1018,6 +1291,11 @@ class VaultState:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException(404): si no existe la ficha del contacto.
|
HTTPException(404): si no existe la ficha del contacto.
|
||||||
"""
|
"""
|
||||||
|
if _osint_db_backend_enabled():
|
||||||
|
# Flag ON: delega el borrado en el osint_db (DELETE por UID).
|
||||||
|
res = osintdb_client.delete_contact(slug)
|
||||||
|
self.invalidate_dav()
|
||||||
|
return {"slug": slug, "deleted": True, "osint_db": res}
|
||||||
path = self._find_contact_note(slug)
|
path = self._find_contact_note(slug)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1123,6 +1401,7 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
|||||||
"note": None,
|
"note": None,
|
||||||
"phones": [],
|
"phones": [],
|
||||||
"emails": [],
|
"emails": [],
|
||||||
|
"direcciones": [],
|
||||||
"osint": {},
|
"osint": {},
|
||||||
}
|
}
|
||||||
for line in _unfold_lines(vcard_text):
|
for line in _unfold_lines(vcard_text):
|
||||||
@@ -1130,6 +1409,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
|||||||
if not parsed:
|
if not parsed:
|
||||||
continue
|
continue
|
||||||
name, params, value = parsed
|
name, params, value = parsed
|
||||||
|
# ADR es estructurado (7 componentes separados por ';'): NO se des-escapa
|
||||||
|
# antes de partir, para no confundir separadores con contenido escapado.
|
||||||
|
if name == "ADR":
|
||||||
|
adr = _parse_adr_value(value)
|
||||||
|
if adr:
|
||||||
|
out["direcciones"].append(adr)
|
||||||
|
continue
|
||||||
value = _unescape_ical(value.strip())
|
value = _unescape_ical(value.strip())
|
||||||
if name == "UID":
|
if name == "UID":
|
||||||
out["uid"] = value
|
out["uid"] = value
|
||||||
@@ -1156,6 +1442,13 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
|||||||
out["fn"] = ("%s %s" % (comps[1], comps[0])).strip()
|
out["fn"] = ("%s %s" % (comps[1], comps[0])).strip()
|
||||||
elif comps:
|
elif comps:
|
||||||
out["fn"] = comps[0]
|
out["fn"] = comps[0]
|
||||||
|
# Compat con vCards antiguos: la dirección iba en X-OSINT-DIRECCION (un solo
|
||||||
|
# valor) en vez de ADR. Si vino por ahí y no hay ADR, súbela a direcciones[]
|
||||||
|
# para que el frontend la vea como multi-valor; deja también osint.direccion
|
||||||
|
# por si algún lector viejo lo consulta.
|
||||||
|
legacy_dir = out["osint"].get("direccion")
|
||||||
|
if legacy_dir and legacy_dir not in out["direcciones"]:
|
||||||
|
out["direcciones"].append(legacy_dir)
|
||||||
# Alias en español que consume el frontend del task (mismo dato, otra clave).
|
# Alias en español que consume el frontend del task (mismo dato, otra clave).
|
||||||
out["nombre"] = out["fn"]
|
out["nombre"] = out["fn"]
|
||||||
out["alias"] = out["nickname"]
|
out["alias"] = out["nickname"]
|
||||||
@@ -1165,6 +1458,29 @@ def _vcard_to_json(vcard_text: str) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_adr_value(raw: str) -> Optional[str]:
|
||||||
|
"""Extrae la dirección legible de un valor ADR estructurado (RFC 6350).
|
||||||
|
|
||||||
|
El ADR tiene 7 componentes separados por ``;``:
|
||||||
|
``po-box;extended;street;locality;region;postal-code;country``. Esta función
|
||||||
|
une los componentes no vacíos (des-escapados) en una sola línea legible, con
|
||||||
|
preferencia por ``street``; si solo hay un campo, lo devuelve. Devuelve
|
||||||
|
``None`` si el ADR queda vacío.
|
||||||
|
"""
|
||||||
|
parts = raw.split(";")
|
||||||
|
# Des-escapa cada componente por separado (el ';' ya se usó para partir).
|
||||||
|
comps = [_unescape_ical(p.strip()) for p in parts]
|
||||||
|
nonempty = [c for c in comps if c]
|
||||||
|
if not nonempty:
|
||||||
|
return None
|
||||||
|
# street es el 3er componente (índice 2). Si está, suele bastar; si hay más
|
||||||
|
# (locality, region, etc.) se concatenan con coma para una línea legible.
|
||||||
|
if len(comps) >= 3 and comps[2]:
|
||||||
|
tail = [c for c in comps[3:] if c]
|
||||||
|
return ", ".join([comps[2]] + tail) if tail else comps[2]
|
||||||
|
return ", ".join(nonempty)
|
||||||
|
|
||||||
|
|
||||||
_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
|
_VEVENT_RE = re.compile(r"BEGIN:VEVENT(.*?)END:VEVENT", re.DOTALL | re.IGNORECASE)
|
||||||
_ICAL_DT_RE = re.compile(
|
_ICAL_DT_RE = re.compile(
|
||||||
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$"
|
r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$"
|
||||||
@@ -1262,6 +1578,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
|
|||||||
"location": None,
|
"location": None,
|
||||||
"description": None,
|
"description": None,
|
||||||
"color": None,
|
"color": None,
|
||||||
|
"rrule": None,
|
||||||
|
"recurring": False,
|
||||||
|
"occurrence": False,
|
||||||
}
|
}
|
||||||
for line in _unfold_lines(vevent_block):
|
for line in _unfold_lines(vevent_block):
|
||||||
parsed = _parse_property(line)
|
parsed = _parse_property(line)
|
||||||
@@ -1295,6 +1614,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
|
|||||||
out["description"] = _unescape_ical(value)
|
out["description"] = _unescape_ical(value)
|
||||||
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
|
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
|
||||||
out["color"] = value
|
out["color"] = value
|
||||||
|
elif name == "RRULE":
|
||||||
|
out["rrule"] = value
|
||||||
|
out["recurring"] = True
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -1327,6 +1649,98 @@ def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
|
|||||||
return True
|
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
|
# 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
|
Refleja el esquema canónico del frontmatter (CONVENTIONS.md §3b/§6). Los
|
||||||
campos vacíos se normalizan a ``null``/``[]`` al escribir la ficha, nunca se
|
campos vacíos se normalizan a ``null``/``[]`` al escribir la ficha, nunca se
|
||||||
omiten, para que el score de completitud sea consistente.
|
omiten, para que el score de completitud sea consistente.
|
||||||
|
|
||||||
|
Multi-valor: un contacto puede tener VARIOS teléfonos, emails y direcciones
|
||||||
|
(``telefonos``/``emails``/``direcciones``). Los campos singulares
|
||||||
|
``telefono``/``email``/``direccion`` se conservan por compatibilidad con
|
||||||
|
clientes y lectores viejos: el validador ``model_post_init`` los reconcilia
|
||||||
|
con las listas (singular → ``[valor]`` si la lista está vacía; y el singular
|
||||||
|
se rellena con ``lista[0]`` para que los lectores que solo miran el singular
|
||||||
|
sigan funcionando).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tipo: str = Field(default="persona")
|
tipo: str = Field(default="persona")
|
||||||
nombre: str
|
nombre: str
|
||||||
aliases: list[str] = Field(default_factory=list)
|
aliases: list[str] = Field(default_factory=list)
|
||||||
|
# Singulares (compat) — el primer elemento de cada lista multi-valor.
|
||||||
telefono: Optional[str] = None
|
telefono: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
dni: Optional[str] = None
|
|
||||||
direccion: Optional[str] = None
|
direccion: Optional[str] = None
|
||||||
|
# Multi-valor: listas completas de teléfonos, emails y direcciones.
|
||||||
|
telefonos: list[str] = Field(default_factory=list)
|
||||||
|
emails: list[str] = Field(default_factory=list)
|
||||||
|
direcciones: list[str] = Field(default_factory=list)
|
||||||
|
dni: Optional[str] = None
|
||||||
pais: Optional[str] = None
|
pais: Optional[str] = None
|
||||||
contexto: Optional[str] = None
|
contexto: Optional[str] = None
|
||||||
relaciones: list[str] = Field(default_factory=list)
|
relaciones: list[str] = Field(default_factory=list)
|
||||||
notas: Optional[str] = None
|
notas: Optional[str] = None
|
||||||
|
# Libreta (addressbook) destino. Solo se consume con el flag OSINT_DB_BACKEND
|
||||||
|
# ON (el osint_db enruta el contacto a esa colección). Con el flag OFF se
|
||||||
|
# ignora: hoy solo existe la libreta por defecto en el vault. None → libreta
|
||||||
|
# por defecto.
|
||||||
|
collection: Optional[str] = None
|
||||||
|
|
||||||
|
def model_post_init(self, __context: object) -> None:
|
||||||
|
"""Reconcilia los campos singulares con las listas multi-valor.
|
||||||
|
|
||||||
|
Para cada par (singular, lista): si la lista llega vacía pero el singular
|
||||||
|
trae valor, la lista se siembra con ``[singular]`` (cliente viejo que solo
|
||||||
|
envía el campo singular); y siempre se rellena el singular con el primer
|
||||||
|
elemento normalizado de la lista, para que los lectores que solo miran el
|
||||||
|
singular (frontmatter compat, vCard heredado) sigan funcionando.
|
||||||
|
"""
|
||||||
|
for singular, plural in (
|
||||||
|
("telefono", "telefonos"),
|
||||||
|
("email", "emails"),
|
||||||
|
("direccion", "direcciones"),
|
||||||
|
):
|
||||||
|
lista = _norm_list(getattr(self, plural))
|
||||||
|
if not lista:
|
||||||
|
single = _norm_str(getattr(self, singular))
|
||||||
|
if single:
|
||||||
|
lista = [single]
|
||||||
|
object.__setattr__(self, plural, lista)
|
||||||
|
object.__setattr__(self, singular, lista[0] if lista else None)
|
||||||
|
|
||||||
|
|
||||||
def _norm_str(value: Optional[str]) -> Optional[str]:
|
def _norm_str(value: Optional[str]) -> Optional[str]:
|
||||||
@@ -1396,15 +1850,23 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
|
|||||||
nombre = data.nombre.strip()
|
nombre = data.nombre.strip()
|
||||||
aliases = _norm_list(data.aliases)
|
aliases = _norm_list(data.aliases)
|
||||||
relaciones = _norm_list(data.relaciones)
|
relaciones = _norm_list(data.relaciones)
|
||||||
|
# Listas multi-valor ya reconciladas en ContactIn.model_post_init; el campo
|
||||||
|
# singular = primer elemento (o None) para los lectores viejos.
|
||||||
|
telefonos = _norm_list(data.telefonos)
|
||||||
|
emails = _norm_list(data.emails)
|
||||||
|
direcciones = _norm_list(data.direcciones)
|
||||||
if data.tipo == "organizacion":
|
if data.tipo == "organizacion":
|
||||||
return {
|
return {
|
||||||
"tipo": "organizacion",
|
"tipo": "organizacion",
|
||||||
"nombre": nombre,
|
"nombre": nombre,
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"aliases": aliases,
|
"aliases": aliases,
|
||||||
"telefono": _norm_str(data.telefono),
|
"telefono": telefonos[0] if telefonos else None,
|
||||||
"email": _norm_str(data.email),
|
"telefonos": telefonos,
|
||||||
"direccion": _norm_str(data.direccion),
|
"email": emails[0] if emails else None,
|
||||||
|
"emails": emails,
|
||||||
|
"direccion": direcciones[0] if direcciones else None,
|
||||||
|
"direcciones": direcciones,
|
||||||
"pais": _norm_str(data.pais),
|
"pais": _norm_str(data.pais),
|
||||||
"relaciones": relaciones,
|
"relaciones": relaciones,
|
||||||
"contexto": _norm_str(data.contexto),
|
"contexto": _norm_str(data.contexto),
|
||||||
@@ -1420,9 +1882,12 @@ def _contact_frontmatter(data: "ContactIn", slug: str) -> dict:
|
|||||||
"sexo": None,
|
"sexo": None,
|
||||||
"fecha_nacimiento": None,
|
"fecha_nacimiento": None,
|
||||||
"dni": _norm_str(data.dni),
|
"dni": _norm_str(data.dni),
|
||||||
"telefono": _norm_str(data.telefono),
|
"telefono": telefonos[0] if telefonos else None,
|
||||||
"email": _norm_str(data.email),
|
"telefonos": telefonos,
|
||||||
"direccion": _norm_str(data.direccion),
|
"email": emails[0] if emails else None,
|
||||||
|
"emails": emails,
|
||||||
|
"direccion": direcciones[0] if direcciones else None,
|
||||||
|
"direcciones": direcciones,
|
||||||
"pais": _norm_str(data.pais),
|
"pais": _norm_str(data.pais),
|
||||||
"relaciones": relaciones,
|
"relaciones": relaciones,
|
||||||
"contexto": _norm_str(data.contexto),
|
"contexto": _norm_str(data.contexto),
|
||||||
@@ -1440,20 +1905,46 @@ def _contact_body(notas: Optional[str]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _vcard_escape(value: 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 (
|
return (
|
||||||
value.replace("\\", "\\\\")
|
value.replace("\\", "\\\\")
|
||||||
|
.replace("\r", "")
|
||||||
.replace("\n", "\\n")
|
.replace("\n", "\\n")
|
||||||
.replace(",", "\\,")
|
.replace(",", "\\,")
|
||||||
.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:
|
def _build_vcard(frontmatter: dict, slug: str) -> str:
|
||||||
"""Serializa un frontmatter de contacto a un VCARD 3.0 con el UID = slug.
|
"""Serializa un frontmatter de contacto a un VCARD 3.0 con el UID = slug.
|
||||||
|
|
||||||
Mapea: nombre→FN, aliases→NICKNAME, telefono→TEL, email→EMAIL, notas→NOTE,
|
Soporta multi-valor: emite una línea ``TEL`` por teléfono, una ``EMAIL`` por
|
||||||
organización→ORG; y los campos OSINT (dni, direccion, pais, contexto, sexo,
|
email y una ``ADR`` por dirección (campos ``telefonos``/``emails``/
|
||||||
|
``direcciones`` del frontmatter; cae a los singulares ``telefono``/... por
|
||||||
|
compat). Mapea además: nombre→FN, aliases→NICKNAME, notas→NOTE,
|
||||||
|
organización→ORG; y los campos OSINT (dni, pais, contexto, sexo,
|
||||||
fecha_nacimiento) a propiedades ``X-OSINT-*`` que el parser ``_vcard_to_json``
|
fecha_nacimiento) a propiedades ``X-OSINT-*`` que el parser ``_vcard_to_json``
|
||||||
ya entiende. El UID es el slug → idempotente: re-subir el mismo slug
|
ya entiende. El UID es el slug → idempotente: re-subir el mismo slug
|
||||||
sobrescribe el recurso ``<slug>.vcf``.
|
sobrescribe el recurso ``<slug>.vcf``.
|
||||||
@@ -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)))
|
lines.append("NICKNAME:%s" % _vcard_escape(",".join(str(a) for a in aliases)))
|
||||||
if frontmatter.get("tipo") == "organizacion":
|
if frontmatter.get("tipo") == "organizacion":
|
||||||
lines.append("ORG:%s" % _vcard_escape(nombre))
|
lines.append("ORG:%s" % _vcard_escape(nombre))
|
||||||
tel = frontmatter.get("telefono")
|
# Multi-valor: una línea TEL/EMAIL por elemento.
|
||||||
if tel:
|
for tel in _vcard_value_list(frontmatter, "telefonos", "telefono"):
|
||||||
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel)))
|
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(tel))
|
||||||
email = frontmatter.get("email")
|
for email in _vcard_value_list(frontmatter, "emails", "email"):
|
||||||
if email:
|
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(email))
|
||||||
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(email)))
|
# Direcciones → ADR estructurado (la dirección va en el componente street;
|
||||||
|
# los separadores ';' del ADR NO se escapan, solo el contenido). Una línea
|
||||||
|
# ADR por dirección. El parser _vcard_to_json reconstruye la lista desde ADR.
|
||||||
|
for adr in _vcard_value_list(frontmatter, "direcciones", "direccion"):
|
||||||
|
lines.append("ADR;TYPE=HOME:;;%s;;;;" % _vcard_escape(adr))
|
||||||
# Campos OSINT → X-OSINT-* (los recoge _vcard_to_json en el bloque osint).
|
# Campos OSINT → X-OSINT-* (los recoge _vcard_to_json en el bloque osint).
|
||||||
for fm_key, x_name in (
|
for fm_key, x_name in (
|
||||||
("dni", "X-OSINT-DNI"),
|
("dni", "X-OSINT-DNI"),
|
||||||
("direccion", "X-OSINT-DIRECCION"),
|
|
||||||
("pais", "X-OSINT-PAIS"),
|
("pais", "X-OSINT-PAIS"),
|
||||||
("contexto", "X-OSINT-CONTEXTO"),
|
("contexto", "X-OSINT-CONTEXTO"),
|
||||||
("sexo", "X-OSINT-SEXO"),
|
("sexo", "X-OSINT-SEXO"),
|
||||||
@@ -1534,6 +2028,38 @@ class EventIn(BaseModel):
|
|||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
color: 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]:
|
def _parse_iso_input(value: str) -> Optional[dict]:
|
||||||
@@ -1687,7 +2213,9 @@ def _build_vcalendar(data: "EventIn", uid: str) -> str:
|
|||||||
body.append(vtz)
|
body.append(vtz)
|
||||||
vevent = [
|
vevent = [
|
||||||
"BEGIN: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,
|
"DTSTAMP:%s" % dtstamp,
|
||||||
_ical_dt_property("DTSTART", data.dtstart, tz, data.all_day),
|
_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():
|
if data.color and data.color.strip():
|
||||||
# COLOR (RFC 7986) — nombre CSS3 o, para clientes Apple, el hex va aparte.
|
# COLOR (RFC 7986) — nombre CSS3 o, para clientes Apple, el hex va aparte.
|
||||||
vevent.append("X-APPLE-CALENDAR-COLOR:%s" % data.color.strip())
|
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")
|
vevent.append("END:VEVENT")
|
||||||
body.append("\r\n".join(vevent))
|
body.append("\r\n".join(vevent))
|
||||||
body.append("END:VCALENDAR")
|
body.append("END:VCALENDAR")
|
||||||
@@ -1721,6 +2259,31 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
"""
|
"""
|
||||||
state = VaultState(vault_dir)
|
state = VaultState(vault_dir)
|
||||||
app = FastAPI(title="osint_web", version="0.1.0")
|
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
|
app.state.vault = state
|
||||||
|
|
||||||
# -- Vault --
|
# -- Vault --
|
||||||
@@ -1797,12 +2360,12 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
|
Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
|
||||||
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
|
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
|
||||||
La lista se cachea en memoria al primer acceso (``POST /api/refresh`` la
|
La lista se cachea en memoria al primer acceso (``POST /api/refresh`` la
|
||||||
invalida). Si Xandikos no responde o falta la password → 503 con un JSON
|
invalida). Si Xandikos / el osint_db no responde o falta la password →
|
||||||
de error claro, nunca un crash.
|
503 con un JSON de error claro, nunca un crash.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
contacts = state.contacts()
|
contacts = state.contacts()
|
||||||
except (RuntimeError, DavUnavailable) as exc:
|
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=503, content={"status": "error", "error": str(exc)}
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
)
|
)
|
||||||
@@ -1820,7 +2383,7 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
contacts = state.contacts()
|
contacts = state.contacts()
|
||||||
except (RuntimeError, DavUnavailable) as exc:
|
except (RuntimeError, DavUnavailable, osintdb_client.OsintDbUnavailable) as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=503, content={"status": "error", "error": str(exc)}
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
)
|
)
|
||||||
@@ -1851,8 +2414,15 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
La ficha del vault es la fuente de verdad (CONVENTIONS.md §3b/§6);
|
La ficha del vault es la fuente de verdad (CONVENTIONS.md §3b/§6);
|
||||||
Xandikos se actualiza de inmediato para que el contacto se vea ya en la
|
Xandikos se actualiza de inmediato para que el contacto se vea ya en la
|
||||||
app y en el móvil. 409 si el slug ya existe. Devuelve ``{slug, uid}``.
|
app y en el móvil. 409 si el slug ya existe. Devuelve ``{slug, uid}``.
|
||||||
|
Con el flag ``OSINT_DB_BACKEND`` ON el alta va al osint_db; 503 si no
|
||||||
|
responde.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
result = state.create_contact(data)
|
result = state.create_contact(data)
|
||||||
|
except osintdb_client.OsintDbUnavailable as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
|
)
|
||||||
return JSONResponse(status_code=201, content={"status": "ok", **result})
|
return JSONResponse(status_code=201, content={"status": "ok", **result})
|
||||||
|
|
||||||
@app.put("/api/contact/{slug}")
|
@app.put("/api/contact/{slug}")
|
||||||
@@ -1860,20 +2430,74 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
"""Edita la ficha de un contacto (merge frontmatter) + re-sube el vCard.
|
"""Edita la ficha de un contacto (merge frontmatter) + re-sube el vCard.
|
||||||
|
|
||||||
404 si no existe la ficha. Preserva campos heredados no editables
|
404 si no existe la ficha. Preserva campos heredados no editables
|
||||||
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``.
|
(``sexo``, ``fecha_nacimiento``, ...). Devuelve ``{slug, uid}``. Con el
|
||||||
|
flag ``OSINT_DB_BACKEND`` ON la edición va al osint_db; 503 si no responde.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
result = state.update_contact(slug, data)
|
result = state.update_contact(slug, data)
|
||||||
|
except osintdb_client.OsintDbUnavailable as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
|
)
|
||||||
return JSONResponse(content={"status": "ok", **result})
|
return JSONResponse(content={"status": "ok", **result})
|
||||||
|
|
||||||
@app.delete("/api/contact/{slug}")
|
@app.delete("/api/contact/{slug}")
|
||||||
def api_delete_contact(slug: str) -> JSONResponse:
|
def api_delete_contact(slug: str) -> JSONResponse:
|
||||||
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos.
|
"""Borra un contacto: elimina la ficha ``.md`` + el vCard en Xandikos.
|
||||||
|
|
||||||
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``.
|
404 si no existe la ficha. Devuelve confirmación ``{slug, deleted}``. Con
|
||||||
|
el flag ``OSINT_DB_BACKEND`` ON el borrado va al osint_db; 503 si no
|
||||||
|
responde.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
result = state.delete_contact(slug)
|
result = state.delete_contact(slug)
|
||||||
|
except osintdb_client.OsintDbUnavailable as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
|
)
|
||||||
return JSONResponse(content={"status": "ok", **result})
|
return JSONResponse(content={"status": "ok", **result})
|
||||||
|
|
||||||
|
# -- Libretas (addressbooks) de contactos --
|
||||||
|
|
||||||
|
@app.get("/api/addressbooks")
|
||||||
|
def api_addressbooks() -> JSONResponse:
|
||||||
|
"""Libretas de contactos disponibles para el selector del frontend.
|
||||||
|
|
||||||
|
Cada una: ``{slug, display_name, collection_path, color}``. Con el flag
|
||||||
|
``OSINT_DB_BACKEND`` ON vienen del osint_db; con el flag OFF se devuelve
|
||||||
|
solo la libreta por defecto del vault. 503 si el osint_db no responde.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
books = state.list_addressbooks()
|
||||||
|
except osintdb_client.OsintDbUnavailable as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": "ok",
|
||||||
|
"count": len(books),
|
||||||
|
"addressbooks": books,
|
||||||
|
"default": DEFAULT_ADDRESSBOOK_SLUG,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/addressbooks")
|
||||||
|
def api_create_addressbook(data: AddressbookIn = Body(...)) -> JSONResponse:
|
||||||
|
"""Crea una libreta de contactos nueva.
|
||||||
|
|
||||||
|
Body: ``{slug, name?, color?}``. Requiere el flag ``OSINT_DB_BACKEND``
|
||||||
|
activo (el osint_db crea la colección CardDAV en Xandikos); con el flag
|
||||||
|
OFF devuelve 501 claro. 503 si el osint_db no responde.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
res = state.create_addressbook(data)
|
||||||
|
except osintdb_client.OsintDbUnavailable as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=201, content={"status": "ok", **res})
|
||||||
|
|
||||||
# -- Xandikos: calendario (CalDAV) --
|
# -- Xandikos: calendario (CalDAV) --
|
||||||
|
|
||||||
@app.get("/api/calendars")
|
@app.get("/api/calendars")
|
||||||
@@ -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")
|
@app.get("/api/calendar")
|
||||||
def api_calendar(
|
def api_calendar(
|
||||||
cal: str = Query("", description="colección de calendario (ruta o nombre)"),
|
cal: str = Query("", description="colección de calendario (ruta o nombre)"),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -25,6 +25,17 @@ sys.path.insert(0, os.path.join(_HERE, "..", "server"))
|
|||||||
import main as srv # noqa: E402
|
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
|
# 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)
|
crud_client.delete("/api/contact/%s" % slug)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Contactos multi-valor: varias TEL/EMAIL/ADR + compat singular/lista
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_contactin_reconcilia_singular_y_lista():
|
||||||
|
"""ContactIn reconcilia singular↔lista: cliente viejo (singular) y nuevo (lista)."""
|
||||||
|
# Cliente viejo: solo el campo singular → se siembra la lista.
|
||||||
|
viejo = srv.ContactIn(nombre="X", telefono="111", email="a@x.com", direccion="C1")
|
||||||
|
assert viejo.telefonos == ["111"]
|
||||||
|
assert viejo.emails == ["a@x.com"]
|
||||||
|
assert viejo.direcciones == ["C1"]
|
||||||
|
# El singular se conserva = primer elemento.
|
||||||
|
assert viejo.telefono == "111"
|
||||||
|
|
||||||
|
# Cliente nuevo: listas → el singular se rellena con lista[0].
|
||||||
|
nuevo = srv.ContactIn(
|
||||||
|
nombre="X",
|
||||||
|
telefonos=["111", "222"],
|
||||||
|
emails=["a@x.com", "b@x.com"],
|
||||||
|
direcciones=["C1", "C2"],
|
||||||
|
)
|
||||||
|
assert nuevo.telefonos == ["111", "222"]
|
||||||
|
assert nuevo.telefono == "111"
|
||||||
|
assert nuevo.email == "a@x.com"
|
||||||
|
assert nuevo.direccion == "C1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_vcard_multivalor_emite_n_lineas():
|
||||||
|
"""_build_vcard emite una TEL/EMAIL/ADR por elemento de cada lista."""
|
||||||
|
fm = {
|
||||||
|
"tipo": "persona",
|
||||||
|
"nombre": "Multi Persona",
|
||||||
|
"telefonos": ["111", "222"],
|
||||||
|
"emails": ["a@x.com", "b@x.com"],
|
||||||
|
"direcciones": ["Calle 1", "Calle 2"],
|
||||||
|
"dni": "X",
|
||||||
|
}
|
||||||
|
vc = srv._build_vcard(fm, "multi-persona")
|
||||||
|
assert vc.count("TEL;TYPE=CELL:") == 2
|
||||||
|
assert "TEL;TYPE=CELL:111" in vc and "TEL;TYPE=CELL:222" in vc
|
||||||
|
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
|
||||||
|
assert vc.count("ADR;TYPE=HOME:") == 2
|
||||||
|
assert "ADR;TYPE=HOME:;;Calle 1;;;;" in vc
|
||||||
|
assert "X-OSINT-DNI:X" in vc
|
||||||
|
|
||||||
|
|
||||||
|
def test_vcard_to_json_lee_adr_multivalor():
|
||||||
|
"""_vcard_to_json reconstruye la lista de direcciones desde las líneas ADR."""
|
||||||
|
vcard = (
|
||||||
|
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:adr-1\r\nFN:Con Direcciones\r\n"
|
||||||
|
"ADR;TYPE=HOME:;;Calle Uno 1;;;;\r\n"
|
||||||
|
"ADR;TYPE=HOME:;;Calle Dos 2;Madrid;;28001;España\r\n"
|
||||||
|
"END:VCARD\r\n"
|
||||||
|
)
|
||||||
|
out = srv._vcard_to_json(vcard)
|
||||||
|
assert out["direcciones"][0] == "Calle Uno 1"
|
||||||
|
# El 2º ADR concatena street + locality/region/postal/country legibles.
|
||||||
|
assert "Calle Dos 2" in out["direcciones"][1]
|
||||||
|
assert "Madrid" in out["direcciones"][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vcard_to_json_legacy_x_osint_direccion():
|
||||||
|
"""Compat: una dirección antigua en X-OSINT-DIRECCION sube a direcciones[]."""
|
||||||
|
vcard = (
|
||||||
|
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:legacy-1\r\nFN:Legacy\r\n"
|
||||||
|
"X-OSINT-DIRECCION:Calle Antigua 7\r\nEND:VCARD\r\n"
|
||||||
|
)
|
||||||
|
out = srv._vcard_to_json(vcard)
|
||||||
|
assert "Calle Antigua 7" in out["direcciones"]
|
||||||
|
# Se mantiene también en osint.direccion por si un lector viejo lo consulta.
|
||||||
|
assert out["osint"]["direccion"] == "Calle Antigua 7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_crud_multivalor_round_trip(crud_client, vault):
|
||||||
|
"""Golden multi-valor: crear con 2 teléfonos/emails/direcciones y verlos todos."""
|
||||||
|
calls = crud_client._crud_calls
|
||||||
|
body = {
|
||||||
|
"tipo": "persona",
|
||||||
|
"nombre": "Poli Valor",
|
||||||
|
"telefonos": ["+34600000001", "+34600000002"],
|
||||||
|
"emails": ["uno@x.com", "dos@x.com"],
|
||||||
|
"direcciones": ["Calle A 1", "Calle B 2"],
|
||||||
|
}
|
||||||
|
r = crud_client.post("/api/contact", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
slug = r.json()["slug"]
|
||||||
|
md = os.path.join(vault, "personas", slug + ".md")
|
||||||
|
content = open(md, encoding="utf-8").read()
|
||||||
|
# El frontmatter escribe las listas multi-valor + el singular compat.
|
||||||
|
assert "+34600000001" in content and "+34600000002" in content
|
||||||
|
assert "uno@x.com" in content and "dos@x.com" in content
|
||||||
|
# El vCard emitió las dos líneas TEL/EMAIL/ADR.
|
||||||
|
vc = calls["put"][-1]["vcard"]
|
||||||
|
assert vc.count("TEL;TYPE=CELL:") == 2
|
||||||
|
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
|
||||||
|
assert vc.count("ADR;TYPE=HOME:") == 2
|
||||||
|
crud_client.delete("/api/contact/%s" % slug)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crud_singular_compat_sigue_funcionando(crud_client, vault):
|
||||||
|
"""Edge: un cliente viejo que envía solo el singular sigue funcionando."""
|
||||||
|
calls = crud_client._crud_calls
|
||||||
|
body = {"tipo": "persona", "nombre": "Solo Singular", "telefono": "+34611111111"}
|
||||||
|
r = crud_client.post("/api/contact", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
slug = r.json()["slug"]
|
||||||
|
vc = calls["put"][-1]["vcard"]
|
||||||
|
assert "TEL;TYPE=CELL:+34611111111" in vc
|
||||||
|
assert vc.count("TEL;TYPE=CELL:") == 1
|
||||||
|
crud_client.delete("/api/contact/%s" % slug)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Libretas (addressbooks) + feature flag OSINT_DB_BACKEND
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_flag_off_por_defecto(monkeypatch, tmp_path):
|
||||||
|
"""El flag OSINT_DB_BACKEND está OFF por defecto (archivo ausente → False)."""
|
||||||
|
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
|
||||||
|
assert srv._osint_db_backend_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_flag_lee_archivo(monkeypatch, tmp_path):
|
||||||
|
"""_osint_db_backend_enabled refleja el archivo dev/feature_flags.json."""
|
||||||
|
flags = tmp_path / "feature_flags.json"
|
||||||
|
flags.write_text(
|
||||||
|
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
|
||||||
|
assert srv._osint_db_backend_enabled() is True
|
||||||
|
flags.write_text(
|
||||||
|
'{"flags":{"OSINT_DB_BACKEND":{"enabled":false}}}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert srv._osint_db_backend_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_addressbooks_off_devuelve_libreta_por_defecto(crud_client):
|
||||||
|
"""Con el flag OFF, /api/addressbooks devuelve solo la libreta por defecto."""
|
||||||
|
r = crud_client.get("/api/addressbooks")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["count"] == 1
|
||||||
|
assert data["addressbooks"][0]["slug"] == srv.DEFAULT_ADDRESSBOOK_SLUG
|
||||||
|
assert data["default"] == srv.DEFAULT_ADDRESSBOOK_SLUG
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_addressbook_off_devuelve_501(crud_client, monkeypatch, tmp_path):
|
||||||
|
"""Error: crear libreta con el flag OFF → 501 claro (requiere OSINT_DB_BACKEND)."""
|
||||||
|
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
|
||||||
|
r = crud_client.post("/api/addressbooks", json={"slug": "trabajo", "name": "Trabajo"})
|
||||||
|
assert r.status_code == 501
|
||||||
|
assert "OSINT_DB_BACKEND" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contacts_flag_on_usa_osint_db(crud_client, monkeypatch, tmp_path):
|
||||||
|
"""Con el flag ON, /api/contacts lee del osint_db (mockeado), no de Xandikos."""
|
||||||
|
flags = tmp_path / "feature_flags.json"
|
||||||
|
flags.write_text(
|
||||||
|
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
srv.osintdb_client,
|
||||||
|
"list_contacts",
|
||||||
|
lambda: [
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"collection": "addressbook",
|
||||||
|
"fn": "Desde DuckDB",
|
||||||
|
"tels": '["111", "222"]',
|
||||||
|
"emails": '["a@x.com"]',
|
||||||
|
"note_path": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
r = crud_client.get("/api/contacts")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
contacts = r.json()["contacts"]
|
||||||
|
assert len(contacts) == 1
|
||||||
|
c = contacts[0]
|
||||||
|
assert c["nombre"] == "Desde DuckDB"
|
||||||
|
# Los JSON array de tels/emails se parsean a lista de strings.
|
||||||
|
assert c["telefonos"] == ["111", "222"]
|
||||||
|
assert c["correos"] == ["a@x.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_contacts_flag_on_osint_db_caido_503(crud_client, monkeypatch, tmp_path):
|
||||||
|
"""Error: con el flag ON y el osint_db caído, /api/contacts degrada a 503."""
|
||||||
|
flags = tmp_path / "feature_flags.json"
|
||||||
|
flags.write_text(
|
||||||
|
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
|
||||||
|
|
||||||
|
def _down():
|
||||||
|
raise srv.osintdb_client.OsintDbUnavailable("no arrancado")
|
||||||
|
|
||||||
|
monkeypatch.setattr(srv.osintdb_client, "list_contacts", _down)
|
||||||
|
r = crud_client.get("/api/contacts")
|
||||||
|
assert r.status_code == 503
|
||||||
|
assert r.json()["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
|
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user