Merge pull request #1065 from sanitz/fix/tasks-only-calendar-detection

fix(calendar): detect tasks-only calendars per-calendar instead of scanning whole account
This commit is contained in:
Linus Rath
2026-10-15 23:54:35 +02:00
+52 -36
View File
@@ -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<Set<string>> {
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<string>();
try {
let exhausted = false;
for (let position = 0; position < MAX_SCAN;) {
const queryArgs: Record<string, unknown> = { 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<string, ScannedCalendarObject>();
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
}