[#31]added two function to setup data to be sent and put function

This commit is contained in:
Camille Moussu
2025-07-22 09:50:02 +02:00
parent fd059954f4
commit b1ceebfc7d
12 changed files with 2906 additions and 48 deletions
+7
View File
@@ -23,6 +23,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"i18next": "^23.7.9", "i18next": "^23.7.9",
"ical.js": "^2.2.0",
"ky": "^1.8.1", "ky": "^1.8.1",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
@@ -7450,6 +7451,12 @@
"i18next-resources-for-ts": "bin/i18next-resources-for-ts.js" "i18next-resources-for-ts": "bin/i18next-resources-for-ts.js"
} }
}, },
"node_modules/ical.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.0.tgz",
"integrity": "sha512-P8gjWkTEd5M/SEEvBVPPO/KC+V+HRNRZh3xfCDTVWmUTEfVbL8JaK5GTWS2MJ55aLMhfXhbh7kYzd0nrBARjsA==",
"license": "MPL-2.0"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"dev": true, "dev": true,
+1
View File
@@ -18,6 +18,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"i18next": "^23.7.9", "i18next": "^23.7.9",
"ical.js": "^2.2.0",
"ky": "^1.8.1", "ky": "^1.8.1",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
-2
View File
@@ -1,6 +1,5 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { combineReducers, configureStore } from "@reduxjs/toolkit";
import userReducer from "../features/User/userSlice"; import userReducer from "../features/User/userSlice";
import eventsReducer from "../features/Events/EventsSlice";
import eventsCalendar from "../features/Calendars/CalendarSlice"; import eventsCalendar from "../features/Calendars/CalendarSlice";
import { createReduxHistoryContext } from "redux-first-history"; import { createReduxHistoryContext } from "redux-first-history";
import { createBrowserHistory } from "history"; import { createBrowserHistory } from "history";
@@ -11,7 +10,6 @@ const { createReduxHistory, routerMiddleware, routerReducer } =
const rootReducer = combineReducers({ const rootReducer = combineReducers({
router: routerReducer, router: routerReducer,
user: userReducer, user: userReducer,
events: eventsReducer,
calendars: eventsCalendar, calendars: eventsCalendar,
}); });
+1
View File
@@ -100,6 +100,7 @@ export default function CalendarApp() {
const handleDateSelect = (selectInfo: DateSelectArg) => { const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedRange(selectInfo); setSelectedRange(selectInfo);
console.log(selectInfo);
setAnchorEl(document.body); // fallback: we could use selectInfo.jsEvent.target if from a click setAnchorEl(document.body); // fallback: we could use selectInfo.jsEvent.target if from a click
}; };
+41
View File
@@ -4,6 +4,8 @@ import { CalendarEvent } from "../Events/EventsTypes";
import { getCalendar, getCalendars } from "./CalendarApi"; import { getCalendar, getCalendars } from "./CalendarApi";
import getOpenPaasUserId from "../User/userAPI"; import getOpenPaasUserId from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils"; import { parseCalendarEvent } from "../Events/eventUtils";
import { getEvent, putEvent } from "../Events/EventApi";
import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils";
export const getCalendarsListAsync = createAsyncThunk< export const getCalendarsListAsync = createAsyncThunk<
Record<string, Calendars> // Return type Record<string, Calendars> // Return type
@@ -47,6 +49,22 @@ export const getCalendarDetailAsync = createAsyncThunk<
return { calId, events }; return { calId, events };
}); });
export const putEventAsync = createAsyncThunk<
{ calId: string; event: CalendarEvent }, // Return type
{ cal: Calendars; newEvent: CalendarEvent } // Arg type
>("calendars/putEvent", async ({ cal, newEvent }) => {
const response = await putEvent(cal, newEvent);
const now = new Date();
const event = (await getCalendar(cal.id, {
start: formatDateToYYYYMMDDTHHMMSS(now),
end: formatDateToYYYYMMDDTHHMMSS(new Date(now.getDate() + 1)),
})) as Record<string, any>;
return {
calId: cal.id,
event: parseCalendarEvent([event[1]], "", cal.id),
};
});
const CalendarSlice = createSlice({ const CalendarSlice = createSlice({
name: "calendars", name: "calendars",
initialState: { list: {} as Record<string, Calendars>, pending: false }, initialState: { list: {} as Record<string, Calendars>, pending: false },
@@ -111,11 +129,34 @@ const CalendarSlice = createSlice({
}); });
} }
) )
.addCase(
putEventAsync.fulfilled,
(
state,
action: PayloadAction<{ calId: string; event: CalendarEvent }>
) => {
state.pending = false;
if (!state.list[action.payload.calId]) {
state.list[action.payload.calId] = {
id: action.payload.calId,
events: {},
} as Calendars;
}
state.list[action.payload.calId].events[action.payload.event.uid] =
action.payload.event;
state.list[action.payload.calId].events[
action.payload.event.uid
].color = state.list[action.payload.calId].color;
}
)
.addCase(getCalendarDetailAsync.pending, (state) => { .addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
.addCase(getCalendarsListAsync.pending, (state) => { .addCase(getCalendarsListAsync.pending, (state) => {
state.pending = true; state.pending = true;
})
.addCase(putEventAsync.pending, (state) => {
state.pending = true;
}); });
}, },
}); });
+25
View File
@@ -0,0 +1,25 @@
import { api } from "../../utils/apiUtils";
import { Calendars } from "../Calendars/CalendarTypes";
import { CalendarEvent } from "./EventsTypes";
import { calendarEventToICal, calendarEventToJCal } from "./eventUtils";
export async function putEvent(cal: Calendars, event: CalendarEvent) {
const response = await api(
`dav/calendars/${cal.id}/${event.uid.split(".")[0]}.isc`,
{
method: "PUT",
body: JSON.stringify(calendarEventToICal(cal, event)),
headers: {
"content-type": "text/calendar; charset=utf-8",
},
}
).json();
return response;
}
export async function getEvent(calID: string, eventID: string) {
const response = await api
.get(`dav/calendars/${calID}/${eventID}.ics`)
.json();
return response;
}
+96 -29
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { addEvent } from "../Calendars/CalendarSlice"; import { addEvent, putEventAsync } from "../Calendars/CalendarSlice";
import { CalendarEvent } from "./EventsTypes"; import { CalendarEvent } from "./EventsTypes";
import { DateSelectArg } from "@fullcalendar/core"; import { DateSelectArg } from "@fullcalendar/core";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
@@ -12,7 +12,12 @@ import {
Select, Select,
MenuItem, MenuItem,
SelectChangeEvent, SelectChangeEvent,
FormControl,
InputLabel,
} from "@mui/material"; } from "@mui/material";
import { Calendars } from "../Calendars/CalendarTypes";
import { putEvent } from "./EventApi";
import { TIMEZONES } from "../../utils/timezone-data";
function EventPopover({ function EventPopover({
anchorEl, anchorEl,
@@ -28,30 +33,46 @@ function EventPopover({
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const organizer = useAppSelector((state) => state.user.organiserData); const organizer = useAppSelector((state) => state.user.organiserData);
const calendars = useAppSelector((state) => state.calendars.list); const userId = useAppSelector((state) => state.user.userData.openpaasId);
const userPersonnalCalendars: Calendars[] = useAppSelector((state) =>
Object.keys(state.calendars.list).map((id) => {
if (id.split("/")[0] === userId) {
return state.calendars.list[id];
}
return {} as Calendars;
})
).filter((calendar) => calendar.id);
const timezones = TIMEZONES.aliases;
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [start, setStart] = useState(""); const [start, setStart] = useState("");
const [end, setEnd] = useState(""); const [end, setEnd] = useState("");
const [calendarid, setCalendarid] = useState(""); const [calendarid, setCalendarid] = useState(0);
const [timezone, setTimezone] = useState(
Intl.DateTimeFormat().resolvedOptions().timeZone
);
useEffect(() => { useEffect(() => {
if (selectedRange) { if (selectedRange) {
setStart(selectedRange.startStr); setStart(selectedRange ? formatLocalDateTime(selectedRange.start) : "");
setEnd(selectedRange.endStr ?? ""); setEnd(selectedRange ? formatLocalDateTime(selectedRange.end) : "");
} }
}, [selectedRange]); }, [selectedRange]);
const handleSave = () => { const handleSave = async () => {
if (!calendarid) return; // Prevent crash if no calendar is selected
const newEvent: CalendarEvent = { const newEvent: CalendarEvent = {
title, title,
start: new Date(start ?? ""), start: new Date(start),
end: new Date(end ?? ""), end: new Date(end),
uid: Date.now().toString(36), uid: crypto.randomUUID(),
description, description,
location, location,
organizer, organizer,
timezone,
attendee: [ attendee: [
{ {
cn: organizer.cn, cn: organizer.cn,
@@ -63,15 +84,27 @@ function EventPopover({
}, },
], ],
transp: "OPAQUE", transp: "OPAQUE",
color: calendars[calendarid].color, color: userPersonnalCalendars[calendarid]?.color,
}; };
dispatch(addEvent({ calendarUid: calendarid, event: newEvent })); // dispatch(
// addEvent({
// calendarUid: userPersonnalCalendars[calendarid]?.id,
// event: newEvent,
// })
// );
dispatch(
putEventAsync({
cal: userPersonnalCalendars[calendarid],
newEvent,
})
);
onClose({}, "backdropClick"); onClose({}, "backdropClick");
// Reset // Reset
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setLocation(""); setLocation("");
setCalendarid(0);
}; };
return ( return (
@@ -79,29 +112,35 @@ function EventPopover({
open={open} open={open}
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={onClose} onClose={onClose}
anchorOrigin={{ anchorOrigin={{ vertical: "top", horizontal: "left" }}
vertical: "top", transformOrigin={{ vertical: "top", horizontal: "left" }}
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
> >
<Box p={2} width={300}> <Box p={2} width={500}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Create Event Create Event
</Typography> </Typography>
<Select
onChange={(e: SelectChangeEvent) => setCalendarid(e.target.value)} <FormControl fullWidth margin="dense" size="small">
> <InputLabel id="calendar-select-label">Calendar</InputLabel>
{Object.keys(calendars).map((calendar) => ( <Select
<MenuItem value={calendar}>{calendars[calendar].name}</MenuItem> labelId="calendar-select-label"
))} value={calendarid.toString()}
</Select> label="Calendar"
onChange={(e: SelectChangeEvent) =>
setCalendarid(Number(e.target.value))
}
>
{Object.keys(userPersonnalCalendars).map((value, calendar) => (
<MenuItem key={calendar} value={calendar}>
{userPersonnalCalendars[calendar].name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField <TextField
fullWidth fullWidth
label="title" label="Title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
size="small" size="small"
@@ -145,6 +184,23 @@ function EventPopover({
size="small" size="small"
margin="dense" margin="dense"
/> />
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="timezone-select-label">Time Zone</InputLabel>
<Select
labelId="timezone-select-label"
value={timezone}
label="Time Zone"
onChange={(e: SelectChangeEvent) => setTimezone(e.target.value)}
>
{Object.keys(timezones).map((key) => (
<MenuItem key={key} value={timezones[key].aliasTo}>
{key}
</MenuItem>
))}
</Select>
</FormControl>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}> <Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button <Button
variant="outlined" variant="outlined"
@@ -152,7 +208,11 @@ function EventPopover({
> >
Cancel Cancel
</Button> </Button>
<Button variant="contained" onClick={handleSave}> <Button
variant="contained"
onClick={handleSave}
disabled={!(calendarid ?? false) || !title}
>
Save Save
</Button> </Button>
</Box> </Box>
@@ -162,3 +222,10 @@ function EventPopover({
} }
export default EventPopover; export default EventPopover;
function formatLocalDateTime(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
-16
View File
@@ -1,16 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { CalendarEvent } from "./EventsTypes";
const eventsSlice = createSlice({
name: "events",
initialState: [] as CalendarEvent[],
reducers: {
addEvent: (state, action: PayloadAction<CalendarEvent>) => {
state.push(action.payload);
},
},
});
export const { addEvent } = eventsSlice.actions;
export default eventsSlice.reducer;
+1
View File
@@ -18,4 +18,5 @@ export interface CalendarEvent {
allday?: Boolean; allday?: Boolean;
error?: string; error?: string;
status?: string; status?: string;
timezone: string;
} }
+137 -1
View File
@@ -1,6 +1,8 @@
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes"; import { userAttendee } from "../User/userDataTypes";
import { CalendarEvent } from "./EventsTypes"; import { CalendarEvent } from "./EventsTypes";
import ICAL from "ical.js";
import { TIMEZONES } from "../../utils/timezone-data";
type RawEntry = [string, Record<string, string>, string, any]; type RawEntry = [string, Record<string, string>, string, any];
export function parseCalendarEvent( export function parseCalendarEvent(
@@ -83,3 +85,137 @@ export function parseCalendarEvent(
return event as CalendarEvent; return event as CalendarEvent;
} }
export function calendarEventToJCal(event: CalendarEvent): any[] {
const tzid = event.timezone ?? "UTC"; // Fallback to UTC if no timezone provided
const vevent: any[] = [
"vevent",
[
["uid", {}, "text", event.uid],
["transp", {}, "text", event.transp ?? "OPAQUE"],
[
"dtstart",
{ tzid },
"date-time",
event.start.toISOString().split(".")[0],
],
["class", {}, "text", event.class ?? "PUBLIC"],
[
"x-openpaas-videoconference",
{},
"unknown",
event.x_openpass_videoconference ?? null,
],
["summary", {}, "text", event.title ?? ""],
],
[],
];
if (event.end) {
vevent[1].push([
"dtend",
{ tzid },
"date-time",
event.start.toISOString().split(".")[0],
]);
}
if (event.organizer) {
vevent[1].push([
"organizer",
{ cn: event.organizer.cn },
"cal-address",
`mailto:${event.organizer.cal_address}`,
]);
}
event.attendee.forEach((att) => {
vevent[1].push([
"attendee",
{
cn: att.cn,
partstat: att.partstat,
rsvp: att.rsvp,
role: att.role,
cutype: att.cutype,
},
"cal-address",
`mailto:${att.cal_address}`,
]);
});
const vtimezone: any[] = ["vtimezone", [["tzid", {}, "text", tzid]], []];
return ["vcalendar", [], [vevent, vtimezone]];
}
function formatDateToICal(date: Date, tz?: string) {
// Format date like: 20250214T110000 (local time)
// If tz is provided, use TZID param in DTSTART/DTEND
const pad = (n: number) => n.toString().padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}
export function calendarEventToICal(
cal: Calendars,
event: CalendarEvent
): string {
const lines = [];
lines.push("BEGIN:VCALENDAR");
lines.push("VERSION:2.0");
lines.push("PRODID:-//Sabre//Sabre VObject 4.1.3//EN");
if (cal.calscale) lines.push(`CALSCALE:${cal.calscale}`);
if (cal.version) lines.push(`VERSION:${cal.version}`);
lines.push(TIMEZONES.zones[event.timezone].ics);
lines.push("BEGIN:VEVENT");
lines.push(`UID:${event.uid}`);
if (event.transp) lines.push(`TRANSP:${event.transp.toUpperCase()}`);
if (event.start)
lines.push(
`DTSTART${event.timezone ? `;TZID=${event.timezone}` : ""}:` +
formatDateToICal(event.start)
);
if (event.end)
lines.push(
`DTEND${event.timezone ? `;TZID=${event.timezone}` : ""}:` +
formatDateToICal(event.end)
);
lines.push(`DTSTAMP:${ICAL.Time.now().toICALString()}`);
if (event.class) lines.push(`CLASS:${event.class.toUpperCase()}`);
if (event.x_openpass_videoconference !== undefined)
lines.push(
`X-OPENPAAS-VIDEOCONFERENCE:${event.x_openpass_videoconference || ""}`
);
if (event.title) lines.push(`SUMMARY:${event.title}`);
if (event.description) lines.push(`DESCRIPTION:${event.description}`);
if (event.location) lines.push(`LOCATION:${event.location}`);
if (event.organizer) {
lines.push(
`ORGANIZER;CN=${event.organizer.cn}:${event.organizer.cal_address}`
);
}
event.attendee.forEach((att) => {
lines.push(
`ATTENDEE;PARTSTAT=${att.partstat};RSVP=${att.rsvp};ROLE=${att.role};CUTYPE=${att.cutype};CN=${att.cn}:${att.cal_address}`
);
});
if (event.stamp) lines.push(`DTSTAMP:${formatDateToICal(event.stamp)}Z`);
if (event.sequence !== undefined) lines.push(`SEQUENCE:${event.sequence}`);
lines.push("END:VEVENT");
lines.push("END:VCALENDAR\n");
console.debug(lines.join("\r\n"));
return lines.join("\r\n");
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
// timezone.ts
// Ensure ICAL is imported before using this module.
import ICAL from "ical.js";
// TIMEZONES data must be imported or defined separately.
import { TIMEZONES } from "./timezone-data";
// Core timezone registration functionality
export function registerTimezones() {
for (const [key, data] of Object.entries(TIMEZONES.zones)) {
ICAL.TimezoneService.register(key, buildTimezone(key, data.ics));
}
for (const [key, data] of Object.entries(TIMEZONES.aliases)) {
ICAL.TimezoneService.register(key, findTimezone(data.aliasTo));
}
}
function buildTimezone(tzid: string, ics: string): any {
return (
ICAL.TimezoneService.get(tzid) ||
new ICAL.Timezone(new ICAL.Component(ICAL.parse(ics)))
);
}
function findTimezone(tzid: string): any {
if (TIMEZONES.zones[tzid]) {
return buildTimezone(tzid, TIMEZONES.zones[tzid].ics);
}
const alias = TIMEZONES.aliases[tzid];
if (alias && alias.aliasTo) {
return findTimezone(alias.aliasTo);
}
throw new Error(`Unknown timezone alias: ${tzid}`);
}