Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / calendar / src / app / helpers / encryptedSearch / esUtils.ts
blobca8d96d38bc9ee03adc62522ca706b2374c0d4c4
1 import type { Location } from 'history';
3 import { CryptoProxy } from '@proton/crypto';
4 import type { CachedItem, ESEvent, ESItemEvent, EncryptedItemWithInfo, EventsObject } from '@proton/encrypted-search';
5 import {
6     ES_MAX_CONCURRENT,
7     ES_MAX_ITEMS_PER_BATCH,
8     ES_SYNC_ACTIONS,
9     apiHelper,
10     decryptFromDB,
11     normalizeKeyword,
12     readAllLastEvents,
13     readMetadataBatch,
14     readMetadataItem,
15     readSortedIDs,
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';
23 import type {
24     CalendarEvent,
25     CalendarEventWithoutBlob,
26     CalendarEventsIDsQuery,
27     VcalAttendeeProperty,
28     VcalOrganizerProperty,
29 } from '@proton/shared/lib/interfaces/calendar';
30 import type {
31     CalendarEventManager,
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';
40 import type {
41     ESAttendeeModel,
42     ESCalendarContent,
43     ESCalendarMetadata,
44     ESCalendarSearchParams,
45     ESOrganizerModel,
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);
57     return orderArray[0];
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) {
65         return undefined;
66     }
67     return isNumber(string) ? parseInt(string, 10) : undefined;
70 export const generatePathnameWithSearchParams = (location: Location, searchQuery: CalendarSearchQuery) => {
71     const parts = location.pathname.split('/');
72     parts[1] = 'search';
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);
83     return {
84         calendarID,
85         page: stringToInt(page),
86         keyword: keyword ?? undefined,
87         begin: stringToInt(begin),
88         end: stringToInt(end),
89     };
92 export const parseSearchParams = (location: Location) => {
93     const searchParameters = extractSearchParameters(location);
94     const isSearch = checkIsSearch(searchParameters);
96     return {
97         isSearch,
98         esSearchParams: {
99             ...searchParameters,
100             ...(isSearch && searchParameters?.keyword
101                 ? { normalizedKeywords: normalizeKeyword(searchParameters.keyword) }
102                 : undefined),
103         },
104     };
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 = {
122         Limit,
123         AfterID: undefined,
124     };
126     while (result.length > previousLength) {
127         previousLength = result.length;
129         const { IDs } = await api<{ IDs: string[] }>(queryEventsIDs(calendarID, params));
130         result.push(...IDs);
132         params.AfterID = IDs[IDs.length - 1];
133     }
135     return result;
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 (
149     eventID: string,
150     calendarID: string,
151     api: Api,
152     getCalendarEventRaw: GetCalendarEventRaw,
153     signal?: AbortSignal
154 ): Promise<ESCalendarMetadata> => {
155     const response = await apiHelper<{ Event: CalendarEvent }>(
156         api,
157         signal,
158         getEvent(calendarID, eventID),
159         'queryEventMetadata'
160     );
162     if (!response?.Event) {
163         throw new Error('Could not fetch event metadata');
164     }
166     const { Event } = response;
168     let hasError = false;
169     const { veventComponent } = await getCalendarEventRaw(Event).catch((error) => {
170         console.error('cannot decrypt event: ', error);
171         hasError = true;
172         return {
173             veventComponent: {
174                 status: undefined,
175                 summary: undefined,
176                 location: undefined,
177                 description: undefined,
178                 organizer: undefined,
179                 attendee: [],
180             },
181         };
182     });
184     return {
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)),
192         ID: Event.ID,
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,
207         RRule: Event.RRule,
208         UID: Event.UID,
209         RecurrenceID: Event.RecurrenceID,
210         Exdates: Event.Exdates,
211         IsDecryptable: !hasError,
212         Color: Event.Color,
213     };
216 export const getAllESEventsFromCalendar = async (
217     calendarID: string,
218     api: Api,
219     getCalendarEventRaw: GetCalendarEventRaw
220 ) => {
221     const eventIDs = await getAllEventsIDs(calendarID, api, 1000);
222     return runInQueue(
223         eventIDs.map((eventID) => () => getESEvent(eventID, calendarID, api, getCalendarEventRaw)),
224         ES_MAX_CONCURRENT
225     );
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
232  */
233 export const getESEventsFromCalendarInBatch = async ({
234     calendarID,
235     limit,
236     eventCursor,
237     api,
238     getCalendarEventRaw,
239 }: {
240     calendarID: string;
241     limit: number;
242     eventCursor?: string;
243     api: Api;
244     getCalendarEventRaw: GetCalendarEventRaw;
245 }) => {
246     const params: CalendarEventsIDsQuery = {
247         Limit: limit,
248         AfterID: eventCursor,
249     };
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(
255         eventIDs.map(
256             // eslint-disable-next-line @typescript-eslint/no-loop-func
257             (eventID) => () => getESEvent(eventID, calendarID, api, getCalendarEventRaw)
258         ),
259         ES_MAX_CONCURRENT
260     );
262     return {
263         events,
264         cursor,
265     };
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);
276     if (!itemID) {
277         return;
278     }
279     return readMetadataItem<ESCalendarMetadata>(userID, itemID, indexKey);
282 const handleCreateRecurrenceIDInMap = (map: SimpleMap<number[]>, event: CalendarEventWithoutBlob) => {
283     const { CalendarID, UID, RecurrenceID } = event;
284     if (!RecurrenceID) {
285         return;
286     }
287     pushToRecurrenceIDsMap(map, CalendarID, UID, RecurrenceID);
290 const handleDeleteRecurrenceIDInMap = async (
291     map: SimpleMap<number[]>,
292     eventID: string,
293     userID: string,
294     itemIDs: string[],
295     indexKey: CryptoKey
296 ) => {
297     const metadata = await getItemMetadataFromEventID(eventID, userID, itemIDs, indexKey);
298     if (!metadata?.RecurrenceID) {
299         return;
300     }
301     pushToRecurrenceIDsMap(map, metadata.CalendarID, metadata.UID, metadata.RecurrenceID);
305  * Builds a global map of recurrence ids
306  */
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) {
313         const {
314             metadata: { CalendarID, UID, RecurrenceID },
315         } = iteration.value;
316         iteration = iterator.next();
317         if (!RecurrenceID) {
318             continue;
319         }
320         pushToRecurrenceIDsMap(result, CalendarID, UID, RecurrenceID);
321     }
323     return result;
326 export const updateRecurrenceIDsMap = async (
327     userID: string,
328     indexKey: CryptoKey,
329     events: CalendarEventsEventManager[],
330     updateMap: (setter: (map: SimpleMap<number[]>) => SimpleMap<number[]>) => void
331 ) => {
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)) || []));
338     }
340     await Promise.all(
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);
349             }
350         })
351     );
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;
358         });
359         Object.entries(deletions).forEach(([UID, recurrenceIDs]) => {
360             result[UID] = result[UID]?.filter((recurrenceID) => !recurrenceIDs?.includes(recurrenceID));
361         });
363         return result;
364     });
368  * Returns all the elements stored in the IDB and flagged as not decryptable
369  */
370 export const searchUndecryptedElements = async (
371     userID: string,
372     indexKey: CryptoKey,
373     abortSearchingRef?: React.MutableRefObject<AbortController>
374 ): Promise<ESCalendarMetadata[]> => {
375     const results: ESCalendarMetadata[] = [];
377     let remainingIDs = await readSortedIDs(userID, false);
379     if (!remainingIDs?.length) {
380         return results;
381     }
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) {
389             return results;
390         }
392         const plaintextMetadatas: ESCalendarMetadata[] = await Promise.all(
393             metadatas
394                 .filter((item): item is EncryptedItemWithInfo => !!item)
395                 .map(async (encryptedMetadata) => {
396                     const plaintextMetadata = await decryptFromDB<ESCalendarMetadata>(
397                         encryptedMetadata.aesGcmCiphertext,
398                         indexKey,
399                         'searchUndecryptedElements'
400                     );
402                     return plaintextMetadata;
403                 })
404         );
406         const undecryptedMetadatas = plaintextMetadatas.filter((item) => !item.IsDecryptable);
408         results.push(...undecryptedMetadatas);
409     }
411     return results;
414 export const processCoreEvents = async ({
415     userID,
416     Calendars,
417     Refresh,
418     EventID,
419     api,
420     getCalendarEventRaw,
421 }: {
422     userID: string;
423     Calendars: CalendarEventManager[];
424     Refresh: number;
425     EventID: string;
426     api: Api;
427     getCalendarEventRaw: GetCalendarEventRaw;
428 }): Promise<ESEvent<ESCalendarMetadata> | undefined> => {
429     if (!Calendars.length && !Refresh) {
430         return;
431     }
433     // Get all existing event loops
434     const oldEventsObject = await readAllLastEvents(userID);
435     if (!oldEventsObject) {
436         return;
437     }
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)
453                 .map((item) => ({
454                     ID: generateEventUniqueId(item.CalendarID, item.ID),
455                     Action: ES_SYNC_ACTIONS.CREATE,
456                     ItemMetadata: item,
457                 }));
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)) || [];
464             Items.push(
465                 ...itemIDs
466                     .filter((itemID) => getCalendarIDFromUniqueId(itemID) === ID)
467                     .map((itemID) => ({ ID: itemID, Action: ES_SYNC_ACTIONS.DELETE, ItemMetadata: undefined }))
468             );
469         }
470     }
472     for (const componentID in oldEventsObject) {
473         if (!eventLoopsToDelete.includes(componentID)) {
474             const lastEventID = oldEventsObject[componentID];
475             newEventsObject[componentID] = lastEventID;
476         }
477     }
478     newEventsObject[CALENDAR_CORE_LOOP] = EventID;
480     return {
481         Refresh,
482         Items,
483         eventsToStore: newEventsObject,
484     };
487 export const processCalendarEvents = async (
488     CalendarEvents: CalendarEventsEventManager[],
489     Refresh: number,
490     userID: string,
491     CalendarModelEventID: string,
492     api: Api,
493     getCalendarEventRaw: GetCalendarEventRaw
494 ): Promise<ESEvent<ESCalendarMetadata> | undefined> => {
495     if (!CalendarEvents.length && !Refresh) {
496         return;
497     }
499     // Get all existing event loops
500     const oldEventsObject = await readAllLastEvents(userID);
501     if (!oldEventsObject) {
502         return;
503     }
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);
517             if (itemID) {
518                 calendarID = getCalendarIDFromUniqueId(itemID);
519                 Items.push({ ID: itemID, Action: ES_SYNC_ACTIONS.DELETE, ItemMetadata: undefined });
520             }
521         } else {
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);
527             if (esItem) {
528                 Items.push({ ID: generateEventUniqueId(CalendarID, ID), Action: esAction, ItemMetadata: esItem });
529             }
530         }
532         if (calendarID) {
533             newEventsObject[calendarID] = CalendarModelEventID;
534         }
535     }
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;
542         }
543     }
545     return {
546         Refresh,
547         Items,
548         eventsToStore: newEventsObject,
549     };