mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: load calendar events and tasks on Stalwart 1.0
Stalwart 1.0 refuses a CalendarEvent/get that asks for recurrenceOverrides together with utcStart or utcEnd, and every calendar read did. The grid, tasks, reminders, search, contact activity and the agenda plugin came back empty with no error, and creating an event or task threw after the server had created it, so a retry made a duplicate. - read overrides with a second get in the same request, only where they are needed - treat an error answer to a get or query as a failure; the calendar keeps the last events and says it could not load them, with a retry - never throw after the server created an event or task - let the server expand series for /api/calendar-agenda
This commit is contained in:
@@ -3,7 +3,7 @@ import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from '@/lib/recurrence-instances';
|
||||
import { parseISO } from 'date-fns';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
@@ -12,8 +12,9 @@ import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
*
|
||||
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
|
||||
* account from the stored Stalwart auth context, queries upcoming
|
||||
* CalendarEvents over JMAP, expands recurring series server-side, and returns
|
||||
* a slim, structured-cloneable agenda the plugin can render directly.
|
||||
* CalendarEvents over JMAP with Stalwart expanding recurring series into
|
||||
* occurrences, and returns a slim, structured-cloneable agenda the plugin can
|
||||
* render directly.
|
||||
*
|
||||
* Credentials never leave the server — the sandboxed plugin only ever sees
|
||||
* the resulting agenda DTOs.
|
||||
@@ -25,14 +26,18 @@ const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
|
||||
const PRINCIPALS_OWNER_CAP = 'urn:ietf:params:jmap:principals:owner';
|
||||
|
||||
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
|
||||
// agenda actually needs (start/recurrence/display fields).
|
||||
// agenda shows of an occurrence. The server expands the series, so no rule or
|
||||
// overrides are needed - and Stalwart 1.0 refuses a get that asks for
|
||||
// `recurrenceOverrides` together with utcStart/utcEnd.
|
||||
const EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
|
||||
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
|
||||
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'baseEventId',
|
||||
] as const;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const GET_BATCH = 100;
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string;
|
||||
uid: string | null;
|
||||
@@ -142,40 +147,48 @@ export async function POST(request: NextRequest) {
|
||||
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
|
||||
|
||||
const now = new Date();
|
||||
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
let horizon = new Date(now.getTime() + days * DAY_MS);
|
||||
// Expand from the start of today so all-day / already-running events still
|
||||
// show in the agenda.
|
||||
const windowStart = new Date(now);
|
||||
windowStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// ── 1) Query event IDs in range + load calendars (colours) ──
|
||||
const queryReq = {
|
||||
using,
|
||||
methodCalls: [
|
||||
[
|
||||
'CalendarEvent/query',
|
||||
{
|
||||
accountId,
|
||||
// Mirror the app's calendar store: an { after, before } window lets
|
||||
// Stalwart evaluate recurrence so masters with occurrences in range
|
||||
// are returned (a `before`-only filter can drop unbounded series).
|
||||
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||
limit: 1000,
|
||||
},
|
||||
'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.
|
||||
let queryRes: unknown;
|
||||
let queryResp: Record<string, unknown> | null;
|
||||
for (;;) {
|
||||
queryRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||
using,
|
||||
methodCalls: [
|
||||
[
|
||||
'CalendarEvent/query',
|
||||
{
|
||||
accountId,
|
||||
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||
expandRecurrences: true,
|
||||
limit: 1000,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'Calendar/get',
|
||||
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
|
||||
'c',
|
||||
],
|
||||
],
|
||||
[
|
||||
'Calendar/get',
|
||||
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
|
||||
'c',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions);
|
||||
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
|
||||
if (!queryResp) {
|
||||
}, fetchOptions);
|
||||
queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
|
||||
if (queryResp) break;
|
||||
const err = findResponse(queryRes, 'error', '0');
|
||||
const span = horizon.getTime() - now.getTime();
|
||||
if (isOccurrenceLimit(err) && span > DAY_MS) {
|
||||
horizon = new Date(now.getTime() + Math.max(DAY_MS, Math.floor(span / 2)));
|
||||
continue;
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
|
||||
{ status: 502 },
|
||||
@@ -198,34 +211,53 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
|
||||
}
|
||||
|
||||
// ── 2) Fetch full event objects (batched) ──
|
||||
const raw: Array<Record<string, unknown>> = [];
|
||||
const BATCH = 100;
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const batch = ids.slice(i, i + BATCH);
|
||||
const getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||
using,
|
||||
methodCalls: [
|
||||
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
|
||||
],
|
||||
}, fetchOptions);
|
||||
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
|
||||
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
|
||||
// ── 2) Fetch the occurrences (batched) ──
|
||||
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 getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||
using,
|
||||
methodCalls: [
|
||||
['CalendarEvent/get', { accountId, ids: eventIds.slice(i, i + GET_BATCH), properties }, '0'],
|
||||
],
|
||||
}, 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');
|
||||
}
|
||||
list.push(...((getResp.list as Array<Record<string, unknown>>) || []));
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
let occurrences: Partial<CalendarEvent>[];
|
||||
const bases = new Map<string, Partial<CalendarEvent>>();
|
||||
try {
|
||||
occurrences = (await getEvents(ids, EVENT_PROPERTIES))
|
||||
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>));
|
||||
|
||||
// ── 3) Hydrate occurrences from their base events ──
|
||||
// An expanded all-day occurrence comes back without showWithoutTime,
|
||||
// and the base event has the duration an override leaves out.
|
||||
const baseIds = Array.from(new Set(occurrences
|
||||
.filter((e) => isServerRecurrenceInstance(e as CalendarEvent) && e.recurrenceId)
|
||||
.map((e) => e.baseEventId as string)));
|
||||
for (const base of await getEvents(baseIds, RECURRENCE_BASE_PROPERTIES)) {
|
||||
if (typeof base.id === 'string') bases.set(base.id, normalizeCalendarEventLike(base as Partial<CalendarEvent>));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof AgendaFetchError)) throw error;
|
||||
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||
}
|
||||
|
||||
// ── 3) Normalize + expand recurrences server-side ──
|
||||
const normalized = raw
|
||||
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
|
||||
const valid = occurrences
|
||||
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
|
||||
// Drop malformed events without a parseable start (would crash format()/
|
||||
// parseISO downstream) — mirrors the calendar store guard (#316).
|
||||
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
|
||||
|
||||
const expanded = expandRecurringEvents(
|
||||
normalized,
|
||||
windowStart.toISOString(),
|
||||
horizon.toISOString(),
|
||||
);
|
||||
const expanded = hydrateRecurrenceInstances(valid, bases);
|
||||
|
||||
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
|
||||
const agenda: AgendaEvent[] = expanded
|
||||
@@ -263,6 +295,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A method error on one of the agenda's gets, answered with 502. */
|
||||
class AgendaFetchError extends Error {}
|
||||
|
||||
/**
|
||||
* Whether the query failed on the server's cap on expanded occurrences:
|
||||
* `cannotCalculateOccurrences` on Stalwart 1.0, `invalidArguments` ("The
|
||||
* number of expanded recurrences exceeds the server limit") on 0.16.
|
||||
*/
|
||||
function isOccurrenceLimit(err: Record<string, unknown> | null): boolean {
|
||||
if (err?.type === 'cannotCalculateOccurrences') return true;
|
||||
return err?.type === 'invalidArguments' && /exceeds/i.test(String(err.description ?? ''));
|
||||
}
|
||||
|
||||
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CalendarLoadErrorNotice } from '../calendar-load-error-notice';
|
||||
|
||||
// A range that fails to load keeps the events it had; the notice says so and
|
||||
// offers to load the range again.
|
||||
|
||||
describe('CalendarLoadErrorNotice', () => {
|
||||
it('says the events may be out of date and retries on request', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<CalendarLoadErrorNotice onRetry={onRetry} />);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('events_load_failed');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
||||
format, parseISO,
|
||||
} from "date-fns";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { EVENTS_LOAD_ERROR, 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";
|
||||
@@ -26,6 +26,7 @@ import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||
import { TaskListView } from "@/components/calendar/task-list-view";
|
||||
import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice";
|
||||
import { TaskToolbar } from "@/components/calendar/task-toolbar";
|
||||
import { TaskModal } from "@/components/calendar/task-modal";
|
||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
@@ -236,7 +237,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
// A failed range load gets the notice above the grid instead.
|
||||
if (error && error !== EVENTS_LOAD_ERROR) {
|
||||
toast.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
@@ -470,6 +472,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||
|
||||
// Loads the range the view shows now - not the store's dateRange, which a
|
||||
// failed load leaves at the range loaded before it.
|
||||
const retryEventsLoad = useCallback(() => {
|
||||
if (!client || !dateRange) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
void fetchAllAccountsEventsFn(accountClients, dateRange.start, dateRange.end);
|
||||
return;
|
||||
}
|
||||
void fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}, [client, dateRange, multiAccountEnabled, accountClients, fetchAllAccountsEventsFn, fetchEvents]);
|
||||
|
||||
// The arrows step from what is on screen, which may have been scrolled
|
||||
// away from the selected day.
|
||||
const navigatePrev = useCallback(() => {
|
||||
@@ -1549,6 +1562,9 @@ 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" && (
|
||||
<CalendarLoadErrorNotice onRetry={retryEventsLoad} />
|
||||
)}
|
||||
{viewContent}
|
||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CalendarLoadErrorNoticeProps {
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown above the grid while the visible range could not be loaded. The grid
|
||||
* keeps the events it had, so this says why they may be out of date.
|
||||
*/
|
||||
export function CalendarLoadErrorNotice({ onRetry }: CalendarLoadErrorNoticeProps) {
|
||||
const t = useTranslations("calendar.status");
|
||||
return (
|
||||
<div role="alert" className="flex items-center gap-2 border-b border-border bg-warning/10 px-4 py-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-warning" />
|
||||
<span className="min-w-0 flex-1">{t("events_load_failed")}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import { OVERRIDES_WITH_UTC_ERROR, asksForOverridesWithUtc, type StalwartVersion } from './helpers/stalwart-calendar';
|
||||
|
||||
// ── module mocks (hoisted) ───────────────────────────────────────────────────
|
||||
vi.mock('next/server', () => {
|
||||
@@ -86,3 +87,117 @@ describe('calendar-agenda route (principals:owner declaration)', () => {
|
||||
expect(using).toContain('urn:ietf:params:jmap:principals:owner');
|
||||
});
|
||||
});
|
||||
|
||||
// The agenda lets Stalwart expand recurring series (expandRecurrences) and
|
||||
// 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> };
|
||||
let calls: Call[];
|
||||
let answer: (call: Call) => [string, Record<string, unknown>] | undefined;
|
||||
|
||||
const OCCURRENCES: Record<string, Record<string, unknown>> = {
|
||||
// A timed occurrence of the weekly series "b".
|
||||
maaaaab: {
|
||||
id: 'maaaaab', baseEventId: 'b', '@type': 'Event', uid: 'b@example.com', title: 'Standup',
|
||||
start: '2026-09-26T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-09-26T08:00:00Z', utcEnd: '2026-09-26T09:00:00Z',
|
||||
recurrenceId: '2026-09-26T10:00:00', calendarIds: { cal: true },
|
||||
},
|
||||
// An all-day occurrence of "c": the server drops showWithoutTime.
|
||||
maaaaac: {
|
||||
id: 'maaaaac', baseEventId: 'c', '@type': 'Event', uid: 'c@example.com', title: 'Holiday',
|
||||
start: '2026-09-27T00:00:00', duration: 'PT24H', timeZone: 'UTC',
|
||||
utcStart: '2026-09-27T00:00:00Z', utcEnd: '2026-09-28T00:00:00Z',
|
||||
recurrenceId: '2026-09-27T00:00:00', calendarIds: { cal: true },
|
||||
},
|
||||
};
|
||||
const BASES: Record<string, Record<string, unknown>> = {
|
||||
b: { id: 'b', recurrenceRule: { frequency: 'weekly' }, recurrenceOverrides: null, showWithoutTime: false, timeZone: 'Europe/Berlin', duration: 'PT1H' },
|
||||
c: { id: 'c', recurrenceRule: { frequency: 'yearly' }, recurrenceOverrides: null, showWithoutTime: true, timeZone: null, duration: 'P1D' },
|
||||
};
|
||||
|
||||
function defaultAnswer({ method, args }: Call): [string, Record<string, unknown>] {
|
||||
if (method === 'CalendarEvent/query') return ['CalendarEvent/query', { ids: Object.keys(OCCURRENCES) }];
|
||||
if (method === 'Calendar/get') return ['Calendar/get', { list: [{ id: 'cal', name: 'Personal', color: '#336699' }] }];
|
||||
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];
|
||||
return Object.fromEntries(Object.entries(source).filter(([key]) => key === 'id' || properties.includes(key)));
|
||||
});
|
||||
return ['CalendarEvent/get', { list }];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-09-25T08:00:00Z'));
|
||||
calls = [];
|
||||
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 methodResponses = JSON.parse(body).methodCalls.map(([method, args, callId]: [string, Record<string, unknown>, string]) => {
|
||||
const call = { method, args };
|
||||
calls.push(call);
|
||||
return [...(answer(call) ?? defaultAnswer(call)), callId];
|
||||
});
|
||||
return { ok: true, json: async () => ({ methodResponses }) };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const gets = () => calls.filter((c) => c.method === 'CalendarEvent/get');
|
||||
|
||||
it(`lets the server expand series and hydrates the occurrences on Stalwart ${version}`, async () => {
|
||||
const res = await POST(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const { events } = await res.json() as { events: Array<Record<string, unknown>> };
|
||||
|
||||
const query = calls.find((c) => c.method === 'CalendarEvent/query')!;
|
||||
expect(query.args.expandRecurrences).toBe(true);
|
||||
expect(gets().filter((g) => asksForOverridesWithUtc(g.args))).toEqual([]);
|
||||
const [instanceGet, baseGet] = gets();
|
||||
expect(instanceGet.args.properties).not.toContain('recurrenceOverrides');
|
||||
expect(instanceGet.args.properties).not.toContain('recurrenceRule');
|
||||
expect(baseGet.args.ids).toEqual(['b', 'c']);
|
||||
expect(baseGet.args.properties).not.toContain('utcStart');
|
||||
|
||||
expect(events.map((e) => [e.id, e.title, e.allDay, e.color])).toEqual([
|
||||
['maaaaab', 'Standup', false, '#336699'],
|
||||
['maaaaac', 'Holiday', true, '#336699'],
|
||||
]);
|
||||
expect(events[0]).toMatchObject({ start: '2026-09-26T08:00:00.000Z', end: '2026-09-26T09:00:00.000Z' });
|
||||
});
|
||||
|
||||
it(`answers 502 when a get fails on Stalwart ${version}`, async () => {
|
||||
answer = ({ method }) => method === 'CalendarEvent/get' ? ['error', { type: 'serverFail', description: 'Store unavailable' }] : undefined;
|
||||
|
||||
const res = await POST(makeReq());
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(await res.json()).toEqual({ error: 'Store unavailable' });
|
||||
});
|
||||
|
||||
it(`looks less far ahead when the range expands to too many occurrences on Stalwart ${version}`, async () => {
|
||||
const tooMany = version === '1.0'
|
||||
? { type: 'cannotCalculateOccurrences', description: 'The number of expanded recurrences exceeds the server limit' }
|
||||
: { type: 'invalidArguments', description: 'The number of expanded recurrences exceeds the server limit' };
|
||||
let refusals = 2;
|
||||
answer = ({ method }) => method === 'CalendarEvent/query' && refusals-- > 0 ? ['error', tooMany] : 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([80, 40, 20]);
|
||||
expect(((await res.json()) as { events: unknown[] }).events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// Mock Stalwart calendar servers for JMAPClient tests. What differs between
|
||||
// the versions, as far as these tests go:
|
||||
// - 1.0 refuses a CalendarEvent/get that asks for `recurrenceOverrides`
|
||||
// together with `utcStart`/`utcEnd` (jmap/src/calendar_event/get.rs,
|
||||
// ProjectionRequest::new), before reading any id;
|
||||
// - 0.16 answers any property list.
|
||||
// Both advertise `maxExpandedQueryDuration` in the account's calendars
|
||||
// capability (0.16 hardcodes 365 days and never enforces it).
|
||||
import { vi } from 'vitest';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances';
|
||||
|
||||
export type StalwartVersion = '0.16' | '1.0';
|
||||
|
||||
/** Stalwart 1.0's answer to a CalendarEvent/get that asks for both. */
|
||||
export const OVERRIDES_WITH_UTC_ERROR = {
|
||||
type: 'invalidArguments',
|
||||
description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd',
|
||||
} as const;
|
||||
|
||||
/** True for a CalendarEvent/get Stalwart 1.0 refuses outright. */
|
||||
export function asksForOverridesWithUtc(args: Record<string, unknown>): boolean {
|
||||
const properties = args.properties;
|
||||
if (!Array.isArray(properties)) return false;
|
||||
return properties.includes('recurrenceOverrides')
|
||||
&& (properties.includes('utcStart') || properties.includes('utcEnd'));
|
||||
}
|
||||
|
||||
export interface RecordedCall {
|
||||
method: string;
|
||||
args: Record<string, unknown>;
|
||||
callId: string;
|
||||
/** Index of the HTTP request the call was part of. */
|
||||
request: number;
|
||||
}
|
||||
|
||||
export type MethodAnswer = [string, Record<string, unknown>];
|
||||
|
||||
export interface StalwartCalendarMockOptions {
|
||||
version: StalwartVersion;
|
||||
/** Stored events and tasks, by id. */
|
||||
objects?: Record<string, Record<string, unknown>>;
|
||||
/** What CalendarEvent/query?expandRecurrences=true hands out, by synthetic id. */
|
||||
occurrences?: Record<string, Record<string, unknown>>;
|
||||
/** Whether CalendarEvent/set accepts synthetic ids (the probe answers notFound). Default true. */
|
||||
syntheticIds?: boolean;
|
||||
/** Answer a call yourself; return undefined for the default answer. */
|
||||
answer?: (call: RecordedCall) => MethodAnswer | undefined;
|
||||
}
|
||||
|
||||
export interface StalwartCalendarMock {
|
||||
calls: RecordedCall[];
|
||||
/** Every CalendarEvent/get sent so far. */
|
||||
gets(): RecordedCall[];
|
||||
queries(): RecordedCall[];
|
||||
objects: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** A session like Stalwart's, with the calendars capability on the account. */
|
||||
export function stalwartCalendarSession(
|
||||
version: StalwartVersion,
|
||||
options: { maxExpandedQueryDuration?: string | null; accounts?: Record<string, unknown> } = {},
|
||||
) {
|
||||
const calendarCapability: Record<string, unknown> = {
|
||||
maxCalendarsPerEvent: version === '1.0' ? 10 : null,
|
||||
minDateTime: '0001-01-01T00:00:00Z',
|
||||
maxDateTime: '9999-12-31T23:59:59Z',
|
||||
maxParticipantsPerEvent: 20,
|
||||
mayCreateCalendar: true,
|
||||
};
|
||||
const duration = options.maxExpandedQueryDuration === undefined ? 'P365D' : options.maxExpandedQueryDuration;
|
||||
if (duration !== null) calendarCapability.maxExpandedQueryDuration = duration;
|
||||
return {
|
||||
capabilities: {
|
||||
'urn:ietf:params:jmap:core': { maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
|
||||
'urn:ietf:params:jmap:calendars': {},
|
||||
},
|
||||
accounts: {
|
||||
'acct-1': {
|
||||
name: 'user@example.com',
|
||||
isPersonal: true,
|
||||
accountCapabilities: { 'urn:ietf:params:jmap:calendars': calendarCapability },
|
||||
},
|
||||
...options.accounts,
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1', 'urn:ietf:params:jmap:calendars': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
function project(object: Record<string, unknown>, properties: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(properties)) return { ...object };
|
||||
const out: Record<string, unknown> = { id: object.id };
|
||||
for (const key of properties as string[]) {
|
||||
if (key in object) out[key] = object[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point `fetchSpy` (a spy on globalThis.fetch) at a mock Stalwart calendar
|
||||
* server. Every method call is recorded, including the request it came in.
|
||||
*/
|
||||
export function installStalwartCalendarMock(
|
||||
fetchSpy: ReturnType<typeof vi.spyOn>,
|
||||
options: StalwartCalendarMockOptions,
|
||||
): StalwartCalendarMock {
|
||||
const calls: RecordedCall[] = [];
|
||||
const objects = { ...options.objects };
|
||||
const occurrences = { ...options.occurrences };
|
||||
let requests = 0;
|
||||
let created = 0;
|
||||
|
||||
const defaultAnswer = (call: RecordedCall): MethodAnswer => {
|
||||
const { method, args } = call;
|
||||
if (method === 'Calendar/get') {
|
||||
return ['Calendar/get', { accountId: args.accountId, list: [{ id: 'cal', name: 'Personal', isDefault: true }], notFound: [] }];
|
||||
}
|
||||
if (method === 'CalendarEvent/query') {
|
||||
const filter = (args.filter ?? {}) as Record<string, unknown>;
|
||||
let ids = args.expandRecurrences ? Object.keys(occurrences) : Object.keys(objects);
|
||||
if (typeof filter.uid === 'string') ids = ids.filter((id) => objects[id]?.uid === filter.uid);
|
||||
const position = (args.position as number | undefined) ?? 0;
|
||||
const limit = (args.limit as number | undefined) ?? ids.length;
|
||||
return ['CalendarEvent/query', { accountId: args.accountId, ids: ids.slice(position, position + limit), position }];
|
||||
}
|
||||
if (method === 'CalendarEvent/get') {
|
||||
if (options.version === '1.0' && asksForOverridesWithUtc(args)) return ['error', { ...OVERRIDES_WITH_UTC_ERROR }];
|
||||
const list: Record<string, unknown>[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of (args.ids as string[] | null) ?? Object.keys(objects)) {
|
||||
const occurrence = occurrences[id];
|
||||
const object = occurrence ?? objects[id];
|
||||
if (!object) {
|
||||
notFound.push(id);
|
||||
continue;
|
||||
}
|
||||
const projected = project(object, args.properties);
|
||||
// 1.0 answers recurrenceOverrides: null for a synthetic id.
|
||||
if (occurrence && Array.isArray(args.properties) && (args.properties as string[]).includes('recurrenceOverrides')) {
|
||||
projected.recurrenceOverrides = null;
|
||||
}
|
||||
list.push(projected);
|
||||
}
|
||||
return ['CalendarEvent/get', { accountId: args.accountId, list, notFound }];
|
||||
}
|
||||
if (method === 'CalendarEvent/set') {
|
||||
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||
if (update && SYNTHETIC_ID_PROBE in update) {
|
||||
const failure = options.syntheticIds === false
|
||||
? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] }
|
||||
: { type: 'notFound' };
|
||||
return ['CalendarEvent/set', { notUpdated: { [SYNTHETIC_ID_PROBE]: failure } }];
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||
if (create) {
|
||||
const createdMap: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(create)) {
|
||||
const id = `created-${++created}`;
|
||||
objects[id] = { ...value, id };
|
||||
createdMap[key] = { id };
|
||||
}
|
||||
result.created = createdMap;
|
||||
}
|
||||
if (update) {
|
||||
for (const [id, patch] of Object.entries(update)) objects[id] = { ...objects[id], ...patch };
|
||||
result.updated = Object.fromEntries(Object.keys(update).map((id) => [id, null]));
|
||||
}
|
||||
return ['CalendarEvent/set', result];
|
||||
}
|
||||
return ['error', { type: 'unknownMethod' }];
|
||||
};
|
||||
|
||||
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}'));
|
||||
const request = requests++;
|
||||
const methodResponses: unknown[] = [];
|
||||
for (const [method, args, callId] of body.methodCalls ?? []) {
|
||||
const call: RecordedCall = { method, args, callId, request };
|
||||
calls.push(call);
|
||||
const answer = options.answer?.(call) ?? defaultAnswer(call);
|
||||
methodResponses.push([...answer, callId]);
|
||||
}
|
||||
return jsonResponse({ methodResponses });
|
||||
});
|
||||
|
||||
return {
|
||||
calls,
|
||||
gets: () => calls.filter((c) => c.method === 'CalendarEvent/get'),
|
||||
queries: () => calls.filter((c) => c.method === 'CalendarEvent/query'),
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
/** A JMAPClient connected to `session` through `fetchSpy`. */
|
||||
export async function connectCalendarClient(
|
||||
fetchSpy: ReturnType<typeof vi.spyOn>,
|
||||
session: ReturnType<typeof stalwartCalendarSession>,
|
||||
): Promise<JMAPClient> {
|
||||
// Loaded here so tests that only need the mock data (the agenda route)
|
||||
// do not pull in the whole client.
|
||||
const { JMAPClient } = await import('@/lib/jmap/client');
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(session));
|
||||
const client = new JMAPClient('https://mail.example.com', 'user@example.com', 'pass123');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
return client;
|
||||
}
|
||||
@@ -95,3 +95,64 @@ describe('calendar fan-out to shared accounts without access', () => {
|
||||
expect(callsForAccount('acct-1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar fan-out when an account fails', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let client: JMAPClient;
|
||||
let failing: Record<string, { type: string; description: string }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
failing = {};
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
|
||||
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
|
||||
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}'));
|
||||
const methodResponses = (body.methodCalls ?? []).map(([method, args, callId]: [string, Record<string, unknown>, string]) => {
|
||||
const failure = failing[args.accountId as string];
|
||||
if (failure) return ['error', failure, callId];
|
||||
if (method === 'CalendarEvent/query') return ['CalendarEvent/query', { ids: ['e1'] }, callId];
|
||||
return ['CalendarEvent/get', {
|
||||
list: [{ id: 'e1', '@type': 'Event', title: `Event of ${args.accountId}`, start: '2026-09-22T10:00:00', calendarIds: { c: true } }],
|
||||
}, callId];
|
||||
});
|
||||
return jsonResponse({ methodResponses });
|
||||
});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
consoleErrorSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('fails the whole query when the user’s own calendars fail on Stalwart 1.0', async () => {
|
||||
failing['acct-1'] = { type: 'invalidArguments', description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd' };
|
||||
|
||||
await expect(client.queryAllCalendarEvents({})).rejects.toThrow(/recurrenceOverrides cannot be requested/);
|
||||
});
|
||||
|
||||
it('does not take a refusal of the user’s own account for a share without access on 0.16', async () => {
|
||||
failing['acct-1'] = { type: 'forbidden', description: 'You do not have access to this account' };
|
||||
await expect(client.queryAllCalendarEvents({})).rejects.toThrow(/do not have access/);
|
||||
|
||||
// Not remembered as "no access": the next load asks again.
|
||||
delete failing['acct-1'];
|
||||
const events = await client.queryAllCalendarEvents({});
|
||||
expect(events.map((e) => e.title)).toEqual(['Event of acct-1', 'Event of ev']);
|
||||
});
|
||||
|
||||
it('leaves out a shared account that fails for another reason and keeps the rest on 0.16', async () => {
|
||||
failing.ev = { type: 'serverFail', description: 'Temporary failure' };
|
||||
|
||||
const events = await client.queryAllCalendarEvents({});
|
||||
|
||||
expect(events.map((e) => e.title)).toEqual(['Event of acct-1']);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,4 +124,47 @@ describe('calendar batch failures surface (#434)', () => {
|
||||
expect(result.failed).toEqual(['new-0']);
|
||||
expect(result.notCreated['new-0']).toMatchObject({ description: 'Missing start' });
|
||||
});
|
||||
|
||||
it('batchCreateCalendarEvents reads the events back without their overrides', async () => {
|
||||
const client = await connectedClient();
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
methodResponses: [['CalendarEvent/set', { created: { 'new-0': { id: 'ev1' } } }, '0']],
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev1', title: 'A', start: '2026-09-22T10:00:00' }] }, '0']],
|
||||
}));
|
||||
|
||||
const result = await client.batchCreateCalendarEvents([{ title: 'A', start: '2026-09-22T10:00:00' }]);
|
||||
|
||||
expect(result.created).toMatchObject([{ id: 'ev1', title: 'A' }]);
|
||||
const readBack = JSON.parse(String((fetchSpy.mock.calls[1][1] as RequestInit).body));
|
||||
expect(readBack.methodCalls).toHaveLength(1);
|
||||
expect(readBack.methodCalls[0][1].properties).toContain('utcStart');
|
||||
expect(readBack.methodCalls[0][1].properties).not.toContain('recurrenceOverrides');
|
||||
});
|
||||
|
||||
it('batchCreateCalendarEvents still counts the created events when Stalwart 1.0 refuses the read-back', async () => {
|
||||
const client = await connectedClient();
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
methodResponses: [['CalendarEvent/set', {
|
||||
created: { 'new-0': { id: 'ev1' }, 'new-1': { id: 'ev2' } },
|
||||
}, '0']],
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
methodResponses: [['error', {
|
||||
type: 'invalidArguments',
|
||||
description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd',
|
||||
}, '0']],
|
||||
}));
|
||||
|
||||
const result = await client.batchCreateCalendarEvents([
|
||||
{ title: 'A', start: '2026-09-22T10:00:00' },
|
||||
{ title: 'B', start: '2026-09-23T10:00:00' },
|
||||
]);
|
||||
|
||||
expect(result.created).toMatchObject([{ id: 'ev1', title: 'A' }, { id: 'ev2', title: 'B' }]);
|
||||
expect(result.failed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { JMAPClient } from '../jmap/client';
|
||||
import { jmapErrorType } from '../jmap/jmap-error';
|
||||
import {
|
||||
OVERRIDES_WITH_UTC_ERROR,
|
||||
asksForOverridesWithUtc,
|
||||
connectCalendarClient,
|
||||
installStalwartCalendarMock,
|
||||
stalwartCalendarSession,
|
||||
type StalwartCalendarMock,
|
||||
type StalwartVersion,
|
||||
} from './helpers/stalwart-calendar';
|
||||
|
||||
// Stalwart 1.0 fails a CalendarEvent/get that asks for recurrenceOverrides
|
||||
// together with utcStart or utcEnd - the whole call, before any id is read.
|
||||
// Every get the client sends must keep them apart; the reads that need the
|
||||
// overrides get them from a second call in the same request. 0.16 accepts
|
||||
// either, so the split must not change what callers get back there.
|
||||
|
||||
const RANGE = { after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' };
|
||||
|
||||
const SERIES = {
|
||||
id: 'b', '@type': 'Event', uid: 'series@example.com', title: 'Daily standup', baseEventId: 'b',
|
||||
start: '2026-09-07T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z', showWithoutTime: false,
|
||||
recurrenceRule: { frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } },
|
||||
calendarIds: { cal: true },
|
||||
};
|
||||
const SINGLE = {
|
||||
id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off', baseEventId: 'd',
|
||||
start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z', showWithoutTime: false,
|
||||
calendarIds: { cal: true },
|
||||
};
|
||||
const TASK = {
|
||||
id: 't', '@type': 'Task', uid: 'task@example.com', title: 'Pay rent', due: '2026-09-10T00:00:00',
|
||||
progress: 'needs-action', calendarIds: { cal: true },
|
||||
};
|
||||
const OCCURRENCES = {
|
||||
maaaaab: {
|
||||
...SERIES, id: 'maaaaab', baseEventId: 'b', recurrenceRule: null, recurrenceOverrides: null,
|
||||
start: '2026-09-08T10:00:00', utcStart: '2026-09-08T08:00:00Z', utcEnd: '2026-09-08T09:00:00Z',
|
||||
recurrenceId: '2026-09-08T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
},
|
||||
eaaaaad: { ...SINGLE, id: 'eaaaaad', baseEventId: 'd' },
|
||||
};
|
||||
|
||||
describe.each<StalwartVersion>(['0.16', '1.0'])('calendar reads on Stalwart %s', (version) => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let client: JMAPClient;
|
||||
let server: StalwartCalendarMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
client = await connectCalendarClient(fetchSpy, stalwartCalendarSession(version));
|
||||
server = installStalwartCalendarMock(fetchSpy, {
|
||||
version,
|
||||
objects: { b: SERIES, d: SINGLE, t: TASK },
|
||||
occurrences: OCCURRENCES,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The invariant: no get ever asks for both.
|
||||
expect(server.gets().filter((call) => asksForOverridesWithUtc(call.args))).toEqual([]);
|
||||
client.disconnect();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it(`loads a server-expanded range with the series context of its occurrences on Stalwart ${version}`, async () => {
|
||||
const events = await client.queryCalendarEvents(RANGE);
|
||||
|
||||
expect(events.map((e) => e.id)).toEqual(['maaaaab', 'eaaaaad']);
|
||||
const occurrence = events[0];
|
||||
expect(occurrence.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
|
||||
expect(occurrence.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
|
||||
expect(occurrence.utcStart).toBe('2026-09-08T08:00:00Z');
|
||||
// Expanded occurrences never ask for overrides; the hydration get has no utc*.
|
||||
const [instanceGet, hydrationGet] = server.gets();
|
||||
expect(instanceGet.args.properties).toContain('utcStart');
|
||||
expect(instanceGet.args.properties).not.toContain('recurrenceOverrides');
|
||||
expect(hydrationGet.args.properties).toContain('recurrenceOverrides');
|
||||
expect(hydrationGet.args.properties).not.toContain('utcStart');
|
||||
});
|
||||
|
||||
it(`reads a series with its overrides from a second get in the same request on Stalwart ${version}`, async () => {
|
||||
const [series] = await client.queryCalendarEvents({ uid: 'series@example.com' });
|
||||
|
||||
expect(series.id).toBe('b');
|
||||
expect(series.utcStart).toBe('2026-09-07T08:00:00Z');
|
||||
expect(series.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
|
||||
const gets = server.gets();
|
||||
expect(gets).toHaveLength(2);
|
||||
expect(gets[0].request).toBe(gets[1].request);
|
||||
expect(gets[1].args).toMatchObject({ ids: ['b'], properties: ['id', 'recurrenceOverrides'] });
|
||||
});
|
||||
|
||||
it(`reads one event with its overrides, and null for an unknown id, on Stalwart ${version}`, async () => {
|
||||
const event = await client.getCalendarEvent('b');
|
||||
expect(event?.title).toBe('Daily standup');
|
||||
expect(event?.utcEnd).toBe('2026-09-07T09:00:00Z');
|
||||
expect(event?.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
|
||||
expect(event?.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
|
||||
|
||||
await expect(client.getCalendarEvent('gone')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it(`lists every event by uid and calendar, without tasks, on Stalwart ${version}`, async () => {
|
||||
const events = await client.getCalendarEvents();
|
||||
|
||||
expect(events.map((e) => [e.id, e.uid, e.calendarIds])).toEqual([
|
||||
['b', 'series@example.com', { cal: true }],
|
||||
['d', 'single@example.com', { cal: true }],
|
||||
]);
|
||||
expect(server.gets()[0].args.properties).toEqual(['id', '@type', 'uid', 'calendarIds', 'due', 'progress', 'percentComplete']);
|
||||
});
|
||||
|
||||
it(`loads tasks on Stalwart ${version}`, async () => {
|
||||
const tasks = await client.getCalendarTasks();
|
||||
|
||||
expect(tasks.map((t) => [t.id, t.title, t.due])).toEqual([['t', 'Pay rent', '2026-09-10T00:00:00']]);
|
||||
expect(server.gets()[0].args.properties).not.toContain('utcStart');
|
||||
});
|
||||
|
||||
it(`reads created events and tasks back on Stalwart ${version}`, async () => {
|
||||
const created = await client.createCalendarEvent({
|
||||
title: 'Lunch', start: '2026-09-09T12:00:00', duration: 'PT1H', calendarIds: { cal: true },
|
||||
});
|
||||
expect(created).toMatchObject({ id: 'created-1', title: 'Lunch' });
|
||||
|
||||
const batch = await client.batchCreateCalendarEvents([
|
||||
{ title: 'A', start: '2026-09-11T10:00:00', calendarIds: { cal: true } },
|
||||
{ title: 'B', start: '2026-09-12T10:00:00', calendarIds: { cal: true } },
|
||||
]);
|
||||
expect(batch.created.map((e) => e.title)).toEqual(['A', 'B']);
|
||||
|
||||
const task = await client.createCalendarTask({ title: 'File taxes', calendarIds: { cal: true } });
|
||||
expect(task).toMatchObject({ id: 'created-4', title: 'File taxes', '@type': 'Task' });
|
||||
});
|
||||
|
||||
it(`scans calendars for tasks-only ones on Stalwart ${version}`, async () => {
|
||||
const calendars = await client.getAllCalendars();
|
||||
expect(calendars.map((c) => [c.id, c.isTasksOnly])).toEqual([['cal', false]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a refused CalendarEvent/get', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let client: JMAPClient;
|
||||
|
||||
// What every get looked like to the client before the split: Stalwart 1.0
|
||||
// refusing the whole call.
|
||||
const refuseGets = { answer: (call: { method: string }) =>
|
||||
call.method === 'CalendarEvent/get' ? ['error', { ...OVERRIDES_WITH_UTC_ERROR }] as [string, Record<string, unknown>] : undefined };
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
client = await connectCalendarClient(fetchSpy, stalwartCalendarSession('1.0'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('fails the read instead of returning an empty list on Stalwart 1.0', async () => {
|
||||
installStalwartCalendarMock(fetchSpy, {
|
||||
version: '1.0', objects: { b: SERIES, t: TASK }, occurrences: OCCURRENCES, ...refuseGets,
|
||||
});
|
||||
|
||||
for (const read of [
|
||||
() => client.queryCalendarEvents(RANGE),
|
||||
() => client.queryAllCalendarEvents(RANGE),
|
||||
() => client.getCalendarEvent('b'),
|
||||
() => client.getCalendarEvents(),
|
||||
() => client.getCalendarTasks(),
|
||||
]) {
|
||||
const error = await read().then(() => null, (e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(jmapErrorType(error)).toBe('invalidArguments');
|
||||
expect((error as Error).message).toContain('recurrenceOverrides cannot be requested together with utcStart or utcEnd');
|
||||
}
|
||||
});
|
||||
|
||||
it('still returns what was created when the read-back fails on Stalwart 1.0', async () => {
|
||||
const server = installStalwartCalendarMock(fetchSpy, { version: '1.0', ...refuseGets });
|
||||
|
||||
const event = await client.createCalendarEvent({
|
||||
title: 'Lunch', start: '2026-09-09T12:00:00', duration: 'PT1H', calendarIds: { cal: true },
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }] as never,
|
||||
}, true);
|
||||
expect(event).toMatchObject({ id: 'created-1', title: 'Lunch', start: '2026-09-09T12:00:00' });
|
||||
expect(event.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
|
||||
|
||||
const task = await client.createCalendarTask({ title: 'File taxes', calendarIds: { cal: true } });
|
||||
expect(task).toMatchObject({ id: 'created-2', title: 'File taxes', '@type': 'Task' });
|
||||
|
||||
const batch = await client.batchCreateCalendarEvents([{ title: 'A', start: '2026-09-11T10:00:00', calendarIds: { cal: true } }]);
|
||||
expect(batch.created).toMatchObject([{ id: 'created-3', title: 'A' }]);
|
||||
|
||||
// Each create went out once; nothing was retried or queried afterwards.
|
||||
expect(server.calls.filter((c) => c.method === 'CalendarEvent/set')).toHaveLength(3);
|
||||
expect(server.queries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still returns what was created when the read-back fails in transit on Stalwart 0.16', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const server = installStalwartCalendarMock(fetchSpy, { version: '0.16' });
|
||||
const answering = fetchSpy.getMockImplementation()!;
|
||||
fetchSpy.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(init?.body).includes('"CalendarEvent/get"')) return new Response('Service Unavailable', { status: 503 });
|
||||
return answering(url, init);
|
||||
});
|
||||
|
||||
const event = await client.createCalendarEvent({ title: 'Lunch', start: '2026-09-09T12:00:00', calendarIds: { cal: true } });
|
||||
expect(event).toMatchObject({ id: 'created-1', title: 'Lunch' });
|
||||
expect(server.calls.filter((c) => c.method === 'CalendarEvent/set')).toHaveLength(1);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { jmapErrorType } from '../jmap/jmap-error';
|
||||
import { toLocalDateTime } from '../timezone';
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
@@ -11,19 +12,18 @@ function clientAnswering(answer: (args: Record<string, unknown>) => [string, Rec
|
||||
vi.spyOn(client as unknown as { supportsSyntheticCalendarIds: () => Promise<boolean> }, 'supportsSyntheticCalendarIds')
|
||||
.mockResolvedValue(true);
|
||||
vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
|
||||
.mockImplementation(async (calls: Call[]) => {
|
||||
const [method, args, id] = calls[0];
|
||||
if (method === 'CalendarEvent/query') {
|
||||
queries.push(args);
|
||||
return { methodResponses: [[...answer(args), id]] };
|
||||
}
|
||||
const ids = (args.ids as string[]) ?? [];
|
||||
return {
|
||||
methodResponses: [['CalendarEvent/get', {
|
||||
.mockImplementation(async (calls: Call[]) => ({
|
||||
methodResponses: calls.map(([method, args, id]) => {
|
||||
if (method === 'CalendarEvent/query') {
|
||||
queries.push(args);
|
||||
return [...answer(args), id];
|
||||
}
|
||||
const ids = (args.ids as string[]) ?? [];
|
||||
return ['CalendarEvent/get', {
|
||||
list: ids.map((eid) => ({ id: eid, '@type': 'Event', start: '2026-09-22T10:00:00', duration: 'PT1H', calendarIds: { c: true } })),
|
||||
}, id]],
|
||||
};
|
||||
});
|
||||
}, id];
|
||||
}),
|
||||
}));
|
||||
return { client, queries };
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('queryCalendarEvents range handling', () => {
|
||||
expect(filter.after.endsWith('Z')).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to series when the range expands past the server limit', async () => {
|
||||
it('falls back to series when the range expands past the server limit on 0.16', async () => {
|
||||
const { client, queries } = clientAnswering((args) => args.expandRecurrences
|
||||
? ['error', { type: 'invalidArguments', description: 'The number of expanded recurrences exceeds the server limit' }]
|
||||
: ['CalendarEvent/query', { ids: ['s1'] }]);
|
||||
@@ -58,6 +58,23 @@ describe('queryCalendarEvents range handling', () => {
|
||||
expect(events.map((e) => e.id)).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('falls back to series on Stalwart 1.0 cannotCalculateOccurrences', async () => {
|
||||
const { client, queries } = clientAnswering((args) => args.expandRecurrences
|
||||
? ['error', { type: 'cannotCalculateOccurrences', description: 'The number of expanded recurrences exceeds the server limit' }]
|
||||
: ['CalendarEvent/query', { ids: ['s1'] }]);
|
||||
const events = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' });
|
||||
expect(queries.map((q) => !!q.expandRecurrences)).toEqual([true, false]);
|
||||
expect(events.map((e) => e.id)).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('fails the range when Stalwart 1.0 refuses the query, instead of returning no events', async () => {
|
||||
const { client } = clientAnswering(() => ['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }]);
|
||||
const error = await client.queryCalendarEvents({ after: '2026-09-01T00:00:00', before: '2026-09-30T23:59:59' })
|
||||
.then(() => null, (e: unknown) => e);
|
||||
expect(jmapErrorType(error)).toBe('invalidArguments');
|
||||
expect((error as Error).message).toContain('Invalid timeZone');
|
||||
});
|
||||
|
||||
it('pages through more than one page of ids', async () => {
|
||||
const all = Array.from({ length: 1500 }, (_, i) => `e${i}`);
|
||||
const { client, queries } = clientAnswering((args) => {
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('server-side recurrence expansion with synthetic ids', () => {
|
||||
expect(single.recurrenceRules ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps client-side expansion (no expandRecurrences) when the server rejects synthetic ids', async () => {
|
||||
it('keeps client-side expansion (no expandRecurrences) when the server rejects synthetic ids on 0.16.19', async () => {
|
||||
serverSupportsSyntheticIds = false;
|
||||
const events = await client.queryCalendarEvents(RANGE);
|
||||
await client.queryCalendarEvents(RANGE);
|
||||
@@ -181,13 +181,16 @@ describe('server-side recurrence expansion with synthetic ids', () => {
|
||||
expect(query.args.expandRecurrences).toBeUndefined();
|
||||
}
|
||||
// Raw base events with real ids come back for the browser to expand -
|
||||
// one full get per query, no base-event hydration get.
|
||||
// per query one full get plus a get of the overrides in the same
|
||||
// request (Stalwart 1.0 refuses both in one get), no hydration get.
|
||||
expect(events.map((e) => e.id)).toEqual(['b', 'd']);
|
||||
expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
|
||||
expect(gets()).toHaveLength(2);
|
||||
for (const get of gets()) {
|
||||
expect(get.args.properties).toContain('title');
|
||||
}
|
||||
expect(events[0].recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
|
||||
expect(gets().map((g) => g.args.properties)).toEqual([
|
||||
expect.arrayContaining(['title', 'utcStart']), ['id', 'recurrenceOverrides'],
|
||||
expect.arrayContaining(['title', 'utcStart']), ['id', 'recurrenceOverrides'],
|
||||
]);
|
||||
expect(gets()[0].args.properties).not.toContain('recurrenceOverrides');
|
||||
});
|
||||
|
||||
it('treats a failed probe as unsupported', async () => {
|
||||
|
||||
@@ -121,4 +121,41 @@ describe('JMAPClient task operations (#958)', () => {
|
||||
expect(createPayload.title).toBe('New Task');
|
||||
expect(createPayload).not.toHaveProperty('progressUpdated');
|
||||
});
|
||||
|
||||
it('createCalendarTask reads the task back without utcStart/utcEnd', async () => {
|
||||
await client.createCalendarTask({ title: 'New Task' });
|
||||
|
||||
const getCall = sentCalls.find((c) => c.method === 'CalendarEvent/get');
|
||||
expect(getCall?.args.properties).toEqual(expect.arrayContaining(['due', 'progress', 'recurrenceOverrides']));
|
||||
expect(getCall?.args.properties).not.toContain('utcStart');
|
||||
expect(getCall?.args.properties).not.toContain('utcEnd');
|
||||
});
|
||||
|
||||
it('createCalendarTask returns the created task when Stalwart 1.0 refuses the read-back, without creating it twice', async () => {
|
||||
const answer = vi.mocked(globalThis.fetch).getMockImplementation()!;
|
||||
vi.mocked(globalThis.fetch).mockImplementation(async (url, init) => {
|
||||
const body = JSON.parse((init?.body as string) || '{}');
|
||||
if (body.methodCalls?.[0]?.[0] === 'CalendarEvent/get') {
|
||||
sentCalls.push({ method: 'CalendarEvent/get', args: body.methodCalls[0][1] });
|
||||
return jsonResponse({ methodResponses: [['error', {
|
||||
type: 'invalidArguments',
|
||||
description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd',
|
||||
}, '0']] });
|
||||
}
|
||||
return answer(url, init);
|
||||
});
|
||||
|
||||
const created = await client.createCalendarTask({ title: 'New Task', due: '2026-09-30T00:00:00', calendarIds: { 'cal-1': true } });
|
||||
|
||||
expect(created).toMatchObject({ id: 'created-id-123', '@type': 'Task', title: 'New Task', due: '2026-09-30T00:00:00' });
|
||||
expect(sentCalls.filter((c) => c.method === 'CalendarEvent/set')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getCalendarTasks fails instead of returning no tasks when Stalwart 1.0 refuses the query', async () => {
|
||||
vi.mocked(globalThis.fetch).mockImplementation(async () => jsonResponse({
|
||||
methodResponses: [['error', { type: 'invalidArguments', description: 'Invalid timeZone "Mars/Olympus"' }, '0']],
|
||||
}));
|
||||
|
||||
await expect(client.getCalendarTasks()).rejects.toThrow(/Invalid timeZone/);
|
||||
});
|
||||
});
|
||||
|
||||
+242
-272
@@ -15,7 +15,7 @@ import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||||
import { decodeFileNodeName } from "./filenode-name";
|
||||
import { contactFromWire, contactToWire } from "./contact-wire";
|
||||
import { SchedulingDeniedError } from "./scheduling-error";
|
||||
import { jmapError } from "./jmap-error";
|
||||
import { jmapError, jmapErrorType } from "./jmap-error";
|
||||
|
||||
export { SchedulingDeniedError };
|
||||
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
|
||||
@@ -381,6 +381,10 @@ function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stalwart 1.0 fails a CalendarEvent/get that asks for `recurrenceOverrides`
|
||||
// together with `utcStart` or `utcEnd` (invalidArguments, before any id is
|
||||
// read), so no property list may hold both. Where a caller needs the
|
||||
// overrides, fetchCalendarEventsById reads them with a get of their own.
|
||||
const CALENDAR_EVENT_PROPERTIES = [
|
||||
'id',
|
||||
'@type',
|
||||
@@ -414,7 +418,6 @@ const CALENDAR_EVENT_PROPERTIES = [
|
||||
'recurrenceId',
|
||||
'recurrenceIdTimeZone',
|
||||
'recurrenceRule',
|
||||
'recurrenceOverrides',
|
||||
'excludedRecurrenceRule',
|
||||
'useDefaultAlerts',
|
||||
'alerts',
|
||||
@@ -427,7 +430,25 @@ const CALENDAR_EVENT_PROPERTIES = [
|
||||
'baseEventId',
|
||||
] as const;
|
||||
|
||||
// Task-specific properties for CalendarEvent/get when fetching Task objects
|
||||
/** The second get of fetchCalendarEventsById: the overrides, by id. */
|
||||
const OVERRIDE_PROPERTIES = ['id', 'recurrenceOverrides'] as const;
|
||||
|
||||
// getCalendarEvents' callers (import de-duplication, clearing a calendar,
|
||||
// iCal subscription refresh) match events by uid and calendar only; the task
|
||||
// keys let isTaskObject keep tasks out.
|
||||
const CALENDAR_EVENT_IDENTITY_PROPERTIES = [
|
||||
'id',
|
||||
'@type',
|
||||
'uid',
|
||||
'calendarIds',
|
||||
'due',
|
||||
'progress',
|
||||
'percentComplete',
|
||||
] as const;
|
||||
|
||||
// Task-specific properties for CalendarEvent/get when fetching Task objects.
|
||||
// No utcStart/utcEnd: the task UI works from `due`, and asking for them next
|
||||
// to recurrenceOverrides fails the get on Stalwart 1.0.
|
||||
const CALENDAR_TASK_PROPERTIES = [
|
||||
'id',
|
||||
'@type',
|
||||
@@ -443,8 +464,6 @@ const CALENDAR_TASK_PROPERTIES = [
|
||||
'duration',
|
||||
'timeZone',
|
||||
'showWithoutTime',
|
||||
'utcStart',
|
||||
'utcEnd',
|
||||
'progress',
|
||||
'priority',
|
||||
'privacy',
|
||||
@@ -647,6 +666,33 @@ function assertQuerySucceeded(response: JMAPResponse, what: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to call `callId` of a request. A method error throws with its
|
||||
* JMAP type: read as an empty list, a refused calendar get or query used to
|
||||
* show an empty calendar instead of a failure.
|
||||
*/
|
||||
function methodResult(response: JMAPResponse, callId: string, what: string): JMAPResponseResult {
|
||||
const entry = response.methodResponses?.find((r) => r[2] === callId);
|
||||
if (!entry) throw new Error(`${what} failed: no response`);
|
||||
const [name, result] = entry;
|
||||
if (name === 'error') {
|
||||
throw jmapError(`${what} failed: ${result?.description || result?.type || 'unknown error'}`, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an expanded CalendarEvent/query failed on the server's cap on
|
||||
* occurrences per query: `cannotCalculateOccurrences` on Stalwart 1.0,
|
||||
* `invalidArguments` ("The number of expanded recurrences exceeds the server
|
||||
* limit") on 0.16.
|
||||
*/
|
||||
function isOccurrenceLimitError(error: unknown): boolean {
|
||||
const type = jmapErrorType(error);
|
||||
if (type === 'cannotCalculateOccurrences') return true;
|
||||
return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : '');
|
||||
}
|
||||
|
||||
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
|
||||
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
|
||||
}
|
||||
@@ -6409,9 +6455,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to delete calendar");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event of the account (or of `calendarIds`), with only the
|
||||
* identity properties (CALENDAR_EVENT_IDENTITY_PROPERTIES) filled in.
|
||||
*/
|
||||
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||||
const timeZone = getUserTimeZone();
|
||||
|
||||
// Page through the query to collect ALL ids. Callers (import UID
|
||||
@@ -6434,14 +6483,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const queryResponse = await this.request([
|
||||
["CalendarEvent/query", queryArgs, "0"],
|
||||
], this.calendarUsing());
|
||||
|
||||
// Check for JMAP method-level errors
|
||||
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||||
const error = queryResponse.methodResponses[0][1];
|
||||
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
||||
}
|
||||
|
||||
const pageIds: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||||
const pageIds: string[] = methodResult(queryResponse, "0", "CalendarEvent/query").ids || [];
|
||||
ids.push(...pageIds);
|
||||
if (pageIds.length < QUERY_PAGE) break;
|
||||
position += pageIds.length;
|
||||
@@ -6449,30 +6491,49 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Batch the /get calls to stay within server max-objects limit
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||||
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||||
const getResponse = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
ids: batchIds,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
|
||||
allEvents.push(...events);
|
||||
}
|
||||
}
|
||||
|
||||
return allEvents
|
||||
const allEvents = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_EVENT_IDENTITY_PROPERTIES, { timeZone });
|
||||
return (allEvents as unknown as CalendarEvent[])
|
||||
.filter((event) => !isTaskObject(event))
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* CalendarEvent/get of `ids` in batches of maxObjectsInGet. With
|
||||
* `withOverrides`, each batch also asks for OVERRIDE_PROPERTIES in a second
|
||||
* call of the same request, and the overrides are merged in by id: Stalwart
|
||||
* 1.0 refuses `recurrenceOverrides` in the same get as `utcStart`/`utcEnd`.
|
||||
* A method error on either call fails the whole fetch.
|
||||
*/
|
||||
private async fetchCalendarEventsById(
|
||||
accountId: string,
|
||||
ids: string[],
|
||||
properties: readonly string[],
|
||||
options: { withOverrides?: boolean; timeZone?: string } = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const { withOverrides = false, timeZone } = options;
|
||||
const objects: Record<string, unknown>[] = [];
|
||||
for (const batch of batched(ids, this.getMaxObjectsInGet())) {
|
||||
const args = { accountId, ids: batch, ...(timeZone ? { timeZone } : {}) };
|
||||
const calls: JMAPMethodCall[] = [["CalendarEvent/get", { ...args, properties: [...properties] }, "0"]];
|
||||
if (withOverrides) {
|
||||
calls.push(["CalendarEvent/get", { ...args, properties: [...OVERRIDE_PROPERTIES] }, "1"]);
|
||||
}
|
||||
const response = await this.request(calls, this.calendarUsing());
|
||||
const list = (methodResult(response, "0", "CalendarEvent/get").list || []) as Record<string, unknown>[];
|
||||
if (withOverrides) {
|
||||
const overrides = new Map<unknown, unknown>();
|
||||
for (const entry of (methodResult(response, "1", "CalendarEvent/get").list || []) as Record<string, unknown>[]) {
|
||||
if ('recurrenceOverrides' in entry) overrides.set(entry.id, entry.recurrenceOverrides);
|
||||
}
|
||||
for (const object of list) {
|
||||
if (overrides.has(object.id)) object.recurrenceOverrides = overrides.get(object.id);
|
||||
}
|
||||
}
|
||||
objects.push(...list);
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
// Shared accounts the server rejected calendar access for - probed once,
|
||||
// then skipped for the rest of the session (see getCalendarCapableAccountIds
|
||||
// for why the fan-out has to probe on suspicion).
|
||||
@@ -6534,41 +6595,41 @@ export class JMAPClient implements IJMAPClient {
|
||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||
limit?: number
|
||||
): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
const primaryId = this.getCalendarsAccountId();
|
||||
const accountIds = this.getCalendarCapableAccountIds();
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
const primaryId = this.getCalendarsAccountId();
|
||||
const accountIds = this.getCalendarCapableAccountIds();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
|
||||
const account = this.accounts[accountId];
|
||||
for (const accountId of accountIds) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
|
||||
const account = this.accounts[accountId];
|
||||
|
||||
try {
|
||||
const events = await this.queryCalendarEvents(filter, sort, limit, accountId);
|
||||
const mapped = events.map((event) => ({
|
||||
...event,
|
||||
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
||||
originalId: event.id,
|
||||
originalCalendarIds: event.calendarIds,
|
||||
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
||||
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||||
),
|
||||
accountId,
|
||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||
isShared: !isPrimary,
|
||||
}));
|
||||
allEvents.push(...mapped);
|
||||
} catch (error) {
|
||||
console.error(`Failed to query calendar events for account ${accountId}:`, error);
|
||||
}
|
||||
let events: CalendarEvent[];
|
||||
try {
|
||||
events = await this.queryCalendarEvents(filter, sort, limit, accountId);
|
||||
} 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;
|
||||
console.error(`Failed to query calendar events for account ${accountId}:`, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
} catch (error) {
|
||||
console.error('Failed to query all calendar events:', error);
|
||||
return this.queryCalendarEvents(filter, sort, limit);
|
||||
allEvents.push(...events.map((event) => ({
|
||||
...event,
|
||||
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
||||
originalId: event.id,
|
||||
originalCalendarIds: event.calendarIds,
|
||||
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
||||
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||||
),
|
||||
accountId,
|
||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||
isShared: !isPrimary,
|
||||
})));
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
}
|
||||
|
||||
async queryCalendarEvents(
|
||||
@@ -6577,8 +6638,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
limit?: number,
|
||||
targetAccountId?: string
|
||||
): Promise<CalendarEvent[]> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
try {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const timeZone = getUserTimeZone();
|
||||
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
@@ -6603,75 +6664,33 @@ export class JMAPClient implements IJMAPClient {
|
||||
// lib/recurrence-expansion.ts, which runs on whatever comes back.
|
||||
let expandRecurrences = Boolean(filter.after && filter.before)
|
||||
&& await this.supportsSyntheticCalendarIds();
|
||||
if (expandRecurrences) {
|
||||
queryArgs.expandRecurrences = true;
|
||||
}
|
||||
if (sort) {
|
||||
queryArgs.sort = sort;
|
||||
}
|
||||
|
||||
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||||
// Without an explicit limit, page through the whole range: a busy
|
||||
// calendar (or a long expanded range) holds more than one page.
|
||||
const pageSize = limit || 1000;
|
||||
const maxIds = limit || 20000;
|
||||
|
||||
// First, query to get IDs
|
||||
const ids: string[] = [];
|
||||
for (let position = 0; ids.length < maxIds;) {
|
||||
const queryResponse = await this.request([
|
||||
["CalendarEvent/query", { ...queryArgs, limit: pageSize, position }, "0"],
|
||||
], this.calendarUsing());
|
||||
const [name, result] = queryResponse.methodResponses?.[0] ?? [];
|
||||
|
||||
if (name === "error") {
|
||||
// Stalwart fails the whole query once a range expands to more than
|
||||
// maxRecurrenceExpansions occurrences. Ask for the series instead;
|
||||
let ids: string[] | null = null;
|
||||
if (expandRecurrences) {
|
||||
try {
|
||||
ids = await this.queryCalendarEventIds({ ...queryArgs, expandRecurrences: true }, limit);
|
||||
} catch (error) {
|
||||
// Stalwart fails the whole query once a range expands to more
|
||||
// occurrences than it allows. Ask for the series instead;
|
||||
// lib/recurrence-expansion.ts expands them on the client.
|
||||
if (expandRecurrences && result?.type === "invalidArguments" &&
|
||||
/expanded recurrences exceeds/i.test(result?.description ?? "")) {
|
||||
debug.log('calendar', 'Range expands past the server limit; expanding on the client instead');
|
||||
expandRecurrences = false;
|
||||
delete queryArgs.expandRecurrences;
|
||||
ids.length = 0;
|
||||
position = 0;
|
||||
continue;
|
||||
}
|
||||
// Keep the JMAP error type so the catch below can tell an expected
|
||||
// access rejection apart from a genuine failure.
|
||||
throw Object.assign(
|
||||
new Error(result?.description || result?.type || "CalendarEvent/query failed"),
|
||||
{ jmapErrorType: result?.type },
|
||||
);
|
||||
if (!isOccurrenceLimitError(error)) throw error;
|
||||
debug.log('calendar', 'Range expands past the server limit; expanding on the client instead');
|
||||
expandRecurrences = false;
|
||||
}
|
||||
|
||||
const page: string[] = result?.ids || [];
|
||||
ids.push(...page);
|
||||
if (limit || page.length < pageSize) break;
|
||||
position += page.length;
|
||||
}
|
||||
ids ??= await this.queryCalendarEventIds(queryArgs, limit);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Batch the /get calls to stay within server max-objects limit
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||||
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||||
const getResponse = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
ids: batchIds,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
|
||||
allEvents.push(...events);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = allEvents
|
||||
// Only series the client expands itself need their overrides here; a
|
||||
// server-expanded occurrence gets them from its base event below.
|
||||
const allEvents = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_EVENT_PROPERTIES, {
|
||||
withOverrides: !expandRecurrences,
|
||||
timeZone,
|
||||
});
|
||||
const normalized = (allEvents as unknown as CalendarEvent[])
|
||||
.filter((event) => !isTaskObject(event))
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
const filtered = expandRecurrences
|
||||
@@ -6696,20 +6715,41 @@ export class JMAPClient implements IJMAPClient {
|
||||
// The fan-out over shared accounts probes on suspicion (see
|
||||
// getCalendarCapableAccountIds) and may hit accounts that grant no
|
||||
// calendar access at all. Remember the rejection and go quiet instead
|
||||
// of re-probing - and re-logging - on every range change.
|
||||
const type = (error as { jmapErrorType?: string } | null)?.jmapErrorType;
|
||||
// of re-probing - and re-logging - on every range change. Anything
|
||||
// else, and any failure of the user's own calendars, is the caller's.
|
||||
const type = jmapErrorType(error);
|
||||
const denied = type === 'forbidden' || type === 'accountNotFound' ||
|
||||
/not have access/i.test(error instanceof Error ? error.message : '');
|
||||
if (targetAccountId && denied) {
|
||||
if (targetAccountId && accountId !== this.getCalendarsAccountId() && denied) {
|
||||
this.calendarAccessDenied.add(targetAccountId);
|
||||
debug.log('calendar', `No calendar access to account ${targetAccountId} - skipping it from now on`);
|
||||
return [];
|
||||
}
|
||||
console.error('Failed to query calendar events:', error);
|
||||
return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids a CalendarEvent/query matches. Without `limit` it pages through
|
||||
* the whole result: a busy calendar (or a long expanded range) holds more
|
||||
* than one page.
|
||||
*/
|
||||
private async queryCalendarEventIds(queryArgs: Record<string, unknown>, limit?: number): Promise<string[]> {
|
||||
const pageSize = limit || 1000;
|
||||
const maxIds = limit || 20000;
|
||||
const ids: string[] = [];
|
||||
for (let position = 0; ids.length < maxIds;) {
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", { ...queryArgs, limit: pageSize, position }, "0"],
|
||||
], this.calendarUsing());
|
||||
const page: string[] = methodResult(response, "0", "CalendarEvent/query").ids || [];
|
||||
ids.push(...page);
|
||||
if (limit || page.length < pageSize) break;
|
||||
position += page.length;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-expanded occurrences come back without their series' recurrence
|
||||
* rule / overrides (and all-day ones without showWithoutTime). Fetch those
|
||||
@@ -6730,45 +6770,20 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (baseIds.length === 0) return events;
|
||||
|
||||
const bases = new Map<string, Partial<CalendarEvent>>();
|
||||
for (const batch of batched(baseIds, this.getMaxObjectsInGet())) {
|
||||
const response = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...RECURRENCE_BASE_PROPERTIES],
|
||||
ids: batch,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, "0"],
|
||||
], this.calendarUsing());
|
||||
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") continue;
|
||||
for (const base of (response.methodResponses[0][1].list || []) as Partial<CalendarEvent>[]) {
|
||||
if (base.id) bases.set(base.id, normalizeCalendarEventLike(base));
|
||||
}
|
||||
for (const base of await this.fetchCalendarEventsById(accountId, baseIds, RECURRENCE_BASE_PROPERTIES, { timeZone })) {
|
||||
if (typeof base.id === 'string') bases.set(base.id, normalizeCalendarEventLike(base as Partial<CalendarEvent>));
|
||||
}
|
||||
return hydrateRecurrenceInstances(events, bases);
|
||||
}
|
||||
|
||||
/** One event with its recurrence overrides, or null when the server has no such id. */
|
||||
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
|
||||
try {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const timeZone = getUserTimeZone();
|
||||
const response = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
ids: [id],
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const list = response.methodResponses[0][1].list || [];
|
||||
return list[0] ? normalizeCalendarEventLike(list[0] as CalendarEvent) : null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get calendar event:', error);
|
||||
return null;
|
||||
}
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const [event] = await this.fetchCalendarEventsById(accountId, [id], CALENDAR_EVENT_PROPERTIES, {
|
||||
withOverrides: true,
|
||||
timeZone: getUserTimeZone(),
|
||||
});
|
||||
return event ? normalizeCalendarEventLike(event as unknown as CalendarEvent) : null;
|
||||
}
|
||||
|
||||
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent> {
|
||||
@@ -6827,37 +6842,28 @@ export class JMAPClient implements IJMAPClient {
|
||||
});
|
||||
|
||||
if (createdId) {
|
||||
const created = await this.getCalendarEvent(createdId, targetAccountId);
|
||||
// The event exists from here on, and its invitations are on their
|
||||
// way: a failed read-back must not report a failed create, or a
|
||||
// retry creates the event twice. Fall back to what was sent.
|
||||
let created: CalendarEvent | null = null;
|
||||
try {
|
||||
created = await this.getCalendarEvent(createdId, targetAccountId);
|
||||
} catch (refetchError) {
|
||||
debug.warn('calendar', 'CalendarEvent/create could not read the created event back', refetchError);
|
||||
}
|
||||
if (!created) {
|
||||
debug.warn('calendar', 'CalendarEvent/create returns the sent event for the created id', { createdId, targetAccountId });
|
||||
created = normalizeCalendarEventLike({ ...cleanEvent, ...result.created["new-event"], id: createdId } as CalendarEvent);
|
||||
}
|
||||
debug.log('calendar', 'CalendarEvent/create fetched created event', {
|
||||
...getCalendarEventDebugSnapshot(created),
|
||||
hasParticipants: !!created?.participants,
|
||||
participantCount: created?.participants ? Object.keys(created.participants).length : 0,
|
||||
participants: created?.participants || null,
|
||||
replyTo: created?.replyTo || null,
|
||||
});
|
||||
|
||||
if (created?.uid) {
|
||||
try {
|
||||
const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId);
|
||||
debug.log('calendar', 'CalendarEvent/create verification query by uid', {
|
||||
uid: created.uid,
|
||||
matchCount: verificationMatches.length,
|
||||
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
|
||||
});
|
||||
} catch (verificationError) {
|
||||
debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError);
|
||||
}
|
||||
}
|
||||
|
||||
if (created) {
|
||||
debug.groupEnd();
|
||||
return created;
|
||||
}
|
||||
|
||||
debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
|
||||
createdId,
|
||||
targetAccountId,
|
||||
hasParticipants: !!created.participants,
|
||||
participantCount: created.participants ? Object.keys(created.participants).length : 0,
|
||||
participants: created.participants || null,
|
||||
replyTo: created.replyTo || null,
|
||||
});
|
||||
debug.groupEnd();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6881,6 +6887,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
|
||||
|
||||
const createdIds: string[] = [];
|
||||
const sentById = new Map<string, Record<string, unknown>>();
|
||||
const failed: string[] = [];
|
||||
const notCreated: Record<string, CalendarSetError> = {};
|
||||
const indexed = events.map((event, index) => ({ event, index }));
|
||||
@@ -6914,6 +6921,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const key = `new-${index}`;
|
||||
if (result.created?.[key]?.id) {
|
||||
createdIds.push(result.created[key].id);
|
||||
sentById.set(result.created[key].id, { ...createMap[key], ...result.created[key] });
|
||||
} else if (result.notCreated?.[key]) {
|
||||
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
|
||||
failed.push(key);
|
||||
@@ -6927,25 +6935,18 @@ export class JMAPClient implements IJMAPClient {
|
||||
return { created: [], failed, notCreated };
|
||||
}
|
||||
|
||||
// Fetch the created events back for their server-assigned properties
|
||||
const refetchTimeZone = getUserTimeZone();
|
||||
const createdEvents: CalendarEvent[] = [];
|
||||
|
||||
for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) {
|
||||
const getResponse = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
ids: batchIds,
|
||||
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const list = getResponse.methodResponses[0][1].list || [];
|
||||
createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)));
|
||||
}
|
||||
// Read the created events back for their server-assigned properties.
|
||||
// They exist whatever the read-back says, so what was sent stands in for
|
||||
// any event it does not return.
|
||||
let fetched: Record<string, unknown>[] = [];
|
||||
try {
|
||||
fetched = await this.fetchCalendarEventsById(accountId, createdIds, CALENDAR_EVENT_PROPERTIES, { timeZone: getUserTimeZone() });
|
||||
} catch (error) {
|
||||
debug.warn('calendar', 'CalendarEvent/batchCreate could not read the created events back', error);
|
||||
}
|
||||
const fetchedById = new Map(fetched.map((event) => [event.id, event]));
|
||||
const createdEvents = createdIds.map((id) =>
|
||||
normalizeCalendarEventLike((fetchedById.get(id) ?? sentById.get(id)) as unknown as CalendarEvent));
|
||||
|
||||
debug.log('calendar', 'CalendarEvent/batchCreate result', {
|
||||
requested: events.length,
|
||||
@@ -7182,15 +7183,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", queryArgs, "0"],
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "error") {
|
||||
const error = response.methodResponses[0][1];
|
||||
debug.warn('tasks', 'CalendarTask/fetch query failed', error);
|
||||
debug.groupEnd();
|
||||
return [];
|
||||
}
|
||||
|
||||
const pageIds: string[] = response.methodResponses?.[0]?.[1]?.ids || [];
|
||||
const pageIds: string[] = methodResult(response, "0", "CalendarEvent/query").ids || [];
|
||||
ids.push(...pageIds);
|
||||
if (pageIds.length < QUERY_PAGE) break;
|
||||
position += pageIds.length;
|
||||
@@ -7203,23 +7196,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
// Fetch the objects in batches that respect the server's /get limit.
|
||||
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||||
const allObjects: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||||
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||||
const getResponse = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_TASK_PROPERTIES],
|
||||
ids: batchIds,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
allObjects.push(...(getResponse.methodResponses[0][1].list || []));
|
||||
}
|
||||
}
|
||||
const allObjects = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_TASK_PROPERTIES, { timeZone });
|
||||
|
||||
const tasks: CalendarTask[] = [];
|
||||
for (const obj of allObjects) {
|
||||
@@ -7246,9 +7223,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
debug.groupEnd();
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
// A refused query or get is a failure, not an empty task list: the
|
||||
// task store keeps showing what it has.
|
||||
debug.error('CalendarTask/fetch failed', error);
|
||||
debug.groupEnd();
|
||||
return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7289,43 +7268,34 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to create task - no id returned");
|
||||
}
|
||||
|
||||
// Fetch back with task-specific properties
|
||||
// Read the task back with the task properties. It exists whatever the
|
||||
// read-back says: fall back to what was sent rather than report a failed
|
||||
// create, which a retry would duplicate.
|
||||
debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
|
||||
const refetchTimeZone = getUserTimeZone();
|
||||
const getResponse = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_TASK_PROPERTIES],
|
||||
ids: [createdId],
|
||||
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const list = getResponse.methodResponses[0][1].list || [];
|
||||
const notFound = getResponse.methodResponses[0][1].notFound || [];
|
||||
debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound });
|
||||
if (list[0]) {
|
||||
const created = normalizeCalendarTask({ ...list[0], '@type': 'Task' as const } as CalendarTask);
|
||||
debug.log('tasks', 'CalendarTask/create final task object', {
|
||||
id: created.id,
|
||||
uid: created.uid,
|
||||
'@type': created['@type'],
|
||||
title: created.title,
|
||||
due: created.due,
|
||||
start: created.start,
|
||||
progress: created.progress,
|
||||
showWithoutTime: created.showWithoutTime,
|
||||
calendarIds: created.calendarIds,
|
||||
});
|
||||
debug.groupEnd();
|
||||
return created;
|
||||
}
|
||||
let fetched: Record<string, unknown> | undefined;
|
||||
try {
|
||||
[fetched] = await this.fetchCalendarEventsById(accountId, [createdId], CALENDAR_TASK_PROPERTIES, { timeZone: getUserTimeZone() });
|
||||
} catch (error) {
|
||||
debug.warn('tasks', 'CalendarTask/create could not read the created task back', error);
|
||||
}
|
||||
|
||||
debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId);
|
||||
if (!fetched) debug.warn('tasks', 'CalendarTask/create returns the sent task for id', createdId);
|
||||
const created = normalizeCalendarTask({
|
||||
...(fetched ?? { ...cleanTask, ...serverCreated, id: createdId }),
|
||||
'@type': 'Task' as const,
|
||||
} as CalendarTask);
|
||||
debug.log('tasks', 'CalendarTask/create final task object', {
|
||||
id: created.id,
|
||||
uid: created.uid,
|
||||
'@type': created['@type'],
|
||||
title: created.title,
|
||||
due: created.due,
|
||||
start: created.start,
|
||||
progress: created.progress,
|
||||
showWithoutTime: created.showWithoutTime,
|
||||
calendarIds: created.calendarIds,
|
||||
});
|
||||
debug.groupEnd();
|
||||
throw new Error("Failed to fetch created task");
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "جارٍ تحميل التقويمات...",
|
||||
"loading_events": "جارٍ تحميل الأحداث..."
|
||||
"loading_events": "جارٍ تحميل الأحداث...",
|
||||
"events_load_failed": "تعذّر تحميل الأحداث. قد لا يكون التقويم محدَّثًا.",
|
||||
"retry": "حاول مرة أخرى"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "عنوان الحدث الجديد",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Carregant els calendaris...",
|
||||
"loading_events": "Carregant els esdeveniments..."
|
||||
"loading_events": "Carregant els esdeveniments...",
|
||||
"events_load_failed": "No s'han pogut carregar els esdeveniments. És possible que el calendari no estigui actualitzat.",
|
||||
"retry": "Torna-ho a provar"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Títol del nou esdeveniment",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Načítání kalendářů...",
|
||||
"loading_events": "Načítání událostí..."
|
||||
"loading_events": "Načítání událostí...",
|
||||
"events_load_failed": "Události se nepodařilo načíst. Kalendář nemusí být aktuální.",
|
||||
"retry": "Zkusit znovu"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Název nové události",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Indlæser kalendere...",
|
||||
"loading_events": "Indlæser begivenheder..."
|
||||
"loading_events": "Indlæser begivenheder...",
|
||||
"events_load_failed": "Begivenhederne kunne ikke indlæses. Kalenderen er muligvis ikke opdateret.",
|
||||
"retry": "Prøv igen"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Ny begivenhedstitel",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Kalender werden geladen...",
|
||||
"loading_events": "Termine werden geladen..."
|
||||
"loading_events": "Termine werden geladen...",
|
||||
"events_load_failed": "Termine konnten nicht geladen werden. Der Kalender ist möglicherweise nicht aktuell.",
|
||||
"retry": "Erneut versuchen"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Titel des neuen Termins",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Loading calendars...",
|
||||
"loading_events": "Loading events..."
|
||||
"loading_events": "Loading events...",
|
||||
"events_load_failed": "Couldn't load events. The calendar may be out of date.",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "New event title",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Cargando calendarios...",
|
||||
"loading_events": "Cargando eventos..."
|
||||
"loading_events": "Cargando eventos...",
|
||||
"events_load_failed": "No se pudieron cargar los eventos. Es posible que el calendario no esté actualizado.",
|
||||
"retry": "Intentar de nuevo"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Título del nuevo evento",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "در حال بارگذاری تقویمها...",
|
||||
"loading_events": "در حال بارگذاری رویدادها..."
|
||||
"loading_events": "در حال بارگذاری رویدادها...",
|
||||
"events_load_failed": "بارگذاری رویدادها ممکن نشد. ممکن است تقویم بهروز نباشد.",
|
||||
"retry": "دوباره تلاش کنید"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "عنوان رویداد جدید",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Chargement des calendriers...",
|
||||
"loading_events": "Chargement des événements..."
|
||||
"loading_events": "Chargement des événements...",
|
||||
"events_load_failed": "Impossible de charger les événements. Le calendrier n'est peut-être pas à jour.",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Titre du nouvel événement",
|
||||
|
||||
@@ -2957,7 +2957,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "טוען יומנים...",
|
||||
"loading_events": "טוען אירועים..."
|
||||
"loading_events": "טוען אירועים...",
|
||||
"events_load_failed": "לא ניתן לטעון את האירועים. ייתכן שהיומן אינו מעודכן.",
|
||||
"retry": "נסה שוב"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "כותרת אירוע חדשה",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Naptárak betöltése...",
|
||||
"loading_events": "Események betöltése..."
|
||||
"loading_events": "Események betöltése...",
|
||||
"events_load_failed": "Nem sikerült betölteni az eseményeket. Előfordulhat, hogy a naptár nem naprakész.",
|
||||
"retry": "Próbáld újra"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Új esemény címe",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Caricamento calendari...",
|
||||
"loading_events": "Caricamento eventi..."
|
||||
"loading_events": "Caricamento eventi...",
|
||||
"events_load_failed": "Impossibile caricare gli eventi. Il calendario potrebbe non essere aggiornato.",
|
||||
"retry": "Riprova"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Titolo del nuovo evento",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "カレンダーを読み込み中...",
|
||||
"loading_events": "予定を読み込み中..."
|
||||
"loading_events": "予定を読み込み中...",
|
||||
"events_load_failed": "予定を読み込めませんでした。カレンダーが最新でない可能性があります。",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "新しい予定のタイトル",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "캘린더를 불러오는 중...",
|
||||
"loading_events": "일정을 불러오는 중..."
|
||||
"loading_events": "일정을 불러오는 중...",
|
||||
"events_load_failed": "일정을 불러오지 못했어요. 캘린더가 최신 상태가 아닐 수 있어요.",
|
||||
"retry": "다시 시도"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "새 일정 제목",
|
||||
|
||||
@@ -3032,7 +3032,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Ielādē kalendārus...",
|
||||
"loading_events": "Ielādē pasākumus..."
|
||||
"loading_events": "Ielādē pasākumus...",
|
||||
"events_load_failed": "Neizdevās ielādēt pasākumus. Kalendārs var nebūt aktuāls.",
|
||||
"retry": "Mēģināt vēlreiz"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Jauna pasākuma nosaukums",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Хуанли ачаалж байна...",
|
||||
"loading_events": "Арга хэмжээг ачаалж байна..."
|
||||
"loading_events": "Арга хэмжээг ачаалж байна...",
|
||||
"events_load_failed": "Арга хэмжээг ачаалж чадсангүй. Хуанли шинэчлэгдээгүй байж магадгүй.",
|
||||
"retry": "Дахин оролдоно уу"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Шинэ үйл явдлын гарчиг",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Laster inn kalendere...",
|
||||
"loading_events": "Laster inn hendelser..."
|
||||
"loading_events": "Laster inn hendelser...",
|
||||
"events_load_failed": "Kunne ikke laste inn hendelser. Kalenderen er kanskje ikke oppdatert.",
|
||||
"retry": "Prøv igjen"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Tittel på ny hendelse",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Agenda's laden...",
|
||||
"loading_events": "Evenementen laden..."
|
||||
"loading_events": "Evenementen laden...",
|
||||
"events_load_failed": "Evenementen konden niet worden geladen. De agenda is mogelijk niet actueel.",
|
||||
"retry": "Opnieuw proberen"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Titel nieuw evenement",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Ładowanie kalendarzy...",
|
||||
"loading_events": "Ładowanie wydarzeń..."
|
||||
"loading_events": "Ładowanie wydarzeń...",
|
||||
"events_load_failed": "Nie udało się wczytać wydarzeń. Kalendarz może być nieaktualny.",
|
||||
"retry": "Spróbuj ponownie"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Tytuł nowego wydarzenia",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Carregando calendários...",
|
||||
"loading_events": "Carregando eventos..."
|
||||
"loading_events": "Carregando eventos...",
|
||||
"events_load_failed": "Não foi possível carregar os eventos. O calendário pode estar desatualizado.",
|
||||
"retry": "Tentar novamente"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Título do novo evento",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Se încarcă calendarele...",
|
||||
"loading_events": "Se încarcă evenimente..."
|
||||
"loading_events": "Se încarcă evenimente...",
|
||||
"events_load_failed": "Nu s-au putut încărca evenimentele. Este posibil ca calendarul să nu fie actualizat.",
|
||||
"retry": "Încercați din nou"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Titlul unui eveniment nou",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Загрузка календарей...",
|
||||
"loading_events": "Загрузка событий..."
|
||||
"loading_events": "Загрузка событий...",
|
||||
"events_load_failed": "Не удалось загрузить события. Календарь может быть неактуален.",
|
||||
"retry": "Попробовать снова"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Название нового события",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Načítavanie kalendárov...",
|
||||
"loading_events": "Načítavanie udalostí..."
|
||||
"loading_events": "Načítavanie udalostí...",
|
||||
"events_load_failed": "Udalosti sa nepodarilo načítať. Kalendár nemusí byť aktuálny.",
|
||||
"retry": "Skúsiť znova"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Názov novej udalosti",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Takvimler yükleniyor...",
|
||||
"loading_events": "Etkinlikler yükleniyor..."
|
||||
"loading_events": "Etkinlikler yükleniyor...",
|
||||
"events_load_failed": "Etkinlikler yüklenemedi. Takvim güncel olmayabilir.",
|
||||
"retry": "Tekrar dene"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Yeni etkinlik başlığı",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Завантаження календарів...",
|
||||
"loading_events": "Завантаження подій..."
|
||||
"loading_events": "Завантаження подій...",
|
||||
"events_load_failed": "Не вдалося завантажити події. Календар може бути неактуальним.",
|
||||
"retry": "Спробуйте знову"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "Нова назва події",
|
||||
|
||||
@@ -3059,7 +3059,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "正在載入行事曆…",
|
||||
"loading_events": "正在載入活動…"
|
||||
"loading_events": "正在載入活動…",
|
||||
"events_load_failed": "無法載入活動。行事曆可能不是最新狀態。",
|
||||
"retry": "再試一次"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "新活動標題",
|
||||
|
||||
@@ -3033,7 +3033,9 @@
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "正在加载日历...",
|
||||
"loading_events": "正在加载事件..."
|
||||
"loading_events": "正在加载事件...",
|
||||
"events_load_failed": "无法加载事件。日历可能不是最新的。",
|
||||
"retry": "再试一次"
|
||||
},
|
||||
"quick_create": {
|
||||
"placeholder": "新活动标题",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useCalendarStore, resolveMutationTarget } from '../calendar-store';
|
||||
import { EVENTS_LOAD_ERROR, useCalendarStore, resolveMutationTarget } from '../calendar-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { jmapError } from '@/lib/jmap/jmap-error';
|
||||
|
||||
// Occurrences produced by server-side recurrence expansion carry synthetic
|
||||
// ids (lib/recurrence-instances.ts). The store writes "this occurrence only"
|
||||
@@ -369,3 +370,53 @@ describe('rsvpEvent on an expanded occurrence', () => {
|
||||
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a range that fails to load', () => {
|
||||
// What an unfixed client got from Stalwart 1.0 for every range.
|
||||
const refused = () => jmapError(
|
||||
'CalendarEvent/get failed: recurrenceOverrides cannot be requested together with utcStart or utcEnd',
|
||||
{ type: 'invalidArguments' },
|
||||
);
|
||||
const NEXT = ['2026-10-01T00:00:00', '2026-10-31T23:59:59'] as const;
|
||||
|
||||
it('keeps the events on screen and reports the failure on Stalwart 1.0', async () => {
|
||||
const client = fakeClient();
|
||||
client.queryAllCalendarEvents.mockResolvedValueOnce([occurrence()]);
|
||||
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
|
||||
|
||||
client.queryAllCalendarEvents.mockRejectedValueOnce(refused());
|
||||
await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]);
|
||||
|
||||
const state = useCalendarStore.getState();
|
||||
expect(state.events.map((e) => e.id)).toEqual(['maaaaab']);
|
||||
expect(state.error).toBe(EVENTS_LOAD_ERROR);
|
||||
expect(state.isLoadingEvents).toBe(false);
|
||||
// Still the range that is actually loaded.
|
||||
expect(state.dateRange).toEqual({ start: RANGE[0], end: RANGE[1] });
|
||||
|
||||
client.queryAllCalendarEvents.mockResolvedValueOnce([]);
|
||||
await useCalendarStore.getState().fetchEvents(client, NEXT[0], NEXT[1]);
|
||||
expect(useCalendarStore.getState().error).toBeNull();
|
||||
expect(useCalendarStore.getState().events).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the events of an account that fails when loading every account', async () => {
|
||||
const first = fakeClient();
|
||||
first.queryAllCalendarEvents.mockResolvedValue([occurrence({ title: 'First' })]);
|
||||
const second = fakeClient();
|
||||
second.queryAllCalendarEvents
|
||||
.mockResolvedValueOnce([occurrence({ title: 'Second, loaded before' })])
|
||||
.mockRejectedValueOnce(refused());
|
||||
const accounts = [{ localAccountId: 'A@h', client: first }, { localAccountId: 'B@h', client: second }];
|
||||
|
||||
await useCalendarStore.getState().fetchAllAccountsEvents(accounts, RANGE[0], RANGE[1]);
|
||||
await useCalendarStore.getState().fetchAllAccountsEvents(accounts, NEXT[0], NEXT[1]);
|
||||
|
||||
const state = useCalendarStore.getState();
|
||||
expect(state.events.map((e) => [e.id, e.title])).toEqual([
|
||||
['A@h::maaaaab', 'First'],
|
||||
['B@h::maaaaab', 'Second, loaded before'],
|
||||
]);
|
||||
expect(state.error).toBe(EVENTS_LOAD_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -479,6 +479,12 @@ 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[];
|
||||
@@ -631,8 +637,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
||||
} catch (error) {
|
||||
// Keep the events already on screen: an empty grid would read as
|
||||
// "no events".
|
||||
debug.error('Failed to fetch events:', error);
|
||||
set({ error: 'Failed to load events', isLoadingEvents: false });
|
||||
set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -668,6 +676,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
refetchVisibleRange = () => get().fetchAllAccountsEvents(accounts, start, end, { silent: true });
|
||||
set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null });
|
||||
try {
|
||||
let failed = false;
|
||||
const results = await Promise.all(
|
||||
accounts.map(async ({ client, localAccountId }) => {
|
||||
try {
|
||||
@@ -679,14 +688,21 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
return prefixEventsWithLocalAccount(expanded, localAccountId);
|
||||
} catch (error) {
|
||||
debug.error(`Failed to fetch events for account ${localAccountId}:`, error);
|
||||
return [] as CalendarEvent[];
|
||||
failed = true;
|
||||
// Keep what this account showed instead of blanking it.
|
||||
return get().events.filter(e => e.localAccountId === localAccountId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } });
|
||||
set({
|
||||
events: results.flat(),
|
||||
isLoadingEvents: false,
|
||||
dateRange: { start, end },
|
||||
...(failed ? { error: EVENTS_LOAD_ERROR } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch all-account events:', error);
|
||||
set({ error: 'Failed to load events', isLoadingEvents: false });
|
||||
set({ error: EVENTS_LOAD_ERROR, isLoadingEvents: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user