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