1 import type { Location } from 'history';
3 import { CryptoProxy } from '@proton/crypto';
4 import type { CachedItem, ESEvent, ESItemEvent, EncryptedItemWithInfo, EventsObject } from '@proton/encrypted-search';
7 ES_MAX_ITEMS_PER_BATCH,
16 } from '@proton/encrypted-search';
17 import { getEvent, queryEventsIDs, queryLatestModelEventID } from '@proton/shared/lib/api/calendars';
18 import { EVENT_ACTIONS } from '@proton/shared/lib/constants';
19 import runInQueue from '@proton/shared/lib/helpers/runInQueue';
20 import { getSearchParams as getSearchParamsFromURL, stringifySearchParams } from '@proton/shared/lib/helpers/url';
21 import { isNumber } from '@proton/shared/lib/helpers/validators';
22 import type { Api, SimpleMap } from '@proton/shared/lib/interfaces';
25 CalendarEventWithoutBlob,
26 CalendarEventsIDsQuery,
28 VcalOrganizerProperty,
29 } from '@proton/shared/lib/interfaces/calendar';
32 CalendarEventsEventManager,
33 } from '@proton/shared/lib/interfaces/calendar/EventManager';
34 import type { GetCalendarEventRaw } from '@proton/shared/lib/interfaces/hooks/GetCalendarEventRaw';
35 import unique from '@proton/utils/unique';
37 import { propertiesToAttendeeModel } from '../../components/eventModal/eventForm/propertiesToAttendeeModel';
38 import { propertiesToOrganizerModel } from '../../components/eventModal/eventForm/propertiesToOrganizerModel';
39 import type { CalendarSearchQuery } from '../../containers/calendar/interface';
44 ESCalendarSearchParams,
46 } from '../../interfaces/encryptedSearch';
47 import { generateEventUniqueId, getCalendarIDFromUniqueId, getEventIDFromUniqueId } from '../event';
48 import { CALENDAR_CORE_LOOP } from './constants';
50 export const getEventKey = (calendarID: string, uid: string) => `${calendarID}-${uid}`;
52 export const generateOrder = async (ID: string) => {
53 const numericalID = ID.split('').map((char) => char.charCodeAt(0));
54 const digest = await CryptoProxy.computeHash({ algorithm: 'unsafeMD5', data: Uint8Array.from(numericalID) });
55 const orderArray = new Uint32Array(digest.buffer);
60 const checkIsSearch = (searchParams: ESCalendarSearchParams) =>
61 !!searchParams.calendarID || !!searchParams.begin || !!searchParams.end || !!searchParams.keyword;
63 const stringToInt = (string: string | undefined): number | undefined => {
64 if (string === undefined) {
67 return isNumber(string) ? parseInt(string, 10) : undefined;
70 export const generatePathnameWithSearchParams = (location: Location, searchQuery: CalendarSearchQuery) => {
71 const parts = location.pathname.split('/');
74 const pathname = parts.join('/');
75 const hash = stringifySearchParams(searchQuery as { [key: string]: string }, '#');
77 return pathname + hash;
80 export const extractSearchParameters = (location: Location): ESCalendarSearchParams => {
81 const { calendarID, keyword, begin, end, page } = getSearchParamsFromURL(location.hash);
85 page: stringToInt(page),
86 keyword: keyword ?? undefined,
87 begin: stringToInt(begin),
88 end: stringToInt(end),
92 export const parseSearchParams = (location: Location) => {
93 const searchParameters = extractSearchParameters(location);
94 const isSearch = checkIsSearch(searchParameters);
100 ...(isSearch && searchParameters?.keyword
101 ? { normalizedKeywords: normalizeKeyword(searchParameters.keyword) }
107 export const transformOrganizer = (organizer: ESOrganizerModel) => [
108 organizer.email.toLocaleLowerCase(),
109 organizer.cn.toLocaleLowerCase(),
112 export const transformAttendees = (attendees: ESAttendeeModel[]) => [
113 ...attendees.map((attendee) => attendee.email.toLocaleLowerCase()),
114 ...attendees.map((attendee) => attendee.cn.toLocaleLowerCase()),
117 export const getAllEventsIDs = async (calendarID: string, api: Api, Limit: number = 100) => {
118 const result: string[] = [];
119 let previousLength = -1;
121 const params: CalendarEventsIDsQuery = {
126 while (result.length > previousLength) {
127 previousLength = result.length;
129 const { IDs } = await api<{ IDs: string[] }>(queryEventsIDs(calendarID, params));
132 params.AfterID = IDs[IDs.length - 1];
138 export const extractOrganizer = (organizerProperty?: VcalOrganizerProperty): ESOrganizerModel => {
139 const { email = '', cn = '' } = propertiesToOrganizerModel(organizerProperty) || {};
140 return { email, cn };
143 export const extractAttendees = (attendeeProperty: VcalAttendeeProperty[]): ESAttendeeModel[] => {
144 const attendees = propertiesToAttendeeModel(attendeeProperty);
145 return attendees.map(({ email, cn, role, partstat }) => ({ email, cn, role, partstat }));
148 export const getESEvent = async (
152 getCalendarEventRaw: GetCalendarEventRaw,
154 ): Promise<ESCalendarMetadata> => {
155 const response = await apiHelper<{ Event: CalendarEvent }>(
158 getEvent(calendarID, eventID),
162 if (!response?.Event) {
163 throw new Error('Could not fetch event metadata');
166 const { Event } = response;
168 let hasError = false;
169 const { veventComponent } = await getCalendarEventRaw(Event).catch((error) => {
170 console.error('cannot decrypt event: ', error);
177 description: undefined,
178 organizer: undefined,
185 Status: veventComponent.status?.value || '',
186 Summary: veventComponent.summary?.value || '',
187 Location: veventComponent.location?.value || '',
188 Description: veventComponent.description?.value || '',
189 Attendees: veventComponent.attendee ?? [],
190 Organizer: veventComponent.organizer,
191 Order: await generateOrder(generateEventUniqueId(calendarID, eventID)),
193 SharedEventID: Event.SharedEventID,
194 CalendarID: Event.CalendarID,
195 CreateTime: Event.CreateTime,
196 ModifyTime: Event.ModifyTime,
197 Permissions: Event.Permissions,
198 IsOrganizer: Event.IsOrganizer,
199 IsProtonProtonInvite: Event.IsProtonProtonInvite,
200 IsPersonalSingleEdit: Event.IsPersonalSingleEdit,
201 Author: Event.Author,
202 StartTime: Event.StartTime,
203 StartTimezone: Event.StartTimezone,
204 EndTime: Event.EndTime,
205 EndTimezone: Event.EndTimezone,
206 FullDay: Event.FullDay,
209 RecurrenceID: Event.RecurrenceID,
210 Exdates: Event.Exdates,
211 IsDecryptable: !hasError,
216 export const getAllESEventsFromCalendar = async (
219 getCalendarEventRaw: GetCalendarEventRaw
221 const eventIDs = await getAllEventsIDs(calendarID, api, 1000);
223 eventIDs.map((eventID) => () => getESEvent(eventID, calendarID, api, getCalendarEventRaw)),
229 * Fetches a batch of events from a calendar
231 * @returns if calendar has still more events after cursor + limit, then it will be return last fetched event as new cursor
233 export const getESEventsFromCalendarInBatch = async ({
242 eventCursor?: string;
244 getCalendarEventRaw: GetCalendarEventRaw;
246 const params: CalendarEventsIDsQuery = {
248 AfterID: eventCursor,
251 const { IDs: eventIDs } = await api<{ IDs: string[] }>(queryEventsIDs(calendarID, params));
252 const cursor = eventIDs.length === limit ? eventIDs[eventIDs.length - 1] : undefined;
254 const events = await runInQueue(
256 // eslint-disable-next-line @typescript-eslint/no-loop-func
257 (eventID) => () => getESEvent(eventID, calendarID, api, getCalendarEventRaw)
268 const pushToRecurrenceIDsMap = (map: SimpleMap<number[]>, calendarID: string, UID: string, recurrenceID: number) => {
269 const key = getEventKey(calendarID, UID);
270 const entry = map[key];
271 map[key] = entry ? [...entry, recurrenceID] : [recurrenceID];
274 const getItemMetadataFromEventID = async (eventID: string, userID: string, itemIDs: string[], indexKey: CryptoKey) => {
275 const itemID = itemIDs.find((itemID) => getEventIDFromUniqueId(itemID) === eventID);
279 return readMetadataItem<ESCalendarMetadata>(userID, itemID, indexKey);
282 const handleCreateRecurrenceIDInMap = (map: SimpleMap<number[]>, event: CalendarEventWithoutBlob) => {
283 const { CalendarID, UID, RecurrenceID } = event;
287 pushToRecurrenceIDsMap(map, CalendarID, UID, RecurrenceID);
290 const handleDeleteRecurrenceIDInMap = async (
291 map: SimpleMap<number[]>,
297 const metadata = await getItemMetadataFromEventID(eventID, userID, itemIDs, indexKey);
298 if (!metadata?.RecurrenceID) {
301 pushToRecurrenceIDsMap(map, metadata.CalendarID, metadata.UID, metadata.RecurrenceID);
305 * Builds a global map of recurrence ids
307 export const buildRecurrenceIDsMap = (cache: Map<string, CachedItem<ESCalendarMetadata, ESCalendarContent>>) => {
308 const result: SimpleMap<number[]> = {};
309 const iterator = cache.values();
310 let iteration = iterator.next();
312 while (!iteration.done) {
314 metadata: { CalendarID, UID, RecurrenceID },
316 iteration = iterator.next();
320 pushToRecurrenceIDsMap(result, CalendarID, UID, RecurrenceID);
326 export const updateRecurrenceIDsMap = async (
329 events: CalendarEventsEventManager[],
330 updateMap: (setter: (map: SimpleMap<number[]>) => SimpleMap<number[]>) => void
332 const additions: SimpleMap<number[]> = {};
333 const deletions: SimpleMap<number[]> = {};
335 const itemIDs: string[] = [];
336 if (events.some(({ Action }) => [EVENT_ACTIONS.DELETE, EVENT_ACTIONS.UPDATE].includes(Action))) {
337 itemIDs.push(...((await readSortedIDs(userID, false)) || []));
341 events.map(async (event) => {
342 if (event.Action === EVENT_ACTIONS.DELETE) {
343 handleDeleteRecurrenceIDInMap(deletions, event.ID, userID, itemIDs, indexKey);
344 } else if (event.Action === EVENT_ACTIONS.CREATE) {
345 handleCreateRecurrenceIDInMap(additions, event.Event);
346 } else if (event.Action === EVENT_ACTIONS.UPDATE) {
347 handleCreateRecurrenceIDInMap(additions, event.Event);
348 handleDeleteRecurrenceIDInMap(deletions, event.ID, userID, itemIDs, indexKey);
353 updateMap((map: SimpleMap<number[]>) => {
354 const result: SimpleMap<number[]> = { ...map };
355 Object.entries(additions).forEach(([UID, recurrenceIDs]) => {
356 const value = unique([...(result[UID] || []), ...(recurrenceIDs || [])]);
357 result[UID] = value.length ? value : undefined;
359 Object.entries(deletions).forEach(([UID, recurrenceIDs]) => {
360 result[UID] = result[UID]?.filter((recurrenceID) => !recurrenceIDs?.includes(recurrenceID));
368 * Returns all the elements stored in the IDB and flagged as not decryptable
370 export const searchUndecryptedElements = async (
373 abortSearchingRef?: React.MutableRefObject<AbortController>
374 ): Promise<ESCalendarMetadata[]> => {
375 const results: ESCalendarMetadata[] = [];
377 let remainingIDs = await readSortedIDs(userID, false);
379 if (!remainingIDs?.length) {
383 while (remainingIDs.length) {
384 const IDs = remainingIDs.slice(0, ES_MAX_ITEMS_PER_BATCH);
385 remainingIDs = remainingIDs?.slice(ES_MAX_ITEMS_PER_BATCH);
387 const metadatas = await readMetadataBatch(userID, IDs);
388 if (!metadatas || abortSearchingRef?.current.signal.aborted) {
392 const plaintextMetadatas: ESCalendarMetadata[] = await Promise.all(
394 .filter((item): item is EncryptedItemWithInfo => !!item)
395 .map(async (encryptedMetadata) => {
396 const plaintextMetadata = await decryptFromDB<ESCalendarMetadata>(
397 encryptedMetadata.aesGcmCiphertext,
399 'searchUndecryptedElements'
402 return plaintextMetadata;
406 const undecryptedMetadatas = plaintextMetadatas.filter((item) => !item.IsDecryptable);
408 results.push(...undecryptedMetadatas);
414 export const processCoreEvents = async ({
423 Calendars: CalendarEventManager[];
427 getCalendarEventRaw: GetCalendarEventRaw;
428 }): Promise<ESEvent<ESCalendarMetadata> | undefined> => {
429 if (!Calendars.length && !Refresh) {
433 // Get all existing event loops
434 const oldEventsObject = await readAllLastEvents(userID);
435 if (!oldEventsObject) {
439 const eventLoopsToDelete = [];
441 const newEventsObject: EventsObject = {};
443 const Items: ESItemEvent<ESCalendarMetadata>[] = [];
445 for (const { ID, Action } of Calendars) {
446 if (Action === EVENT_ACTIONS.CREATE) {
447 // Get events from API and add them, plus add calendarID to event object
448 const { CalendarModelEventID } = await api<{ CalendarModelEventID: string }>(queryLatestModelEventID(ID));
449 newEventsObject[ID] = CalendarModelEventID;
451 const maybeEventsFromCalendar = (await getAllESEventsFromCalendar(ID, api, getCalendarEventRaw))
452 .filter((item): item is ESCalendarMetadata => !!item)
454 ID: generateEventUniqueId(item.CalendarID, item.ID),
455 Action: ES_SYNC_ACTIONS.CREATE,
459 Items.push(...maybeEventsFromCalendar);
460 } else if (Action === EVENT_ACTIONS.DELETE) {
461 // Get from IDB all items such that itemID={calendarID}.* and delete them, plus+ remove calendarID from event object
462 eventLoopsToDelete.push(ID);
463 const itemIDs = (await readSortedIDs(userID, false)) || [];
466 .filter((itemID) => getCalendarIDFromUniqueId(itemID) === ID)
467 .map((itemID) => ({ ID: itemID, Action: ES_SYNC_ACTIONS.DELETE, ItemMetadata: undefined }))
472 for (const componentID in oldEventsObject) {
473 if (!eventLoopsToDelete.includes(componentID)) {
474 const lastEventID = oldEventsObject[componentID];
475 newEventsObject[componentID] = lastEventID;
478 newEventsObject[CALENDAR_CORE_LOOP] = EventID;
483 eventsToStore: newEventsObject,
487 export const processCalendarEvents = async (
488 CalendarEvents: CalendarEventsEventManager[],
491 CalendarModelEventID: string,
493 getCalendarEventRaw: GetCalendarEventRaw
494 ): Promise<ESEvent<ESCalendarMetadata> | undefined> => {
495 if (!CalendarEvents.length && !Refresh) {
499 // Get all existing event loops
500 const oldEventsObject = await readAllLastEvents(userID);
501 if (!oldEventsObject) {
504 const Items: ESItemEvent<ESCalendarMetadata>[] = [];
505 const newEventsObject: EventsObject = {};
507 const itemIDs = (await readSortedIDs(userID, false)) || [];
509 for (const CalendarEvent of CalendarEvents) {
510 const { ID, Action } = CalendarEvent;
511 let calendarID: string | undefined;
513 if (Action === EVENT_ACTIONS.DELETE) {
514 // If it's a delete event, we should have the item already in IDB, therefore
515 // we can deduce the calendar ID from it
516 const itemID = itemIDs.find((itemID) => getEventIDFromUniqueId(itemID) === ID);
518 calendarID = getCalendarIDFromUniqueId(itemID);
519 Items.push({ ID: itemID, Action: ES_SYNC_ACTIONS.DELETE, ItemMetadata: undefined });
522 // In case of update or create events, instead, we have the calendar ID in the event
523 const { CalendarID } = CalendarEvent.Event;
524 calendarID = CalendarID;
525 const esAction = EVENT_ACTIONS.UPDATE ? ES_SYNC_ACTIONS.UPDATE_METADATA : ES_SYNC_ACTIONS.CREATE;
526 const esItem = await getESEvent(ID, CalendarID, api, getCalendarEventRaw);
528 Items.push({ ID: generateEventUniqueId(CalendarID, ID), Action: esAction, ItemMetadata: esItem });
533 newEventsObject[calendarID] = CalendarModelEventID;
537 for (const componentID in oldEventsObject) {
538 const last: string | undefined = newEventsObject[componentID];
539 if (typeof last === 'undefined') {
540 const lastEventID = oldEventsObject[componentID];
541 newEventsObject[componentID] = lastEventID;
548 eventsToStore: newEventsObject,