diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 3faac220d..54565ca37 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -9,7 +9,7 @@ import { FirstTouchGate } from "./first-touch-gate"; import { debug } from "@/lib/debug"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; import { SYNTHETIC_ID_PROBE, RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; -import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection"; +import { isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection"; import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection"; import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; import { decodeFileNodeName } from "./filenode-name"; @@ -6521,53 +6521,69 @@ export class JMAPClient implements IJMAPClient { /** * Ids of the given calendars that hold only tasks and no events, so the event * calendar UI can hide them. Stalwart's Calendar/get exposes no - * supported-component set, so this is derived by scanning the objects. + * supported-component set, so this is derived by inspecting objects per calendar. * - * Cheap in the common case and safe on the edges: each page is classified as - * it arrives and, as soon as EVERY calendar has been seen to contain an event, - * the scan stops (a normal event account exits after one page). It only marks - * a calendar tasks-only after scanning ALL of that account's objects; if the - * account has more than the scan cap, it fails OPEN (marks nothing) rather - * than risk hiding a calendar whose events sit past the cap. Any error also - * fails open. Empty calendars are never marked (they must stay visible). + * Queries each calendar specifically via `inCalendar` condition and checks + * whether it contains any non-task (event) objects. Empty calendars stay visible. */ private async getTasksOnlyCalendarIds(accountId: string, rawCalendarIds: string[]): Promise> { if (rawCalendarIds.length === 0) return new Set(); - const QUERY_PAGE = 500; - const MAX_SCAN = 5000; // safety bound; beyond this we fail open + const QUERY_LIMIT = 200; const PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds']; const timeZone = getUserTimeZone(); - const objects: ScannedCalendarObject[] = []; - const remaining = new Set(rawCalendarIds); // calendars not yet known to hold an event + const tasksOnly = new Set(); try { - let exhausted = false; - for (let position = 0; position < MAX_SCAN;) { - const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; - if (timeZone) queryArgs.timeZone = timeZone; - const qResp = await this.request([["CalendarEvent/query", queryArgs, "0"]], this.calendarUsing()); - if (qResp.methodResponses?.[0]?.[0] === "error") return new Set(); // fail open - const pageIds: string[] = qResp.methodResponses?.[0]?.[1]?.ids || []; - if (pageIds.length === 0) { exhausted = true; break; } + const queryCalls: JMAPMethodCall[] = rawCalendarIds.map((calId, idx) => [ + "CalendarEvent/query", + { + accountId, + filter: { inCalendar: calId }, + limit: QUERY_LIMIT, + ...(timeZone ? { timeZone } : {}), + }, + `q_${idx}`, + ]); - const getResp = await this.request([ - ["CalendarEvent/get", { accountId, ids: pageIds, properties: PROPERTIES, ...(timeZone ? { timeZone } : {}) }, "0"] - ], this.calendarUsing()); - const list = (getResp.methodResponses?.[0]?.[1]?.list || []) as ScannedCalendarObject[]; - for (const obj of list) { - objects.push(obj); - if (!isTaskLikeObject(obj) && obj.calendarIds) { - for (const id of Object.keys(obj.calendarIds)) remaining.delete(id); - } + const qResp = await this.request(queryCalls, this.calendarUsing()); + if (!qResp.methodResponses || qResp.methodResponses.length === 0) return new Set(); + + const calQueriesToFetch: { calId: string; pageIds: string[] }[] = []; + for (let i = 0; i < rawCalendarIds.length; i++) { + const tag = `q_${i}`; + const match = qResp.methodResponses.find((r) => r[2] === tag && r[0] === "CalendarEvent/query"); + if (!match) continue; + const pageIds: string[] = match[1]?.ids || []; + if (pageIds.length > 0) { + calQueriesToFetch.push({ calId: rawCalendarIds[i], pageIds }); } - // Every calendar has at least one event -> none can be tasks-only. - if (remaining.size === 0) return new Set(); - if (pageIds.length < QUERY_PAGE) { exhausted = true; break; } - position += pageIds.length; } - if (!exhausted) return new Set(); // hit the cap without finishing -> hide nothing - return findTasksOnlyCalendarIds(objects, rawCalendarIds); + + if (calQueriesToFetch.length === 0) return new Set(); + + const allIdsToFetch = Array.from(new Set(calQueriesToFetch.flatMap((c) => c.pageIds))); + const getResp = await this.request([ + ["CalendarEvent/get", { accountId, ids: allIdsToFetch, properties: PROPERTIES, ...(timeZone ? { timeZone } : {}) }, "0"] + ], this.calendarUsing()); + + if (getResp.methodResponses?.[0]?.[0] !== "CalendarEvent/get") return new Set(); + const list = (getResp.methodResponses[0][1]?.list || []) as ScannedCalendarObject[]; + const objectMap = new Map(); + for (const obj of list) { + if (obj.id) objectMap.set(obj.id as string, obj); + } + + for (const { calId, pageIds } of calQueriesToFetch) { + const calObjects = pageIds.map((id) => objectMap.get(id)).filter(Boolean) as ScannedCalendarObject[]; + if (calObjects.length === 0) continue; + const hasEvent = calObjects.some((obj) => !isTaskLikeObject(obj)); + if (!hasEvent) { + tasksOnly.add(calId); + } + } + + return tasksOnly; } catch { return new Set(); // fail open }