405 back reload token with synctokens (#436)
* [#405] added syncToken in calendar params and fetch with sync token * [#405] changed reload to work with sync-token * [#405] fixup promise handling, added calendar adding and removing hanlding with refresh * [#405] fixed event expansion calls * [#405 & refactor] added helperfunction to get base event uid + refactored synctoken updates management * [#405] added pMap lib to process event expansion * [#405] added flag for no synctoken / new synctoken
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
removeCalendar,
|
||||
updateAclCalendar,
|
||||
} from "./CalendarApi";
|
||||
import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
|
||||
import { getUserDetails } from "../User/userAPI";
|
||||
import { parseCalendarEvent } from "../Events/eventUtils";
|
||||
import {
|
||||
deleteEvent,
|
||||
@@ -31,133 +31,17 @@ import { getCalendarVisibility } from "../../components/Calendar/utils/calendarU
|
||||
import { importFile } from "../../utils/apiUtils";
|
||||
import { formatReduxError } from "../../utils/errorUtils";
|
||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||
import { getCalendarDetailAsync } from "./services/getCalendarDetailAsync";
|
||||
import { refreshCalendarWithSyncToken } from "./services/refreshCalendar";
|
||||
import { getCalendarsListAsync } from "./services/getCalendarsListAsync";
|
||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||
|
||||
// Define error type for rejected actions
|
||||
interface RejectedError {
|
||||
export interface RejectedError {
|
||||
message: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export const getCalendarsListAsync = createAsyncThunk<
|
||||
{ importedCalendars: Record<string, Calendar>; errors: string }, // Return type
|
||||
void, // Arg type
|
||||
{ rejectValue: RejectedError; state: any } // ThunkAPI config
|
||||
>("calendars/getCalendars", async (_, { rejectWithValue, getState }) => {
|
||||
const state = getState() as any;
|
||||
if (Object.keys(state.calendars.list).length > 0) {
|
||||
return {
|
||||
importedCalendars: state.calendars.list,
|
||||
errors: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCalendars: Record<string, Calendar> = {};
|
||||
const user = (await getOpenPaasUser()) as Record<string, string>;
|
||||
const calendars = (await getCalendars(user.id)) as Record<string, any>;
|
||||
const rawCalendars = calendars._embedded["dav:calendar"] as Record<
|
||||
string,
|
||||
any
|
||||
>[];
|
||||
const errors: string[] = [];
|
||||
|
||||
const normalizedCalendars = rawCalendars.map((cal) => {
|
||||
const description = cal["caldav:description"];
|
||||
let delegated = false;
|
||||
let source = cal["calendarserver:source"]
|
||||
? cal["calendarserver:source"]._links.self.href
|
||||
: cal._links.self.href;
|
||||
const link = cal._links.self.href;
|
||||
if (cal["calendarserver:delegatedsource"]) {
|
||||
source = cal["calendarserver:delegatedsource"];
|
||||
delegated = true;
|
||||
}
|
||||
const id = source.replace("/calendars/", "").replace(".json", "");
|
||||
const ownerId = id.split("/")[0];
|
||||
const visibility = getCalendarVisibility(cal["acl"]);
|
||||
return {
|
||||
cal,
|
||||
description,
|
||||
delegated,
|
||||
source,
|
||||
link,
|
||||
id,
|
||||
ownerId,
|
||||
visibility,
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueOwnerIds = Array.from(
|
||||
new Set(normalizedCalendars.map(({ ownerId }) => ownerId).filter(Boolean))
|
||||
);
|
||||
|
||||
const ownerDataMap = new Map<string, any>();
|
||||
const OWNER_BATCH_SIZE = 20;
|
||||
|
||||
const fetchOwnerData = async (ownerId: string) => {
|
||||
try {
|
||||
const data = await getUserDetails(ownerId);
|
||||
ownerDataMap.set(ownerId, data);
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch user details for ${ownerId}:`, error);
|
||||
ownerDataMap.set(ownerId, {
|
||||
firstname: "",
|
||||
lastname: "Unknown User",
|
||||
emails: [],
|
||||
});
|
||||
errors.push(formatReduxError(error));
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < uniqueOwnerIds.length; i += OWNER_BATCH_SIZE) {
|
||||
const chunk = uniqueOwnerIds.slice(i, i + OWNER_BATCH_SIZE);
|
||||
await Promise.all(chunk.map((ownerId) => fetchOwnerData(ownerId)));
|
||||
}
|
||||
|
||||
normalizedCalendars.forEach(
|
||||
({ cal, description, delegated, link, id, ownerId, visibility }) => {
|
||||
const ownerData = ownerDataMap.get(ownerId) || {
|
||||
firstname: "",
|
||||
lastname: "Unknown User",
|
||||
emails: [],
|
||||
};
|
||||
const name =
|
||||
ownerId !== user.id && cal["dav:name"] === "#default"
|
||||
? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
|
||||
ownerData.lastname
|
||||
}` + "'s calendar"
|
||||
: cal["dav:name"];
|
||||
|
||||
const color = {
|
||||
light: cal["apple:color"] ?? "#006BD8",
|
||||
dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF",
|
||||
};
|
||||
importedCalendars[id] = {
|
||||
id,
|
||||
name,
|
||||
link,
|
||||
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
|
||||
ownerData.lastname
|
||||
}`,
|
||||
ownerEmails: ownerData.emails,
|
||||
description,
|
||||
delegated,
|
||||
color,
|
||||
visibility,
|
||||
events: {},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { importedCalendars, errors: errors.join("\n") };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue({
|
||||
message: formatReduxError(err),
|
||||
status: err.response?.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const getTempCalendarsListAsync = createAsyncThunk<
|
||||
Record<string, Calendar>,
|
||||
User,
|
||||
@@ -220,51 +104,6 @@ export const getTempCalendarsListAsync = createAsyncThunk<
|
||||
}
|
||||
});
|
||||
|
||||
export const getCalendarDetailAsync = createAsyncThunk<
|
||||
{ calId: string; events: CalendarEvent[]; calType?: string },
|
||||
{
|
||||
calId: string;
|
||||
match: { start: string; end: string };
|
||||
calType?: string;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
{ rejectValue: RejectedError }
|
||||
>(
|
||||
"calendars/getCalendarDetails",
|
||||
async ({ calId, match, calType, signal }, { rejectWithValue }) => {
|
||||
try {
|
||||
const calendar = (await getCalendar(calId, match, signal)) as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
const color = calendar["apple:color"];
|
||||
const events: CalendarEvent[] = calendar._embedded["dav:item"].flatMap(
|
||||
(eventdata: any) => {
|
||||
const vevents = eventdata.data[2] as any[][];
|
||||
const valarm = eventdata.data[2][0][2][0];
|
||||
const eventURL = eventdata._links.self.href;
|
||||
return vevents.map((vevent: any[]) => {
|
||||
return parseCalendarEvent(
|
||||
vevent[1],
|
||||
color,
|
||||
calId,
|
||||
eventURL,
|
||||
valarm
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return { calId, events, calType };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue({
|
||||
message: formatReduxError(err),
|
||||
status: err.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const putEventAsync = createAsyncThunk<
|
||||
{ calId: string; events: CalendarEvent[]; calType?: "temp" },
|
||||
{ cal: Calendar; newEvent: CalendarEvent; calType?: "temp" },
|
||||
@@ -692,9 +531,9 @@ const CalendarSlice = createSlice({
|
||||
action.payload.event;
|
||||
state.list[action.payload.calendarUid].events[
|
||||
action.payload.event.uid
|
||||
].URL = `/calendars/${action.payload.calendarUid}/${
|
||||
action.payload.event.uid.split("/")[0]
|
||||
}.isc`;
|
||||
].URL = `/calendars/${action.payload.calendarUid}/${extractEventBaseUuid(
|
||||
action.payload.event.uid
|
||||
)}.ics`;
|
||||
},
|
||||
removeEvent: (
|
||||
state,
|
||||
@@ -781,6 +620,7 @@ const CalendarSlice = createSlice({
|
||||
calId: string;
|
||||
events: CalendarEvent[];
|
||||
calType?: string;
|
||||
syncToken?: string;
|
||||
}>
|
||||
) => {
|
||||
state.pending = false;
|
||||
@@ -789,6 +629,8 @@ const CalendarSlice = createSlice({
|
||||
if (!state[type][action.payload.calId]) {
|
||||
return;
|
||||
}
|
||||
state[type][action.payload.calId].syncToken =
|
||||
action.payload.syncToken;
|
||||
action.payload.events.forEach((event) => {
|
||||
state[type][action.payload.calId].events[event.uid] = event;
|
||||
});
|
||||
@@ -894,7 +736,7 @@ const CalendarSlice = createSlice({
|
||||
if (recurrenceId) {
|
||||
Object.keys(state.list[action.payload.calId].events).forEach(
|
||||
(element) => {
|
||||
if (element.split("/")[0] === baseId) {
|
||||
if (extractEventBaseUuid(element) === baseId) {
|
||||
delete state.list[action.payload.calId].events[element];
|
||||
}
|
||||
}
|
||||
@@ -991,6 +833,41 @@ const CalendarSlice = createSlice({
|
||||
state.pending = false;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(refreshCalendarWithSyncToken.fulfilled, (state, action) => {
|
||||
state.pending = false;
|
||||
state.error = null;
|
||||
|
||||
const {
|
||||
calId,
|
||||
deletedEvents,
|
||||
createdOrUpdatedEvents,
|
||||
calType,
|
||||
syncToken,
|
||||
syncStatus,
|
||||
} = action.payload;
|
||||
|
||||
const target =
|
||||
calType === "temp" ? state.templist[calId] : state.list[calId];
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncStatus === "SUCCESS") {
|
||||
const deletedSet = new Set(deletedEvents); // working with a Set for deletion avoids O(nxm) complexity
|
||||
Object.keys(target.events)
|
||||
.filter((eventKey) => {
|
||||
const baseUid = extractEventBaseUuid(eventKey);
|
||||
return deletedSet.has(eventKey) || deletedSet.has(baseUid);
|
||||
})
|
||||
.forEach((eventKey) => delete target.events[eventKey]);
|
||||
|
||||
for (const event of createdOrUpdatedEvents) {
|
||||
target.events[event.uid] = event;
|
||||
}
|
||||
target.syncToken = syncToken;
|
||||
}
|
||||
})
|
||||
// Pending cases
|
||||
.addCase(getCalendarDetailAsync.pending, (state) => {
|
||||
state.pending = true;
|
||||
@@ -1040,6 +917,9 @@ const CalendarSlice = createSlice({
|
||||
.addCase(importEventFromFileAsync.pending, (state) => {
|
||||
state.pending = true;
|
||||
})
|
||||
.addCase(refreshCalendarWithSyncToken.pending, (state) => {
|
||||
state.pending = true;
|
||||
})
|
||||
// Rejected cases
|
||||
.addCase(getCalendarsListAsync.rejected, (state, action) => {
|
||||
if (action.payload?.status !== 401) {
|
||||
@@ -1166,6 +1046,13 @@ const CalendarSlice = createSlice({
|
||||
action.payload?.message ||
|
||||
action.error.message ||
|
||||
"Failed to import event from file";
|
||||
})
|
||||
.addCase(refreshCalendarWithSyncToken.rejected, (state, action) => {
|
||||
state.pending = false;
|
||||
state.error =
|
||||
action.payload?.message ||
|
||||
action.error.message ||
|
||||
"Failed to refresh calendar";
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,4 +15,5 @@ export interface Calendar {
|
||||
events: Record<string, CalendarEvent>;
|
||||
visibility: "private" | "public";
|
||||
lastCacheCleared?: number;
|
||||
syncToken?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { api } from "../../../utils/apiUtils";
|
||||
import { Calendar } from "../CalendarTypes";
|
||||
import { DavSyncResponse } from "./types";
|
||||
|
||||
export async function fetchSyncTokenChanges(
|
||||
calendar: Calendar
|
||||
): Promise<DavSyncResponse> {
|
||||
const response = await api(`dav/calendars/${calendar.id}.json`, {
|
||||
method: "REPORT",
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"sync-token": calendar.syncToken,
|
||||
}),
|
||||
});
|
||||
const update: DavSyncResponse = await response.json();
|
||||
return update;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface DavSyncItem {
|
||||
status: number;
|
||||
_links: CalDavLink;
|
||||
}
|
||||
|
||||
export type CalDavLink = {
|
||||
self?: {
|
||||
href?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CalDavItem = {
|
||||
data?: unknown[];
|
||||
_links?: CalDavLink;
|
||||
};
|
||||
|
||||
export interface DavSyncResponse {
|
||||
_embedded?: {
|
||||
"dav:item"?: DavSyncItem[];
|
||||
};
|
||||
"sync-token"?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { formatReduxError } from "../../../utils/errorUtils";
|
||||
import { CalendarEvent } from "../../Events/EventsTypes";
|
||||
import { getCalendar } from "../CalendarApi";
|
||||
import { RejectedError } from "../CalendarSlice";
|
||||
import { extractCalendarEvents } from "../utils/extractCalendarEvents";
|
||||
|
||||
export const getCalendarDetailAsync = createAsyncThunk<
|
||||
{
|
||||
calId: string;
|
||||
events: CalendarEvent[];
|
||||
calType?: string;
|
||||
syncToken?: string;
|
||||
},
|
||||
{
|
||||
calId: string;
|
||||
match: { start: string; end: string };
|
||||
calType?: string;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
{ rejectValue: RejectedError }
|
||||
>(
|
||||
"calendars/getCalendarDetails",
|
||||
async ({ calId, match, calType, signal }, { rejectWithValue }) => {
|
||||
try {
|
||||
const calendar = (await getCalendar(calId, match, signal)) as any;
|
||||
|
||||
const color = calendar["apple:color"];
|
||||
const syncToken = calendar._embedded?.["sync-token"];
|
||||
|
||||
const items = calendar._embedded?.["dav:item"];
|
||||
const events: CalendarEvent[] = Array.isArray(items)
|
||||
? items.flatMap((item: any) =>
|
||||
extractCalendarEvents(item, { calId, color })
|
||||
)
|
||||
: [];
|
||||
|
||||
return { calId, events, calType, syncToken };
|
||||
} catch (err: any) {
|
||||
return rejectWithValue({
|
||||
message: formatReduxError(err),
|
||||
status: err.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { formatReduxError } from "../../../utils/errorUtils";
|
||||
import { getOpenPaasUser, getUserDetails } from "../../User/userAPI";
|
||||
import { getCalendars } from "../CalendarApi";
|
||||
import { RejectedError } from "../CalendarSlice";
|
||||
import { Calendar } from "../CalendarTypes";
|
||||
import { normalizeCalendar } from "../utils/normalizeCalendar";
|
||||
|
||||
export const getCalendarsListAsync = createAsyncThunk<
|
||||
{ importedCalendars: Record<string, Calendar>; errors: string },
|
||||
void,
|
||||
{ rejectValue: RejectedError; state: any }
|
||||
>("calendars/getCalendars", async (_, { rejectWithValue, getState }) => {
|
||||
const state = getState() as any;
|
||||
const existingCalendars = state.calendars.list || {};
|
||||
const existingUser = { id: state.user?.userData?.openpaasId || undefined };
|
||||
try {
|
||||
const fetchedCalendars: Record<string, Calendar> = {};
|
||||
const user = existingUser.id
|
||||
? existingUser
|
||||
: ((await getOpenPaasUser()) as Record<string, string>);
|
||||
const calendars = (await getCalendars(user.id)) as Record<string, any>;
|
||||
const rawCalendars = calendars._embedded["dav:calendar"] as Record<
|
||||
string,
|
||||
any
|
||||
>[];
|
||||
const errors: string[] = [];
|
||||
|
||||
const normalizedCalendars = rawCalendars.map((cal) =>
|
||||
normalizeCalendar(cal)
|
||||
);
|
||||
|
||||
const uniqueOwnerIds = Array.from(
|
||||
new Set(normalizedCalendars.map(({ ownerId }) => ownerId).filter(Boolean))
|
||||
);
|
||||
|
||||
const ownerDataMap = new Map<string, any>();
|
||||
const OWNER_BATCH_SIZE = 20;
|
||||
|
||||
const fetchOwnerData = async (ownerId: string) => {
|
||||
try {
|
||||
const data = await getUserDetails(ownerId);
|
||||
ownerDataMap.set(ownerId, data);
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch user details for ${ownerId}:`, error);
|
||||
ownerDataMap.set(ownerId, {
|
||||
firstname: "",
|
||||
lastname: "Unknown User",
|
||||
emails: [],
|
||||
});
|
||||
errors.push(formatReduxError(error));
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < uniqueOwnerIds.length; i += OWNER_BATCH_SIZE) {
|
||||
const chunk = uniqueOwnerIds.slice(i, i + OWNER_BATCH_SIZE);
|
||||
await Promise.all(chunk.map((ownerId) => fetchOwnerData(ownerId)));
|
||||
}
|
||||
|
||||
normalizedCalendars.forEach(
|
||||
({ cal, description, delegated, link, id, ownerId, visibility }) => {
|
||||
const ownerData = ownerDataMap.get(ownerId) || {
|
||||
firstname: "",
|
||||
lastname: "Unknown User",
|
||||
emails: [],
|
||||
};
|
||||
const name =
|
||||
ownerId !== user.id && cal["dav:name"] === "#default"
|
||||
? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}` +
|
||||
"'s calendar"
|
||||
: cal["dav:name"];
|
||||
|
||||
const color = {
|
||||
light: cal["apple:color"] ?? "#006BD8",
|
||||
dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF",
|
||||
};
|
||||
fetchedCalendars[id] = {
|
||||
id,
|
||||
name,
|
||||
link,
|
||||
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`,
|
||||
ownerEmails: ownerData.emails,
|
||||
description,
|
||||
delegated,
|
||||
color,
|
||||
visibility,
|
||||
events: {},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const importedCalendars: Record<string, Calendar> = {};
|
||||
|
||||
const fetchedIds = new Set(Object.keys(fetchedCalendars));
|
||||
const existingIds = new Set(Object.keys(existingCalendars));
|
||||
|
||||
const added = [...fetchedIds].filter((id) => !existingIds.has(id));
|
||||
|
||||
existingIds.forEach((id) => {
|
||||
if (fetchedIds.has(id)) {
|
||||
const existingCal = existingCalendars[id];
|
||||
const fetchedCal = fetchedCalendars[id];
|
||||
|
||||
if (fetchedCal) {
|
||||
importedCalendars[id] = {
|
||||
...fetchedCal,
|
||||
color: existingCal.color,
|
||||
events: existingCal.events || {},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add new calendars
|
||||
added.forEach((id) => {
|
||||
importedCalendars[id] = fetchedCalendars[id];
|
||||
});
|
||||
|
||||
return {
|
||||
importedCalendars,
|
||||
errors: errors.join("\n"),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return rejectWithValue({
|
||||
message: formatReduxError(err),
|
||||
status: err.response?.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import pMap from "p-map";
|
||||
import { formatReduxError } from "../../../utils/errorUtils";
|
||||
import { CalendarEvent } from "../../Events/EventsTypes";
|
||||
import { fetchSyncTokenChanges } from "../api/fetchSyncTokenChanges";
|
||||
import { RejectedError } from "../CalendarSlice";
|
||||
import { Calendar } from "../CalendarTypes";
|
||||
import { expandEventFunction } from "../utils/expandEventFunction";
|
||||
import { processSyncUpdates } from "../utils/processSyncTokenUpdates";
|
||||
|
||||
export interface SyncTokenUpdates {
|
||||
calId: string;
|
||||
deletedEvents: string[];
|
||||
createdOrUpdatedEvents: CalendarEvent[];
|
||||
calType?: "temp";
|
||||
syncToken?: string;
|
||||
syncStatus?: string;
|
||||
}
|
||||
|
||||
export const refreshCalendarWithSyncToken = createAsyncThunk<
|
||||
SyncTokenUpdates,
|
||||
{
|
||||
calendar: Calendar;
|
||||
calType?: "temp";
|
||||
calendarRange: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
maxConcurrency?: number;
|
||||
},
|
||||
{
|
||||
rejectValue: RejectedError;
|
||||
}
|
||||
>(
|
||||
"calendars/refreshWithSyncToken",
|
||||
async (
|
||||
{ calendar, maxConcurrency = 8, calendarRange, calType },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
if (!calendar?.syncToken) {
|
||||
return {
|
||||
calId: calendar.id,
|
||||
deletedEvents: [],
|
||||
createdOrUpdatedEvents: [],
|
||||
calType,
|
||||
syncStatus: "NO_SYNC_TOKEN",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetchSyncTokenChanges(calendar);
|
||||
const newSyncToken = response["sync-token"];
|
||||
const updates = response?._embedded?.["dav:item"] ?? [];
|
||||
|
||||
const { toDelete, toExpand } = processSyncUpdates(updates);
|
||||
|
||||
const createdOrUpdatedEvents = await pMap(
|
||||
toExpand,
|
||||
expandEventFunction(calendarRange, calendar),
|
||||
{ concurrency: maxConcurrency }
|
||||
);
|
||||
|
||||
return {
|
||||
calId: calendar.id,
|
||||
deletedEvents: toDelete,
|
||||
createdOrUpdatedEvents: createdOrUpdatedEvents
|
||||
.flat()
|
||||
.filter(Boolean) as CalendarEvent[],
|
||||
calType,
|
||||
syncToken: newSyncToken,
|
||||
syncStatus: newSyncToken ? "SUCCESS" : "NO_NEW_SYNC_TOKEN",
|
||||
};
|
||||
} catch (err: any) {
|
||||
return rejectWithValue({
|
||||
message: formatReduxError(err),
|
||||
status: err.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
||||
import { reportEvent } from "../../Events/EventApi";
|
||||
import { CalendarEvent } from "../../Events/EventsTypes";
|
||||
import { Calendar } from "../CalendarTypes";
|
||||
import { extractCalendarEvents } from "./extractCalendarEvents";
|
||||
|
||||
export function expandEventFunction(
|
||||
calendarRange: { start: Date; end: Date },
|
||||
calendar: Calendar
|
||||
): (item: string) => Promise<CalendarEvent[] | undefined> {
|
||||
return async (eventUrl) => {
|
||||
try {
|
||||
const item = await reportEvent({ URL: eventUrl } as CalendarEvent, {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||
});
|
||||
const events: CalendarEvent[] = extractCalendarEvents(item, {
|
||||
calId: calendar.id,
|
||||
color: calendar.color,
|
||||
});
|
||||
return events;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch event", eventUrl);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defaultColors } from "../../../components/Calendar/utils/calendarColorsUtils";
|
||||
import { CalendarEvent } from "../../Events/EventsTypes";
|
||||
import { parseCalendarEvent } from "../../Events/eventUtils";
|
||||
import { CalDavItem } from "../api/types";
|
||||
|
||||
export function extractCalendarEvents(
|
||||
item: CalDavItem,
|
||||
options: {
|
||||
calId: string;
|
||||
color?: Record<string, string>;
|
||||
}
|
||||
): CalendarEvent[] {
|
||||
const data = item.data;
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// VEVENTS are at index 2
|
||||
const vevents = data[2];
|
||||
if (!Array.isArray(vevents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventURL = item._links?.self?.href;
|
||||
if (!eventURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return vevents
|
||||
.map((vevent) => {
|
||||
if (!Array.isArray(vevent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventProps = vevent[1];
|
||||
if (!Array.isArray(eventProps)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valarm = extractValarm(vevent);
|
||||
|
||||
return parseCalendarEvent(
|
||||
eventProps,
|
||||
options?.color ?? defaultColors[0],
|
||||
options.calId,
|
||||
eventURL,
|
||||
valarm
|
||||
);
|
||||
})
|
||||
.filter(Boolean) as CalendarEvent[];
|
||||
}
|
||||
|
||||
function extractValarm(vevent: any[]) {
|
||||
const subComponents = vevent[2];
|
||||
if (!Array.isArray(subComponents)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const valarmComponent = subComponents.find(
|
||||
(component) => Array.isArray(component) && component[0] === "valarm"
|
||||
);
|
||||
|
||||
return valarmComponent;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getCalendarVisibility } from "../../../components/Calendar/utils/calendarUtils";
|
||||
|
||||
export function normalizeCalendar(rawCalendar: Record<string, any>) {
|
||||
const description = rawCalendar["caldav:description"];
|
||||
let delegated = false;
|
||||
let source = rawCalendar["calendarserver:source"]
|
||||
? rawCalendar["calendarserver:source"]._links.self.href
|
||||
: rawCalendar._links.self.href;
|
||||
const link = rawCalendar._links.self.href;
|
||||
if (rawCalendar["calendarserver:delegatedsource"]) {
|
||||
source = rawCalendar["calendarserver:delegatedsource"];
|
||||
delegated = true;
|
||||
}
|
||||
const id = source.replace("/calendars/", "").replace(".json", "");
|
||||
const ownerId = id.split("/")[0];
|
||||
const visibility = getCalendarVisibility(rawCalendar["acl"]);
|
||||
return {
|
||||
cal: rawCalendar,
|
||||
description,
|
||||
delegated,
|
||||
source,
|
||||
link,
|
||||
id,
|
||||
ownerId,
|
||||
visibility,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DavSyncItem } from "../api/types";
|
||||
|
||||
export interface ProcessedSyncUpdates {
|
||||
toDelete: string[];
|
||||
toExpand: string[];
|
||||
}
|
||||
|
||||
export function processSyncUpdates(
|
||||
updates: DavSyncItem[]
|
||||
): ProcessedSyncUpdates {
|
||||
const toDelete: string[] = [];
|
||||
const toExpand: string[] = [];
|
||||
|
||||
for (const update of updates) {
|
||||
const href = update?._links?.self?.href;
|
||||
if (!href) continue;
|
||||
const fileName = extractFileNameFromHref(href);
|
||||
|
||||
if (update.status === 404) {
|
||||
toDelete.push(fileName);
|
||||
} else if (update.status === 200) {
|
||||
toExpand.push(href);
|
||||
toDelete.push(fileName); // we delete the old version of the event to replace it by the new when it's updated
|
||||
} else if (update.status === 410) {
|
||||
throw new Error("SYNC_TOKEN_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
return { toDelete, toExpand };
|
||||
}
|
||||
|
||||
function extractFileNameFromHref(href: string): string {
|
||||
const fileNameMatch = href.match(/\/([^\/]+)\.ics$/); // CalDAV href are like /calendars/userID/CalendarID/EventId.ics
|
||||
return fileNameMatch ? fileNameMatch[1] : href;
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import ICAL from "ical.js";
|
||||
import moment from "moment-timezone";
|
||||
import { detectDateTimeFormat } from "../../components/Event/utils/dateTimeHelpers";
|
||||
import { User } from "../../components/Attendees/PeopleSearch";
|
||||
import { CalDavItem } from "../Calendars/api/types";
|
||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||
|
||||
function resolveTimezoneId(tzid?: string): string | undefined {
|
||||
if (!tzid) return undefined;
|
||||
@@ -23,6 +24,22 @@ function resolveTimezoneId(tzid?: string): string | undefined {
|
||||
return tzid;
|
||||
}
|
||||
|
||||
export async function reportEvent(
|
||||
event: CalendarEvent,
|
||||
match: { start: string; end: string }
|
||||
): Promise<CalDavItem> {
|
||||
const response = await api(`dav${event.URL}`, {
|
||||
method: "REPORT",
|
||||
body: JSON.stringify({ match }),
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`REPORT request failed with status ${response.status}`);
|
||||
}
|
||||
const eventData: CalDavItem = await response.json();
|
||||
return eventData;
|
||||
}
|
||||
|
||||
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
const response = await api.get(`dav${event.URL}`);
|
||||
const eventData = await response.text();
|
||||
@@ -227,7 +244,7 @@ export const deleteEventInstance = async (
|
||||
const seriesEvent = await getEvent(
|
||||
{
|
||||
...event,
|
||||
uid: event.uid.split("/")[0],
|
||||
uid: extractEventBaseUuid(event.uid),
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
EventFormContext,
|
||||
} from "../../utils/eventFormTempStorage";
|
||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||
|
||||
function EventUpdateModal({
|
||||
eventId,
|
||||
@@ -261,7 +262,7 @@ function EventUpdateModal({
|
||||
setCalendarid(calId);
|
||||
|
||||
// Handle repetition properly - check both current event and base event
|
||||
const baseEventId = event.uid.split("/")[0];
|
||||
const baseEventId = extractEventBaseUuid(event.uid);
|
||||
const baseEvent = calendarsList[calId]?.events[baseEventId];
|
||||
const repetitionSource = event.repetition || baseEvent?.repetition;
|
||||
|
||||
@@ -478,7 +479,7 @@ function EventUpdateModal({
|
||||
|
||||
Object.keys(seriesEvents).forEach((eventId) => {
|
||||
const instance = seriesEvents[eventId];
|
||||
if (instance && eventId.split("/")[0] === baseUID) {
|
||||
if (instance && extractEventBaseUuid(eventId) === baseUID) {
|
||||
instances[eventId] = { ...instance };
|
||||
}
|
||||
});
|
||||
@@ -614,7 +615,7 @@ function EventUpdateModal({
|
||||
event.repetition?.freq &&
|
||||
!repetition.freq
|
||||
) {
|
||||
const baseUID = event.uid.split("/")[0];
|
||||
const baseUID = extractEventBaseUuid(event.uid);
|
||||
|
||||
// Save current form data to temp storage before closing
|
||||
const formDataToSave = saveCurrentFormData();
|
||||
@@ -653,7 +654,7 @@ function EventUpdateModal({
|
||||
|
||||
// Collect all instances that need to be deleted
|
||||
const instancesToDelete = Object.keys(targetCalendar.events)
|
||||
.filter((eventId) => eventId.split("/")[0] === baseUID)
|
||||
.filter((eventId) => extractEventBaseUuid(eventId) === baseUID)
|
||||
.map((eventId) => targetCalendar.events[eventId]);
|
||||
|
||||
// Get unique URLs to avoid deleting same file multiple times
|
||||
@@ -819,7 +820,7 @@ function EventUpdateModal({
|
||||
}
|
||||
} else if (typeOfAction === "all") {
|
||||
// Update all instances - check if repetition rules changed
|
||||
const baseUID = event.uid.split("/")[0];
|
||||
const baseUID = extractEventBaseUuid(event.uid);
|
||||
|
||||
const changes = detectRecurringEventChanges(
|
||||
event,
|
||||
@@ -1074,7 +1075,7 @@ function EventUpdateModal({
|
||||
moveEventAsync({
|
||||
cal: targetCalendar,
|
||||
newEvent,
|
||||
newURL: `/calendars/${newCalId}/${event.uid.split("/")[0]}.ics`,
|
||||
newURL: `/calendars/${newCalId}/${extractEventBaseUuid(event.uid)}.ics`,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
detectDateTimeFormat,
|
||||
} from "../../components/Event/utils/dateTimeHelpers";
|
||||
import { createAttendee } from "../User/models/attendee.mapper";
|
||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||
type RawEntry = [string, Record<string, string>, string, any];
|
||||
|
||||
function resolveTimezoneId(tzid?: string): string | undefined {
|
||||
@@ -189,7 +190,7 @@ export function parseCalendarEvent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.calId = calendarid;
|
||||
event.URL = eventURL;
|
||||
if (!event.uid || !event.start) {
|
||||
console.error(
|
||||
@@ -293,7 +294,7 @@ export function makeVevent(
|
||||
const vevent: any[] = [
|
||||
"vevent",
|
||||
[
|
||||
["uid", {}, "text", event.uid.split("/")[0]],
|
||||
["uid", {}, "text", extractEventBaseUuid(event.uid)],
|
||||
["transp", {}, "text", event.transp ?? "OPAQUE"],
|
||||
[
|
||||
"dtstart",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { userAttendee } from "../User/models/attendee";
|
||||
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
|
||||
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||
|
||||
export interface TimezoneListResult {
|
||||
zones: string[];
|
||||
@@ -133,7 +134,7 @@ export function populateFormFromEvent(
|
||||
// Handle repetition - check both current event and base event (for update modal)
|
||||
let repetitionSource = event.repetition;
|
||||
if (calendarsList && calId && event.uid) {
|
||||
const baseEventId = event.uid.split("/")[0];
|
||||
const baseEventId = extractEventBaseUuid(event.uid);
|
||||
const baseEvent = calendarsList[calId]?.events[baseEventId];
|
||||
if (baseEvent?.repetition) {
|
||||
repetitionSource = baseEvent.repetition;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import { Auth } from "./oidcAuth";
|
||||
import { Loading } from "../../components/Loading/Loading";
|
||||
import { push } from "redux-first-history";
|
||||
import { redirectTo } from "../../utils/apiUtils";
|
||||
import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice";
|
||||
import { getCalendarsListAsync } from "../Calendars/CalendarSlice";
|
||||
import { getCalendarsListAsync } from "../Calendars/services/getCalendarsListAsync";
|
||||
|
||||
export function HandleLogin() {
|
||||
const userData = useAppSelector((state) => state.user);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
setUserError,
|
||||
} from "./userSlice";
|
||||
import { Loading } from "../../components/Loading/Loading";
|
||||
import { getCalendarsListAsync } from "../Calendars/CalendarSlice";
|
||||
import { getCalendarsListAsync } from "../Calendars/services/getCalendarsListAsync";
|
||||
|
||||
export function CallbackResume() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
Reference in New Issue
Block a user