1 import type { History } from 'history';
10 } from '@proton/encrypted-search';
13 ES_MAX_ITEMS_PER_BATCH,
16 metadataIndexingProgress,
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';
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';
40 getESEventsFromCalendarInBatch,
42 searchUndecryptedElements,
49 calendarIDs: string[];
52 getCalendarEventRaw: GetCalendarEventRaw;
55 interface ItemMetadataQueryResult {
56 resultMetadata?: ESCalendarMetadata[];
57 setRecoveryPoint?: (setIDB?: boolean) => Promise<void>;
60 const popOneCalendar = (
62 ): Pick<MetadataRecoveryPoint, 'remainingCalendarIDs' | 'currentCalendarId'> => {
63 const [first, ...rest] = calendarIDs;
66 remainingCalendarIDs: rest,
67 currentCalendarId: first,
71 export const getESCallbacks = ({
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
81 let metadataRecoveryPoint: MetadataRecoveryPoint | undefined;
83 const queryItemsMetadata = async (
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);
93 const localRecoveryPoint: MetadataRecoveryPoint = await metadataIndexingProgress.readRecoveryPoint(userID);
94 if (localRecoveryPoint) {
95 recoveryPoint = localRecoveryPoint;
99 const { currentCalendarId } = recoveryPoint;
100 if (!currentCalendarId) {
101 return { resultMetadata: [] };
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
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,
112 eventCursor: recoveryPoint.eventCursor,
117 const newRecoveryPoint: MetadataRecoveryPoint = newCursor
120 eventCursor: newCursor,
122 : popOneCalendar(recoveryPoint.remainingCalendarIDs);
124 const setNewRecoveryPoint = esdbExists
125 ? async (setIDB: boolean = true) => {
126 metadataRecoveryPoint = newRecoveryPoint;
128 await metadataIndexingProgress.setRecoveryPoint(userID, newRecoveryPoint);
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);
140 resultMetadata: esMetadataEvents,
141 setRecoveryPoint: setNewRecoveryPoint,
145 const getPreviousEventID = async () => {
146 const eventObject: { [key: string]: string } = {};
148 // Calendars previous events
150 calendarIDs.map(async (ID) => {
151 const { CalendarModelEventID } = await api<{ CalendarModelEventID: string }>(
152 queryLatestModelEventID(ID)
154 eventObject[ID] = CalendarModelEventID;
158 // Core previous event
159 const { EventID } = await api<{ EventID: string }>(getLatestID());
160 eventObject[CALENDAR_CORE_LOOP] = EventID;
165 const getItemInfo = (item: ESCalendarMetadata): ESItemInfo => ({
166 ID: generateEventUniqueId(item.CalendarID, item.ID),
167 timepoint: [item.CreateTime, item.Order],
170 const getSearchParams = () => {
171 const { isSearch, esSearchParams } = parseSearchParams(history.location);
178 const getKeywords = (esSearchParams: ESCalendarSearchParams) =>
179 esSearchParams.keyword ? normalizeKeyword(esSearchParams.keyword) : [];
181 const searchKeywords = (
183 itemToSearch: CachedItem<ESCalendarMetadata, ESCalendarContent>,
184 hasApostrophe: boolean
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)),
196 return testKeywords(keywords, stringsToSearch, hasApostrophe);
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);
204 const getEventFromIDB = async (previousEventsObject?: EventsObject) => ({
206 shouldRefresh: false,
207 eventsToStore: previousEventsObject ?? {},
210 const applyFilters = (esSearchParams: ESCalendarSearchParams, metadata: ESCalendarMetadata) => {
211 const { calendarID, begin, end } = esSearchParams;
213 if (!metadata.IsDecryptable) {
217 const { CalendarID, StartTime, EndTime, RRule } = metadata;
219 // If it's the wrong calendar, we exclude it
220 if (calendarID && calendarID !== CalendarID) {
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') {
230 // Check it's within time window selected by the user
231 if ((begin && EndTime < begin) || (end && StartTime > end)) {
238 const correctDecryptionErrors = async (
241 abortIndexingRef: React.MutableRefObject<AbortController>,
242 esStatus: ESStatusBooleans,
243 recordProgress: RecordProgress
245 if (esStatus.isEnablingEncryptedSearch || !esStatus.esEnabled) {
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);
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>(
273 ).catch((error: any) => {
274 if (!(error?.message === 'Operation aborted') && !(error?.name === 'AbortError')) {
275 esSentryReport('storeItemsBatches: storeItems', { error });
285 correctedEventsCount += decryptedMetadatas.length;
286 void recordProgress(correctedEventsCount, 'metadata');
289 return correctedEventsCount;
302 correctDecryptionErrors,