feat: search events in the calendar

This commit is contained in:
Linus Rath
2026-10-29 04:12:48 +01:00
parent 9b87100a15
commit b50270d8f3
8 changed files with 588 additions and 2 deletions
@@ -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<CalendarEvent>, 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();
+24
View File
@@ -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<string, unknown>,
accountId?: string,
limit = 100,
): Promise<string[]> {
const account = accountId || jmapClient.accountId;
return withCalendarTimeZone(async (timeZone) => {
const args: Record<string, unknown> = {
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.
@@ -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<CalendarEvent | null>(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<Row[]>(() => {
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 (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => choose(event)}
accessibilityRole="button"
accessibilityLabel={title}
>
<View style={styles.when}>
<Text style={styles.whenDay}>
{format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })}
</Text>
<Text style={styles.whenTime}>
{event.showWithoutTime
? t('calendar.events.all_day', 'All day')
: `${format(start, time, { locale })} – ${format(end, time, { locale })}`}
</Text>
</View>
<View style={[styles.dot, inactive ? { borderWidth: 1.5, borderColor: color } : { backgroundColor: color }]} />
<View style={styles.what}>
<Text style={[styles.title, inactive && styles.inactive]} numberOfLines={1}>{title}</Text>
{(meta || repeats) ? (
<View style={styles.metaRow}>
{place ? <MapPin size={12} color={c.textMuted} /> : null}
{meta ? <Text style={styles.meta} numberOfLines={1}>{meta}</Text> : null}
{repeats && (
<>
<Repeat size={12} color={c.textMuted} />
<Text style={styles.meta}>{t('calendar.search.repeats', 'Repeats')}</Text>
</>
)}
</View>
) : null}
</View>
</Pressable>
);
};
return (
<SafeAreaModal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={onClose}
onDismiss={() => {
const event = pending.current;
pending.current = null;
if (event) onSelectEvent(event);
}}
>
<SafeAreaView style={styles.container} edges={['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.headerBtn}
accessibilityRole="button"
accessibilityLabel={t('calendar.search.close', 'Close search')}
>
<ArrowLeft size={20} color={c.text} />
</Pressable>
<View style={styles.field}>
<Search size={16} color={c.textMuted} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t('calendar.search.title', 'Search events')}
placeholderTextColor={c.mutedForeground}
style={styles.input}
autoFocus
autoCorrect={false}
returnKeyType="search"
accessibilityLabel={t('calendar.search.title', 'Search events')}
/>
{query ? (
<Pressable
onPress={() => setQuery('')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('calendar.search.clear', 'Clear search')}
>
<X size={16} color={c.textMuted} />
</Pressable>
) : null}
</View>
</View>
{searching && rows.length === 0 ? (
<View style={styles.center}><ActivityIndicator color={c.primary} /></View>
) : results && rows.length === 0 ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>
{t('calendar.search.no_results', 'No events match “{query}”', { query: query.trim() })}
</Text>
<Text style={styles.emptyHint}>
{t('calendar.search.no_results_hint', 'Search looks at titles, places, descriptions and guests.')}
</Text>
</View>
) : (
<FlatList
data={rows}
keyExtractor={(row) => row.key}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
ListHeaderComponent={searching ? <ActivityIndicator style={styles.more} color={c.primary} /> : null}
renderItem={({ item }) => (item.kind === 'header'
? <Text style={styles.section}>{item.label}</Text>
: renderEvent(item.event))}
/>
)}
</SafeAreaView>
</SafeAreaModal>
);
}
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 },
});
}
+43
View File
@@ -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>): 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']);
});
});
+92
View File
@@ -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<string, unknown> | 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<string, CalendarEvent>();
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 };
}
+24
View File
@@ -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<CalendarEvent | null>(null);
const [searchVisible, setSearchVisible] = React.useState(false);
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
const [modalVisible, setModalVisible] = React.useState(false);
@@ -982,6 +985,18 @@ export default function CalendarScreen() {
</View>
</Pressable>
<View style={styles.headerActions}>
<Pressable
style={styles.headerBtn}
onPress={() => {
setMonthPanelOpen(false);
setSearchVisible(true);
}}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={t('calendar.search.title', 'Search events')}
>
<Search size={20} color={c.text} />
</Pressable>
{/* Without free scrolling the views don't swipe: keep arrows. */}
{!freeScroll && (
<>
@@ -1283,6 +1298,15 @@ export default function CalendarScreen() {
onClose={() => setCalendarEditTarget(null)}
/>
<CalendarSearchSheet
visible={searchVisible}
calendars={calendars}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onClose={() => setSearchVisible(false)}
/>
<CalendarShareSheet
calendar={shareTarget}
onShare={shareCalendar}
@@ -26,6 +26,7 @@ vi.mock('../../api/calendar', () => ({
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<string, boolean>,
})));
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();
});
});
+37
View File
@@ -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<string, CalendarEvent>();
/**
* 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<CalendarEvent[]> {
const filter = searchFilter(terms);
if (!filter || !jmapClient.isConnected) return [];
const generation = storeGeneration;
const calendars = useCalendarStore.getState().calendars;
const accounts = new Set<string | undefined>([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. */