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:
DarkSky
2026-02-05 03:04:21 +08:00
committed by GitHub
parent 403f16b404
commit a655b79166
34 changed files with 2995 additions and 217 deletions

View File

@@ -288,6 +288,10 @@
"type": "Object",
"desc": "Google Calendar integration config",
"link": "https://developers.google.com/calendar/api/guides/push"
},
"caldav": {
"type": "Object",
"desc": "CalDAV integration config"
}
},
"captcha": {

View File

@@ -12,6 +12,15 @@ import useSWR from 'swr';
import useSWRImmutable from 'swr/immutable';
import useSWRInfinite from 'swr/infinite';
export type UseQueryConfig<Query extends GraphQLQuery = GraphQLQuery> = Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError,
(options: QueryOptions<Query>) => Promise<QueryResponse<Query>>
>,
'fetcher'
>;
/**
* A `useSWR` wrapper for sending graphql queries
*
@@ -32,14 +41,7 @@ import useSWRInfinite from 'swr/infinite';
*/
type useQueryFn = <Query extends GraphQLQuery>(
options?: QueryOptions<Query>,
config?: Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError,
(options: QueryOptions<Query>) => Promise<QueryResponse<Query>>
>,
'fetcher'
>
config?: UseQueryConfig<Query>
) => SWRResponse<
QueryResponse<Query>,
GraphQLError,

View File

@@ -130,3 +130,53 @@ export const empty = style({
color: cssVarV2.text.secondary,
padding: '12px 0',
});
export const caldavDialog = style({
display: 'flex',
flexDirection: 'column',
gap: 20,
});
export const caldavField = style({
display: 'flex',
flexDirection: 'column',
gap: 10,
});
export const caldavLabel = style({
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.text.secondary,
});
export const caldavProviderButton = style({
width: '100%',
justifyContent: 'space-between',
});
export const caldavHint = style({
display: 'flex',
gap: 6,
flexWrap: 'wrap',
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.text.secondary,
});
export const caldavLink = style({
color: cssVarV2.text.primary,
textDecoration: 'underline',
});
export const caldavError = style({
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.status.error,
});
export const caldavFooter = style({
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
marginTop: 12,
});

View File

@@ -1,12 +1,27 @@
import { Button, Loading, Menu, MenuItem, notify } from '@affine/component';
import {
Button,
Input,
Loading,
Menu,
MenuItem,
Modal,
notify,
} from '@affine/component';
import {
useQuery,
type UseQueryConfig,
} from '@affine/core/components/hooks/use-query';
import { GraphQLService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { UserFriendlyError } from '@affine/error';
import {
type CalendarAccountsQuery,
calendarAccountsQuery,
type CalendarProvidersQuery,
calendarProvidersQuery,
CalendarProviderType,
type GraphQLQuery,
linkCalDavAccountMutation,
linkCalendarAccountMutation,
unlinkCalendarAccountMutation,
} from '@affine/graphql';
@@ -14,6 +29,7 @@ import { useI18n } from '@affine/i18n';
import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import {
type FormEvent,
type ReactNode,
useCallback,
useEffect,
@@ -28,6 +44,10 @@ type CalendarAccount = NonNullable<
CalendarAccountsQuery['currentUser']
>['calendarAccounts'][number];
type CalendarCalDAVProvider = NonNullable<
CalendarProvidersQuery['serverConfig']
>['calendarCalDAVProviders'][number];
const providerMeta = {
[CalendarProviderType.Google]: {
label: 'Google Calendar',
@@ -41,68 +61,328 @@ const providerMeta = {
Record<CalendarProviderType, { label: string; icon: ReactNode }>
>;
const CalDAVLinkDialog = ({
open,
providers,
onClose,
onLinked,
}: {
open: boolean;
providers: CalendarCalDAVProvider[];
onClose: () => void;
onLinked: () => void;
}) => {
const t = useI18n();
const gqlService = useService(GraphQLService);
const [providerId, setProviderId] = useState<string | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<{
provider?: string;
username?: string;
password?: string;
}>({});
const selectedProvider = useMemo(() => {
if (providerId) {
const match = providers.find(provider => provider.id === providerId);
if (match) {
return match;
}
}
return providers[0] ?? null;
}, [providerId, providers]);
useEffect(() => {
if (!open) return;
setProviderId(providers[0]?.id ?? null);
setUsername('');
setPassword('');
setDisplayName('');
setErrors({});
}, [open, providers]);
const handleProviderSelect = useCallback(
(provider: CalendarCalDAVProvider) => {
setProviderId(provider.id);
setErrors(prev => ({ ...prev, provider: undefined }));
},
[]
);
const handleUsernameInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
setUsername(event.currentTarget.value);
setErrors(prev => ({ ...prev, username: undefined }));
},
[]
);
const handlePasswordInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
setPassword(event.currentTarget.value);
setErrors(prev => ({ ...prev, password: undefined }));
},
[]
);
const handleDisplayNameInput = useCallback(
(event: FormEvent<HTMLInputElement>) => {
setDisplayName(event.currentTarget.value);
},
[]
);
const handleSubmit = useCallback(async () => {
const nextErrors: {
provider?: string;
username?: string;
password?: string;
} = {};
if (!selectedProvider) {
nextErrors.provider =
t['com.affine.integration.calendar.caldav.field.provider.error']();
}
if (!username.trim()) {
nextErrors.username =
t['com.affine.integration.calendar.caldav.field.username.error']();
}
if (!password) {
nextErrors.password =
t['com.affine.integration.calendar.caldav.field.password.error']();
}
if (Object.keys(nextErrors).length) {
setErrors(nextErrors);
return;
}
setSubmitting(true);
try {
await gqlService.gql({
query: linkCalDavAccountMutation,
variables: {
input: {
providerPresetId: selectedProvider!.id,
username: username.trim(),
password,
displayName: displayName.trim() || null,
},
},
});
onLinked();
onClose();
} catch (error) {
const message =
error instanceof UserFriendlyError ? error.message : String(error);
notify.error({
title: t['com.affine.integration.calendar.caldav.link.failed'](),
message: message || undefined,
});
} finally {
setSubmitting(false);
}
}, [
displayName,
gqlService,
onClose,
onLinked,
password,
selectedProvider,
t,
username,
]);
return (
<Modal
open={open}
width={480}
title={t['com.affine.integration.calendar.caldav.link.title']()}
onOpenChange={nextOpen => {
if (!nextOpen) onClose();
}}
contentOptions={{ className: styles.caldavDialog }}
>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.provider']()}
</div>
<Menu
items={providers.map(provider => (
<MenuItem
key={provider.id}
onSelect={() => handleProviderSelect(provider)}
>
{provider.label}
</MenuItem>
))}
contentOptions={{ align: 'start' }}
>
<Button
className={styles.caldavProviderButton}
disabled={!providers.length}
>
{selectedProvider?.label ??
t[
'com.affine.integration.calendar.caldav.field.provider.placeholder'
]()}
</Button>
</Menu>
{errors.provider ? (
<div className={styles.caldavError}>{errors.provider}</div>
) : null}
{selectedProvider?.requiresAppPassword ? (
<div className={styles.caldavHint}>
{t['com.affine.integration.calendar.caldav.hint.app-password']()}
{selectedProvider.docsUrl ? (
<a
className={styles.caldavLink}
href={selectedProvider.docsUrl}
target="_blank"
rel="noreferrer noopener"
>
{t['com.affine.integration.calendar.caldav.hint.learn-more']()}
</a>
) : null}
</div>
) : selectedProvider?.docsUrl ? (
<div className={styles.caldavHint}>
<a
className={styles.caldavLink}
href={selectedProvider.docsUrl}
target="_blank"
rel="noreferrer noopener"
>
{t['com.affine.integration.calendar.caldav.hint.guide']()}
</a>
</div>
) : null}
</div>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.username']()}
</div>
<Input
value={username}
onInput={handleUsernameInput}
placeholder={t[
'com.affine.integration.calendar.caldav.field.username.placeholder'
]()}
status={errors.username ? 'error' : 'default'}
disabled={submitting}
/>
{errors.username ? (
<div className={styles.caldavError}>{errors.username}</div>
) : null}
</div>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.password']()}
</div>
<Input
value={password}
onInput={handlePasswordInput}
placeholder={t[
'com.affine.integration.calendar.caldav.field.password.placeholder'
]()}
type="password"
status={errors.password ? 'error' : 'default'}
disabled={submitting}
/>
{errors.password ? (
<div className={styles.caldavError}>{errors.password}</div>
) : null}
</div>
<div className={styles.caldavField}>
<div className={styles.caldavLabel}>
{t['com.affine.integration.calendar.caldav.field.displayName']()}
</div>
<Input
value={displayName}
onInput={handleDisplayNameInput}
placeholder={t[
'com.affine.integration.calendar.caldav.field.displayName.placeholder'
]()}
disabled={submitting}
/>
</div>
<div className={styles.caldavFooter}>
<Button disabled={submitting} onClick={onClose}>
{t['Cancel']()}
</Button>
<Button
variant="primary"
loading={submitting}
disabled={submitting || !providers.length}
onClick={() => void handleSubmit()}
>
{t['com.affine.integration.calendar.account.link']()}
</Button>
</div>
</Modal>
);
};
export const IntegrationsPanel = () => {
const t = useI18n();
const gqlService = useService(GraphQLService);
const urlService = useService(UrlService);
const [accounts, setAccounts] = useState<CalendarAccount[]>([]);
const [providers, setProviders] = useState<CalendarProviderType[]>([]);
const [loading, setLoading] = useState(true);
const [linking, setLinking] = useState(false);
const [unlinkingAccountId, setUnlinkingAccountId] = useState<string | null>(
null
);
const [openedExternalWindow, setOpenedExternalWindow] = useState(false);
const revalidate = useCallback(
async (signal?: AbortSignal) => {
setLoading(true);
try {
const [accountsData, providersData] = await Promise.all([
gqlService.gql({
query: calendarAccountsQuery,
context: { signal },
}),
gqlService.gql({
query: calendarProvidersQuery,
context: { signal },
}),
]);
setAccounts(accountsData.currentUser?.calendarAccounts ?? []);
setProviders(providersData.serverConfig.calendarProviders ?? []);
} catch (error) {
if (
signal?.aborted ||
(error instanceof UserFriendlyError && error.is('REQUEST_ABORTED'))
) {
return;
}
notify.error({
title: 'Failed to load calendar accounts',
message: String(error) || undefined,
});
} finally {
setLoading(false);
}
},
[gqlService]
const [caldavDialogOpen, setCaldavDialogOpen] = useState(false);
const makeConfig: <Query extends GraphQLQuery>(
title: string
) => UseQueryConfig<Query> = useCallback(
title => ({
suspense: false,
revalidateOnFocus: openedExternalWindow,
onError: error => {
notify.error({ title, message: String(error) || undefined });
},
}),
[openedExternalWindow]
);
useEffect(() => {
const controller = new AbortController();
revalidate(controller.signal).catch(() => undefined);
return () => controller.abort();
}, [revalidate]);
const {
data: accountsData,
isLoading: accountsLoading,
mutate: mutateAccounts,
} = useQuery(
{ query: calendarAccountsQuery },
useMemo(
() =>
makeConfig(t['com.affine.integration.calendar.account.load-error']()),
[makeConfig, t]
)
);
useEffect(() => {
if (!openedExternalWindow) return;
const handleFocus = () => {
revalidate().catch(() => undefined);
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [openedExternalWindow, revalidate]);
const {
data: providersData,
isLoading: providersLoading,
mutate: mutateProviders,
} = useQuery(
{ query: calendarProvidersQuery },
useMemo(
() =>
makeConfig(t['com.affine.integration.calendar.provider.load-error']()),
[makeConfig, t]
)
);
const accounts: CalendarAccount[] =
accountsData?.currentUser?.calendarAccounts ?? [];
const providers = useMemo(
() => providersData?.serverConfig.calendarProviders ?? [],
[providersData]
);
const caldavProviders =
providersData?.serverConfig.calendarCalDAVProviders ?? [];
const loading = accountsLoading || providersLoading;
const providerOptions = useMemo(() => {
return providers.map(provider => {
@@ -117,6 +397,11 @@ export const IntegrationsPanel = () => {
const handleLink = useCallback(
async (provider: CalendarProviderType) => {
if (provider === CalendarProviderType.CalDAV) {
setCaldavDialogOpen(true);
return;
}
setLinking(true);
try {
const data = await gqlService.gql({
@@ -131,12 +416,14 @@ export const IntegrationsPanel = () => {
urlService.openExternal(data.linkCalendarAccount);
setOpenedExternalWindow(true);
} catch (error) {
notify.error({ title: 'Failed to start calendar authorization' });
notify.error({
title: t['com.affine.integration.calendar.auth.start-error'](),
});
} finally {
setLinking(false);
}
},
[gqlService, urlService]
[gqlService, t, urlService]
);
const handleUnlink = useCallback(
@@ -149,104 +436,147 @@ export const IntegrationsPanel = () => {
accountId,
},
});
setAccounts(prev => prev.filter(account => account.id !== accountId));
await mutateAccounts(
current => {
if (!current?.currentUser) {
return current;
}
return {
...current,
currentUser: {
...current.currentUser,
calendarAccounts: current.currentUser.calendarAccounts.filter(
account => account.id !== accountId
),
},
};
},
{
revalidate: false,
}
);
} catch (error) {
notify.error({ title: 'Failed to unlink calendar account' });
notify.error({
title: t['com.affine.integration.calendar.account.unlink-error'](),
});
} finally {
setUnlinkingAccountId(null);
}
},
[gqlService]
[gqlService, mutateAccounts, t]
);
return (
<CollapsibleWrapper
title={t['com.affine.integration.integrations']()}
caption={t['com.affine.integration.setting.description']()}
>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<div className={styles.panelTitle}>
<TodayIcon />
<span>{t['com.affine.integration.calendar.name']()}</span>
</div>
{providerOptions.length ? (
<Menu
items={providerOptions.map(option => (
<MenuItem
key={option.provider}
prefixIcon={option.icon}
onSelect={() => void handleLink(option.provider)}
>
{option.label}
</MenuItem>
))}
contentOptions={{ align: 'end' }}
>
<Button variant="primary" loading={linking}>
Link
<>
<CalDAVLinkDialog
open={caldavDialogOpen}
providers={caldavProviders}
onClose={() => setCaldavDialogOpen(false)}
onLinked={() => {
void Promise.all([mutateAccounts(), mutateProviders()]).catch(
() => undefined
);
}}
/>
<CollapsibleWrapper
title={t['com.affine.integration.integrations']()}
caption={t['com.affine.integration.setting.description']()}
>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<div className={styles.panelTitle}>
<TodayIcon />
<span>{t['com.affine.integration.calendar.name']()}</span>
</div>
{providerOptions.length ? (
<Menu
items={providerOptions.map(option => (
<MenuItem
key={option.provider}
prefixIcon={option.icon}
onSelect={() => void handleLink(option.provider)}
>
{option.label}
</MenuItem>
))}
contentOptions={{ align: 'end' }}
>
<Button variant="primary" loading={linking}>
{t['com.affine.integration.calendar.account.link']()}
</Button>
</Menu>
) : (
<Button variant="primary" disabled>
{t['com.affine.integration.calendar.account.link']()}
</Button>
</Menu>
)}
</div>
{loading ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : accounts.length ? (
<div className={styles.accountList}>
{accounts.map(account => {
const meta = providerMeta[account.provider];
const title =
account.displayName ?? account.email ?? account.id;
const subtitle = account.displayName ? account.email : null;
const showStatus =
account.status !== 'active' || Boolean(account.lastError);
const statusMessage = account.lastError
? t['com.affine.integration.calendar.account.status.failed']({
error: account.lastError,
})
: t[
'com.affine.integration.calendar.account.status.failed-reconnect'
]();
return (
<div key={account.id} className={styles.accountRow}>
<div className={styles.accountInfo}>
<div className={styles.accountIcon}>
{meta?.icon ?? <LinkIcon />}
</div>
<div className={styles.accountDetails}>
<div className={styles.accountName}>{title}</div>
<div className={styles.accountMeta}>
{subtitle ? <span>{subtitle}</span> : null}
<span>
{t['com.affine.integration.calendar.account.count'](
{ count: String(account.calendarsCount) }
)}
</span>
</div>
{showStatus ? (
<div className={styles.accountStatus}>
<span className={styles.statusDot} />
{statusMessage}
</div>
) : null}
</div>
</div>
<div className={styles.accountActions}>
<Button
variant="error"
disabled={unlinkingAccountId === account.id}
onClick={() => void handleUnlink(account.id)}
>
{t['com.affine.integration.calendar.account.unlink']()}
</Button>
</div>
</div>
);
})}
</div>
) : (
<Button variant="primary" disabled>
Link
</Button>
<div className={styles.empty}>
{t['com.affine.integration.calendar.account.linked-empty']()}
</div>
)}
</div>
{loading ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : accounts.length ? (
<div className={styles.accountList}>
{accounts.map(account => {
const meta = providerMeta[account.provider];
const title = account.displayName ?? account.email ?? account.id;
const subtitle = account.displayName ? account.email : null;
const showStatus =
account.status !== 'active' || Boolean(account.lastError);
return (
<div key={account.id} className={styles.accountRow}>
<div className={styles.accountInfo}>
<div className={styles.accountIcon}>
{meta?.icon ?? <LinkIcon />}
</div>
<div className={styles.accountDetails}>
<div className={styles.accountName}>{title}</div>
<div className={styles.accountMeta}>
{subtitle ? <span>{subtitle}</span> : null}
<span>
{account.calendarsCount} calendar
{account.calendarsCount === 1 ? '' : 's'}
</span>
</div>
{showStatus ? (
<div className={styles.accountStatus}>
<span className={styles.statusDot} />
Authorization failed. Please reconnect your account.
</div>
) : null}
</div>
</div>
<div className={styles.accountActions}>
<Button
variant="error"
disabled={unlinkingAccountId === account.id}
onClick={() => void handleUnlink(account.id)}
>
Unlink
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className={styles.empty}>No calendar accounts linked yet.</div>
)}
</div>
</CollapsibleWrapper>
</CollapsibleWrapper>
</>
);
};

View File

@@ -1,27 +1,27 @@
{
"ar": 98,
"ca": 99,
"ar": 97,
"ca": 98,
"da": 4,
"de": 99,
"el-GR": 98,
"de": 98,
"el-GR": 97,
"en": 100,
"es-AR": 98,
"es-CL": 99,
"es": 98,
"fa": 98,
"fr": 99,
"es-AR": 97,
"es-CL": 98,
"es": 97,
"fa": 97,
"fr": 98,
"hi": 2,
"it-IT": 99,
"it-IT": 98,
"it": 1,
"ja": 98,
"ko": 99,
"ja": 97,
"ko": 98,
"nb-NO": 48,
"pl": 100,
"pt-BR": 98,
"ru": 99,
"sv-SE": 98,
"uk": 98,
"pl": 98,
"pt-BR": 97,
"ru": 98,
"sv-SE": 97,
"uk": 97,
"ur": 2,
"zh-Hans": 100,
"zh-Hant": 98
"zh-Hans": 99,
"zh-Hant": 97
}

View File

@@ -8281,6 +8281,114 @@ export function useAFFiNEI18N(): {
* `All day`
*/
["com.affine.integration.calendar.all-day"](): string;
/**
* `Failed to load calendar accounts`
*/
["com.affine.integration.calendar.account.load-error"](): string;
/**
* `Failed to load calendar providers`
*/
["com.affine.integration.calendar.provider.load-error"](): string;
/**
* `Failed to start calendar authorization`
*/
["com.affine.integration.calendar.auth.start-error"](): string;
/**
* `Failed to unlink calendar account`
*/
["com.affine.integration.calendar.account.unlink-error"](): string;
/**
* `Unlink`
*/
["com.affine.integration.calendar.account.unlink"](): string;
/**
* `Link`
*/
["com.affine.integration.calendar.account.link"](): string;
/**
* `No calendar accounts linked yet.`
*/
["com.affine.integration.calendar.account.linked-empty"](): string;
/**
* `Authorization failed: {{error}}`
*/
["com.affine.integration.calendar.account.status.failed"](options: {
readonly error: string;
}): string;
/**
* `Authorization failed. Please reconnect your account.`
*/
["com.affine.integration.calendar.account.status.failed-reconnect"](): string;
/**
* `{{count}} calendar`
*/
["com.affine.integration.calendar.account.count"](options: {
readonly count: string;
}): string;
/**
* `Link CalDAV account`
*/
["com.affine.integration.calendar.caldav.link.title"](): string;
/**
* `Failed to link CalDAV account`
*/
["com.affine.integration.calendar.caldav.link.failed"](): string;
/**
* `Provider`
*/
["com.affine.integration.calendar.caldav.field.provider"](): string;
/**
* `Select provider`
*/
["com.affine.integration.calendar.caldav.field.provider.placeholder"](): string;
/**
* `Please select a provider.`
*/
["com.affine.integration.calendar.caldav.field.provider.error"](): string;
/**
* `Username`
*/
["com.affine.integration.calendar.caldav.field.username"](): string;
/**
* `email@example.com`
*/
["com.affine.integration.calendar.caldav.field.username.placeholder"](): string;
/**
* `Username is required.`
*/
["com.affine.integration.calendar.caldav.field.username.error"](): string;
/**
* `Password`
*/
["com.affine.integration.calendar.caldav.field.password"](): string;
/**
* `Password or app-specific password`
*/
["com.affine.integration.calendar.caldav.field.password.placeholder"](): string;
/**
* `Password is required.`
*/
["com.affine.integration.calendar.caldav.field.password.error"](): string;
/**
* `Display name (optional)`
*/
["com.affine.integration.calendar.caldav.field.displayName"](): string;
/**
* `My CalDAV`
*/
["com.affine.integration.calendar.caldav.field.displayName.placeholder"](): string;
/**
* `App-specific password required.`
*/
["com.affine.integration.calendar.caldav.hint.app-password"](): string;
/**
* `Learn more`
*/
["com.affine.integration.calendar.caldav.hint.learn-more"](): string;
/**
* `Provider setup guide`
*/
["com.affine.integration.calendar.caldav.hint.guide"](): string;
/**
* `New doc`
*/

View File

@@ -2078,6 +2078,32 @@
"com.affine.integration.calendar.new-url-label": "Calendar URL",
"com.affine.integration.calendar.save-error": "An error occurred while saving the calendar settings",
"com.affine.integration.calendar.all-day": "All day",
"com.affine.integration.calendar.account.load-error": "Failed to load calendar accounts",
"com.affine.integration.calendar.provider.load-error": "Failed to load calendar providers",
"com.affine.integration.calendar.auth.start-error": "Failed to start calendar authorization",
"com.affine.integration.calendar.account.unlink-error": "Failed to unlink calendar account",
"com.affine.integration.calendar.account.unlink": "Unlink",
"com.affine.integration.calendar.account.link": "Link",
"com.affine.integration.calendar.account.linked-empty": "No calendar accounts linked yet.",
"com.affine.integration.calendar.account.status.failed": "Authorization failed: {{error}}",
"com.affine.integration.calendar.account.status.failed-reconnect": "Authorization failed. Please reconnect your account.",
"com.affine.integration.calendar.account.count": "{{count}} calendar",
"com.affine.integration.calendar.caldav.link.title": "Link CalDAV account",
"com.affine.integration.calendar.caldav.link.failed": "Failed to link CalDAV account",
"com.affine.integration.calendar.caldav.field.provider": "Provider",
"com.affine.integration.calendar.caldav.field.provider.placeholder": "Select provider",
"com.affine.integration.calendar.caldav.field.provider.error": "Please select a provider.",
"com.affine.integration.calendar.caldav.field.username": "Username",
"com.affine.integration.calendar.caldav.field.username.placeholder": "email@example.com",
"com.affine.integration.calendar.caldav.field.username.error": "Username is required.",
"com.affine.integration.calendar.caldav.field.password": "Password",
"com.affine.integration.calendar.caldav.field.password.placeholder": "Password or app-specific password",
"com.affine.integration.calendar.caldav.field.password.error": "Password is required.",
"com.affine.integration.calendar.caldav.field.displayName": "Display name (optional)",
"com.affine.integration.calendar.caldav.field.displayName.placeholder": "My CalDAV",
"com.affine.integration.calendar.caldav.hint.app-password": "App-specific password required.",
"com.affine.integration.calendar.caldav.hint.learn-more": "Learn more",
"com.affine.integration.calendar.caldav.hint.guide": "Provider setup guide",
"com.affine.integration.calendar.new-doc": "New doc",
"com.affine.integration.calendar.show-events": "Show calendar events",
"com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE",