fix: dedupe scheduling emails, Stalwart-compatible calendar filters
This commit is contained in:
@@ -323,6 +323,89 @@ describe('expandRecurringEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// excludedRecurrenceRules (EXRULE)
|
||||
// -----------------------------------------------------------------------
|
||||
describe('excludedRecurrenceRules', () => {
|
||||
it('removes occurrences generated by excluded rules', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
excludedRecurrenceRules: [{
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'weekly',
|
||||
byDay: [{ day: 'tu' }],
|
||||
} as any],
|
||||
});
|
||||
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
expect(days).not.toContain('2025-01-07');
|
||||
expect(days).toContain('2025-01-06');
|
||||
expect(days).toContain('2025-01-08');
|
||||
expect(result).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Long-running series
|
||||
// -----------------------------------------------------------------------
|
||||
describe('long-running series', () => {
|
||||
it('expands a daily series started years before the visible range', () => {
|
||||
const event = makeEvent({
|
||||
start: '2022-01-03T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
});
|
||||
const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
'2025-06-02T09:00:00',
|
||||
'2025-06-03T09:00:00',
|
||||
'2025-06-04T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands a weekly series started years before the visible range', () => {
|
||||
const event = makeEvent({
|
||||
start: '2020-01-06T09:00:00', // Monday
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
});
|
||||
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
|
||||
});
|
||||
|
||||
it('still respects count for old series (no fast-forward shortcut)', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 } as any],
|
||||
});
|
||||
// Range far after the 5 occurrences ran out
|
||||
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event-timezone DST handling
|
||||
// -----------------------------------------------------------------------
|
||||
describe('event timezone DST handling', () => {
|
||||
it('keeps wall time across a DST transition in the event timezone', () => {
|
||||
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
|
||||
const event = makeEvent({
|
||||
start: '2025-03-03T10:00:00',
|
||||
timeZone: 'America/New_York',
|
||||
utcStart: '2025-03-03T15:00:00Z',
|
||||
utcEnd: '2025-03-03T16:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
} as Partial<CalendarEvent>);
|
||||
const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00');
|
||||
const utcStarts = result.map(e => (e as any).utcStart);
|
||||
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
|
||||
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
|
||||
const utcEnds = result.map(e => (e as any).utcEnd);
|
||||
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// All-day events
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+150
-170
@@ -358,12 +358,53 @@ function foldIcsLine(line: string): string {
|
||||
return chunks.join('\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a TEXT property value per RFC 5545 §3.3.11. Backslash, semicolon,
|
||||
* comma and newlines must be escaped - otherwise a title like "1,2;3" or a
|
||||
* multi-line description corrupts the component.
|
||||
* @see https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11
|
||||
*/
|
||||
function escapeIcsText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r\n|\r|\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a parameter value (e.g. CN=) per RFC 5545 §3.2: values containing
|
||||
* COLON, SEMICOLON or COMMA must be quoted; DQUOTE itself is not allowed in
|
||||
* parameter values, so replace it.
|
||||
*/
|
||||
function icsParamValue(value: string): string {
|
||||
const cleaned = value.replace(/[\r\n"]/g, "'");
|
||||
return /[;:,]/.test(cleaned) ? `"${cleaned}"` : cleaned;
|
||||
}
|
||||
|
||||
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
|
||||
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
|
||||
function stripMessageIdBrackets(id: string): string {
|
||||
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CalendarEvent/query filter restricting results to the given
|
||||
* calendars. Stalwart implements the singular `inCalendar` condition (one
|
||||
* calendar id per condition), not the draft's plural `inCalendars` array -
|
||||
* sending the plural form fails the whole query with `unsupportedFilter`.
|
||||
* Multiple calendars are expressed as an OR of singular conditions.
|
||||
*/
|
||||
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
|
||||
if (calendarIds.length === 1) {
|
||||
return { inCalendar: calendarIds[0] };
|
||||
}
|
||||
return {
|
||||
operator: 'OR',
|
||||
conditions: calendarIds.map((id) => ({ inCalendar: id })),
|
||||
};
|
||||
}
|
||||
|
||||
// Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox
|
||||
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
|
||||
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
|
||||
@@ -2374,7 +2415,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
/**
|
||||
* Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP.
|
||||
* This is needed when the server does not handle sendSchedulingMessages.
|
||||
*
|
||||
* Fallback only: when `sendSchedulingMessages` is passed to the
|
||||
* CalendarEvent/set RSVP patch, the server sends the iTIP REPLY itself -
|
||||
* calling this in addition produces duplicate reply emails.
|
||||
*/
|
||||
async sendImipReply(opts: {
|
||||
organizerEmail: string;
|
||||
@@ -2474,14 +2518,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
if (opts.summary) {
|
||||
lines.push(`SUMMARY:${opts.summary}`);
|
||||
lines.push(`SUMMARY:${escapeIcsText(opts.summary)}`);
|
||||
}
|
||||
if (opts.sequence != null) {
|
||||
lines.push(`SEQUENCE:${opts.sequence}`);
|
||||
}
|
||||
const orgCn = opts.organizerName ? `;CN=${opts.organizerName}` : '';
|
||||
const orgCn = opts.organizerName ? `;CN=${icsParamValue(opts.organizerName)}` : '';
|
||||
lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`);
|
||||
const attCn = opts.attendeeName ? `;CN=${opts.attendeeName}` : '';
|
||||
const attCn = opts.attendeeName ? `;CN=${icsParamValue(opts.attendeeName)}` : '';
|
||||
lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`);
|
||||
lines.push('END:VEVENT');
|
||||
lines.push('END:VCALENDAR');
|
||||
@@ -2568,7 +2612,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
/**
|
||||
* Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event.
|
||||
* Used when creating or updating an event with participants.
|
||||
*
|
||||
* Fallback only: when `sendSchedulingMessages` is passed to CalendarEvent/set,
|
||||
* the server (Stalwart) queues the iTIP messages itself - calling this in
|
||||
* addition produces duplicate invitation emails. Note the generated ICS is
|
||||
* a minimal snapshot (no RRULE/VTIMEZONE), so server-side scheduling should
|
||||
* always be preferred.
|
||||
*/
|
||||
async sendImipInvitation(event: CalendarEvent): Promise<void> {
|
||||
if (!event.participants) return;
|
||||
@@ -2639,7 +2688,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.utcEnd) {
|
||||
// Prefer DURATION over DTEND (RFC 5545 §3.6.1): DTSTART above is a local
|
||||
// date-time (optionally with TZID) while event.utcEnd is a UTC instant -
|
||||
// emitting both mixed reference frames, and paired a floating DTSTART
|
||||
// with a UTC DTEND for events without a timezone.
|
||||
if (event.duration) {
|
||||
lines.push(`DURATION:${event.duration}`);
|
||||
} else if (event.utcEnd) {
|
||||
if (event.showWithoutTime) {
|
||||
const dateOnly = event.utcEnd.replace(/[-]/g, '').substring(0, 8);
|
||||
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
||||
@@ -2647,23 +2702,20 @@ export class JMAPClient implements IJMAPClient {
|
||||
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
|
||||
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
|
||||
}
|
||||
} else if (event.duration) {
|
||||
// Fallback: emit DURATION when utcEnd is absent (RFC 5545 §3.6.1)
|
||||
lines.push(`DURATION:${event.duration}`);
|
||||
}
|
||||
|
||||
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
||||
if (event.description) lines.push(`DESCRIPTION:${event.description}`);
|
||||
if (event.title) lines.push(`SUMMARY:${escapeIcsText(event.title)}`);
|
||||
if (event.description) lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`);
|
||||
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
||||
if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`);
|
||||
|
||||
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
||||
const orgCn = organizerName ? `;CN=${icsParamValue(organizerName)}` : '';
|
||||
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
||||
|
||||
for (const attendee of attendees) {
|
||||
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
||||
if (!email) continue;
|
||||
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
||||
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
|
||||
const partstat = attendee.participationStatus
|
||||
? `;PARTSTAT=${attendee.participationStatus.toUpperCase()}`
|
||||
: ';PARTSTAT=NEEDS-ACTION';
|
||||
@@ -2738,7 +2790,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
/**
|
||||
* Send an iMIP (RFC 6047) CANCEL email to all participants of a calendar event.
|
||||
* Used when deleting an event that has participants.
|
||||
*
|
||||
* Fallback only: when `sendSchedulingMessages` is passed to the
|
||||
* CalendarEvent/set destroy, the server sends the iTIP CANCEL itself -
|
||||
* calling this in addition produces duplicate cancellation emails.
|
||||
*/
|
||||
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
||||
if (!event.participants) return;
|
||||
@@ -2810,16 +2865,16 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
||||
if (event.title) lines.push(`SUMMARY:${escapeIcsText(event.title)}`);
|
||||
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
||||
|
||||
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
||||
const orgCn = organizerName ? `;CN=${icsParamValue(organizerName)}` : '';
|
||||
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
||||
|
||||
for (const attendee of attendees) {
|
||||
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
||||
if (!email) continue;
|
||||
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
||||
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
|
||||
lines.push(`ATTENDEE${cn}:mailto:${email}`);
|
||||
}
|
||||
|
||||
@@ -4193,7 +4248,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
queryArgs.filter = { inCalendars: calendarIds };
|
||||
queryArgs.filter = buildInCalendarFilter(calendarIds);
|
||||
}
|
||||
|
||||
// First, query to get all IDs
|
||||
@@ -4693,86 +4748,96 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
|
||||
|
||||
/**
|
||||
* Fetch all Task objects via the CalendarEvent endpoints.
|
||||
*
|
||||
* Stalwart has no `types` filter on CalendarEvent/query (it fails the whole
|
||||
* query with `unsupportedFilter`), and the previous CalendarEvent/get
|
||||
* ids:null fallback was capped at the server's maxObjectsInGet (500 by
|
||||
* default), silently hiding tasks in larger accounts. Instead, page through
|
||||
* CalendarEvent/query (which returns events *and* tasks), fetch in
|
||||
* /get-sized batches, and detect tasks client-side.
|
||||
*/
|
||||
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
debug.group('CalendarTask/fetch', 'tasks');
|
||||
debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
|
||||
|
||||
try {
|
||||
// Strategy 1: query with types filter (JMAP spec compliant)
|
||||
const filter: Record<string, unknown> = { types: ['Task'] };
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
filter.inCalendars = calendarIds;
|
||||
}
|
||||
// Page through the query to collect all object ids.
|
||||
const QUERY_PAGE = 1000;
|
||||
const MAX_IDS = 50000; // safety bound
|
||||
const ids: string[] = [];
|
||||
for (let position = 0; position < MAX_IDS;) {
|
||||
const queryArgs: Record<string, unknown> = { accountId, limit: QUERY_PAGE, position };
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
queryArgs.filter = buildInCalendarFilter(calendarIds);
|
||||
}
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", queryArgs, "0"],
|
||||
], this.calendarUsing());
|
||||
|
||||
debug.log('tasks', 'CalendarTask/fetch query filter', filter);
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_TASK_PROPERTIES],
|
||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.calendarUsing());
|
||||
|
||||
const queryResponse = response.methodResponses?.[0];
|
||||
const getResponse = response.methodResponses?.[1];
|
||||
|
||||
debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]);
|
||||
debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]);
|
||||
|
||||
if (queryResponse?.[0] === "error") {
|
||||
debug.warn('tasks', 'CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
|
||||
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
||||
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
||||
debug.groupEnd();
|
||||
return tasks;
|
||||
}
|
||||
|
||||
if (getResponse?.[0] === "CalendarEvent/get") {
|
||||
const list = (getResponse[1].list || []) as CalendarTask[];
|
||||
const queryIds = queryResponse?.[1]?.ids || [];
|
||||
debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
|
||||
debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects');
|
||||
|
||||
// If the types filter returned 0 results, the server may have silently
|
||||
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
|
||||
// a full scan so we can detect tasks by their properties.
|
||||
if (queryIds.length === 0) {
|
||||
debug.warn('tasks', 'CalendarTask/fetch types filter returned 0 results, falling back to full scan');
|
||||
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
|
||||
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
|
||||
if (response.methodResponses?.[0]?.[0] === "error") {
|
||||
const error = response.methodResponses[0][1];
|
||||
debug.warn('tasks', 'CalendarTask/fetch query failed', error);
|
||||
debug.groupEnd();
|
||||
return tasks;
|
||||
return [];
|
||||
}
|
||||
|
||||
list.forEach((task, i) => {
|
||||
debug.log('tasks', `CalendarTask/fetch [${i}]`, {
|
||||
id: task.id,
|
||||
uid: task.uid,
|
||||
'@type': task['@type'],
|
||||
title: task.title,
|
||||
due: task.due,
|
||||
start: task.start,
|
||||
progress: task.progress,
|
||||
showWithoutTime: task.showWithoutTime,
|
||||
calendarIds: task.calendarIds,
|
||||
});
|
||||
});
|
||||
|
||||
const results = list.map((task) => ({
|
||||
...task,
|
||||
'@type': 'Task' as const,
|
||||
}));
|
||||
debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks');
|
||||
debug.groupEnd();
|
||||
return results;
|
||||
const pageIds: string[] = response.methodResponses?.[0]?.[1]?.ids || [];
|
||||
ids.push(...pageIds);
|
||||
if (pageIds.length < QUERY_PAGE) break;
|
||||
position += pageIds.length;
|
||||
}
|
||||
|
||||
debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses);
|
||||
debug.log('tasks', 'CalendarTask/fetch query returned', ids.length, 'object ids');
|
||||
if (ids.length === 0) {
|
||||
debug.groupEnd();
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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,
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
allObjects.push(...(getResponse.methodResponses[0][1].list || []));
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: CalendarTask[] = [];
|
||||
for (const obj of allObjects) {
|
||||
const type = obj['@type'];
|
||||
const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task';
|
||||
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
|
||||
// set to something other than 'Event'. Detect them by the presence of
|
||||
// task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2
|
||||
// defines as Task-only - a VEVENT will never include them in the response.
|
||||
// We check for key presence (even if null) because Stalwart may return null
|
||||
// instead of the RFC defaults (e.g. progress default is "needs-action").
|
||||
// @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2
|
||||
const hasTaskFields = ('due' in obj)
|
||||
|| ('progress' in obj)
|
||||
|| ('percentComplete' in obj);
|
||||
const isCalDavTask = type !== 'Event' && hasTaskFields;
|
||||
|
||||
if (!isExplicitTask && !isCalDavTask) continue;
|
||||
|
||||
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
|
||||
}
|
||||
|
||||
debug.log('tasks', 'CalendarTask/fetch complete,', tasks.length, 'tasks of', allObjects.length, 'objects');
|
||||
debug.groupEnd();
|
||||
return [];
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
debug.error('CalendarTask/fetch failed', error);
|
||||
debug.groupEnd();
|
||||
@@ -4780,91 +4845,6 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for servers that don't support the `types` filter in CalendarEvent/query.
|
||||
* Uses CalendarEvent/get with ids:null to fetch ALL calendar objects (JMAP spec),
|
||||
* since CalendarEvent/query may only return Event-type objects on some servers.
|
||||
*/
|
||||
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
debug.log('calendar', 'CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
|
||||
|
||||
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
|
||||
const response = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
ids: null,
|
||||
properties: [...CALENDAR_TASK_PROPERTIES],
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
|
||||
debug.warn('calendar', 'CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
|
||||
return [];
|
||||
}
|
||||
|
||||
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
|
||||
debug.log('tasks', 'CalendarTask/fallback total calendar objects returned:', allObjects.length);
|
||||
|
||||
const tasks: CalendarTask[] = [];
|
||||
const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
|
||||
|
||||
allObjects.forEach((obj) => {
|
||||
const type = obj['@type'];
|
||||
const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task';
|
||||
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
|
||||
// set to something other than 'Event'. Detect them by the presence of
|
||||
// task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2
|
||||
// defines as Task-only - a VEVENT will never include them in the response.
|
||||
// We check for key presence (even if null) because Stalwart may return null
|
||||
// instead of the RFC defaults (e.g. progress default is "needs-action").
|
||||
// @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2
|
||||
const hasTaskFields = ('due' in obj)
|
||||
|| ('progress' in obj)
|
||||
|| ('percentComplete' in obj);
|
||||
const isCalDavTask = type !== 'Event' && hasTaskFields;
|
||||
|
||||
debug.log('tasks', 'CalendarTask/fallback scan', {
|
||||
id: obj.id,
|
||||
'@type': type,
|
||||
title: obj.title,
|
||||
hasProgress: 'progress' in obj,
|
||||
progress: obj.progress,
|
||||
due: obj.due,
|
||||
isExplicitTask,
|
||||
isCalDavTask,
|
||||
});
|
||||
|
||||
if (!isExplicitTask && !isCalDavTask) return;
|
||||
|
||||
// Filter by calendar if requested
|
||||
if (calendarIdSet) {
|
||||
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
|
||||
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
|
||||
debug.log('tasks', 'CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
|
||||
});
|
||||
|
||||
debug.log('tasks', 'CalendarTask/fallback detected', tasks.length, 'tasks');
|
||||
tasks.forEach((t, i) => {
|
||||
debug.log('tasks', `CalendarTask/fallback [${i}]`, {
|
||||
id: t.id,
|
||||
uid: t.uid,
|
||||
title: t.title,
|
||||
due: t.due,
|
||||
progress: t.progress,
|
||||
showWithoutTime: t.showWithoutTime,
|
||||
calendarIds: t.calendarIds,
|
||||
});
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const { '@type': _type, ...taskData } = task;
|
||||
|
||||
+192
-27
@@ -11,7 +11,7 @@
|
||||
* in the browser.
|
||||
*/
|
||||
|
||||
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
||||
import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns';
|
||||
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
|
||||
|
||||
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
||||
@@ -64,6 +64,10 @@ export function expandRecurringEvents(
|
||||
return result;
|
||||
}
|
||||
|
||||
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
|
||||
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
||||
}
|
||||
|
||||
function expandEvent(
|
||||
master: CalendarEvent,
|
||||
rangeStart: Date,
|
||||
@@ -77,13 +81,23 @@ function expandEvent(
|
||||
const occurrences: CalendarEvent[] = [];
|
||||
const seenDates = new Set<string>();
|
||||
|
||||
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
|
||||
// (iCalendar EXRULE) are removed from the recurrence set.
|
||||
const excludedDates = new Set<string>();
|
||||
for (const exRule of master.excludedRecurrenceRules || []) {
|
||||
// includeStartDate=false: "the series start is always an occurrence"
|
||||
// applies to recurrence rules, not to exclusion rules.
|
||||
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
|
||||
excludedDates.add(occurrenceDateKey(master, date));
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
|
||||
for (const date of dates) {
|
||||
const dateKey = master.showWithoutTime
|
||||
? format(date, 'yyyy-MM-dd')
|
||||
: date.toISOString();
|
||||
const dateKey = occurrenceDateKey(master, date);
|
||||
|
||||
if (excludedDates.has(dateKey)) continue;
|
||||
if (seenDates.has(dateKey)) continue;
|
||||
seenDates.add(dateKey);
|
||||
|
||||
@@ -106,9 +120,9 @@ function expandEvent(
|
||||
if (isNaN(overrideDate.getTime())) continue;
|
||||
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
|
||||
|
||||
const dateKey = master.showWithoutTime
|
||||
? format(overrideDate, 'yyyy-MM-dd')
|
||||
: overrideDate.toISOString();
|
||||
// Overrides take precedence over excludedRecurrenceRules (they re-add
|
||||
// a concrete instance), so only dedupe against already-generated dates.
|
||||
const dateKey = occurrenceDateKey(master, overrideDate);
|
||||
if (seenDates.has(dateKey)) continue;
|
||||
seenDates.add(dateKey);
|
||||
|
||||
@@ -118,6 +132,65 @@ function expandEvent(
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
// Cache Intl formatters per IANA timezone id - constructing them is expensive
|
||||
// and expansion runs over hundreds of occurrences. `null` marks ids the
|
||||
// runtime rejected so we don't retry them.
|
||||
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
|
||||
|
||||
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
|
||||
let formatter = tzFormatterCache.get(timeZone);
|
||||
if (formatter === undefined) {
|
||||
try {
|
||||
formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
formatter = null;
|
||||
}
|
||||
tzFormatterCache.set(timeZone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
|
||||
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of formatter.formatToParts(new Date(instant))) {
|
||||
if (part.type !== 'literal') map[part.type] = part.value;
|
||||
}
|
||||
const hour = map.hour === '24' ? 0 : Number(map.hour);
|
||||
return Date.UTC(
|
||||
Number(map.year), Number(map.month) - 1, Number(map.day),
|
||||
hour, Number(map.minute), Number(map.second),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
|
||||
* and return the corresponding UTC instant. Two fixup iterations converge
|
||||
* for all real offsets, including across DST transitions.
|
||||
*/
|
||||
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
|
||||
const formatter = getTzFormatter(timeZone);
|
||||
if (!formatter) return null;
|
||||
const wallAsUtc = Date.UTC(
|
||||
wall.getFullYear(), wall.getMonth(), wall.getDate(),
|
||||
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
|
||||
);
|
||||
let guess = wallAsUtc;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
||||
}
|
||||
return new Date(guess);
|
||||
}
|
||||
|
||||
function createOccurrence(
|
||||
master: CalendarEvent,
|
||||
date: Date,
|
||||
@@ -133,17 +206,33 @@ function createOccurrence(
|
||||
// return the correct dates instead of the master's original UTC times.
|
||||
let utcStart: string | undefined;
|
||||
let utcEnd: string | undefined;
|
||||
if (!master.showWithoutTime && master.utcStart && master.start) {
|
||||
const masterLocal = parseISO(master.start);
|
||||
const masterUtc = parseISO(master.utcStart);
|
||||
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
|
||||
utcStart = new Date(date.getTime() + offsetMs).toISOString();
|
||||
if (!master.showWithoutTime) {
|
||||
let durationMs: number | null = null;
|
||||
if (master.utcStart && master.utcEnd) {
|
||||
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
|
||||
if (!isNaN(ms)) durationMs = ms;
|
||||
}
|
||||
|
||||
// Shift utcEnd by the same amount as utcStart
|
||||
if (master.utcEnd) {
|
||||
const masterUtcEnd = parseISO(master.utcEnd);
|
||||
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
|
||||
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
|
||||
// Convert the occurrence's wall time using the event's own timezone, so
|
||||
// occurrences on the other side of a DST transition in that zone keep
|
||||
// their wall time. Reusing the master's fixed UTC offset (the fallback
|
||||
// below) would shift them by the DST delta.
|
||||
const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
|
||||
if (zoned) {
|
||||
utcStart = zoned.toISOString();
|
||||
if (durationMs !== null) {
|
||||
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
|
||||
}
|
||||
} else if (master.utcStart && master.start) {
|
||||
// Floating events (or an unrecognized timezone id): keep the master's
|
||||
// offset, which by definition doesn't vary.
|
||||
const masterLocal = parseISO(master.start);
|
||||
const masterUtc = parseISO(master.utcStart);
|
||||
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
|
||||
utcStart = new Date(date.getTime() + offsetMs).toISOString();
|
||||
if (durationMs !== null) {
|
||||
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +307,7 @@ function generateDates(
|
||||
rawRule: CalendarRecurrenceRule,
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
includeStartDate = true,
|
||||
): Date[] {
|
||||
const rule = addImplicitByX(rawRule, eventStart);
|
||||
const dates: Date[] = [];
|
||||
@@ -225,20 +315,34 @@ function generateDates(
|
||||
const countLimit = rule.count || Infinity;
|
||||
const until = rule.until ? parseISO(rule.until) : null;
|
||||
let totalCount = 0;
|
||||
let current = new Date(eventStart);
|
||||
// Without a `count` limit we can jump straight to the period containing
|
||||
// the visible range. With one, every occurrence since the series start
|
||||
// must be generated so it counts against `count`.
|
||||
let current = rule.count
|
||||
? new Date(eventStart)
|
||||
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
|
||||
|
||||
const maxIterations = 2000;
|
||||
// Cap on *emitted* (in-range) dates. This must not count occurrences
|
||||
// before rangeStart, otherwise a series started long ago (e.g. a daily
|
||||
// event from two years back) exhausts the budget before reaching the
|
||||
// visible range and silently renders nothing.
|
||||
const maxOccurrences = 500;
|
||||
let iterations = 0;
|
||||
|
||||
while (iterations++ < maxIterations) {
|
||||
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
||||
if (until && current > until) break;
|
||||
// For frequencies that produce one candidate per iteration at a time,
|
||||
// we can stop when we pass rangeEnd. But for frequencies that expand
|
||||
// into multiple candidates per period, we need the candidate generation.
|
||||
if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly'
|
||||
&& rule.frequency !== 'weekly') break;
|
||||
// Stop once no candidate in this or a later period can fall before
|
||||
// rangeEnd. Monthly/yearly candidates may precede the period anchor
|
||||
// within the same month/year, so floor the anchor before comparing.
|
||||
if (rule.frequency === 'monthly') {
|
||||
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
|
||||
} else if (rule.frequency === 'yearly') {
|
||||
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
|
||||
} else if (current >= rangeEnd) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate candidates for the current period, then filter
|
||||
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
|
||||
@@ -250,7 +354,7 @@ function generateDates(
|
||||
|
||||
for (const d of filtered) {
|
||||
if (until && d > until) break;
|
||||
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
||||
|
||||
// Spec rule 4: eliminate dates before event start
|
||||
if (d < eventStart) continue;
|
||||
@@ -262,7 +366,7 @@ function generateDates(
|
||||
if (d >= rangeEnd) break;
|
||||
}
|
||||
|
||||
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
||||
|
||||
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
|
||||
if (current <= eventStart && iterations === 1) {
|
||||
@@ -272,7 +376,7 @@ function generateDates(
|
||||
}
|
||||
|
||||
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
|
||||
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
|
||||
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
|
||||
if (eventStart >= rangeStart && eventStart < rangeEnd) {
|
||||
// Check it's not already in the list
|
||||
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
|
||||
@@ -535,6 +639,67 @@ function advancePeriod(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jump to the period just before the visible range in O(1), so long-running
|
||||
// series don't burn the iteration budget on years of out-of-range periods.
|
||||
// Only valid for rules without `count` (counted rules must enumerate every
|
||||
// occurrence from the series start).
|
||||
// ---------------------------------------------------------------------------
|
||||
function fastForwardToRange(
|
||||
eventStart: Date,
|
||||
frequency: CalendarRecurrenceRule['frequency'],
|
||||
interval: number,
|
||||
rangeStart: Date,
|
||||
): Date {
|
||||
if (eventStart >= rangeStart) return new Date(eventStart);
|
||||
|
||||
let periods: number;
|
||||
switch (frequency) {
|
||||
case 'secondly':
|
||||
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
|
||||
break;
|
||||
case 'minutely':
|
||||
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
|
||||
break;
|
||||
case 'hourly':
|
||||
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
|
||||
break;
|
||||
case 'daily':
|
||||
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
|
||||
break;
|
||||
case 'weekly':
|
||||
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
|
||||
break;
|
||||
case 'monthly': {
|
||||
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
|
||||
+ (rangeStart.getMonth() - eventStart.getMonth());
|
||||
periods = Math.floor(months / interval);
|
||||
break;
|
||||
}
|
||||
case 'yearly':
|
||||
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
|
||||
break;
|
||||
default:
|
||||
return new Date(eventStart);
|
||||
}
|
||||
|
||||
// Land one full period early so candidates inside the boundary period
|
||||
// (which can precede the period anchor) are still generated.
|
||||
periods = Math.max(0, periods - 1);
|
||||
if (periods === 0) return new Date(eventStart);
|
||||
|
||||
switch (frequency) {
|
||||
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
|
||||
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
|
||||
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
|
||||
case 'daily': return addDays(eventStart, periods * interval);
|
||||
case 'weekly': return addWeeks(eventStart, periods * interval);
|
||||
case 'monthly': return addMonths(eventStart, periods * interval);
|
||||
case 'yearly': return addYears(eventStart, periods * interval);
|
||||
default: return new Date(eventStart);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: expand byDay within a specific month (monthly or yearly context)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user