feat: implement parallel calendar loading with batch processing
- Refactor getCalendarsListAsync to batch fetch getUserDetails - Deduplicate ownerIds before fetching - Process in batches of 20 with Promise.all - Add error handling with fallback data - Implement batch loading for selected calendar events - Load events in batches of 5 calendars - Use sorted selectedCalendars to prevent infinite loops - Add cancellation support for cleanup - Add prefetch mechanism for hidden calendars - Prefetch calendars not currently selected - Track prefetched state to avoid duplicate requests - Non-blocking background loading - Apply batch loading to temp calendars - Separate cache tracking for temp calendars - Batch load temp calendar events - Clean up cache when temp calendars are removed
This commit is contained in:
committed by
Benoit TELLIER
parent
2d9ff88c1b
commit
990e290066
@@ -5,7 +5,7 @@ import interactionPlugin from "@fullcalendar/interaction";
|
|||||||
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
|
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
|
||||||
import "./Calendar.styl";
|
import "./Calendar.styl";
|
||||||
import "./CustomCalendar.styl";
|
import "./CustomCalendar.styl";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
import EventPopover from "../../features/Events/EventModal";
|
import EventPopover from "../../features/Events/EventModal";
|
||||||
import { CalendarEvent } from "../../features/Events/EventsTypes";
|
import { CalendarEvent } from "../../features/Events/EventsTypes";
|
||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
updateSlotLabelVisibility,
|
updateSlotLabelVisibility,
|
||||||
eventToFullCalendarFormat,
|
eventToFullCalendarFormat,
|
||||||
extractEvents,
|
extractEvents,
|
||||||
updateCalsDetails,
|
|
||||||
} from "./utils/calendarUtils";
|
} from "./utils/calendarUtils";
|
||||||
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
|
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
|
||||||
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
|
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
|
||||||
@@ -71,7 +70,6 @@ export default function CalendarApp({
|
|||||||
const calendars = useAppSelector((state) => state.calendars.list);
|
const calendars = useAppSelector((state) => state.calendars.list);
|
||||||
const tempcalendars =
|
const tempcalendars =
|
||||||
useAppSelector((state) => state.calendars.templist) ?? {};
|
useAppSelector((state) => state.calendars.templist) ?? {};
|
||||||
const pending = useAppSelector((state) => state.calendars.pending);
|
|
||||||
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
|
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
|
||||||
|
|
||||||
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => {
|
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => {
|
||||||
@@ -102,7 +100,7 @@ export default function CalendarApp({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialLoadRef.current && Object.keys(calendars).length > 0 && userId) {
|
if (initialLoadRef.current && Object.keys(calendars || {}).length > 0 && userId) {
|
||||||
const cached = localStorage.getItem("selectedCalendars");
|
const cached = localStorage.getItem("selectedCalendars");
|
||||||
if (cached && cached.length > 0) {
|
if (cached && cached.length > 0) {
|
||||||
const parsed = JSON.parse(cached) as string[];
|
const parsed = JSON.parse(cached) as string[];
|
||||||
@@ -120,7 +118,7 @@ export default function CalendarApp({
|
|||||||
|
|
||||||
// Save selected cals to cache
|
// Save selected cals to cache
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(calendars).length > 0) {
|
if (Object.keys(calendars || {}).length > 0) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"selectedCalendars",
|
"selectedCalendars",
|
||||||
JSON.stringify(selectedCalendars)
|
JSON.stringify(selectedCalendars)
|
||||||
@@ -129,22 +127,25 @@ export default function CalendarApp({
|
|||||||
}, [selectedCalendars]);
|
}, [selectedCalendars]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateDarkColor(calendars, theme, dispatch);
|
updateDarkColor(calendars || {}, theme, dispatch);
|
||||||
}, [
|
}, [
|
||||||
theme,
|
theme,
|
||||||
Object.values(calendars)
|
Object.values(calendars || {})
|
||||||
.map((c) => c.color?.dark)
|
.map((c) => c.color?.dark)
|
||||||
.join(","),
|
.join(","),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const validCalendarIds = new Set(Object.keys(calendars));
|
const validCalendarIds = new Set(Object.keys(calendars || {}));
|
||||||
setSelectedCalendars((prev) =>
|
setSelectedCalendars((prev) =>
|
||||||
prev.filter((calId) => validCalendarIds.has(calId))
|
prev.filter((calId) => validCalendarIds.has(calId))
|
||||||
);
|
);
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
const calendarRange = getCalendarRange(selectedDate);
|
const calendarRange = useMemo(
|
||||||
|
() => getCalendarRange(selectedDate),
|
||||||
|
[selectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
// Create a stable string key for the range
|
// Create a stable string key for the range
|
||||||
const rangeKey = `${formatDateToYYYYMMDDTHHMMSS(
|
const rangeKey = `${formatDateToYYYYMMDDTHHMMSS(
|
||||||
@@ -153,35 +154,118 @@ export default function CalendarApp({
|
|||||||
|
|
||||||
let filteredEvents: CalendarEvent[] = extractEvents(
|
let filteredEvents: CalendarEvent[] = extractEvents(
|
||||||
selectedCalendars,
|
selectedCalendars,
|
||||||
calendars
|
calendars || {}
|
||||||
);
|
);
|
||||||
|
|
||||||
let filteredTempEvents: CalendarEvent[] = extractEvents(
|
let filteredTempEvents: CalendarEvent[] = extractEvents(
|
||||||
Object.keys(tempcalendars),
|
Object.keys(tempcalendars || {}),
|
||||||
tempcalendars
|
tempcalendars || {}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortedSelectedCalendars = useMemo(
|
||||||
|
() => [...selectedCalendars].sort(),
|
||||||
|
[selectedCalendars]
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefetchedCalendarsRef = useRef<Record<string, string>>({});
|
||||||
|
const tempFetchedRangesRef = useRef<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rangeKey || sortedSelectedCalendars.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const ACTIVE_BATCH_SIZE = 5;
|
||||||
|
|
||||||
|
const loadCalendars = async () => {
|
||||||
|
const pendingIds = sortedSelectedCalendars.filter(
|
||||||
|
(id) => fetchedRangesRef.current[id] !== rangeKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pendingIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
|
||||||
|
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < pendingIds.length && !cancelled;
|
||||||
|
i += ACTIVE_BATCH_SIZE
|
||||||
|
) {
|
||||||
|
const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE);
|
||||||
|
|
||||||
|
chunk.forEach((id) => {
|
||||||
|
fetchedRangesRef.current[id] = rangeKey;
|
||||||
|
prefetchedCalendarsRef.current[id] = "active";
|
||||||
|
});
|
||||||
|
|
||||||
|
const requests = chunk.map(async (id) => {
|
||||||
|
try {
|
||||||
|
await dispatch(
|
||||||
|
getCalendarDetailAsync({
|
||||||
|
calId: id,
|
||||||
|
match: {
|
||||||
|
start: rangeStart,
|
||||||
|
end: rangeEnd,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load calendar ${id}:`, error);
|
||||||
|
fetchedRangesRef.current[id] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(requests);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCalendars();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
dispatch,
|
||||||
|
rangeKey,
|
||||||
|
sortedSelectedCalendars,
|
||||||
|
calendarRange,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rangeKey) return;
|
if (!rangeKey) return;
|
||||||
selectedCalendars.forEach((id) => {
|
|
||||||
if (fetchedRangesRef.current[id] === rangeKey) return;
|
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
|
||||||
fetchedRangesRef.current[id] = rangeKey;
|
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
|
||||||
|
|
||||||
|
const hiddenCalendars = Object.keys(calendars || {})
|
||||||
|
.filter((id) => !selectedCalendars.includes(id))
|
||||||
|
.filter((id) => prefetchedCalendarsRef.current[id] !== rangeKey);
|
||||||
|
|
||||||
|
hiddenCalendars.forEach((id) => {
|
||||||
|
prefetchedCalendarsRef.current[id] = rangeKey;
|
||||||
dispatch(
|
dispatch(
|
||||||
getCalendarDetailAsync({
|
getCalendarDetailAsync({
|
||||||
calId: id,
|
calId: id,
|
||||||
match: {
|
match: {
|
||||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
start: rangeStart,
|
||||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
end: rangeEnd,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
|
.unwrap()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`Prefetch calendar ${id} failed:`, error);
|
||||||
|
prefetchedCalendarsRef.current[id] = "";
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
rangeKey,
|
calendars,
|
||||||
selectedCalendars,
|
selectedCalendars,
|
||||||
|
rangeKey,
|
||||||
|
calendarRange,
|
||||||
dispatch,
|
dispatch,
|
||||||
calendarRange.start,
|
|
||||||
calendarRange.end,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,6 +273,7 @@ export default function CalendarApp({
|
|||||||
const calendar = calendars[calId];
|
const calendar = calendars[calId];
|
||||||
if (calendar?.lastCacheCleared) {
|
if (calendar?.lastCacheCleared) {
|
||||||
delete fetchedRangesRef.current[calId];
|
delete fetchedRangesRef.current[calId];
|
||||||
|
prefetchedCalendarsRef.current[calId] = "";
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
getCalendarDetailAsync({
|
getCalendarDetailAsync({
|
||||||
@@ -201,6 +286,7 @@ export default function CalendarApp({
|
|||||||
);
|
);
|
||||||
|
|
||||||
fetchedRangesRef.current[calId] = rangeKey;
|
fetchedRangesRef.current[calId] = rangeKey;
|
||||||
|
prefetchedCalendarsRef.current[calId] = rangeKey;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -208,40 +294,79 @@ export default function CalendarApp({
|
|||||||
selectedCalendars.map((id) => calendars[id]?.lastCacheCleared).join(","),
|
selectedCalendars.map((id) => calendars[id]?.lastCacheCleared).join(","),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [prevTempCalendars, setPrevTempCalendars] = useState<string[]>([]);
|
const tempCalendarIds = useMemo(
|
||||||
const [prevRangeKey, setPrevRangeKey] = useState<string>("");
|
() => Object.keys(tempcalendars || {}).sort(),
|
||||||
|
[tempcalendars]
|
||||||
|
);
|
||||||
|
|
||||||
const tempCalendarControllersRef = useRef<Map<string, AbortController>>(
|
const tempCalendarControllersRef = useRef<Map<string, AbortController>>(
|
||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateCalsDetails(
|
const currentIds = new Set(tempCalendarIds);
|
||||||
Object.keys(tempcalendars),
|
Object.keys(tempFetchedRangesRef.current).forEach((id) => {
|
||||||
prevTempCalendars,
|
if (!currentIds.has(id)) {
|
||||||
pending,
|
delete tempFetchedRangesRef.current[id];
|
||||||
rangeKey,
|
|
||||||
prevRangeKey,
|
|
||||||
dispatch,
|
|
||||||
calendarRange,
|
|
||||||
"temp",
|
|
||||||
tempCalendarControllersRef.current
|
|
||||||
);
|
|
||||||
|
|
||||||
prevTempCalendars.forEach((calId) => {
|
|
||||||
if (!Object.keys(tempcalendars).includes(calId)) {
|
|
||||||
const controller = tempCalendarControllersRef.current.get(calId);
|
|
||||||
if (controller) {
|
|
||||||
controller.abort();
|
|
||||||
tempCalendarControllersRef.current.delete(calId);
|
|
||||||
}
|
|
||||||
delete fetchedRangesRef.current[calId];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, [tempCalendarIds]);
|
||||||
|
|
||||||
setPrevTempCalendars(Object.keys(tempcalendars));
|
useEffect(() => {
|
||||||
setPrevRangeKey(rangeKey);
|
if (!rangeKey || tempCalendarIds.length === 0) return;
|
||||||
}, [rangeKey, Object.keys(tempcalendars).join(","), pending]);
|
|
||||||
|
let cancelled = false;
|
||||||
|
const TEMP_BATCH_SIZE = 5;
|
||||||
|
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
|
||||||
|
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
|
||||||
|
|
||||||
|
const loadTempCalendars = async () => {
|
||||||
|
const pendingIds = tempCalendarIds.filter(
|
||||||
|
(id) => tempFetchedRangesRef.current[id] !== rangeKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pendingIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < pendingIds.length && !cancelled;
|
||||||
|
i += TEMP_BATCH_SIZE
|
||||||
|
) {
|
||||||
|
const chunk = pendingIds.slice(i, i + TEMP_BATCH_SIZE);
|
||||||
|
chunk.forEach((id) => {
|
||||||
|
tempFetchedRangesRef.current[id] = rangeKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
const requests = chunk.map(async (id) => {
|
||||||
|
try {
|
||||||
|
await dispatch(
|
||||||
|
getCalendarDetailAsync({
|
||||||
|
calId: id,
|
||||||
|
match: {
|
||||||
|
start: rangeStart,
|
||||||
|
end: rangeEnd,
|
||||||
|
},
|
||||||
|
calType: "temp",
|
||||||
|
})
|
||||||
|
).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load temp calendar ${id}:`, error);
|
||||||
|
tempFetchedRangesRef.current[id] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(requests);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTempCalendars();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [dispatch, rangeKey, tempCalendarIds, calendarRange]);
|
||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,13 @@ export const getCalendarsListAsync = createAsyncThunk<
|
|||||||
const importedCalendars: Record<string, Calendars> = {};
|
const importedCalendars: Record<string, Calendars> = {};
|
||||||
const user = (await getOpenPaasUser()) as Record<string, string>;
|
const user = (await getOpenPaasUser()) as Record<string, string>;
|
||||||
const calendars = (await getCalendars(user.id)) as Record<string, any>;
|
const calendars = (await getCalendars(user.id)) as Record<string, any>;
|
||||||
const rawCalendars = calendars._embedded["dav:calendar"];
|
const rawCalendars = calendars._embedded["dav:calendar"] as Record<
|
||||||
const errors = [];
|
string,
|
||||||
for (const cal of rawCalendars) {
|
any
|
||||||
|
>[];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const normalizedCalendars = rawCalendars.map((cal) => {
|
||||||
const description = cal["caldav:description"];
|
const description = cal["caldav:description"];
|
||||||
let delegated = false;
|
let delegated = false;
|
||||||
let source = cal["calendarserver:source"]
|
let source = cal["calendarserver:source"]
|
||||||
@@ -62,50 +66,87 @@ export const getCalendarsListAsync = createAsyncThunk<
|
|||||||
const id = source.replace("/calendars/", "").replace(".json", "");
|
const id = source.replace("/calendars/", "").replace(".json", "");
|
||||||
const ownerId = id.split("/")[0];
|
const ownerId = id.split("/")[0];
|
||||||
const visibility = getCalendarVisibility(cal["acl"]);
|
const visibility = getCalendarVisibility(cal["acl"]);
|
||||||
|
return {
|
||||||
|
cal,
|
||||||
|
description,
|
||||||
|
delegated,
|
||||||
|
source,
|
||||||
|
link,
|
||||||
|
id,
|
||||||
|
ownerId,
|
||||||
|
visibility,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Safely fetch owner data with fallback
|
const uniqueOwnerIds = Array.from(
|
||||||
let ownerData: any;
|
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 {
|
try {
|
||||||
ownerData = await getUserDetails(ownerId);
|
const data = await getUserDetails(ownerId);
|
||||||
} catch (error) {
|
ownerDataMap.set(ownerId, data);
|
||||||
console.error(
|
} catch (error: any) {
|
||||||
`Failed to fetch user details for ${id.split("/")[0]}:`,
|
console.error(`Failed to fetch user details for ${ownerId}:`, error);
|
||||||
error
|
ownerDataMap.set(ownerId, {
|
||||||
);
|
firstname: "",
|
||||||
// Provide fallback data
|
lastname: "Unknown User",
|
||||||
ownerData = {
|
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: "",
|
firstname: "",
|
||||||
lastname: "Unknown User",
|
lastname: "Unknown User",
|
||||||
emails: [],
|
emails: [],
|
||||||
};
|
};
|
||||||
errors.push(error);
|
const name =
|
||||||
}
|
ownerId !== user.id && cal["dav:name"] === "#default"
|
||||||
const name =
|
? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
|
||||||
ownerId !== user.id && cal["dav:name"] === "#default"
|
ownerData.lastname
|
||||||
? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
|
}` + "'s calendar"
|
||||||
ownerData.lastname
|
: cal["dav:name"];
|
||||||
}` + "'s calendar"
|
|
||||||
: cal["dav:name"];
|
|
||||||
|
|
||||||
const color = {
|
const color = {
|
||||||
light: cal["apple:color"] ?? "#006BD8",
|
light: cal["apple:color"] ?? "#006BD8",
|
||||||
dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF",
|
dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF",
|
||||||
};
|
};
|
||||||
importedCalendars[id] = {
|
importedCalendars[id] = {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
link,
|
link,
|
||||||
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
|
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
|
||||||
ownerData.lastname
|
ownerData.lastname
|
||||||
}`,
|
}`,
|
||||||
ownerEmails: ownerData.emails,
|
ownerEmails: ownerData.emails,
|
||||||
description,
|
description,
|
||||||
delegated,
|
delegated,
|
||||||
color,
|
color,
|
||||||
visibility,
|
visibility,
|
||||||
events: {},
|
events: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return { importedCalendars, errors: errors.join("\n") };
|
return { importedCalendars, errors: errors.join("\n") };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user