merge: calendario con recurrencia, multi-agenda, vista lista y linea de ahora
This commit is contained in:
@@ -132,6 +132,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 +163,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 +189,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 {
|
||||
@@ -258,6 +282,9 @@ export const deleteContact = (slug: string) =>
|
||||
|
||||
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,90 @@ 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: ventana amplia desde hoy (~90 días) para una agenda útil.
|
||||
return {
|
||||
from: dayjs().format("YYYY-MM-DD"),
|
||||
to: dayjs().add(90, "day").format("YYYY-MM-DD"),
|
||||
};
|
||||
}, [cursor, view]);
|
||||
|
||||
// Refresca el instante "ahora" cada minuto (línea roja en semana/día).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchCalendars()
|
||||
setNow(dayjs().tz(tz));
|
||||
const id = window.setInterval(() => setNow(dayjs().tz(tz)), 60000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [tz]);
|
||||
|
||||
// Carga de calendarios (selector). `selectHref` permite seleccionar uno recién
|
||||
// creado por su href cuando la lista se refresca tras crearlo.
|
||||
const loadCalendars = useCallback((selectHref?: string) => {
|
||||
return fetchCalendars()
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
if (d.status === "ok" && d.calendars) {
|
||||
setCalendars(d.calendars);
|
||||
if (!activeCal && d.default) setActiveCal(d.default);
|
||||
else if (!activeCal && d.calendars[0]) setActiveCal(d.calendars[0].href);
|
||||
if (selectHref) {
|
||||
setActiveCal(selectHref);
|
||||
} else {
|
||||
setActiveCal((cur) => {
|
||||
if (cur) return cur;
|
||||
if (d.default) return d.default;
|
||||
if (d.calendars && d.calendars[0]) return d.calendars[0].href;
|
||||
return cur;
|
||||
});
|
||||
}
|
||||
}
|
||||
return d;
|
||||
})
|
||||
.catch(() => {
|
||||
/* el selector degrada a "calendario" implícito; no es fatal */
|
||||
return null;
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Carga de eventos de la colección activa.
|
||||
useEffect(() => {
|
||||
loadCalendars();
|
||||
}, [loadCalendars]);
|
||||
|
||||
// Carga de eventos de la colección activa, dentro del rango visible. Pasar
|
||||
// from/to hace que el backend expanda las series recurrentes por ocurrencia.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchCalendar(activeCal)
|
||||
fetchCalendar(activeCal, range.from, range.to)
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
if (d.status !== "ok") {
|
||||
@@ -107,7 +171,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 +244,7 @@ export function CalendarView() {
|
||||
description: "",
|
||||
color: "",
|
||||
cal: activeCal,
|
||||
rrule: "",
|
||||
});
|
||||
},
|
||||
[tz, activeCal],
|
||||
@@ -206,11 +271,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 +340,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 +388,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 +468,7 @@ export function CalendarView() {
|
||||
{ value: "mes", label: "Mes" },
|
||||
{ value: "semana", label: "Semana" },
|
||||
{ value: "dia", label: "Día" },
|
||||
{ value: "lista", label: "Lista" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
@@ -362,12 +490,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 +514,7 @@ export function CalendarView() {
|
||||
events={events}
|
||||
tz={tz}
|
||||
calColor={calColor}
|
||||
now={now}
|
||||
onSlot={openNew}
|
||||
onEvent={openEdit}
|
||||
/>
|
||||
@@ -391,6 +529,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 +589,7 @@ function TimeGrid({
|
||||
events,
|
||||
tz,
|
||||
calColor,
|
||||
now,
|
||||
onSlot,
|
||||
onEvent,
|
||||
}: {
|
||||
@@ -409,11 +597,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 +714,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 +903,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 +919,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}>Próximos 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 el rango.
|
||||
</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,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 }))}
|
||||
|
||||
+190
-1
@@ -165,6 +165,10 @@ 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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -705,6 +709,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 +772,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) -----------------------
|
||||
|
||||
@@ -1262,6 +1311,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 +1347,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 +1382,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1534,6 +1681,25 @@ 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
|
||||
|
||||
|
||||
def _parse_iso_input(value: str) -> Optional[dict]:
|
||||
@@ -1701,6 +1867,13 @@ 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()
|
||||
vevent.append("RRULE:%s" % rrule)
|
||||
vevent.append("END:VEVENT")
|
||||
body.append("\r\n".join(vevent))
|
||||
body.append("END:VCALENDAR")
|
||||
@@ -1898,6 +2071,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)"),
|
||||
|
||||
Reference in New Issue
Block a user