mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: reload a failed calendar range and expand multi-word event searches
Four smaller calendar fixes found in review of the Stalwart 1.0 work. - A range load that failed left `dateRange` on the range loaded before, so a push or a pull-to-refresh reloaded that one instead of the range on screen, and any action that reset `error` hid the "may be out of date" notice. The store now takes the range asked for and keeps the failure in `eventsLoadError`, which only a successful range load clears. - The agenda plugin's expanded query had no sort, so its limit could keep later occurrences instead of the next ones. It now sorts by start, looks less far ahead when the server refuses the range as too long (`expandDurationTooLarge`, an administrator's shorter `maxExpandedQueryDuration`) instead of answering 502, and sends its gets several per request. - With a limit (search, contact activity) the chunks of a long range went out one request each. Up to four now share a request, and what the later ones return past the limit is dropped. Global search passes its AbortSignal down, so a replaced search stops between requests. - A search of several words sends its date bounds as conditions of a flat AND. The client did not see them as a range: it neither expanded recurrences nor turned the UTC bounds into the request's wall clock, so such a search found series instead of occurrences, over a range shifted by the zone's offset. The bounds are now lifted out of the AND, converted and chunked, and each chunk keeps one flat AND of single conditions - the shape 1.0 expands; it refuses an AND holding a condition with both bounds.
This commit is contained in:
@@ -37,6 +37,8 @@ const EVENT_PROPERTIES = [
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const GET_BATCH = 100;
|
||||
// Stalwart's default maxCallsInRequest, for a session that names none.
|
||||
const MAX_CALLS_FALLBACK = 16;
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string;
|
||||
@@ -154,10 +156,11 @@ export async function POST(request: NextRequest) {
|
||||
windowStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// ── 1) Query the occurrences in range + load calendars (colours) ──
|
||||
// expandRecurrences returns one id per occurrence (it needs both bounds).
|
||||
// A range that expands to more occurrences than Stalwart allows in one
|
||||
// query is refused; the agenda then looks less far ahead instead of
|
||||
// failing.
|
||||
// expandRecurrences returns one id per occurrence (it needs both bounds),
|
||||
// the earliest first so the limit keeps the next ones. A range the server
|
||||
// will not expand - more occurrences than it allows in one query, or
|
||||
// longer than its maxExpandedQueryDuration - is refused; the agenda then
|
||||
// looks less far ahead instead of failing.
|
||||
let queryRes: unknown;
|
||||
let queryResp: Record<string, unknown> | null;
|
||||
for (;;) {
|
||||
@@ -170,6 +173,7 @@ export async function POST(request: NextRequest) {
|
||||
accountId,
|
||||
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||
expandRecurrences: true,
|
||||
sort: [{ property: 'start', isAscending: true }],
|
||||
limit: 1000,
|
||||
},
|
||||
'0',
|
||||
@@ -185,7 +189,7 @@ export async function POST(request: NextRequest) {
|
||||
if (queryResp) break;
|
||||
const err = findResponse(queryRes, 'error', '0');
|
||||
const span = horizon.getTime() - now.getTime();
|
||||
if (isOccurrenceLimit(err) && span > DAY_MS) {
|
||||
if ((isOccurrenceLimit(err) || err?.type === 'expandDurationTooLarge') && span > DAY_MS) {
|
||||
horizon = new Date(now.getTime() + Math.max(DAY_MS, Math.floor(span / 2)));
|
||||
continue;
|
||||
}
|
||||
@@ -211,22 +215,29 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
|
||||
}
|
||||
|
||||
// ── 2) Fetch the occurrences (batched) ──
|
||||
// ── 2) Fetch the occurrences (batched, several gets per request) ──
|
||||
const coreCapability = session.capabilities?.['urn:ietf:params:jmap:core'] as { maxCallsInRequest?: unknown } | undefined;
|
||||
const maxCalls = typeof coreCapability?.maxCallsInRequest === 'number' && coreCapability.maxCallsInRequest > 0
|
||||
? coreCapability.maxCallsInRequest
|
||||
: MAX_CALLS_FALLBACK;
|
||||
const getEvents = async (eventIds: string[], properties: readonly string[]) => {
|
||||
const list: Array<Record<string, unknown>> = [];
|
||||
for (let i = 0; i < eventIds.length; i += GET_BATCH) {
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < eventIds.length; i += GET_BATCH) batches.push(eventIds.slice(i, i + GET_BATCH));
|
||||
for (let first = 0; first < batches.length; first += maxCalls) {
|
||||
const group = batches.slice(first, first + maxCalls);
|
||||
const getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||
using,
|
||||
methodCalls: [
|
||||
['CalendarEvent/get', { accountId, ids: eventIds.slice(i, i + GET_BATCH), properties }, '0'],
|
||||
],
|
||||
methodCalls: group.map((ids, i) => ['CalendarEvent/get', { accountId, ids, properties }, String(i)]),
|
||||
}, fetchOptions);
|
||||
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
|
||||
if (!getResp) {
|
||||
const err = findResponse(getRes, 'error', '0');
|
||||
throw new AgendaFetchError((err?.description as string) || 'CalendarEvent/get failed');
|
||||
for (const [i] of group.entries()) {
|
||||
const getResp = findResponse(getRes, 'CalendarEvent/get', String(i));
|
||||
if (!getResp) {
|
||||
const err = findResponse(getRes, 'error', String(i));
|
||||
throw new AgendaFetchError((err?.description as string) || 'CalendarEvent/get failed');
|
||||
}
|
||||
list.push(...((getResp.list as Array<Record<string, unknown>>) || []));
|
||||
}
|
||||
list.push(...((getResp.list as Array<Record<string, unknown>>) || []));
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
||||
format, parseISO,
|
||||
} from "date-fns";
|
||||
import { EVENTS_LOAD_ERROR, useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { isCalendarViewMode } from "@/stores/calendar-store";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -129,7 +129,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error, eventsLoadError,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
@@ -246,8 +246,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
// A failed range load gets the notice above the grid instead.
|
||||
if (error && error !== EVENTS_LOAD_ERROR) {
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
@@ -1574,7 +1573,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{error === EVENTS_LOAD_ERROR && normalizedViewMode !== "tasks" && (
|
||||
{eventsLoadError && normalizedViewMode !== "tasks" && (
|
||||
<CalendarLoadErrorNotice onRetry={retryEventsLoad} />
|
||||
)}
|
||||
{viewContent}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import { OVERRIDES_WITH_UTC_ERROR, asksForOverridesWithUtc, type StalwartVersion } from './helpers/stalwart-calendar';
|
||||
import {
|
||||
EXPAND_DURATION_TOO_LARGE,
|
||||
OVERRIDES_WITH_UTC_ERROR,
|
||||
asksForOverridesWithUtc,
|
||||
type StalwartVersion,
|
||||
} from './helpers/stalwart-calendar';
|
||||
|
||||
// ── module mocks (hoisted) ───────────────────────────────────────────────────
|
||||
vi.mock('next/server', () => {
|
||||
@@ -92,8 +97,9 @@ describe('calendar-agenda route (principals:owner declaration)', () => {
|
||||
// fills in the series context from the base events, so it never needs
|
||||
// `recurrenceOverrides` next to utcStart/utcEnd - a get Stalwart 1.0 refuses.
|
||||
describe.each<StalwartVersion>(['0.16', '1.0'])('calendar-agenda route on Stalwart %s', (version) => {
|
||||
type Call = { method: string; args: Record<string, unknown> };
|
||||
type Call = { method: string; args: Record<string, unknown>; request: number };
|
||||
let calls: Call[];
|
||||
let requests: number;
|
||||
let answer: (call: Call) => [string, Record<string, unknown>] | undefined;
|
||||
|
||||
const OCCURRENCES: Record<string, Record<string, unknown>> = {
|
||||
@@ -123,7 +129,7 @@ describe.each<StalwartVersion>(['0.16', '1.0'])('calendar-agenda route on Stalwa
|
||||
if (version === '1.0' && asksForOverridesWithUtc(args)) return ['error', { ...OVERRIDES_WITH_UTC_ERROR }];
|
||||
const properties = args.properties as string[];
|
||||
const list = (args.ids as string[]).map((id) => {
|
||||
const source = OCCURRENCES[id] ?? BASES[id];
|
||||
const source = OCCURRENCES[id] ?? BASES[id] ?? { id };
|
||||
return Object.fromEntries(Object.entries(source).filter(([key]) => key === 'id' || properties.includes(key)));
|
||||
});
|
||||
return ['CalendarEvent/get', { list }];
|
||||
@@ -134,12 +140,14 @@ describe.each<StalwartVersion>(['0.16', '1.0'])('calendar-agenda route on Stalwa
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-09-25T08:00:00Z'));
|
||||
calls = [];
|
||||
requests = 0;
|
||||
answer = () => undefined;
|
||||
mockCreds.mockResolvedValue({ serverUrl: 'https://mail.example.com', username: 'user@example.com', authHeader: 'Basic abc' });
|
||||
mockSession.mockResolvedValue(makeSession({ 'urn:ietf:params:jmap:core': {}, [CALENDAR_CAP]: {} }));
|
||||
mockPost.mockImplementation(async (_url: string, _auth: string, body: string) => {
|
||||
const request = requests++;
|
||||
const methodResponses = JSON.parse(body).methodCalls.map(([method, args, callId]: [string, Record<string, unknown>, string]) => {
|
||||
const call = { method, args };
|
||||
const call = { method, args, request };
|
||||
calls.push(call);
|
||||
return [...(answer(call) ?? defaultAnswer(call)), callId];
|
||||
});
|
||||
@@ -160,6 +168,8 @@ describe.each<StalwartVersion>(['0.16', '1.0'])('calendar-agenda route on Stalwa
|
||||
|
||||
const query = calls.find((c) => c.method === 'CalendarEvent/query')!;
|
||||
expect(query.args.expandRecurrences).toBe(true);
|
||||
// The earliest first, so the query's limit keeps the next occurrences.
|
||||
expect(query.args.sort).toEqual([{ property: 'start', isAscending: true }]);
|
||||
expect(gets().filter((g) => asksForOverridesWithUtc(g.args))).toEqual([]);
|
||||
const [instanceGet, baseGet] = gets();
|
||||
expect(instanceGet.args.properties).not.toContain('recurrenceOverrides');
|
||||
@@ -200,4 +210,53 @@ describe.each<StalwartVersion>(['0.16', '1.0'])('calendar-agenda route on Stalwa
|
||||
expect(spans).toEqual([80, 40, 20]);
|
||||
expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it(`looks less far ahead when the range is longer than the server expands on Stalwart ${version}`, async () => {
|
||||
// An administrator set maxExpandedQueryDuration to 30 days. 1.0 refuses a
|
||||
// longer expanded range; 0.16 never checks.
|
||||
answer = ({ method, args }) => {
|
||||
if (method !== 'CalendarEvent/query' || version !== '1.0') return undefined;
|
||||
const filter = args.filter as { after: string; before: string };
|
||||
return Date.parse(filter.before) - Date.parse(filter.after) > 30 * 86_400_000
|
||||
? ['error', { ...EXPAND_DURATION_TOO_LARGE }]
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const res = await POST({ json: async () => ({ days: 80 }) } as unknown as Parameters<typeof POST>[0]);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const spans = calls.filter((c) => c.method === 'CalendarEvent/query').map((c) => {
|
||||
const filter = c.args.filter as { after: string; before: string };
|
||||
return (Date.parse(filter.before) - Date.parse('2026-09-25T08:00:00Z')) / 86_400_000;
|
||||
});
|
||||
expect(spans).toEqual(version === '1.0' ? [80, 40, 20] : [80]);
|
||||
expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it(`fetches many occurrences with several gets per request on Stalwart ${version}`, async () => {
|
||||
const many = [...Object.keys(OCCURRENCES), ...Array.from({ length: 248 }, (_, i) => `x${i}`)];
|
||||
answer = ({ method }) => method === 'CalendarEvent/query' ? ['CalendarEvent/query', { ids: many }] : undefined;
|
||||
|
||||
const res = await POST(makeReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const occurrenceGets = gets().filter((g) => !(g.args.ids as string[]).includes('b'));
|
||||
expect(occurrenceGets.map((g) => (g.args.ids as string[]).length)).toEqual([100, 100, 50]);
|
||||
expect(new Set(occurrenceGets.map((g) => g.request)).size).toBe(1);
|
||||
expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it(`keeps to the session's maxCallsInRequest when fetching occurrences on Stalwart ${version}`, async () => {
|
||||
mockSession.mockResolvedValue(makeSession({ 'urn:ietf:params:jmap:core': { maxCallsInRequest: 2 }, [CALENDAR_CAP]: {} }));
|
||||
const many = [...Object.keys(OCCURRENCES), ...Array.from({ length: 248 }, (_, i) => `x${i}`)];
|
||||
answer = ({ method }) => method === 'CalendarEvent/query' ? ['CalendarEvent/query', { ids: many }] : undefined;
|
||||
|
||||
const res = await POST(makeReq());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const occurrenceGets = gets().filter((g) => !(g.args.ids as string[]).includes('b'));
|
||||
const [first, , last] = occurrenceGets;
|
||||
expect(occurrenceGets.map((g) => g.request)).toEqual([first.request, first.request, first.request + 1]);
|
||||
expect((last.args.ids as string[]).length).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,12 +248,52 @@ export function occurrenceObjects(seriesId: string, occurrences: RangeOccurrence
|
||||
}]));
|
||||
}
|
||||
|
||||
type FilterNode = Record<string, unknown> & { operator?: string; conditions?: FilterNode[] };
|
||||
|
||||
/** Stalwart 1.0's refusal of an expanded query whose filter is more than one FilterCondition. */
|
||||
export const NOT_A_SINGLE_CONDITION = {
|
||||
type: 'invalidArguments',
|
||||
description: 'The filter must be a single FilterCondition when expanding recurrences',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Whether Stalwart 1.0 expands recurrences for `filter`: no OR or NOT, and at
|
||||
* most one AND - a condition of two keys counts as an AND of its own
|
||||
* (jmap-proto/src/method/query.rs; calendar_event/query.rs).
|
||||
*/
|
||||
export function isSingleFilterCondition(filter: FilterNode): boolean {
|
||||
let ands = 0;
|
||||
const walk = (node: FilterNode): boolean => {
|
||||
if (node.operator) {
|
||||
if (node.operator !== 'AND') return false;
|
||||
ands++;
|
||||
return (node.conditions ?? []).every(walk);
|
||||
}
|
||||
if (Object.keys(node).length > 1) ands++;
|
||||
return true;
|
||||
};
|
||||
return walk(filter) && ands <= 1;
|
||||
}
|
||||
|
||||
/** The after/before of a filter: keys of its own, or conditions anywhere in it. */
|
||||
export function boundsOf(filter: FilterNode): { after?: string; before?: string } {
|
||||
const bounds: { after?: string; before?: string } = {};
|
||||
const walk = (node: FilterNode) => {
|
||||
if (typeof node.after === 'string') bounds.after = node.after;
|
||||
if (typeof node.before === 'string') bounds.before = node.before;
|
||||
for (const condition of node.conditions ?? []) walk(condition);
|
||||
};
|
||||
walk(filter);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers CalendarEvent/query the way Stalwart expands a range: occurrences
|
||||
* that overlap (after, before) strictly, sorted by start when asked, paged.
|
||||
* 1.0 refuses a span over `maxSpanSeconds` (0.16 never checks it), and both
|
||||
* refuse a query that expands to more than `maxInstances` occurrences. A
|
||||
* query without expansion finds the series itself.
|
||||
* 1.0 refuses a span over `maxSpanSeconds` (0.16 never checks it) and a
|
||||
* filter that is more than a single FilterCondition, and both refuse a
|
||||
* query that expands to more than `maxInstances` occurrences. A query
|
||||
* without expansion finds the series itself.
|
||||
*/
|
||||
export function expandingQueryAnswer(
|
||||
version: StalwartVersion,
|
||||
@@ -263,9 +303,11 @@ export function expandingQueryAnswer(
|
||||
if (call.method !== 'CalendarEvent/query') return undefined;
|
||||
const { args } = call;
|
||||
if (!args.expandRecurrences) return ['CalendarEvent/query', { ids: [options.seriesId], position: 0 }];
|
||||
const filter = args.filter as { after: string; before: string };
|
||||
const after = localSeconds(filter.after);
|
||||
const before = localSeconds(filter.before);
|
||||
const filter = args.filter as FilterNode;
|
||||
if (version === '1.0' && !isSingleFilterCondition(filter)) return ['error', { ...NOT_A_SINGLE_CONDITION }];
|
||||
const bounds = boundsOf(filter);
|
||||
const after = localSeconds(bounds.after ?? '');
|
||||
const before = localSeconds(bounds.before ?? '');
|
||||
if (version === '1.0' && before - after > (options.maxSpanSeconds ?? 365 * 86_400)) {
|
||||
return ['error', { ...EXPAND_DURATION_TOO_LARGE }];
|
||||
}
|
||||
|
||||
@@ -272,14 +272,30 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the chunks one at a time in start order and stops at the limit (contact activity) on Stalwart 1.0', async () => {
|
||||
it('reads the chunks in start order, a few per request, and keeps what fits the limit (contact activity) on Stalwart 1.0', async () => {
|
||||
await connect('1.0');
|
||||
|
||||
const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 2);
|
||||
|
||||
// The first chunk fills the limit: no other chunk is asked.
|
||||
expect(bounds()).toEqual([['2025-01-01T00:00:00', '2025-12-31T00:00:00']]);
|
||||
expect(expandedQueries()[0].args.limit).toBe(2);
|
||||
// One request asks all three chunks; the first fills the limit, and
|
||||
// what the others returned is dropped.
|
||||
expect(bounds()).toEqual([
|
||||
['2025-01-01T00:00:00', '2025-12-31T00:00:00'],
|
||||
['2025-12-30T23:59:59', '2026-12-29T23:59:59'],
|
||||
['2026-12-29T23:59:58', '2027-02-16T23:59:59'],
|
||||
]);
|
||||
expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1);
|
||||
expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-first']);
|
||||
});
|
||||
|
||||
it('asks the next few chunks only when the first ones fall short of the limit on Stalwart 1.0', async () => {
|
||||
// Chunks of 99 days: eight for the window, four per request.
|
||||
await connect('1.0', { maxExpandedQueryDuration: 'P100D' });
|
||||
|
||||
const events = await client.queryCalendarEvents(WINDOW, [{ property: 'start', isAscending: true }], 2);
|
||||
|
||||
expect(expandedQueries()).toHaveLength(4);
|
||||
expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1);
|
||||
expect(events.map((e) => e.id).sort()).toEqual(['o-across', 'o-first']);
|
||||
});
|
||||
|
||||
@@ -292,14 +308,15 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => {
|
||||
expect(bounds()).toEqual([
|
||||
['2026-12-29T23:59:58', '2027-02-16T23:59:59'],
|
||||
['2025-12-30T23:59:59', '2026-12-29T23:59:59'],
|
||||
['2025-01-01T00:00:00', '2025-12-31T00:00:00'],
|
||||
]);
|
||||
expect(expandedQueries().map((q) => q.args.limit)).toEqual([2, 1]);
|
||||
expect(expandedQueries()[0].request).not.toBe(expandedQueries()[1].request);
|
||||
expect(new Set(expandedQueries().map((q) => q.request)).size).toBe(1);
|
||||
expect(events.map((e) => e.id).sort()).toEqual(['o-late', 'o-middle']);
|
||||
});
|
||||
|
||||
it('pages on within a chunk when an occurrence both chunks share fills the page on Stalwart 1.0', async () => {
|
||||
await connect('1.0');
|
||||
// One chunk per request, so the paging is what the requests show.
|
||||
await connect('1.0', { maxCallsInRequest: 1 });
|
||||
|
||||
// First chunk: o-first, o-across. Second chunk (by start): o-across,
|
||||
// o-border, o-middle - the shared one comes back again and is skipped.
|
||||
@@ -323,6 +340,23 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => {
|
||||
expect(events.map((e) => e.id).sort()).toEqual(['o-after', 'o-late', 'o-middle']);
|
||||
});
|
||||
|
||||
it('stops between requests when the caller aborts on Stalwart 1.0', async () => {
|
||||
await connect('1.0', { maxCallsInRequest: 1 });
|
||||
const controller = new AbortController();
|
||||
const answering = fetchSpy.getMockImplementation()!;
|
||||
fetchSpy.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await answering(url, init);
|
||||
if (String(init?.body).includes('"expandRecurrences":true')) controller.abort();
|
||||
return response;
|
||||
});
|
||||
|
||||
const error = await client.queryCalendarEvents(WINDOW, undefined, undefined, undefined, { signal: controller.signal })
|
||||
.then(() => null, (e: unknown) => e);
|
||||
|
||||
expect((error as Error).name).toBe('AbortError');
|
||||
expect(expandedQueries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('asks for two chunks for a 365-day look-ahead on Stalwart 1.0', async () => {
|
||||
await connect('1.0');
|
||||
|
||||
@@ -334,6 +368,35 @@ describe('expanded ranges longer than maxExpandedQueryDuration', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Global search sends a query of several text parts as a flat AND of
|
||||
// single-key conditions, the range among them. 1.0 expands recurrences for
|
||||
// that shape (one AND), not for one where a condition holds both bounds.
|
||||
it.each<[StalwartVersion]>([['0.16'], ['1.0']])(
|
||||
'expands and chunks a range given as conditions of an AND on Stalwart %s',
|
||||
async (version) => {
|
||||
await connect(version);
|
||||
|
||||
const events = await client.queryCalendarEvents({
|
||||
operator: 'AND',
|
||||
conditions: [{ after: WINDOW.after }, { before: WINDOW.before }, { text: 'budget' }, { text: 'review' }],
|
||||
});
|
||||
|
||||
expect(expandedQueries()).toHaveLength(3);
|
||||
for (const query of expandedQueries()) {
|
||||
const filter = query.args.filter as { operator: string; conditions: Array<Record<string, unknown>> };
|
||||
expect(filter.operator).toBe('AND');
|
||||
expect(filter.conditions.slice(2)).toEqual([{ text: 'budget' }, { text: 'review' }]);
|
||||
expect(filter.conditions.every((condition) => Object.keys(condition).length === 1)).toBe(true);
|
||||
}
|
||||
expect(expandedQueries().map((q) => (q.args.filter as { conditions: unknown[] }).conditions.slice(0, 2))).toEqual([
|
||||
[{ after: '2025-01-01T00:00:00' }, { before: '2025-12-31T00:00:00' }],
|
||||
[{ after: '2025-12-30T23:59:59' }, { before: '2026-12-29T23:59:59' }],
|
||||
[{ after: '2026-12-29T23:59:58' }, { before: '2027-02-16T23:59:59' }],
|
||||
]);
|
||||
expect(events.map((e) => e.id).sort()).toEqual([...IN_WINDOW].sort());
|
||||
},
|
||||
);
|
||||
|
||||
it('reports a refusal it cannot split as a failure on Stalwart 1.0', async () => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0'));
|
||||
|
||||
@@ -270,10 +270,11 @@ describe('calendar provider', () => {
|
||||
);
|
||||
|
||||
expect(result.hits.map((h) => h.id).sort()).toEqual(['o-2024', 'o-2025', 'o-2026']);
|
||||
// Three years in chunks of 364 days, the latest first, each still
|
||||
// filtered by the search text.
|
||||
// Three years in chunks of 364 days, the latest first, sent together,
|
||||
// each still filtered by the search text.
|
||||
const expanded = server.queries().filter((q) => q.args.expandRecurrences);
|
||||
expect(expanded).toHaveLength(4);
|
||||
expect(new Set(expanded.map((q) => q.request)).size).toBe(1);
|
||||
const bounds = expanded.map((q) => q.args.filter as { after: string; before: string; text: string });
|
||||
expect(bounds.every((filter) => filter.text === 'standup')).toBe(true);
|
||||
expect(bounds[0].before > bounds[1].before && bounds[1].before > bounds[2].before).toBe(true);
|
||||
@@ -283,12 +284,86 @@ describe('calendar provider', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('searches several words over more than a year in chunks on Stalwart 1.0', async () => {
|
||||
// The words go out as an AND of one-key conditions with the bounds among
|
||||
// them. Each chunk keeps that one flat AND with its own bounds: 1.0
|
||||
// expands recurrences for a single AND of single conditions only.
|
||||
const occurrences = [
|
||||
{ id: 'o-2024', start: '2024-03-05T09:00:00', seconds: 3600 },
|
||||
{ id: 'o-2026', start: '2026-08-20T09:00:00', seconds: 3600 },
|
||||
];
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0'));
|
||||
const server = installStalwartCalendarMock(fetchSpy, {
|
||||
version: '1.0',
|
||||
objects: { series: { id: 'series', recurrenceRule: { frequency: 'yearly' }, showWithoutTime: false, duration: 'PT1H' } },
|
||||
occurrences: occurrenceObjects('series', occurrences),
|
||||
answer: expandingQueryAnswer('1.0', { seriesId: 'series', occurrences }),
|
||||
});
|
||||
try {
|
||||
const result = await calendarProvider.remote(
|
||||
parseSearchQuery('"team sync" budget after:2024-01-01 before:2026-12-31'),
|
||||
{ ...account('login-a'), client },
|
||||
{ limit: 10, signal },
|
||||
);
|
||||
|
||||
expect(result.hits.map((h) => h.id).sort()).toEqual(['o-2024', 'o-2026']);
|
||||
const expanded = server.queries().filter((q) => q.args.expandRecurrences);
|
||||
expect(expanded).toHaveLength(4);
|
||||
for (const query of expanded) {
|
||||
const filter = query.args.filter as { operator: string; conditions: Array<Record<string, string>> };
|
||||
expect(filter.operator).toBe('AND');
|
||||
expect(filter.conditions.map((condition) => Object.keys(condition))).toEqual([['after'], ['before'], ['text'], ['text']]);
|
||||
expect(filter.conditions.slice(2)).toEqual([{ text: '"team sync"' }, { text: 'budget' }]);
|
||||
// Wall clock of the request's time zone, as for a filter of bounds.
|
||||
expect(filter.conditions[0].after.endsWith('Z')).toBe(false);
|
||||
}
|
||||
expect(server.queries().filter((q) => !q.args.expandRecurrences)).toHaveLength(0);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops a search the user replaced before its next request on Stalwart 1.0', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0'));
|
||||
const occurrences = [{ id: 'o-2025', start: '2025-06-10T09:00:00', seconds: 3600 }];
|
||||
const server = installStalwartCalendarMock(fetchSpy, {
|
||||
version: '1.0',
|
||||
objects: { series: { id: 'series', recurrenceRule: { frequency: 'yearly' }, showWithoutTime: false, duration: 'PT1H' } },
|
||||
occurrences: occurrenceObjects('series', occurrences),
|
||||
answer: expandingQueryAnswer('1.0', { seriesId: 'series', occurrences }),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const answering = fetchSpy.getMockImplementation()!;
|
||||
fetchSpy.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await answering(url, init);
|
||||
if (String(init?.body).includes('"expandRecurrences":true')) controller.abort();
|
||||
return response;
|
||||
});
|
||||
try {
|
||||
const error = await calendarProvider.remote(
|
||||
parseSearchQuery('standup after:2024-01-01 before:2026-12-31'),
|
||||
{ ...account('login-a'), client },
|
||||
{ limit: 10, signal: controller.signal },
|
||||
).then(() => null, (e: unknown) => e);
|
||||
|
||||
expect((error as Error).name).toBe('AbortError');
|
||||
// The chunk queries went out; the occurrences were not fetched.
|
||||
expect(server.gets().filter((g) => (g.args.ids as string[]).includes('o-2025'))).toHaveLength(0);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('remote queries every JMAP account of the login and flags recurring masters', async () => {
|
||||
const queryAllCalendarEvents = vi.fn(async () => [
|
||||
{ id: 'e9', title: 'Weekly sync', start: '2026-04-01T09:00:00', calendarIds: {}, accountId: 'jmap-a', recurrenceRules: [{}], recurrenceId: null },
|
||||
]);
|
||||
const result = await calendarProvider.remote(parseSearchQuery('sync'), account('login-a', { queryAllCalendarEvents }), { limit: 10, signal });
|
||||
expect(queryAllCalendarEvents).toHaveBeenCalledWith({ text: 'sync' }, [{ property: 'start', isAscending: false }], 10);
|
||||
expect(queryAllCalendarEvents).toHaveBeenCalledWith({ text: 'sync' }, [{ property: 'start', isAscending: false }], 10, { signal });
|
||||
expect(result.hits[0]).toMatchObject({ id: 'e9', isRecurring: true, localAccountId: 'login-a' });
|
||||
|
||||
await calendarProvider.remote(parseSearchQuery('"weekly sync" plan*'), account('login-a', { queryAllCalendarEvents }), { limit: 10, signal });
|
||||
@@ -296,6 +371,7 @@ describe('calendar provider', () => {
|
||||
{ operator: 'AND', conditions: [{ text: '"weekly sync"' }, { text: 'plan*' }] },
|
||||
[{ property: 'start', isAscending: false }],
|
||||
10,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ export const calendarProvider: SearchProvider = {
|
||||
calendarFilterFor(parsed),
|
||||
[{ property: 'start', isAscending: false }],
|
||||
limit,
|
||||
{ signal },
|
||||
);
|
||||
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
const { calendars } = useCalendarStore.getState();
|
||||
|
||||
@@ -49,6 +49,11 @@ export interface CalendarEventUpdateOptions {
|
||||
keepSequence?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarQueryOptions {
|
||||
/** Stops a query of several requests (a long range, several accounts) between them. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface IJMAPClient {
|
||||
// ── Connection lifecycle ──────────────────────────────────────
|
||||
connect(): Promise<void>;
|
||||
@@ -474,8 +479,8 @@ export interface IJMAPClient {
|
||||
): Promise<void>;
|
||||
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: Record<string, { type?: string; description?: string }> }>;
|
||||
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
||||
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise<CalendarEvent[]>;
|
||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise<CalendarEvent[]>;
|
||||
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
||||
|
||||
// ── Calendar Tasks ────────────────────────────────────────────
|
||||
|
||||
+107
-29
@@ -1,7 +1,7 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import type { CalendarEventUpdateOptions, CalendarQueryOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
|
||||
import { textConditions, textFilter } from "./search-utils";
|
||||
import { batched, itemsPerRequest } from "./request-limits";
|
||||
@@ -791,6 +791,66 @@ const EXPANDED_CHUNK_SLACK = DAY_SECONDS;
|
||||
const MIN_OCCURRENCE_CHUNK = 7 * DAY_SECONDS;
|
||||
const MIN_DURATION_CHUNK = 3600;
|
||||
const MAX_EXPANDED_CHUNKS = 64;
|
||||
// With a limit the chunks are read in the sort order, a few per request:
|
||||
// what later chunks return past the limit is dropped.
|
||||
const LIMITED_CHUNKS_PER_REQUEST = 4;
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
/** The range of an expanded CalendarEvent/query, and its filter for other bounds. */
|
||||
interface QueryRange {
|
||||
after?: string;
|
||||
before?: string;
|
||||
withBounds: (after?: string, before?: string) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The after/before bounds of a CalendarEvent filter, and the filter rebuilt
|
||||
* for other bounds. The bounds are keys of the filter itself, or single-key
|
||||
* conditions of a top-level AND - how global search sends a range with
|
||||
* several text parts. Rebuilt, such an AND stays flat: Stalwart 1.0 expands
|
||||
* recurrences only for a single FilterCondition, and reads a condition of
|
||||
* two keys as an AND of its own.
|
||||
*/
|
||||
function rangeOf(filter: CalendarEventFilter): QueryRange {
|
||||
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
|
||||
let after: string | undefined;
|
||||
let before: string | undefined;
|
||||
const rest = filter.conditions.filter((condition) => {
|
||||
const keys = Object.keys(condition);
|
||||
if (keys.length !== 1) return true;
|
||||
if (keys[0] === 'after' && typeof condition.after === 'string') {
|
||||
after = condition.after;
|
||||
return false;
|
||||
}
|
||||
if (keys[0] === 'before' && typeof condition.before === 'string') {
|
||||
before = condition.before;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
after,
|
||||
before,
|
||||
withBounds: (from, to) => ({
|
||||
operator: 'AND',
|
||||
conditions: [...(from ? [{ after: from }] : []), ...(to ? [{ before: to }] : []), ...rest],
|
||||
}),
|
||||
};
|
||||
}
|
||||
const { after, before, ...rest } = filter;
|
||||
return {
|
||||
after,
|
||||
before,
|
||||
withBounds: (from, to) => ({ ...rest, ...(from ? { after: from } : {}), ...(to ? { before: to } : {}) }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of an expanded CalendarEvent/query range, in seconds of the request
|
||||
@@ -7169,7 +7229,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
async queryAllCalendarEvents(
|
||||
filter: CalendarEventFilter,
|
||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||
limit?: number
|
||||
limit?: number,
|
||||
options?: CalendarQueryOptions,
|
||||
): Promise<CalendarEvent[]> {
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
const primaryId = this.getCalendarsAccountId();
|
||||
@@ -7179,15 +7240,16 @@ export class JMAPClient implements IJMAPClient {
|
||||
const isPrimary = accountId === primaryId;
|
||||
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
|
||||
const account = this.accounts[accountId];
|
||||
throwIfAborted(options?.signal);
|
||||
|
||||
let events: CalendarEvent[];
|
||||
try {
|
||||
events = await this.queryCalendarEvents(filter, sort, limit, accountId);
|
||||
events = await this.queryCalendarEvents(filter, sort, limit, accountId, options);
|
||||
} catch (error) {
|
||||
// The user's own calendars failing fails the whole range - an empty
|
||||
// answer would read as "no events". A shared account that fails is
|
||||
// left out of the result.
|
||||
if (isPrimary) throw error;
|
||||
if (isPrimary || isAbortError(error)) throw error;
|
||||
console.error(`Failed to query calendar events for account ${accountId}:`, error);
|
||||
continue;
|
||||
}
|
||||
@@ -7212,12 +7274,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
filter: CalendarEventFilter,
|
||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||
limit?: number,
|
||||
targetAccountId?: string
|
||||
targetAccountId?: string,
|
||||
options?: CalendarQueryOptions,
|
||||
): Promise<CalendarEvent[]> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
try {
|
||||
return await this.withCalendarTimeZone((timeZone) =>
|
||||
this.queryCalendarEventsIn(accountId, filter, sort, limit, timeZone));
|
||||
this.queryCalendarEventsIn(accountId, filter, sort, limit, timeZone, options?.signal));
|
||||
} catch (error) {
|
||||
// The fan-out over shared accounts probes on suspicion (see
|
||||
// getCalendarCapableAccountIds) and may hit accounts that grant no
|
||||
@@ -7243,6 +7306,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
sort: Array<{ property: string; isAscending: boolean }> | undefined,
|
||||
limit: number | undefined,
|
||||
timeZone: string | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CalendarEvent[]> {
|
||||
// Stalwart reads the LocalDateTime after/before filter values in the
|
||||
// request's time zone (UTC without one, shifting range boundaries), and
|
||||
@@ -7255,14 +7319,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
userTimeZone && timeZone ? moveLocalDateTime(value, userTimeZone, timeZone) : value,
|
||||
timeZone,
|
||||
);
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
filter: {
|
||||
...filter,
|
||||
...(filter.after ? { after: bound(filter.after) } : {}),
|
||||
...(filter.before ? { before: bound(filter.before) } : {}),
|
||||
},
|
||||
};
|
||||
const range = rangeOf(filter);
|
||||
const after = range.after ? bound(range.after) : undefined;
|
||||
const before = range.before ? bound(range.before) : undefined;
|
||||
const queryArgs: Record<string, unknown> = { accountId, filter: range.withBounds(after, before) };
|
||||
if (timeZone) {
|
||||
queryArgs.timeZone = timeZone;
|
||||
}
|
||||
@@ -7271,7 +7331,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
// (Stalwart >= 0.16.20; the server needs both range bounds for it).
|
||||
// Older servers keep the client-side expansion in
|
||||
// lib/recurrence-expansion.ts, which runs on whatever comes back.
|
||||
let expandRecurrences = Boolean(filter.after && filter.before)
|
||||
let expandRecurrences = Boolean(after && before)
|
||||
&& await this.supportsSyntheticCalendarIds();
|
||||
if (sort) {
|
||||
queryArgs.sort = sort;
|
||||
@@ -7279,7 +7339,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
let ids: string[] | null = null;
|
||||
if (expandRecurrences) {
|
||||
ids = await this.queryExpandedCalendarEventIds(accountId, queryArgs, limit);
|
||||
ids = await this.queryExpandedCalendarEventIds(accountId, queryArgs, { ...range, after, before }, limit, signal);
|
||||
if (!ids) {
|
||||
// The server will not expand this range within its limits. Ask
|
||||
// for the series instead; lib/recurrence-expansion.ts expands
|
||||
@@ -7290,6 +7350,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
ids ??= await this.queryCalendarEventIds(queryArgs, limit);
|
||||
if (ids.length === 0) return [];
|
||||
throwIfAborted(signal);
|
||||
|
||||
// Only series the client expands itself need their overrides here; a
|
||||
// server-expanded occurrence gets them from its base event below.
|
||||
@@ -7371,18 +7432,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
* down to about a week. Past that the whole range is given up - mixing
|
||||
* server- and client-expanded occurrences would show series twice.
|
||||
*
|
||||
* With a limit the chunks are queried one at a time in the sort order (by
|
||||
* start), until the limit is reached.
|
||||
* With a limit the chunks are read in the sort order (by start), a few
|
||||
* per request, until the limit is reached.
|
||||
*/
|
||||
private async queryExpandedCalendarEventIds(
|
||||
accountId: string,
|
||||
queryArgs: Record<string, unknown>,
|
||||
range: QueryRange,
|
||||
limit?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[] | null> {
|
||||
const expandedArgs = { ...queryArgs, expandRecurrences: true };
|
||||
const filter = queryArgs.filter as Record<string, unknown>;
|
||||
const start = wallClockSeconds(filter.after);
|
||||
const end = wallClockSeconds(filter.before);
|
||||
const start = wallClockSeconds(range.after);
|
||||
const end = wallClockSeconds(range.before);
|
||||
if (start === null || end === null || end <= start) {
|
||||
try {
|
||||
return await this.queryCalendarEventIds(expandedArgs, limit);
|
||||
@@ -7406,22 +7468,27 @@ export class JMAPClient implements IJMAPClient {
|
||||
const maxIds = limit || 20000;
|
||||
const ids = new Set<string>();
|
||||
let pending = chunks;
|
||||
let chunksSent = 0;
|
||||
// Pages are bounded by maxIds; splitting is what has to stop somewhere.
|
||||
const chunksSent = new Set<RangeChunk>();
|
||||
while (pending.length > 0 && ids.size < maxIds) {
|
||||
const batch = pending.splice(0, limit ? 1 : this.getMaxCallsInRequest());
|
||||
// Pages are bounded by maxIds; splitting is what has to stop somewhere.
|
||||
chunksSent += batch.filter((chunk) => chunk.position === 0).length;
|
||||
if (chunksSent > MAX_EXPANDED_CHUNKS) return null;
|
||||
throwIfAborted(signal);
|
||||
const batch = pending.splice(0, limit
|
||||
? Math.min(LIMITED_CHUNKS_PER_REQUEST, this.getMaxCallsInRequest())
|
||||
: this.getMaxCallsInRequest());
|
||||
for (const chunk of batch) if (chunk.position === 0) chunksSent.add(chunk);
|
||||
if (chunksSent.size > MAX_EXPANDED_CHUNKS) return null;
|
||||
const size = limit ? limit - ids.size : pageSize;
|
||||
const response = await this.request(batch.map((chunk, i): JMAPMethodCall => ["CalendarEvent/query", {
|
||||
...expandedArgs,
|
||||
filter: { ...filter, after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) },
|
||||
filter: range.withBounds(localDateTimeOf(chunk.start), localDateTimeOf(chunk.end)),
|
||||
position: chunk.position,
|
||||
limit: size,
|
||||
}, String(i)]), this.calendarUsing());
|
||||
|
||||
// The halves of a refused chunk, and the next page of a full one, go
|
||||
// to the front of the queue.
|
||||
// to the front of the queue. With a limit, a chunk that needs another
|
||||
// query comes before the rest of its batch in the sort order: what the
|
||||
// later chunks returned is dropped and asked for again after it.
|
||||
const again: RangeChunk[] = [];
|
||||
for (const [i, chunk] of batch.entries()) {
|
||||
let page: string[];
|
||||
@@ -7431,13 +7498,24 @@ export class JMAPClient implements IJMAPClient {
|
||||
const halves = splitRefusedChunk(chunk, error);
|
||||
if (!halves) return null;
|
||||
again.push(...(latestFirst ? halves.reverse() : halves));
|
||||
if (limit) {
|
||||
again.push(...batch.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const id of page) {
|
||||
if (ids.size >= maxIds) break;
|
||||
ids.add(id);
|
||||
}
|
||||
if (page.length === size) again.push({ ...chunk, position: chunk.position + page.length });
|
||||
if (ids.size >= maxIds) break;
|
||||
if (page.length === size) {
|
||||
again.push({ ...chunk, position: chunk.position + page.length });
|
||||
if (limit) {
|
||||
again.push(...batch.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pending = [...again, ...pending];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EVENTS_LOAD_ERROR, useCalendarStore, resolveMutationTarget } from '../calendar-store';
|
||||
import { useCalendarStore, resolveMutationTarget } from '../calendar-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { jmapError, jmapSetError } from '@/lib/jmap/jmap-error';
|
||||
@@ -389,17 +389,38 @@ describe('a range that fails to load', () => {
|
||||
|
||||
const state = useCalendarStore.getState();
|
||||
expect(state.events.map((e) => e.id)).toEqual(['maaaaab']);
|
||||
expect(state.error).toBe(EVENTS_LOAD_ERROR);
|
||||
expect(state.eventsLoadError).toBe(true);
|
||||
expect(state.isLoadingEvents).toBe(false);
|
||||
// Still the range that is actually loaded.
|
||||
expect(state.dateRange).toEqual({ start: RANGE[0], end: RANGE[1] });
|
||||
// The range asked for, so a push or a refresh reloads that one.
|
||||
expect(state.dateRange).toEqual({ start: NEXT[0], end: NEXT[1] });
|
||||
|
||||
client.queryAllCalendarEvents.mockResolvedValueOnce([]);
|
||||
await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]);
|
||||
expect(useCalendarStore.getState().error).toBeNull();
|
||||
expect(useCalendarStore.getState().eventsLoadError).toBe(false);
|
||||
expect(useCalendarStore.getState().events).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps reporting the failure through other actions and reloads the range asked for on Stalwart 1.0', async () => {
|
||||
const client = fakeClient({ getAllCalendars: vi.fn().mockResolvedValue([]) });
|
||||
client.queryAllCalendarEvents.mockResolvedValueOnce([occurrence()]);
|
||||
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
|
||||
// From here on every range load fails.
|
||||
client.queryAllCalendarEvents.mockRejectedValue(refused());
|
||||
await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]);
|
||||
|
||||
// A change, and the calendar list reloading, reset `error`.
|
||||
await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Renamed' });
|
||||
await useCalendarStore.getState().fetchCalendars(client);
|
||||
expect(useCalendarStore.getState().error).toBeNull();
|
||||
expect(useCalendarStore.getState().eventsLoadError).toBe(true);
|
||||
|
||||
// What a push does: reload the store's range - the one that failed.
|
||||
const { dateRange } = useCalendarStore.getState();
|
||||
await useCalendarStore.getState().fetchEvents(client, dateRange!.start, dateRange!.end);
|
||||
expect(client.queryAllCalendarEvents).toHaveBeenLastCalledWith({ after: NEXT[0], before: NEXT[1] });
|
||||
expect(useCalendarStore.getState().eventsLoadError).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the events of an account that fails when loading every account', async () => {
|
||||
const first = fakeClient();
|
||||
first.queryAllCalendarEvents.mockResolvedValue([occurrence({ title: 'First' })]);
|
||||
@@ -417,7 +438,8 @@ describe('a range that fails to load', () => {
|
||||
['A@h::maaaaab', 'First'],
|
||||
['B@h::maaaaab', 'Second, loaded before'],
|
||||
]);
|
||||
expect(state.error).toBe(EVENTS_LOAD_ERROR);
|
||||
expect(state.eventsLoadError).toBe(true);
|
||||
expect(state.dateRange).toEqual({ start: NEXT[0], end: NEXT[1] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-12
@@ -518,12 +518,6 @@ export interface FetchEventsOptions {
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `error` a failed range load leaves. The events loaded before stay in
|
||||
* the store; the calendar shows a notice for it instead of a toast.
|
||||
*/
|
||||
export const EVENTS_LOAD_ERROR = 'Failed to load events';
|
||||
|
||||
interface CalendarStore {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
@@ -535,6 +529,13 @@ interface CalendarStore {
|
||||
isLoadingEvents: boolean;
|
||||
supportsCalendar: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* The last range load failed. The events loaded before it stay, and the
|
||||
* calendar says they may be out of date. Only a successful range load
|
||||
* clears it, so other actions that reset `error` leave it alone.
|
||||
*/
|
||||
eventsLoadError: boolean;
|
||||
/** The range last asked for, loaded or not: push and refresh reload it. */
|
||||
dateRange: { start: string; end: string } | null;
|
||||
// The user's ParticipantIdentity list (calendar addresses to organise
|
||||
// as); the default one is used as organizer of new invitations.
|
||||
@@ -585,6 +586,7 @@ const initialState = {
|
||||
supportsCalendar: false,
|
||||
participantIdentities: [],
|
||||
error: null as string | null,
|
||||
eventsLoadError: false,
|
||||
dateRange: null as { start: string; end: string } | null,
|
||||
icalSubscriptions: [] as ICalSubscription[],
|
||||
};
|
||||
@@ -674,12 +676,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
if (droppedEvents > 0) {
|
||||
debug.warn('calendar', 'Calendar fetchEvents dropped malformed events without a start field', { droppedEvents });
|
||||
}
|
||||
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
||||
set({ events, isLoadingEvents: false, eventsLoadError: false, dateRange: { start, end } });
|
||||
} catch (error) {
|
||||
// Keep the events already on screen: an empty grid would read as
|
||||
// "no events".
|
||||
// Keep the events already on screen - an empty grid would read as
|
||||
// "no events" - and take the range as asked for, so a push or a
|
||||
// refresh reloads it rather than the one loaded before.
|
||||
debug.error('Failed to fetch events:', error);
|
||||
set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false });
|
||||
set({ eventsLoadError: true, isLoadingEvents: false, dateRange: { start, end } });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -736,12 +739,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({
|
||||
events: results.flat(),
|
||||
isLoadingEvents: false,
|
||||
eventsLoadError: failed,
|
||||
dateRange: { start, end },
|
||||
...(failed ? { error: EVENTS_LOAD_ERROR } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch all-account events:', error);
|
||||
set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false });
|
||||
set({ eventsLoadError: true, isLoadingEvents: false, dateRange: { start, end } });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user