mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 13:29:02 +08:00
feat: basic caldav support (#14372)
fix #13531 #### PR Dependency Tree * **PR #14372** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * CalDAV calendar integration: link and sync CalDAV-compatible calendars (discovery, listing, event sync). * New UI flow and dialog to link CalDAV accounts with provider selection, credentials, and display name. * **API / Config** * Server exposes CalDAV provider presets in config and new GraphQL mutation to link CalDAV accounts. * New calendar config section for CalDAV with validation and defaults. * **Tests** * Comprehensive CalDAV integration test suite added. * **Chores** * Removed analytics tokens from build configuration and reduced Cloud E2E test shards. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -8,10 +8,12 @@ import { addDays, subDays } from 'date-fns';
|
||||
import {
|
||||
CalendarProviderRequestError,
|
||||
Config,
|
||||
GraphqlBadRequest,
|
||||
Mutex,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import type { CalendarCalDAVProviderPreset } from './config';
|
||||
import {
|
||||
CalendarProvider,
|
||||
CalendarProviderEvent,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
CalendarSyncTokenInvalid,
|
||||
} from './providers';
|
||||
import { CalendarProviderFactory } from './providers';
|
||||
import type { LinkCalDAVAccountInput } from './types';
|
||||
|
||||
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
|
||||
const DEFAULT_PAST_DAYS = 90;
|
||||
@@ -159,6 +162,92 @@ export class CalendarService {
|
||||
return account;
|
||||
}
|
||||
|
||||
async linkCalDAVAccount(params: {
|
||||
userId: string;
|
||||
input: LinkCalDAVAccountInput;
|
||||
}) {
|
||||
const caldavConfig = this.config.calendar.caldav;
|
||||
if (!caldavConfig?.enabled) {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_disabled',
|
||||
message: 'CalDAV integration is not enabled.',
|
||||
});
|
||||
}
|
||||
|
||||
const preset = caldavConfig.providers.find(
|
||||
provider => provider.id === params.input.providerPresetId
|
||||
);
|
||||
if (!preset) {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_provider_not_found',
|
||||
message: 'CalDAV provider is not available.',
|
||||
});
|
||||
}
|
||||
|
||||
const provider = this.requireProvider(CalendarProviderName.CalDAV);
|
||||
if (!('discoverAccount' in provider)) {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_provider_unavailable',
|
||||
message: 'CalDAV provider is not configured.',
|
||||
});
|
||||
}
|
||||
|
||||
const discovery = await (
|
||||
provider as CalendarProvider & {
|
||||
discoverAccount: (input: {
|
||||
preset: CalendarCalDAVProviderPreset;
|
||||
username: string;
|
||||
password: string;
|
||||
}) => Promise<{
|
||||
providerAccountId: string;
|
||||
serverUrl: string;
|
||||
principalUrl: string;
|
||||
calendarHomeUrl: string;
|
||||
authType?: string | null;
|
||||
}>;
|
||||
}
|
||||
).discoverAccount({
|
||||
preset,
|
||||
username: params.input.username,
|
||||
password: params.input.password,
|
||||
});
|
||||
|
||||
const account = await this.models.calendarAccount.upsert({
|
||||
userId: params.userId,
|
||||
provider: CalendarProviderName.CalDAV,
|
||||
providerAccountId: discovery.providerAccountId,
|
||||
displayName: params.input.displayName ?? null,
|
||||
email: params.input.username,
|
||||
accessToken: params.input.password,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
scope: null,
|
||||
status: 'active',
|
||||
lastError: null,
|
||||
providerPresetId: params.input.providerPresetId,
|
||||
serverUrl: discovery.serverUrl,
|
||||
principalUrl: discovery.principalUrl,
|
||||
calendarHomeUrl: discovery.calendarHomeUrl,
|
||||
username: params.input.username,
|
||||
authType: discovery.authType ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.syncAccountCalendars(account.id);
|
||||
} catch (error) {
|
||||
if (error instanceof CalendarProviderRequestError) {
|
||||
await this.models.calendarAccount.updateStatus(
|
||||
account.id,
|
||||
'invalid',
|
||||
error.message
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
async syncAccountCalendars(accountId: string) {
|
||||
const account = await this.models.calendarAccount.get(accountId);
|
||||
if (!account) {
|
||||
@@ -177,7 +266,10 @@ export class CalendarService {
|
||||
return;
|
||||
}
|
||||
|
||||
const calendars = await provider.listCalendars(accessToken);
|
||||
const calendars = await provider.listCalendars({
|
||||
accessToken,
|
||||
account,
|
||||
});
|
||||
const upserted = [];
|
||||
for (const calendar of calendars) {
|
||||
upserted.push(
|
||||
@@ -245,6 +337,7 @@ export class CalendarService {
|
||||
subscriptionId: subscription.id,
|
||||
calendarId: subscription.externalCalendarId,
|
||||
accessToken,
|
||||
account,
|
||||
syncToken: shouldUseSyncToken
|
||||
? (subscription.syncToken ?? undefined)
|
||||
: undefined,
|
||||
@@ -264,6 +357,7 @@ export class CalendarService {
|
||||
subscriptionId: subscription.id,
|
||||
calendarId: subscription.externalCalendarId,
|
||||
accessToken,
|
||||
account,
|
||||
timeMin,
|
||||
timeMax,
|
||||
subscriptionTimezone: subscription.timezone ?? undefined,
|
||||
@@ -410,7 +504,21 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
isProviderAvailable(provider: CalendarProviderName) {
|
||||
return !!this.providerFactory.get(provider);
|
||||
return this.isProviderAvailableFor(provider);
|
||||
}
|
||||
|
||||
isProviderAvailableFor(
|
||||
provider: CalendarProviderName,
|
||||
options?: { oauth?: boolean }
|
||||
) {
|
||||
const instance = this.providerFactory.get(provider);
|
||||
if (!instance) {
|
||||
return false;
|
||||
}
|
||||
if (options?.oauth) {
|
||||
return instance.supportsOAuth;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getAuthUrl(
|
||||
@@ -418,7 +526,14 @@ export class CalendarService {
|
||||
state: string,
|
||||
redirectUri: string
|
||||
) {
|
||||
return this.requireProvider(provider).getAuthUrl(state, redirectUri);
|
||||
const instance = this.requireProvider(provider);
|
||||
if (!instance.supportsOAuth) {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'calendar_provider_oauth_unsupported',
|
||||
message: 'Selected calendar provider does not support OAuth.',
|
||||
});
|
||||
}
|
||||
return instance.getAuthUrl(state, redirectUri);
|
||||
}
|
||||
|
||||
private async syncWithProvider(params: {
|
||||
@@ -426,6 +541,7 @@ export class CalendarService {
|
||||
subscriptionId: string;
|
||||
calendarId: string;
|
||||
accessToken: string;
|
||||
account: CalendarAccount;
|
||||
syncToken?: string;
|
||||
timeMin?: string;
|
||||
timeMax?: string;
|
||||
@@ -434,6 +550,7 @@ export class CalendarService {
|
||||
const response = await params.provider.listEvents({
|
||||
accessToken: params.accessToken,
|
||||
calendarId: params.calendarId,
|
||||
account: params.account,
|
||||
syncToken: params.syncToken,
|
||||
timeMin: params.timeMin,
|
||||
timeMax: params.timeMax,
|
||||
@@ -632,7 +749,8 @@ export class CalendarService {
|
||||
|
||||
private isTokenInvalidError(error: unknown) {
|
||||
if (error instanceof CalendarProviderRequestError) {
|
||||
if (error.status === 401) {
|
||||
const status = error.data?.status ?? error.status;
|
||||
if (status === 401) {
|
||||
return true;
|
||||
}
|
||||
return error.message.includes('invalid_grant');
|
||||
|
||||
Reference in New Issue
Block a user