From b50270d8f367a919dbe2fd2ce6ba6e7d0ef40542 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:12:48 +0100 Subject: [PATCH] feat: search events in the calendar --- .../__tests__/live-stalwart-calendar.test.ts | 20 +- src/api/calendar.ts | 24 ++ .../calendar/CalendarSearchSheet.tsx | 313 ++++++++++++++++++ src/lib/__tests__/calendar-search.test.ts | 43 +++ src/lib/calendar-search.ts | 92 +++++ src/screens/CalendarScreen.tsx | 24 ++ .../calendar-store-account-switch.test.ts | 37 ++- src/stores/calendar-store.ts | 37 +++ 8 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 src/components/calendar/CalendarSearchSheet.tsx create mode 100644 src/lib/__tests__/calendar-search.test.ts create mode 100644 src/lib/calendar-search.ts diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 08193a09..e5b382e8 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -34,7 +34,7 @@ import { queryEvents, isCalendarAccessDenied, } from '../calendar'; -import { useCalendarStore } from '../../stores/calendar-store'; +import { searchCalendarEvents, useCalendarStore } from '../../stores/calendar-store'; import { useSettingsStore } from '../../stores/settings-store'; import { saveOccurrenceEdit, deleteOccurrence, type SeriesEdit } from '../../lib/recurrence-scope-edit'; import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times'; @@ -754,6 +754,24 @@ live('live Stalwart calendar', () => { }, 60_000); }); + describe('event search', () => { + it('finds an event outside the loaded window by a word of its title, and a series by its stored event', async () => { + await connectSingleton(); + const word = RUN.replace(/[^a-z0-9]/gi, ''); + await createEvent({ '@type': 'Event', title: `${RUN} ${word}single`, start: '2027-12-10T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as Partial, calendarId); + await createEvent(seriesData(`${word}series`, '2027-01-04T10:00:00', 4), calendarId); + await useCalendarStore.getState().fetchCalendars(); + + const single = await searchCalendarEvents([`${word}single`]); + expect(single.map((e) => e.title)).toEqual([`${RUN} ${word}single`]); + const series = await searchCalendarEvents([`${word}series`]); + expect(series).toHaveLength(1); + expect(series[0].recurrenceRules?.[0]?.count).toBe(4); + // Every word has to match. + expect(await searchCalendarEvents([`${word}single`, 'nosuchwordanywhere'])).toEqual([]); + }, 60_000); + }); + describe('a zero-length event (#837)', () => { it('ends at its start, stored without a duration or imported from an .ics', async () => { await connectSingleton(); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 1fd0fe14..d66e7471 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1010,6 +1010,30 @@ export async function findEventsByUid(uid: string, accountId?: string): Promise< return getEvents(ids, accountId); } +/** + * The ids of the events a CalendarEvent/query `filter` finds (the event + * search's `text` conditions), latest start first, at most `limit`. Not + * limited to a date window: a series is found by its stored event. + */ +export async function searchEventIds( + filter: Record, + accountId?: string, + limit = 100, +): Promise { + const account = accountId || jmapClient.accountId; + return withCalendarTimeZone(async (timeZone) => { + const args: Record = { + accountId: account, + filter, + sort: [{ property: 'start', isAscending: false }], + limit, + }; + if (timeZone) args.timeZone = timeZone; + const res = await jmapClient.request([['CalendarEvent/query', args, '0']], USING); + return methodResult<{ ids: string[] }>(res).ids ?? []; + }); +} + // `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation // / reply / cancellation emails to participants. We pass it whenever an event // has participants so creating/updating/deleting a meeting notifies attendees. diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx new file mode 100644 index 00000000..67671056 --- /dev/null +++ b/src/components/calendar/CalendarSearchSheet.tsx @@ -0,0 +1,313 @@ +import React from 'react'; +import { + ActivityIndicator, + FlatList, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { format } from 'date-fns'; +import { ArrowLeft, MapPin, Repeat, Search, X } from '../icons'; +import type { Calendar, CalendarEvent } from '../../api/types'; +import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store'; +import { useCalendarLocale } from '../../lib/calendar-locale'; +import { displayNow } from '../../lib/calendar-timezone'; +import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search'; +import { + getEventColor, + getEventEndDate, + getEventStartDate, + getPrimaryCalendarId, + timePattern, + type TimeFormat, +} from '../../lib/calendar-utils'; +import { isInactiveEvent } from '../../lib/calendar-participants'; +import { SafeAreaModal } from '../SafeAreaModal'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; + +const DEBOUNCE_MS = 250; + +interface CalendarSearchSheetProps { + visible: boolean; + calendars: Calendar[]; + timeFormat?: TimeFormat; + currentUserEmails?: string[]; + /** Called once the search is closed. */ + onSelectEvent: (event: CalendarEvent) => void; + onClose: () => void; +} + +type Row = + | { kind: 'header'; key: string; label: string } + | { kind: 'event'; key: string; event: CalendarEvent }; + +/** + * Search the events by title, place, description and participants (webmail + * calendar-search). The loaded events answer at once; the server's text + * search adds the rest of the calendar, whatever the date. + */ +export function CalendarSearchSheet({ + visible, + calendars, + timeFormat, + currentUserEmails = [], + onSelectEvent, + onClose, +}: CalendarSearchSheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale, t } = useCalendarLocale(); + const loaded = useCalendarStore((s) => s.events); + const [query, setQuery] = React.useState(''); + const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null); + + React.useEffect(() => { + if (!visible) { + setQuery(''); + setRemote(null); + } + }, [visible]); + + const terms = React.useMemo(() => searchTerms(query), [query]); + const key = terms.join(' '); + React.useEffect(() => { + if (!visible || terms.length === 0) return; + let cancelled = false; + const timer = setTimeout(() => { + searchCalendarEvents(terms) + .then((events) => { if (!cancelled) setRemote({ query: key, events }); }) + // A server that can't search leaves the loaded matches. + .catch(() => { if (!cancelled) setRemote({ query: key, events: [] }); }); + }, DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, key]); + + // iOS can't present the detail sheet while this one is still going away: + // the event opens once it is gone (onDismiss, iOS only). + const pending = React.useRef(null); + const choose = (event: CalendarEvent) => { + if (Platform.OS === 'ios') { + pending.current = event; + onClose(); + return; + } + onClose(); + onSelectEvent(event); + }; + + const answered = remote?.query === key ? remote.events : null; + const searching = terms.length > 0 && !answered; + const results = React.useMemo(() => { + if (terms.length === 0) return null; + const local = loaded.filter((e) => eventMatches(e, terms)); + return orderSearchResults(local, answered ?? [], displayNow().getTime()); + }, [loaded, terms, answered]); + + const rows = React.useMemo(() => { + if (!results) return []; + const out: Row[] = []; + if (results.upcoming.length > 0) { + out.push({ kind: 'header', key: 'h-upcoming', label: t('calendar.search.upcoming', 'Upcoming') }); + for (const event of results.upcoming) out.push({ kind: 'event', key: `u-${event.id}`, event }); + } + if (results.past.length > 0) { + out.push({ kind: 'header', key: 'h-past', label: t('calendar.search.past', 'Past') }); + for (const event of results.past) out.push({ kind: 'event', key: `p-${event.id}`, event }); + } + return out; + }, [results, t]); + + const time = timePattern(timeFormat); + const thisYear = displayNow().getFullYear(); + const renderEvent = (event: CalendarEvent) => { + const start = getEventStartDate(event); + const end = getEventEndDate(event); + const calendar = calendars.find((cal) => cal.id === getPrimaryCalendarId(event)); + const color = getEventColor(event, calendars); + const inactive = isInactiveEvent(event, currentUserEmails); + const place = event.locations ? Object.values(event.locations)[0]?.name : undefined; + const meta = [place, calendar?.name].filter(Boolean).join(' ยท '); + // A series the server found by its stored event. + const repeats = !event.recurrenceId && !!event.recurrenceRules?.length; + const title = event.title || t('calendar.events.no_title', '(No title)'); + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => choose(event)} + accessibilityRole="button" + accessibilityLabel={title} + > + + + {format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })} + + + {event.showWithoutTime + ? t('calendar.events.all_day', 'All day') + : `${format(start, time, { locale })} โ€“ ${format(end, time, { locale })}`} + + + + + {title} + {(meta || repeats) ? ( + + {place ? : null} + {meta ? {meta} : null} + {repeats && ( + <> + + {t('calendar.search.repeats', 'Repeats')} + + )} + + ) : null} + + + ); + }; + + return ( + { + const event = pending.current; + pending.current = null; + if (event) onSelectEvent(event); + }} + > + + + + + + + + + {query ? ( + setQuery('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('calendar.search.clear', 'Clear search')} + > + + + ) : null} + + + + {searching && rows.length === 0 ? ( + + ) : results && rows.length === 0 ? ( + + + {t('calendar.search.no_results', 'No events match โ€œ{query}โ€', { query: query.trim() })} + + + {t('calendar.search.no_results_hint', 'Search looks at titles, places, descriptions and guests.')} + + + ) : ( + row.key} + keyboardShouldPersistTaps="handled" + automaticallyAdjustKeyboardInsets + ListHeaderComponent={searching ? : null} + renderItem={({ item }) => (item.kind === 'header' + ? {item.label} + : renderEvent(item.event))} + /> + )} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: c.background }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, + field: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 40, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + input: { flex: 1, ...typography.body, color: c.text, paddingVertical: spacing.sm }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl, gap: spacing.sm }, + emptyTitle: { ...typography.bodyMedium, color: c.text, textAlign: 'center' }, + emptyHint: { ...typography.caption, color: c.textMuted, textAlign: 'center' }, + more: { paddingVertical: spacing.sm }, + section: { + ...typography.captionMedium, + color: c.textSecondary, + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.xs, + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + rowPressed: { backgroundColor: c.surface }, + when: { width: 104 }, + whenDay: { ...typography.body, color: c.text }, + whenTime: { ...typography.caption, color: c.textMuted }, + dot: { width: 9, height: 9, borderRadius: 5, marginTop: 6 }, + what: { flex: 1, minWidth: 0 }, + title: { ...typography.bodyMedium, color: c.text }, + inactive: { textDecorationLine: 'line-through', color: c.textMuted }, + metaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: 2 }, + meta: { ...typography.caption, color: c.textMuted, flexShrink: 1 }, + }); +} diff --git a/src/lib/__tests__/calendar-search.test.ts b/src/lib/__tests__/calendar-search.test.ts new file mode 100644 index 00000000..b94844c8 --- /dev/null +++ b/src/lib/__tests__/calendar-search.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import type { CalendarEvent } from '../../api/types'; +import { eventMatches, orderSearchResults, searchFilter, searchTerms } from '../calendar-search'; + +const ev = (partial: Partial): CalendarEvent => ({ + id: 'e', uid: 'u', title: 'Event', calendarIds: { c: true }, start: '2026-03-02T09:00:00', duration: 'PT1H', + ...partial, +} as CalendarEvent); + +describe('calendar search', () => { + it('asks the server for every word', () => { + expect(searchTerms(' Team Sync ')).toEqual(['team', 'sync']); + expect(searchFilter(['team'])).toEqual({ text: 'team' }); + expect(searchFilter(['team', 'sync'])).toEqual({ operator: 'AND', conditions: [{ text: 'team' }, { text: 'sync' }] }); + expect(searchFilter([])).toBeNull(); + }); + + it('matches the loaded events by title, place, description and participants', () => { + const event = ev({ + title: 'Quarterly review', + description: 'Budget', + locations: { l: { '@type': 'Location', name: 'Room Atlas' } } as never, + participants: { p: { calendarAddress: 'mailto:dana@example.com', name: 'Dana' } } as never, + }); + expect(eventMatches(event, searchTerms('review atlas'))).toBe(true); + expect(eventMatches(event, searchTerms('budget dana@example'))).toBe(true); + expect(eventMatches(event, searchTerms('review lunch'))).toBe(false); + expect(eventMatches(event, [])).toBe(false); + }); + + it('shows a series once, by its next occurrence, and the server\'s finds the window lacks', () => { + const now = new Date('2026-03-10T12:00:00').getTime(); + const occurrence = (day: string) => ev({ id: `s:${day}`, uid: 'series', title: 'Standup', start: `2026-03-${day}T09:00:00`, recurrenceId: `2026-03-${day}T09:00:00` }); + const local = [occurrence('09'), occurrence('16'), occurrence('11')]; + const server = [ + ev({ id: 'series-base', uid: 'series', title: 'Standup', start: '2026-01-05T09:00:00', recurrenceRules: [{ frequency: 'weekly' }] as never }), + ev({ id: 'old', uid: 'old', title: 'Standup retro', start: '2025-11-01T09:00:00' }), + ]; + const { upcoming, past } = orderSearchResults(local, server, now); + expect(upcoming.map((e) => e.id)).toEqual(['s:11']); + expect(past.map((e) => e.id)).toEqual(['old']); + }); +}); diff --git a/src/lib/calendar-search.ts b/src/lib/calendar-search.ts new file mode 100644 index 00000000..5876b678 --- /dev/null +++ b/src/lib/calendar-search.ts @@ -0,0 +1,92 @@ +import type { CalendarEvent } from '../api/types'; +import { getEventEndDate, getEventStartDate } from './calendar-utils'; + +// Event search in the calendar (webmail components/calendar/calendar-search +// and hooks/use-calendar-event-search): the loaded events answer at once, +// the server's CalendarEvent/query `text` filter adds the rest. + +/** The words of a search, lower case; every one has to match. */ +export function searchTerms(query: string): string[] { + return query.toLowerCase().split(/\s+/).map((w) => w.trim()).filter(Boolean); +} + +/** The CalendarEvent/query filter for the words: one `text` condition each. */ +export function searchFilter(terms: string[]): Record | null { + if (terms.length === 0) return null; + if (terms.length === 1) return { text: terms[0] }; + return { operator: 'AND', conditions: terms.map((text) => ({ text })) }; +} + +/** What a search looks at: title, description, places and participants. */ +function eventFields(event: CalendarEvent): string[] { + const fields = [event.title ?? '', event.description ?? '']; + for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name); + for (const p of Object.values(event.participants ?? {})) { + if (p.name) fields.push(p.name); + if (p.email) fields.push(p.email); + if (p.calendarAddress) fields.push(p.calendarAddress.replace(/^mailto:/i, '')); + } + return fields.map((f) => f.toLowerCase()); +} + +/** Whether every word of the search is in one of the event's fields. */ +export function eventMatches(event: CalendarEvent, terms: string[]): boolean { + if (terms.length === 0) return false; + const fields = eventFields(event); + return terms.every((term) => fields.some((field) => field.includes(term))); +} + +export interface EventSearchResults { + /** Still to come or going on, soonest first. */ + upcoming: CalendarEvent[]; + /** Over, latest first. */ + past: CalendarEvent[]; +} + +// One row per event: a series found as loaded occurrences and as the +// server's base event shows once. An occurrence renamed on its own keeps a +// row of its own. +const seriesKey = (event: CalendarEvent) => + `${event.accountId ?? ''} ${event.uid || event.originalId || event.id} ${event.title ?? ''}`; + +/** + * The rows of a search: of a series' loaded occurrences the next one to + * come, else the last; the server's events add what the loaded window + * lacks. Tasks and events without a start are left out. + */ +export function orderSearchResults( + local: CalendarEvent[], + remote: CalendarEvent[], + now: number, +): EventSearchResults { + const ends = (event: CalendarEvent) => getEventEndDate(event).getTime(); + const starts = (event: CalendarEvent) => getEventStartDate(event).getTime(); + const bySeries = new Map(); + for (const event of local) { + if (!event.start) continue; + const key = seriesKey(event); + const kept = bySeries.get(key); + if (!kept) { + bySeries.set(key, event); + continue; + } + const keptUpcoming = ends(kept) >= now; + const upcoming = ends(event) >= now; + if (upcoming && (!keptUpcoming || starts(event) < starts(kept))) bySeries.set(key, event); + else if (!upcoming && !keptUpcoming && starts(event) > starts(kept)) bySeries.set(key, event); + } + for (const event of remote) { + if (!event.start) continue; + const key = seriesKey(event); + if (!bySeries.has(key)) bySeries.set(key, event); + } + const upcoming: CalendarEvent[] = []; + const past: CalendarEvent[] = []; + for (const event of bySeries.values()) { + if (Number.isNaN(starts(event))) continue; + (ends(event) >= now ? upcoming : past).push(event); + } + upcoming.sort((a, b) => starts(a) - starts(b)); + past.sort((a, b) => starts(b) - starts(a)); + return { upcoming, past }; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index e310c11f..6ed8d41b 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -23,6 +23,7 @@ import { ListChecks, Menu, Rows2, + Search, Square, } from '../components/icons'; import { @@ -60,6 +61,7 @@ import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; import { CalendarEditSheet, type CalendarEditValues } from '../components/calendar/CalendarEditSheet'; import { CalendarShareSheet } from '../components/calendar/CalendarShareSheet'; +import { CalendarSearchSheet } from '../components/calendar/CalendarSearchSheet'; import { computeScrollWindow, fixedScrollWindowState, @@ -225,6 +227,7 @@ export default function CalendarScreen() { }, [scrollWindow]); const [detailEvent, setDetailEvent] = React.useState(null); + const [searchVisible, setSearchVisible] = React.useState(false); const [modalEvent, setModalEvent] = React.useState(null); const [modalDate, setModalDate] = React.useState(undefined); const [modalVisible, setModalVisible] = React.useState(false); @@ -982,6 +985,18 @@ export default function CalendarScreen() { + { + setMonthPanelOpen(false); + setSearchVisible(true); + }} + hitSlop={4} + accessibilityRole="button" + accessibilityLabel={t('calendar.search.title', 'Search events')} + > + + {/* Without free scrolling the views don't swipe: keep arrows. */} {!freeScroll && ( <> @@ -1283,6 +1298,15 @@ export default function CalendarScreen() { onClose={() => setCalendarEditTarget(null)} /> + setSearchVisible(false)} + /> + ({ setDefaultCalendar: vi.fn(async () => undefined), supportsSyntheticCalendarIds: vi.fn(async () => false), queryExpandedEvents: vi.fn(async () => []), + searchEventIds: vi.fn(async () => []), hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), resetSyntheticIdSupport: vi.fn(), resetTimeZoneChecks: vi.fn(), @@ -47,7 +48,7 @@ vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() })) import * as calendarApi from '../../api/calendar'; import type { CalendarEvent } from '../../api/types'; -import { CalendarAccountChangedError, useCalendarStore } from '../calendar-store'; +import { CalendarAccountChangedError, searchCalendarEvents, useCalendarStore } from '../calendar-store'; const mockUpdate = vi.mocked(calendarApi.updateEvent); const mockDelete = vi.mocked(calendarApi.deleteEvents); @@ -259,3 +260,37 @@ describe('a write the account switch overtook', () => { expect(useCalendarStore.getState().tasks[0].progress).toBe('completed'); }); }); + +describe('searchCalendarEvents', () => { + const mockSearch = vi.mocked(calendarApi.searchEventIds); + + it('searches every account the store shows and edits a find in its own account', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'b', name: 'Mine' }, { id: 'acc-2:t', originalId: 't', accountId: 'acc-2', name: 'Team', isShared: true }] as never, + events: [], + }); + mockSearch.mockImplementation(async (_filter, accountId) => (accountId === 'acc-2' ? ['x'] : ['c'])); + mockGetEvents.mockImplementation(async (ids: string[], accountId?: string) => ids.map((id) => ({ + ...event(id, accountId ? 'Team review' : 'My review'), + calendarIds: (accountId ? { t: true } : { b: true }) as Record, + }))); + + const found = await searchCalendarEvents(['review']); + + expect(mockSearch).toHaveBeenCalledWith({ text: 'review' }, undefined); + expect(mockSearch).toHaveBeenCalledWith({ text: 'review' }, 'acc-2'); + expect(found.map((e) => e.id).sort()).toEqual(['acc-2:x', 'c']); + // Outside the loaded window, yet a change reaches the owner's account. + await useCalendarStore.getState().updateEvent('acc-2:x', { title: 'Renamed' }); + expect(mockUpdate).toHaveBeenCalledWith('x', { title: 'Renamed' }, undefined, 'acc-2'); + }); + + it('hands nothing found before an account switch to the next account', async () => { + mockSearch.mockImplementationOnce(async () => { + switchAccount(); + return ['c']; + }); + await expect(searchCalendarEvents(['review'])).rejects.toBeInstanceOf(CalendarAccountChangedError); + expect(mockGetEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 18146512..4be8ade0 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -18,6 +18,7 @@ import { queryEvents, getEvents as fetchEvents, getEventIdentities, + searchEventIds, scanCalendarObjects, isCalendarAccessDenied, noteCalendarAccessError, @@ -59,6 +60,7 @@ import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-comp import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache'; import { requestDeviceSync } from '../device-sync/app/request'; import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort'; +import { searchFilter } from '../lib/calendar-search'; import { CALENDAR_AUTHORITY } from '../device-sync/types'; // extendRange merges up to twice the requested range (at least half a year, @@ -512,6 +514,41 @@ export async function loadEventsInRange( // here. const knownMasters = new Map(); +/** + * The events of every calendar account the store shows that the server + * finds for `terms` (CalendarEvent/query `text`), whatever their date: a + * series by its stored event. They become known like a fetched master, so + * an edit or a delete from the detail sheet reaches their own account under + * their raw id. Throws CalendarAccountChangedError when the store was reset + * meanwhile; a failing shared account is skipped. + */ +export async function searchCalendarEvents(terms: string[]): Promise { + const filter = searchFilter(terms); + if (!filter || !jmapClient.isConnected) return []; + const generation = storeGeneration; + const calendars = useCalendarStore.getState().calendars; + const accounts = new Set([undefined]); + for (const cal of calendars) if (cal.accountId) accounts.add(cal.accountId); + const found: CalendarEvent[] = []; + for (const accountId of accounts) { + if (generation !== storeGeneration) throw new CalendarAccountChangedError(); + if (accountId && isCalendarAccessDenied(accountId)) continue; + try { + const ids = await searchEventIds(filter, accountId); + if (generation !== storeGeneration) throw new CalendarAccountChangedError(); + const events = ids.length > 0 ? await fetchEvents(ids, accountId) : []; + found.push(...events + .filter((e) => !isTaskLikeObject(e) && !!e.start) + .map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); + } catch (err) { + if (!accountId || err instanceof CalendarAccountChangedError) throw err; + } + } + if (generation !== storeGeneration) throw new CalendarAccountChangedError(); + for (const event of found) knownMasters.set(event.id, event); + return found; +} + export interface MutationTarget { storeEvent?: CalendarEvent; /** Raw JMAP id the change is sent to. */