Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5486db4527 | ||
|
|
3529eb0c0b | ||
|
|
ce519d62b0 |
+1
-2
@@ -11,8 +11,7 @@ jest.mock(
|
||||
'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client',
|
||||
() => ({
|
||||
CalDAVClient: jest.fn().mockImplementation(() => ({
|
||||
listCalendars: jest.fn().mockResolvedValue([]),
|
||||
validateSyncCollectionSupport: jest.fn().mockResolvedValue(undefined),
|
||||
detectServerCapability: jest.fn().mockResolvedValue('sync-collection'),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
+1
-8
@@ -141,20 +141,13 @@ export class ImapSmtpCaldavService {
|
||||
});
|
||||
|
||||
try {
|
||||
await client.listCalendars();
|
||||
await client.validateSyncCollectionSupport();
|
||||
await client.detectServerCapability();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`CALDAV connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
|
||||
if (error.message?.includes('CALDAV_SYNC_COLLECTION_NOT_SUPPORTED')) {
|
||||
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
|
||||
userFriendlyMessage: msg`Your CalDAV server does not support incremental sync (RFC 6578). Please use a compatible provider such as Nextcloud, iCloud, or Fastmail.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'FailedToOpenSocket') {
|
||||
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
|
||||
userFriendlyMessage: msg`We couldn't connect to your CalDAV server. Please check your server settings and try again.`,
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
jest.mock('tsdav', () => {
|
||||
const actual = jest.requireActual('tsdav');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createAccount: jest.fn(),
|
||||
fetchCalendars: jest.fn(),
|
||||
syncCollection: jest.fn(),
|
||||
calendarMultiGet: jest.fn(),
|
||||
propfind: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch',
|
||||
() => ({
|
||||
createBasicDigestAuthFetch: jest.fn(() => globalThis.fetch),
|
||||
}),
|
||||
);
|
||||
|
||||
import {
|
||||
calendarMultiGet,
|
||||
createAccount,
|
||||
fetchCalendars,
|
||||
propfind,
|
||||
syncCollection,
|
||||
} from 'tsdav';
|
||||
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
|
||||
const mockCreateAccount = createAccount as jest.MockedFunction<
|
||||
typeof createAccount
|
||||
>;
|
||||
const mockFetchCalendars = fetchCalendars as jest.MockedFunction<
|
||||
typeof fetchCalendars
|
||||
>;
|
||||
const mockSyncCollection = syncCollection as jest.MockedFunction<
|
||||
typeof syncCollection
|
||||
>;
|
||||
const mockCalendarMultiGet = calendarMultiGet as jest.MockedFunction<
|
||||
typeof calendarMultiGet
|
||||
>;
|
||||
const mockPropfind = propfind as jest.MockedFunction<typeof propfind>;
|
||||
|
||||
const CALENDAR_URL = 'https://caldav.example.com/calendars/user/default/';
|
||||
const FIRST_HREF = `${CALENDAR_URL}event-1.ics`;
|
||||
const SECOND_HREF = `${CALENDAR_URL}event-2.ics`;
|
||||
|
||||
const buildICalData = (uid: string, summary: string) =>
|
||||
[
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`SUMMARY:${summary}`,
|
||||
'DTSTART:20260501T100000Z',
|
||||
'DTEND:20260501T110000Z',
|
||||
'STATUS:CONFIRMED',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const buildClient = () =>
|
||||
new CalDAVClient({
|
||||
serverUrl: 'https://caldav.example.com',
|
||||
username: 'user@example.com',
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
const buildLegacyCalendar = (ctag: string) => ({
|
||||
url: CALENDAR_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: [],
|
||||
ctag,
|
||||
});
|
||||
|
||||
const buildSyncCollectionCalendar = () => ({
|
||||
url: CALENDAR_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: ['syncCollection'],
|
||||
syncToken: 'token-1',
|
||||
});
|
||||
|
||||
describe('CalDAVClient — CTag/ETag fallback', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockCreateAccount.mockResolvedValue({
|
||||
serverUrl: 'https://caldav.example.com',
|
||||
accountType: 'caldav',
|
||||
rootUrl: 'https://caldav.example.com/',
|
||||
principalUrl: 'https://caldav.example.com/principal/',
|
||||
homeUrl: 'https://caldav.example.com/calendars/user/',
|
||||
} as Awaited<ReturnType<typeof createAccount>>);
|
||||
});
|
||||
|
||||
it('returns no events and preserves stored etags when the calendar CTag is unchanged', async () => {
|
||||
mockFetchCalendars.mockResolvedValue([buildLegacyCalendar('ctag-v1')]);
|
||||
|
||||
const client = buildClient();
|
||||
|
||||
const previousEtags = {
|
||||
[FIRST_HREF]: '"etag-1"',
|
||||
[SECOND_HREF]: '"etag-2"',
|
||||
};
|
||||
|
||||
const result = await client.getEvents({
|
||||
startDate: new Date('2026-01-01'),
|
||||
endDate: new Date('2027-01-01'),
|
||||
syncCursor: {
|
||||
syncTokens: {},
|
||||
ctags: { [CALENDAR_URL]: 'ctag-v1' },
|
||||
etags: { [CALENDAR_URL]: previousEtags },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: 'ctag-v1' });
|
||||
expect(result.syncCursor.etags).toEqual({ [CALENDAR_URL]: previousEtags });
|
||||
expect(mockPropfind).not.toHaveBeenCalled();
|
||||
expect(mockCalendarMultiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches every event on the first sync when no etags are stored', async () => {
|
||||
mockFetchCalendars.mockResolvedValue([buildLegacyCalendar('ctag-v1')]);
|
||||
|
||||
mockPropfind.mockResolvedValue([
|
||||
{
|
||||
href: FIRST_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { getetag: '"etag-1"' },
|
||||
},
|
||||
{
|
||||
href: SECOND_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { getetag: '"etag-2"' },
|
||||
},
|
||||
]);
|
||||
|
||||
mockCalendarMultiGet.mockResolvedValue([
|
||||
{
|
||||
href: FIRST_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: {
|
||||
getetag: '"etag-1"',
|
||||
calendarData: buildICalData('uid-1', 'Event One'),
|
||||
},
|
||||
},
|
||||
{
|
||||
href: SECOND_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: {
|
||||
getetag: '"etag-2"',
|
||||
calendarData: buildICalData('uid-2', 'Event Two'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = buildClient();
|
||||
|
||||
const result = await client.getEvents({
|
||||
startDate: new Date('2026-01-01'),
|
||||
endDate: new Date('2027-01-01'),
|
||||
syncCursor: { syncTokens: {} },
|
||||
});
|
||||
|
||||
expect(result.events.map((event) => event.iCalUid).sort()).toEqual([
|
||||
'uid-1',
|
||||
'uid-2',
|
||||
]);
|
||||
expect(result.events.every((event) => !event.isCanceled)).toBe(true);
|
||||
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: 'ctag-v1' });
|
||||
expect(result.syncCursor.etags).toEqual({
|
||||
[CALENDAR_URL]: {
|
||||
[FIRST_HREF]: '"etag-1"',
|
||||
[SECOND_HREF]: '"etag-2"',
|
||||
},
|
||||
});
|
||||
expect(mockCalendarMultiGet).toHaveBeenCalledTimes(1);
|
||||
expect(mockCalendarMultiGet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: CALENDAR_URL,
|
||||
objectUrls: [FIRST_HREF, SECOND_HREF],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only fetches changed hrefs and emits cancelled stub events for vanished hrefs when CTag differs', async () => {
|
||||
mockFetchCalendars.mockResolvedValue([buildLegacyCalendar('ctag-v2')]);
|
||||
|
||||
mockPropfind.mockResolvedValue([
|
||||
{
|
||||
href: FIRST_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { getetag: '"etag-1-updated"' },
|
||||
},
|
||||
]);
|
||||
|
||||
mockCalendarMultiGet.mockResolvedValue([
|
||||
{
|
||||
href: FIRST_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: {
|
||||
getetag: '"etag-1-updated"',
|
||||
calendarData: buildICalData('uid-1', 'Event One Updated'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = buildClient();
|
||||
|
||||
const result = await client.getEvents({
|
||||
startDate: new Date('2026-01-01'),
|
||||
endDate: new Date('2027-01-01'),
|
||||
syncCursor: {
|
||||
syncTokens: {},
|
||||
ctags: { [CALENDAR_URL]: 'ctag-v1' },
|
||||
etags: {
|
||||
[CALENDAR_URL]: {
|
||||
[FIRST_HREF]: '"etag-1"',
|
||||
[SECOND_HREF]: '"etag-2"',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const liveEvents = result.events.filter((event) => !event.isCanceled);
|
||||
const cancelledEvents = result.events.filter((event) => event.isCanceled);
|
||||
|
||||
expect(liveEvents.map((event) => event.iCalUid)).toEqual(['uid-1']);
|
||||
expect(cancelledEvents.map((event) => event.id)).toEqual([SECOND_HREF]);
|
||||
expect(cancelledEvents[0].status).toBe('CANCELLED');
|
||||
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: 'ctag-v2' });
|
||||
expect(result.syncCursor.etags).toEqual({
|
||||
[CALENDAR_URL]: { [FIRST_HREF]: '"etag-1-updated"' },
|
||||
});
|
||||
expect(mockCalendarMultiGet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: CALENDAR_URL,
|
||||
objectUrls: [FIRST_HREF],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a numeric CTag as an opaque string so SabreDAV-style sequence numbers still trigger the unchanged-calendar skip', async () => {
|
||||
mockFetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: CALENDAR_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: [],
|
||||
ctag: 1004 as unknown as string,
|
||||
},
|
||||
]);
|
||||
|
||||
const client = buildClient();
|
||||
|
||||
const result = await client.getEvents({
|
||||
startDate: new Date('2026-01-01'),
|
||||
endDate: new Date('2027-01-01'),
|
||||
syncCursor: {
|
||||
syncTokens: {},
|
||||
ctags: { [CALENDAR_URL]: '1004' },
|
||||
etags: { [CALENDAR_URL]: { [FIRST_HREF]: '"keep"' } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.syncCursor.ctags).toEqual({ [CALENDAR_URL]: '1004' });
|
||||
expect(mockPropfind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes the sync-collection branch when the calendar advertises it', async () => {
|
||||
mockFetchCalendars.mockResolvedValue([buildSyncCollectionCalendar()]);
|
||||
|
||||
mockSyncCollection.mockResolvedValue([
|
||||
{
|
||||
href: FIRST_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { getetag: '"etag-1"' },
|
||||
},
|
||||
]);
|
||||
|
||||
mockCalendarMultiGet.mockResolvedValue([
|
||||
{
|
||||
href: FIRST_HREF,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: {
|
||||
getetag: '"etag-1"',
|
||||
calendarData: buildICalData('uid-1', 'Event One'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = buildClient();
|
||||
|
||||
const result = await client.getEvents({
|
||||
startDate: new Date('2026-01-01'),
|
||||
endDate: new Date('2027-01-01'),
|
||||
});
|
||||
|
||||
expect(mockSyncCollection).toHaveBeenCalledTimes(1);
|
||||
expect(mockPropfind).not.toHaveBeenCalled();
|
||||
expect(result.events.map((event) => event.iCalUid)).toEqual(['uid-1']);
|
||||
});
|
||||
});
|
||||
+287
-130
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import * as ical from 'node-ical';
|
||||
import {
|
||||
calendarMultiGet,
|
||||
@@ -7,10 +8,12 @@ import {
|
||||
type DAVAccount,
|
||||
type DAVCalendar,
|
||||
DAVNamespaceShort,
|
||||
type DAVObject,
|
||||
type DAVResponse,
|
||||
fetchCalendars,
|
||||
propfind,
|
||||
syncCollection,
|
||||
} from 'tsdav';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { createBasicDigestAuthFetch } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch';
|
||||
import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue';
|
||||
@@ -20,8 +23,7 @@ import {
|
||||
type FetchedCalendarEvent,
|
||||
type FetchedCalendarEventParticipant,
|
||||
} from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
const DEFAULT_CALENDAR_TYPE = 'caldav';
|
||||
import { DEFAULT_CALENDAR_TYPE } from './constants/default-calendar-type.constant';
|
||||
|
||||
type CalendarCredentials = {
|
||||
username: string;
|
||||
@@ -44,15 +46,23 @@ type FetchEventsOptions = {
|
||||
syncCursor?: CalDAVSyncCursor;
|
||||
};
|
||||
|
||||
type EtagsByHref = Record<string, string>;
|
||||
|
||||
type CalDAVSyncResult = {
|
||||
events: FetchedCalendarEvent[];
|
||||
newSyncToken?: string;
|
||||
newCtag?: string;
|
||||
newEtags?: EtagsByHref;
|
||||
};
|
||||
|
||||
type CalDAVSyncCursor = {
|
||||
syncTokens: Record<string, string>;
|
||||
ctags?: Record<string, string>;
|
||||
etags?: Record<string, EtagsByHref>;
|
||||
};
|
||||
|
||||
export type CalDAVServerCapability = 'sync-collection' | 'ctag-etag';
|
||||
|
||||
type CalDAVGetEventsResponse = {
|
||||
events: FetchedCalendarEvent[];
|
||||
syncCursor: CalDAVSyncCursor;
|
||||
@@ -147,7 +157,7 @@ export class CalDAVClient {
|
||||
}
|
||||
}
|
||||
|
||||
async validateSyncCollectionSupport(): Promise<void> {
|
||||
async detectServerCapability(): Promise<CalDAVServerCapability> {
|
||||
const account = await this.getAccount();
|
||||
|
||||
const calendars = await fetchCalendars({
|
||||
@@ -163,14 +173,9 @@ export class CalDAVClient {
|
||||
throw new Error('No calendar with event support found');
|
||||
}
|
||||
|
||||
const supportsSyncCollection =
|
||||
eventCalendar.reports?.includes('syncCollection') ?? false;
|
||||
|
||||
if (!supportsSyncCollection) {
|
||||
throw new Error(
|
||||
'CALDAV_SYNC_COLLECTION_NOT_SUPPORTED: Your CalDAV server does not support incremental sync (RFC 6578)',
|
||||
);
|
||||
}
|
||||
return eventCalendar.reports?.includes('syncCollection')
|
||||
? 'sync-collection'
|
||||
: 'ctag-etag';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,138 +349,297 @@ export class CalDAVClient {
|
||||
async getEvents(
|
||||
options: FetchEventsOptions,
|
||||
): Promise<CalDAVGetEventsResponse> {
|
||||
const calendars = await this.listCalendars();
|
||||
const account = await this.getAccount();
|
||||
|
||||
const calendars = (
|
||||
await fetchCalendars({ account, fetch: this.fetchOverride })
|
||||
).filter((calendar) => calendar.components?.includes('VEVENT'));
|
||||
|
||||
const results = new Map<string, CalDAVSyncResult>();
|
||||
|
||||
const syncPromises = calendars.map(async (calendar) => {
|
||||
try {
|
||||
const syncToken =
|
||||
options.syncCursor?.syncTokens[calendar.url] ||
|
||||
calendar.syncToken?.toString();
|
||||
const supportsSyncCollection =
|
||||
calendar.reports?.includes('syncCollection') ?? false;
|
||||
|
||||
const syncResult = await syncCollection({
|
||||
url: calendar.url,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
syncLevel: 1,
|
||||
...(syncToken ? { syncToken } : {}),
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
const result = supportsSyncCollection
|
||||
? await this.fetchEventsViaSyncCollection(calendar, options)
|
||||
: await this.fetchEventsViaCtagEtag(calendar, options);
|
||||
|
||||
const allEvents: FetchedCalendarEvent[] = [];
|
||||
results.set(calendar.url, result);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getEvents`,
|
||||
error,
|
||||
);
|
||||
|
||||
const objectUrls = syncResult
|
||||
.map((event) => event.href)
|
||||
.filter((href): href is string => !!href && this.isValidFormat(href));
|
||||
|
||||
if (objectUrls.length > 0) {
|
||||
try {
|
||||
const calendarObjects = await calendarMultiGet({
|
||||
url: calendar.url,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
objectUrls: objectUrls,
|
||||
depth: '1',
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
for (const calendarObject of calendarObjects) {
|
||||
if (calendarObject.props?.calendarData) {
|
||||
const iCalData = this.extractICalData(
|
||||
calendarObject.props?.calendarData,
|
||||
);
|
||||
|
||||
if (!iCalData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = this.parseICalData(
|
||||
iCalData,
|
||||
calendarObject.href || '',
|
||||
);
|
||||
|
||||
if (
|
||||
event &&
|
||||
this.isEventInTimeRange(
|
||||
{
|
||||
url: calendarObject.href || '',
|
||||
data: calendarObject.props.calendarData,
|
||||
etag: calendarObject.props.getetag,
|
||||
},
|
||||
options.startDate,
|
||||
options.endDate,
|
||||
)
|
||||
) {
|
||||
allEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (fetchError) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getEvents`,
|
||||
fetchError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let newSyncToken = syncToken;
|
||||
|
||||
try {
|
||||
const account = await this.getAccount();
|
||||
const updatedCalendars = await fetchCalendars({
|
||||
account,
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
const updatedCalendar = updatedCalendars.find(
|
||||
(cal) => cal.url === calendar.url,
|
||||
);
|
||||
|
||||
if (updatedCalendar?.syncToken) {
|
||||
newSyncToken = updatedCalendar.syncToken.toString();
|
||||
}
|
||||
} catch (syncTokenError) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getEvents`,
|
||||
syncTokenError,
|
||||
);
|
||||
}
|
||||
|
||||
results.set(calendar.url, {
|
||||
events: allEvents,
|
||||
newSyncToken,
|
||||
});
|
||||
} catch {
|
||||
results.set(calendar.url, {
|
||||
events: [],
|
||||
newSyncToken: options.syncCursor?.syncTokens[calendar.url],
|
||||
newCtag: options.syncCursor?.ctags?.[calendar.url],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(syncPromises);
|
||||
|
||||
const allEvents = Array.from(results.values())
|
||||
.map((result) => result.events)
|
||||
.flat();
|
||||
const allEvents = Array.from(results.values()).flatMap(
|
||||
(result) => result.events,
|
||||
);
|
||||
|
||||
const syncTokens: Record<string, string> = {};
|
||||
const ctags: Record<string, string> = {};
|
||||
const etags: Record<string, EtagsByHref> = {};
|
||||
|
||||
for (const [calendarUrl, result] of results) {
|
||||
if (result.newSyncToken) {
|
||||
syncTokens[calendarUrl] = result.newSyncToken;
|
||||
}
|
||||
|
||||
if (result.newCtag) {
|
||||
ctags[calendarUrl] = result.newCtag;
|
||||
}
|
||||
|
||||
if (result.newEtags) {
|
||||
etags[calendarUrl] = result.newEtags;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: allEvents,
|
||||
syncCursor: { syncTokens },
|
||||
syncCursor: {
|
||||
syncTokens,
|
||||
...(Object.keys(ctags).length > 0 ? { ctags } : {}),
|
||||
...(Object.keys(etags).length > 0 ? { etags } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchEventsViaSyncCollection(
|
||||
calendar: DAVCalendar,
|
||||
options: FetchEventsOptions,
|
||||
): Promise<CalDAVSyncResult> {
|
||||
const previousSyncToken =
|
||||
options.syncCursor?.syncTokens[calendar.url] ||
|
||||
calendar.syncToken?.toString();
|
||||
|
||||
const syncResult = await syncCollection({
|
||||
url: calendar.url,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
syncLevel: 1,
|
||||
...(previousSyncToken ? { syncToken: previousSyncToken } : {}),
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
const objectUrls = syncResult
|
||||
.map((entry) => entry.href)
|
||||
.filter((href): href is string => !!href && this.isValidFormat(href));
|
||||
|
||||
const events = await this.fetchAndParseEvents(
|
||||
calendar.url,
|
||||
objectUrls,
|
||||
options,
|
||||
);
|
||||
|
||||
const newSyncToken = await this.refreshSyncToken(
|
||||
calendar.url,
|
||||
previousSyncToken,
|
||||
);
|
||||
|
||||
return {
|
||||
events,
|
||||
newSyncToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshSyncToken(
|
||||
calendarUrl: string,
|
||||
fallbackToken: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const account = await this.getAccount();
|
||||
const refreshed = await fetchCalendars({
|
||||
account,
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
const refreshedCalendar = refreshed.find(
|
||||
(candidate) => candidate.url === calendarUrl,
|
||||
);
|
||||
|
||||
return refreshedCalendar?.syncToken
|
||||
? refreshedCalendar.syncToken.toString()
|
||||
: fallbackToken;
|
||||
} catch (refreshError) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - refreshSyncToken`,
|
||||
refreshError,
|
||||
);
|
||||
|
||||
return fallbackToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback sync path for legacy CalDAV servers that don't support
|
||||
* RFC 6578 sync-collection.
|
||||
*/
|
||||
private async fetchEventsViaCtagEtag(
|
||||
calendar: DAVCalendar,
|
||||
options: FetchEventsOptions,
|
||||
): Promise<CalDAVSyncResult> {
|
||||
const storedEtags = options.syncCursor?.etags?.[calendar.url] ?? {};
|
||||
|
||||
// Treat ctag as opaque text since some servers return it as a number.
|
||||
const newCtag = isDefined(calendar.ctag)
|
||||
? String(calendar.ctag)
|
||||
: undefined;
|
||||
|
||||
const storedCtag = options.syncCursor?.ctags?.[calendar.url];
|
||||
|
||||
if (isDefined(newCtag) && isDefined(storedCtag) && newCtag === storedCtag) {
|
||||
return {
|
||||
events: [],
|
||||
newCtag,
|
||||
newEtags: storedEtags,
|
||||
};
|
||||
}
|
||||
|
||||
const currentEtags = await this.fetchEtagsByHref(calendar.url);
|
||||
|
||||
const changedHrefs = Object.keys(currentEtags).filter(
|
||||
(href) => storedEtags[href] !== currentEtags[href],
|
||||
);
|
||||
const cancelledHrefs = Object.keys(storedEtags).filter(
|
||||
(href) => !(href in currentEtags),
|
||||
);
|
||||
|
||||
const fetchedEvents = await this.fetchAndParseEvents(
|
||||
calendar.url,
|
||||
changedHrefs,
|
||||
options,
|
||||
);
|
||||
|
||||
const cancelledStubs = cancelledHrefs.map((href) =>
|
||||
this.buildCancelledEvent(href),
|
||||
);
|
||||
|
||||
return {
|
||||
events: [...fetchedEvents, ...cancelledStubs],
|
||||
newCtag,
|
||||
newEtags: currentEtags,
|
||||
};
|
||||
}
|
||||
|
||||
private buildCancelledEvent(href: string): FetchedCalendarEvent {
|
||||
return {
|
||||
id: href,
|
||||
title: '',
|
||||
iCalUid: '',
|
||||
description: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
location: '',
|
||||
isFullDay: false,
|
||||
isCanceled: true,
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: '',
|
||||
externalCreatedAt: '',
|
||||
externalUpdatedAt: '',
|
||||
conferenceSolution: '',
|
||||
participants: [],
|
||||
status: 'CANCELLED',
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchEtagsByHref(calendarUrl: string): Promise<EtagsByHref> {
|
||||
const responses = await propfind({
|
||||
url: calendarUrl,
|
||||
props: { [`${DAVNamespaceShort.DAV}:getetag`]: {} },
|
||||
depth: '1',
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
return responses.reduce<EtagsByHref>((map, response: DAVResponse) => {
|
||||
const href = response.href;
|
||||
const etag = response.props?.getetag;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(href) ||
|
||||
!isNonEmptyString(etag) ||
|
||||
!this.isValidFormat(href)
|
||||
) {
|
||||
return map;
|
||||
}
|
||||
|
||||
map[href] = etag;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private async fetchAndParseEvents(
|
||||
calendarUrl: string,
|
||||
objectUrls: string[],
|
||||
options: FetchEventsOptions,
|
||||
): Promise<FetchedCalendarEvent[]> {
|
||||
if (objectUrls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const calendarObjects = await calendarMultiGet({
|
||||
url: calendarUrl,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
objectUrls,
|
||||
depth: '1',
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
const events: FetchedCalendarEvent[] = [];
|
||||
|
||||
for (const calendarObject of calendarObjects) {
|
||||
if (!calendarObject.props?.calendarData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iCalData = this.extractICalData(
|
||||
calendarObject.props.calendarData,
|
||||
);
|
||||
|
||||
if (!iCalData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = this.parseICalData(iCalData, calendarObject.href || '');
|
||||
|
||||
if (
|
||||
!event ||
|
||||
!this.isEventInTimeRange(event, options.startDate, options.endDate)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return events;
|
||||
} catch (fetchError) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - fetchAndParseEvents`,
|
||||
fetchError,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts iCal data from various CalDAV server response formats.
|
||||
* Some servers return data directly as a string, others nest it under _cdata or some other properties.
|
||||
@@ -506,25 +670,18 @@ export class CalDAVClient {
|
||||
}
|
||||
|
||||
private isEventInTimeRange(
|
||||
davObject: DAVObject,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
event: FetchedCalendarEvent,
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
): boolean {
|
||||
try {
|
||||
if (!davObject.data) return false;
|
||||
if (!event.startsAt || !event.endsAt) return false;
|
||||
|
||||
const parsed = ical.parseICS(davObject.data);
|
||||
const events = Object.values(parsed).filter(
|
||||
(item) => item.type === 'VEVENT',
|
||||
);
|
||||
const eventStart = new Date(event.startsAt);
|
||||
const eventEnd = new Date(event.endsAt);
|
||||
|
||||
if (events.length === 0) return false;
|
||||
const startsBeforeWindowCloses = eventStart < windowEnd;
|
||||
const endsAfterWindowOpens = eventEnd > windowStart;
|
||||
|
||||
const event = events[0] as ical.VEvent;
|
||||
|
||||
return event.start < endDate && event.end > startDate;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return startsBeforeWindowCloses && endsAfterWindowOpens;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
|
||||
export const DEFAULT_CALENDAR_TYPE = 'caldav';
|
||||
Reference in New Issue
Block a user