Update all non-major dependencies
[ProtonMail-WebClient.git] / applications / calendar / src / app / helpers / encryptedSearch / calendarESCallbacks.tsx
blobb37dd2a20dbe8e0f89d8b8141ed05fea15e2b2e9
1 import type { History } from 'history';
3 import type {
4     CachedItem,
5     ESCallbacks,
6     ESItemInfo,
7     ESStatusBooleans,
8     EventsObject,
9     RecordProgress,
10 } from '@proton/encrypted-search';
11 import {
12     ES_MAX_CONCURRENT,
13     ES_MAX_ITEMS_PER_BATCH,
14     checkVersionedESDB,
15     esSentryReport,
16     metadataIndexingProgress,
17     normalizeKeyword,
18     storeItemsMetadata,
19     testKeywords,
20 } from '@proton/encrypted-search';
21 import { getEventsCount, queryLatestModelEventID } from '@proton/shared/lib/api/calendars';
22 import { getLatestID } from '@proton/shared/lib/api/events';
23 import runInQueue from '@proton/shared/lib/helpers/runInQueue';
24 import type { Api } from '@proton/shared/lib/interfaces';
25 import type { GetCalendarEventRaw } from '@proton/shared/lib/interfaces/hooks/GetCalendarEventRaw';
26 import chunk from '@proton/utils/chunk';
28 import type {
29     ESCalendarContent,
30     ESCalendarMetadata,
31     ESCalendarSearchParams,
32     MetadataRecoveryPoint,
33 } from '../../interfaces/encryptedSearch';
34 import { generateEventUniqueId } from '../event';
35 import { CALENDAR_CORE_LOOP, MAX_EVENT_BATCH, MIN_EVENT_BATCH } from './constants';
36 import {
37     extractAttendees,
38     extractOrganizer,
39     getESEvent,
40     getESEventsFromCalendarInBatch,
41     parseSearchParams,
42     searchUndecryptedElements,
43     transformAttendees,
44     transformOrganizer,
45 } from './esUtils';
47 interface Props {
48     api: Api;
49     calendarIDs: string[];
50     history: History;
51     userID: string;
52     getCalendarEventRaw: GetCalendarEventRaw;
55 interface ItemMetadataQueryResult {
56     resultMetadata?: ESCalendarMetadata[];
57     setRecoveryPoint?: (setIDB?: boolean) => Promise<void>;
60 const popOneCalendar = (
61     calendarIDs: string[]
62 ): Pick<MetadataRecoveryPoint, 'remainingCalendarIDs' | 'currentCalendarId'> => {
63     const [first, ...rest] = calendarIDs;
65     return {
66         remainingCalendarIDs: rest,
67         currentCalendarId: first,
68     };
71 export const getESCallbacks = ({
72     api,
73     calendarIDs,
74     history,
75     userID,
76     getCalendarEventRaw,
77 }: Props): ESCallbacks<ESCalendarMetadata, ESCalendarSearchParams, ESCalendarContent> => {
78     // We need to keep the recovery point for metadata indexing in memory
79     // for cases where IDB couldn't be instantiated but we still want to
80     // index content
81     let metadataRecoveryPoint: MetadataRecoveryPoint | undefined;
83     const queryItemsMetadata = async (
84         signal: AbortSignal,
85         isBackgroundIndexing?: boolean
86     ): Promise<ItemMetadataQueryResult> => {
87         let recoveryPoint: MetadataRecoveryPoint = metadataRecoveryPoint ?? popOneCalendar(calendarIDs);
88         // Note that indexing, and therefore an instance of this function,
89         // can exist even without an IDB, because we can index in memory only.
90         // Therefore, we have to check if an IDB exists before querying it
91         const esdbExists = await checkVersionedESDB(userID);
92         if (esdbExists) {
93             const localRecoveryPoint: MetadataRecoveryPoint = await metadataIndexingProgress.readRecoveryPoint(userID);
94             if (localRecoveryPoint) {
95                 recoveryPoint = localRecoveryPoint;
96             }
97         }
99         const { currentCalendarId } = recoveryPoint;
100         if (!currentCalendarId) {
101             return { resultMetadata: [] };
102         }
104         /**
105          * On calendar with a lot of events, having a pseudo-random batch size on each iteration makes the indexing look a bit more dynamic
106          */
107         const batchSize = Math.floor(Math.random() * (MAX_EVENT_BATCH - MIN_EVENT_BATCH + 1)) + MIN_EVENT_BATCH;
109         const { events: esMetadataEvents, cursor: newCursor } = await getESEventsFromCalendarInBatch({
110             calendarID: currentCalendarId,
111             limit: batchSize,
112             eventCursor: recoveryPoint.eventCursor,
113             api,
114             getCalendarEventRaw,
115         });
117         const newRecoveryPoint: MetadataRecoveryPoint = newCursor
118             ? {
119                   ...recoveryPoint,
120                   eventCursor: newCursor,
121               }
122             : popOneCalendar(recoveryPoint.remainingCalendarIDs);
124         const setNewRecoveryPoint = esdbExists
125             ? async (setIDB: boolean = true) => {
126                   metadataRecoveryPoint = newRecoveryPoint;
127                   if (setIDB) {
128                       await metadataIndexingProgress.setRecoveryPoint(userID, newRecoveryPoint);
129                   }
130               }
131             : undefined;
133         // some calendars might have no event so if there is others, we want to forward query to them
134         if (!esMetadataEvents.length && newRecoveryPoint.currentCalendarId) {
135             await setNewRecoveryPoint?.();
136             return queryItemsMetadata(signal, isBackgroundIndexing);
137         }
139         return {
140             resultMetadata: esMetadataEvents,
141             setRecoveryPoint: setNewRecoveryPoint,
142         };
143     };
145     const getPreviousEventID = async () => {
146         const eventObject: { [key: string]: string } = {};
148         // Calendars previous events
149         await Promise.all(
150             calendarIDs.map(async (ID) => {
151                 const { CalendarModelEventID } = await api<{ CalendarModelEventID: string }>(
152                     queryLatestModelEventID(ID)
153                 );
154                 eventObject[ID] = CalendarModelEventID;
155             })
156         );
158         // Core previous event
159         const { EventID } = await api<{ EventID: string }>(getLatestID());
160         eventObject[CALENDAR_CORE_LOOP] = EventID;
162         return eventObject;
163     };
165     const getItemInfo = (item: ESCalendarMetadata): ESItemInfo => ({
166         ID: generateEventUniqueId(item.CalendarID, item.ID),
167         timepoint: [item.CreateTime, item.Order],
168     });
170     const getSearchParams = () => {
171         const { isSearch, esSearchParams } = parseSearchParams(history.location);
172         return {
173             isSearch,
174             esSearchParams,
175         };
176     };
178     const getKeywords = (esSearchParams: ESCalendarSearchParams) =>
179         esSearchParams.keyword ? normalizeKeyword(esSearchParams.keyword) : [];
181     const searchKeywords = (
182         keywords: string[],
183         itemToSearch: CachedItem<ESCalendarMetadata, ESCalendarContent>,
184         hasApostrophe: boolean
185     ) => {
186         const { metadata } = itemToSearch;
188         const stringsToSearch: string[] = [
189             metadata?.Description || '',
190             metadata?.Location || '',
191             metadata?.Summary || '',
192             ...transformOrganizer(extractOrganizer(metadata?.Organizer)),
193             ...transformAttendees(extractAttendees(metadata?.Attendees)),
194         ];
196         return testKeywords(keywords, stringsToSearch, hasApostrophe);
197     };
199     const getTotalItems = async () => {
200         const counts = await Promise.all(calendarIDs.map((ID) => api<{ Total: number }>(getEventsCount(ID))));
201         return counts.reduce((p, c) => p + c.Total, 0);
202     };
204     const getEventFromIDB = async (previousEventsObject?: EventsObject) => ({
205         newEvents: [],
206         shouldRefresh: false,
207         eventsToStore: previousEventsObject ?? {},
208     });
210     const applyFilters = (esSearchParams: ESCalendarSearchParams, metadata: ESCalendarMetadata) => {
211         const { calendarID, begin, end } = esSearchParams;
213         if (!metadata.IsDecryptable) {
214             return false;
215         }
217         const { CalendarID, StartTime, EndTime, RRule } = metadata;
219         // If it's the wrong calendar, we exclude it
220         if (calendarID && calendarID !== CalendarID) {
221             return false;
222         }
224         // If it's recurrent, we don't immediately check the timings since that will
225         // be done upon displaying the search results
226         if (typeof RRule === 'string') {
227             return true;
228         }
230         // Check it's within time window selected by the user
231         if ((begin && EndTime < begin) || (end && StartTime > end)) {
232             return false;
233         }
235         return true;
236     };
238     const correctDecryptionErrors = async (
239         userID: string,
240         indexKey: CryptoKey,
241         abortIndexingRef: React.MutableRefObject<AbortController>,
242         esStatus: ESStatusBooleans,
243         recordProgress: RecordProgress
244     ) => {
245         if (esStatus.isEnablingEncryptedSearch || !esStatus.esEnabled) {
246             return 0;
247         }
249         let correctedEventsCount = 0;
250         let events = await searchUndecryptedElements(userID, indexKey, abortIndexingRef);
251         void recordProgress([0, events.length], 'metadata');
253         const chunks = chunk(events, ES_MAX_ITEMS_PER_BATCH);
255         for (const chunk of chunks) {
256             const metadatas = await runInQueue(
257                 chunk.map(({ ID, CalendarID }) => () => {
258                     return getESEvent(ID, CalendarID, api, getCalendarEventRaw);
259                 }),
260                 ES_MAX_CONCURRENT
261             );
263             const decryptedMetadatas = metadatas.filter((item) => item.IsDecryptable);
265             // if we reach this part of code, es is considered supported
266             const esSupported = true;
267             const success = await storeItemsMetadata<ESCalendarMetadata>(
268                 userID,
269                 decryptedMetadatas,
270                 esSupported,
271                 indexKey,
272                 getItemInfo
273             ).catch((error: any) => {
274                 if (!(error?.message === 'Operation aborted') && !(error?.name === 'AbortError')) {
275                     esSentryReport('storeItemsBatches: storeItems', { error });
276                 }
278                 return false;
279             });
281             if (!success) {
282                 break;
283             }
285             correctedEventsCount += decryptedMetadatas.length;
286             void recordProgress(correctedEventsCount, 'metadata');
287         }
289         return correctedEventsCount;
290     };
292     return {
293         queryItemsMetadata,
294         getPreviousEventID,
295         getItemInfo,
296         getSearchParams,
297         getKeywords,
298         searchKeywords,
299         getTotalItems,
300         getEventFromIDB,
301         applyFilters,
302         correctDecryptionErrors,
303     };