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:
Camille Moussu
2026-01-13 11:06:34 +01:00
committed by GitHub
parent bcc3019e4b
commit 478a0e0eb0
36 changed files with 1323 additions and 281 deletions
@@ -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,
});
}
}
);