diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json
index feefab7043..a0847b3620 100644
--- a/packages/frontend/core/package.json
+++ b/packages/frontend/core/package.json
@@ -53,6 +53,7 @@
"graphemer": "^1.4.0",
"graphql": "^16.9.0",
"history": "^5.3.0",
+ "ical.js": "^2.1.0",
"idb": "^8.0.0",
"idb-keyval": "^6.2.1",
"image-blob-reduce": "^4.1.0",
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts
new file mode 100644
index 0000000000..ccbbe03b22
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts
@@ -0,0 +1,49 @@
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { style } from '@vanilla-extract/css';
+
+export const list = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 24,
+});
+
+export const newButton = style({
+ color: cssVarV2.text.secondary,
+});
+
+export const newDialog = style({
+ maxWidth: 480,
+});
+
+export const newDialogHeader = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+});
+
+export const newDialogTitle = style({
+ fontSize: 15,
+ lineHeight: '24px',
+ fontWeight: 500,
+ color: cssVarV2.text.primary,
+});
+
+export const newDialogContent = style({
+ marginTop: 16,
+ marginBottom: 20,
+});
+
+export const newDialogLabel = style({
+ fontSize: 12,
+ lineHeight: '20px',
+ fontWeight: 500,
+ color: cssVarV2.text.primary,
+ marginBottom: 4,
+});
+
+export const newDialogFooter = style({
+ display: 'flex',
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ gap: 20,
+});
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx
new file mode 100644
index 0000000000..3aec8fdac5
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx
@@ -0,0 +1,123 @@
+import { Button, Input, Modal, notify } from '@affine/component';
+import { IntegrationService } from '@affine/core/modules/integration';
+import { useI18n } from '@affine/i18n';
+import { PlusIcon, TodayIcon } from '@blocksuite/icons/rc';
+import { useLiveData, useService } from '@toeverything/infra';
+import { useCallback, useState } from 'react';
+
+import { IntegrationCardIcon } from '../card';
+import { IntegrationSettingHeader } from '../setting';
+import * as styles from './setting-panel.css';
+import { SubscriptionSetting } from './subscription-setting';
+
+export const CalendarSettingPanel = () => {
+ const t = useI18n();
+ const calendar = useService(IntegrationService).calendar;
+ const subscriptions = useLiveData(calendar.subscriptions$);
+ return (
+ <>
+ }
+ name={t['com.affine.integration.calendar.name']()}
+ desc={t['com.affine.integration.calendar.desc']()}
+ divider={false}
+ />
+
+ {subscriptions.map(subscription => (
+
+ ))}
+
+
+ >
+ );
+};
+
+const AddSubscription = () => {
+ const t = useI18n();
+ const [open, setOpen] = useState(false);
+ const [url, setUrl] = useState('');
+ const [verifying, setVerifying] = useState(false);
+ const calendar = useService(IntegrationService).calendar;
+
+ const handleOpen = useCallback(() => {
+ setOpen(true);
+ }, []);
+ const handleClose = useCallback(() => {
+ setOpen(false);
+ setUrl('');
+ }, []);
+
+ const handleInputChange = useCallback((value: string) => {
+ setUrl(value);
+ }, []);
+
+ const handleAddSub = useCallback(() => {
+ setVerifying(true);
+ calendar
+ .createSubscription(url)
+ .then(() => {
+ setOpen(false);
+ setUrl('');
+ })
+ .catch(() => {
+ notify.error({
+ title: t['com.affine.integration.calendar.new-error'](),
+ });
+ })
+ .finally(() => {
+ setVerifying(false);
+ });
+ }, [calendar, t, url]);
+
+ return (
+ <>
+ }
+ size="large"
+ onClick={handleOpen}
+ className={styles.newButton}
+ >
+ {t['com.affine.integration.calendar.new-subscription']()}
+
+
+
+
+
+
+ {t['com.affine.integration.calendar.new-url-label']()}
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts
new file mode 100644
index 0000000000..9484f1461a
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts
@@ -0,0 +1,84 @@
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { style } from '@vanilla-extract/css';
+
+export const card = style({
+ padding: '8px 16px 12px 16px',
+ borderRadius: 8,
+ background: cssVarV2.layer.background.primary,
+ border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+});
+export const divider = style({
+ height: 8,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ ':before': {
+ content: '',
+ width: '100%',
+ height: 0,
+ borderTop: `0.5px solid ${cssVarV2.tab.divider.divider}`,
+ },
+});
+export const header = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+});
+export const colorPickerTrigger = style({
+ width: 24,
+ height: 24,
+ borderRadius: 4,
+ cursor: 'pointer',
+ background: cssVarV2.layer.background.overlayPanel,
+ border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ ':before': {
+ content: '',
+ width: 11,
+ height: 11,
+ borderRadius: 11,
+ backgroundColor: 'currentColor',
+ },
+});
+export const colorPicker = style({
+ display: 'flex',
+ gap: 4,
+ alignItems: 'center',
+});
+export const colorPickerItem = style({
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ selectors: {
+ '&[data-active="true"]': {
+ boxShadow: `0 0 0 1px ${cssVarV2.button.primary}`,
+ },
+ '&:before': {
+ content: '',
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ background: 'currentColor',
+ },
+ },
+});
+export const name = style({
+ fontSize: 14,
+ fontWeight: 500,
+ lineHeight: '22px',
+ color: cssVarV2.text.primary,
+ width: 0,
+ flexGrow: 1,
+ textOverflow: 'ellipsis',
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+});
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx
new file mode 100644
index 0000000000..e323352c78
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx
@@ -0,0 +1,87 @@
+import { Button, Menu } from '@affine/component';
+import {
+ type CalendarSubscription,
+ IntegrationService,
+} from '@affine/core/modules/integration';
+import { useI18n } from '@affine/i18n';
+import { useLiveData, useService } from '@toeverything/infra';
+import { useCallback, useMemo, useState } from 'react';
+
+import * as styles from './subscription-setting.css';
+
+export const SubscriptionSetting = ({
+ subscription,
+}: {
+ subscription: CalendarSubscription;
+}) => {
+ const t = useI18n();
+ const [menuOpen, setMenuOpen] = useState(false);
+ const calendar = useService(IntegrationService).calendar;
+ const config = useLiveData(subscription.config$);
+ const name = useLiveData(subscription.name$);
+
+ const handleColorChange = useCallback(
+ (color: string) => {
+ calendar.updateSubscription(subscription.url, { color });
+ setMenuOpen(false);
+ },
+ [calendar, subscription.url]
+ );
+
+ const handleUnsubscribe = useCallback(() => {
+ calendar.deleteSubscription(subscription.url);
+ }, [calendar, subscription.url]);
+
+ if (!config) return null;
+
+ return (
+
+
+
+ }
+ >
+
+
+
{name || t['Untitled']()}
+
+
+
+ );
+};
+
+const ColorPicker = ({
+ activeColor,
+ onChange,
+}: {
+ onChange: (color: string) => void;
+ activeColor: string;
+}) => {
+ const calendar = useService(IntegrationService).calendar;
+ const colors = useMemo(() => calendar.colors, [calendar]);
+
+ return (
+
+ {colors.map(color => (
+ - onChange(color)}
+ data-active={color === activeColor}
+ className={styles.colorPickerItem}
+ style={{ color }}
+ />
+ ))}
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx
index 578b46712d..5f3a720a78 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx
@@ -1,23 +1,59 @@
+import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { IntegrationTypeIcon } from '@affine/core/modules/integration';
import type { I18nString } from '@affine/i18n';
+import { TodayIcon } from '@blocksuite/icons/rc';
+import { LiveData } from '@toeverything/infra';
import type { ReactNode } from 'react';
+import { CalendarSettingPanel } from './calendar/setting-panel';
import { ReadwiseSettingPanel } from './readwise/setting-panel';
-export type IntegrationCard = {
+interface IntegrationCard {
id: string;
name: I18nString;
desc: I18nString;
icon: ReactNode;
setting: ReactNode;
-};
+}
-export const INTEGRATION_LIST: IntegrationCard[] = [
+const INTEGRATION_LIST = [
{
- id: 'readwise',
+ id: 'readwise' as const,
name: 'com.affine.integration.readwise.name',
desc: 'com.affine.integration.readwise.desc',
icon: ,
setting: ,
},
-];
+ BUILD_CONFIG.isElectron && {
+ id: 'calendar' as const,
+ name: 'com.affine.integration.calendar.name',
+ desc: 'com.affine.integration.calendar.desc',
+ icon: ,
+ setting: ,
+ },
+] satisfies (IntegrationCard | false)[];
+
+type IntegrationId = Exclude<
+ Extract<(typeof INTEGRATION_LIST)[number], {}>,
+ false
+>['id'];
+
+export type IntegrationItem = Exclude & {
+ id: IntegrationId;
+};
+
+export function getAllowedIntegrationList$(
+ featureFlagService: FeatureFlagService
+) {
+ return LiveData.computed(get => {
+ return INTEGRATION_LIST.filter(item => {
+ if (!item) return false;
+
+ if (item.id === 'calendar') {
+ return get(featureFlagService.flags.enable_calendar_integration.$);
+ }
+
+ return true;
+ }) as IntegrationItem[];
+ });
+}
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx
index 355dd2f175..ebd4341fb4 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx
@@ -1,6 +1,8 @@
import { SettingHeader } from '@affine/component/setting-components';
+import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { useI18n } from '@affine/i18n';
-import { type ReactNode, useState } from 'react';
+import { useLiveData, useService } from '@toeverything/infra';
+import { type ReactNode, useMemo, useState } from 'react';
import { SubPageProvider, useSubPageIsland } from '../../sub-page';
import {
@@ -8,12 +10,21 @@ import {
IntegrationCardContent,
IntegrationCardHeader,
} from './card';
-import { INTEGRATION_LIST } from './constants';
+import { getAllowedIntegrationList$ } from './constants';
import { list } from './index.css';
export const IntegrationSetting = () => {
const t = useI18n();
const [opened, setOpened] = useState(null);
+ const featureFlagService = useService(FeatureFlagService);
+
+ const integrationList = useLiveData(
+ useMemo(
+ () => getAllowedIntegrationList$(featureFlagService),
+ [featureFlagService]
+ )
+ );
+
return (
<>
{
}
/>
- {INTEGRATION_LIST.map(item => {
+ {integrationList.map(item => {
const title =
typeof item.name === 'string'
? t[item.name]()
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts
index e06f09452b..a74949055d 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts
@@ -5,9 +5,13 @@ export const header = style({
display: 'flex',
alignItems: 'center',
gap: 8,
- paddingBottom: 16,
- borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
marginBottom: 24,
+ selectors: {
+ '&[data-divider="true"]': {
+ paddingBottom: 16,
+ borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
+ },
+ },
});
export const headerContent = style({
width: 0,
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx
index 952f02e0bb..c630b42150 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx
@@ -11,14 +11,16 @@ export const IntegrationSettingHeader = ({
name,
desc,
action,
+ divider = true,
}: {
icon: ReactNode;
name: string;
desc: string;
action?: ReactNode;
+ divider?: boolean;
}) => {
return (
-
+
{icon}
diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts
index 58cce3b455..fdb9c83e65 100644
--- a/packages/frontend/core/src/modules/feature-flag/constant.ts
+++ b/packages/frontend/core/src/modules/feature-flag/constant.ts
@@ -276,6 +276,13 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: false,
},
+ enable_calendar_integration: {
+ category: 'affine',
+ displayName: 'Enable Calendar Integration',
+ description: 'Enable calendar integration',
+ configurable: false,
+ defaultState: isCanaryBuild,
+ },
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare
diff --git a/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts b/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts
new file mode 100644
index 0000000000..89e4e5d228
--- /dev/null
+++ b/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts
@@ -0,0 +1,59 @@
+import {
+ catchErrorInto,
+ effect,
+ Entity,
+ fromPromise,
+ LiveData,
+ onComplete,
+ onStart,
+} from '@toeverything/infra';
+import ICAL from 'ical.js';
+import { switchMap } from 'rxjs';
+
+import type {
+ CalendarStore,
+ CalendarSubscriptionConfig,
+} from '../store/calendar';
+
+export class CalendarSubscription extends Entity<{ url: string }> {
+ constructor(private readonly store: CalendarStore) {
+ super();
+ }
+
+ config$ = LiveData.from(
+ this.store.watchSubscription(this.props.url),
+ {} as CalendarSubscriptionConfig
+ );
+ content$ = LiveData.from(
+ this.store.watchSubscriptionCache(this.props.url),
+ ''
+ );
+ name$ = this.content$.selector(content => {
+ if (!content) return '';
+ try {
+ const jCal = ICAL.parse(content ?? '');
+ const vCalendar = new ICAL.Component(jCal);
+ return (vCalendar.getFirstPropertyValue('x-wr-calname') as string) || '';
+ } catch {
+ return '';
+ }
+ });
+
+ url = this.props.url;
+ loading$ = new LiveData(false);
+ error$ = new LiveData(null);
+
+ update = effect(
+ switchMap(() =>
+ fromPromise(async () => {
+ const response = await fetch(this.url);
+ const cache = await response.text();
+ this.store.setSubscriptionCache(this.url, cache).catch(console.error);
+ }).pipe(
+ catchErrorInto(this.error$),
+ onStart(() => this.loading$.setValue(true)),
+ onComplete(() => this.loading$.setValue(false))
+ )
+ )
+ );
+}
diff --git a/packages/frontend/core/src/modules/integration/entities/calendar.ts b/packages/frontend/core/src/modules/integration/entities/calendar.ts
new file mode 100644
index 0000000000..d6419c6575
--- /dev/null
+++ b/packages/frontend/core/src/modules/integration/entities/calendar.ts
@@ -0,0 +1,93 @@
+import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
+import ICAL from 'ical.js';
+import { Observable, switchMap } from 'rxjs';
+
+import type {
+ CalendarStore,
+ CalendarSubscriptionConfig,
+} from '../store/calendar';
+import { CalendarSubscription } from './calendar-subscription';
+
+export class CalendarIntegration extends Entity {
+ constructor(private readonly store: CalendarStore) {
+ super();
+ }
+
+ private readonly subscriptionPool = new ObjectPool<
+ string,
+ CalendarSubscription
+ >();
+
+ colors = this.store.colors;
+ subscriptions$ = LiveData.from(
+ this.store.watchSubscriptionMap().pipe(
+ switchMap(subs => {
+ const refs = Object.entries(subs ?? {}).map(([url]) => {
+ const exists = this.subscriptionPool.get(url);
+ if (exists) {
+ return exists;
+ }
+ const subscription = this.framework.createEntity(
+ CalendarSubscription,
+ { url }
+ );
+ const ref = this.subscriptionPool.put(url, subscription);
+ return ref;
+ });
+
+ return new Observable(subscribe => {
+ subscribe.next(refs.map(ref => ref.obj));
+ return () => {
+ refs.forEach(ref => ref.release());
+ };
+ });
+ })
+ ),
+ []
+ );
+
+ async verifyUrl(_url: string) {
+ let url = _url;
+ try {
+ const urlObj = new URL(url);
+ if (urlObj.protocol === 'webcal:') {
+ urlObj.protocol = 'https';
+ }
+ url = urlObj.toString();
+ } catch (err) {
+ console.error(err);
+ throw new Error('Invalid URL');
+ }
+ try {
+ const response = await fetch(url);
+ const content = await response.text();
+ ICAL.parse(content);
+ return content;
+ } catch (err) {
+ console.error(err);
+ throw new Error('Failed to verify URL');
+ }
+ }
+
+ async createSubscription(url: string) {
+ try {
+ const content = await this.verifyUrl(url);
+ this.store.addSubscription(url);
+ this.store.setSubscriptionCache(url, content).catch(console.error);
+ } catch (err) {
+ console.error(err);
+ throw new Error('Failed to verify URL');
+ }
+ }
+
+ deleteSubscription(url: string) {
+ this.store.removeSubscription(url);
+ }
+
+ updateSubscription(
+ url: string,
+ updates: Partial>
+ ) {
+ this.store.updateSubscription(url, updates);
+ }
+}
diff --git a/packages/frontend/core/src/modules/integration/index.ts b/packages/frontend/core/src/modules/integration/index.ts
index 1d74466920..2c91583f16 100644
--- a/packages/frontend/core/src/modules/integration/index.ts
+++ b/packages/frontend/core/src/modules/integration/index.ts
@@ -3,18 +3,23 @@ import type { Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { WorkspaceDBService } from '../db';
import { DocScope, DocService, DocsService } from '../doc';
-import { GlobalState } from '../storage';
+import { CacheStorage, GlobalState } from '../storage';
import { TagService } from '../tag';
import { WorkspaceScope, WorkspaceService } from '../workspace';
+import { CalendarIntegration } from './entities/calendar';
+import { CalendarSubscription } from './entities/calendar-subscription';
import { ReadwiseIntegration } from './entities/readwise';
import { ReadwiseCrawler } from './entities/readwise-crawler';
import { IntegrationWriter } from './entities/writer';
import { IntegrationService } from './services/integration';
import { IntegrationPropertyService } from './services/integration-property';
+import { CalendarStore } from './store/calendar';
import { IntegrationRefStore } from './store/integration-ref';
import { ReadwiseStore } from './store/readwise';
export { IntegrationService };
+export { CalendarIntegration } from './entities/calendar';
+export { CalendarSubscription } from './entities/calendar-subscription';
export { IntegrationTypeIcon } from './views/icon';
export { DocIntegrationPropertiesTable } from './views/properties-table';
@@ -35,6 +40,14 @@ export function configureIntegrationModule(framework: Framework) {
ReadwiseStore,
DocsService,
])
+ .store(CalendarStore, [
+ GlobalState,
+ CacheStorage,
+ WorkspaceService,
+ WorkspaceServerService,
+ ])
+ .entity(CalendarIntegration, [CalendarStore])
+ .entity(CalendarSubscription, [CalendarStore])
.scope(DocScope)
.service(IntegrationPropertyService, [DocService]);
}
diff --git a/packages/frontend/core/src/modules/integration/services/integration.ts b/packages/frontend/core/src/modules/integration/services/integration.ts
index 93fdfac5ec..649b099a15 100644
--- a/packages/frontend/core/src/modules/integration/services/integration.ts
+++ b/packages/frontend/core/src/modules/integration/services/integration.ts
@@ -1,5 +1,6 @@
import { LiveData, Service } from '@toeverything/infra';
+import { CalendarIntegration } from '../entities/calendar';
import { ReadwiseIntegration } from '../entities/readwise';
import { IntegrationWriter } from '../entities/writer';
@@ -8,6 +9,7 @@ export class IntegrationService extends Service {
readwise = this.framework.createEntity(ReadwiseIntegration, {
writer: this.writer,
});
+ calendar = this.framework.createEntity(CalendarIntegration);
constructor() {
super();
diff --git a/packages/frontend/core/src/modules/integration/store/calendar.ts b/packages/frontend/core/src/modules/integration/store/calendar.ts
new file mode 100644
index 0000000000..3c423c4754
--- /dev/null
+++ b/packages/frontend/core/src/modules/integration/store/calendar.ts
@@ -0,0 +1,150 @@
+import { LiveData, Store } from '@toeverything/infra';
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { exhaustMap, map } from 'rxjs';
+
+import { AuthService, type WorkspaceServerService } from '../../cloud';
+import type { CacheStorage, GlobalState } from '../../storage';
+import type { WorkspaceService } from '../../workspace';
+
+export interface CalendarSubscriptionConfig {
+ color: string;
+ showEvents?: boolean;
+ showAllDayEvents?: boolean;
+}
+type CalendarSubscriptionStore = Record;
+
+export class CalendarStore extends Store {
+ constructor(
+ private readonly globalState: GlobalState,
+ private readonly cacheStorage: CacheStorage,
+ private readonly workspaceService: WorkspaceService,
+ private readonly workspaceServerService: WorkspaceServerService
+ ) {
+ super();
+ }
+
+ public colors = [
+ cssVarV2.calendar.red,
+ cssVarV2.calendar.orange,
+ cssVarV2.calendar.yellow,
+ cssVarV2.calendar.green,
+ cssVarV2.calendar.teal,
+ cssVarV2.calendar.blue,
+ cssVarV2.calendar.purple,
+ cssVarV2.calendar.magenta,
+ cssVarV2.calendar.grey,
+ ];
+
+ public getRandomColor() {
+ return this.colors[Math.floor(Math.random() * this.colors.length)];
+ }
+
+ private _getKey(userId: string, workspaceId: string) {
+ return `calendar:${userId}:${workspaceId}:subscriptions`;
+ }
+
+ private _createSubscription() {
+ return {
+ showEvents: true,
+ showAllDayEvents: true,
+ color: this.getRandomColor(),
+ };
+ }
+
+ authService = this.workspaceServerService.server?.scope.get(AuthService);
+ userId$ =
+ this.workspaceService.workspace.meta.flavour === 'local' ||
+ !this.authService
+ ? new LiveData('__local__')
+ : this.authService.session.account$.map(
+ account => account?.id ?? '__local__'
+ );
+ storageKey$() {
+ const workspaceId = this.workspaceService.workspace.id;
+ return this.userId$.map(userId => this._getKey(userId, workspaceId));
+ }
+ getUserId() {
+ return this.workspaceService.workspace.meta.flavour === 'local' ||
+ !this.authService
+ ? '__local__'
+ : (this.authService.session.account$.value?.id ?? '__local__');
+ }
+
+ getStorageKey() {
+ const workspaceId = this.workspaceService.workspace.id;
+ return this._getKey(this.getUserId(), workspaceId);
+ }
+
+ getCacheKey(url: string) {
+ return `calendar-cache:${url}`;
+ }
+
+ watchSubscriptionMap() {
+ return this.storageKey$().pipe(
+ exhaustMap(storageKey => {
+ return this.globalState.watch(storageKey);
+ })
+ );
+ }
+
+ watchSubscription(url: string) {
+ return this.watchSubscriptionMap().pipe(
+ map(subscriptionMap => {
+ if (!subscriptionMap) {
+ return null;
+ }
+ return subscriptionMap[url] ?? null;
+ })
+ );
+ }
+
+ watchSubscriptionCache(url: string) {
+ return this.cacheStorage.watch(this.getCacheKey(url));
+ }
+
+ getSubscriptionMap() {
+ return (
+ this.globalState.get(
+ this.getStorageKey()
+ ) ?? {}
+ );
+ }
+
+ addSubscription(url: string, config?: Partial) {
+ const subscriptionMap = this.getSubscriptionMap();
+ this.globalState.set(this.getStorageKey(), {
+ ...subscriptionMap,
+ [url]: {
+ // merge default config
+ ...this._createSubscription(),
+ // update if exists
+ ...subscriptionMap[url],
+ ...config,
+ },
+ });
+ }
+
+ removeSubscription(url: string) {
+ this.globalState.set(
+ this.getStorageKey(),
+ Object.fromEntries(
+ Object.entries(this.getSubscriptionMap()).filter(([key]) => key !== url)
+ )
+ );
+ }
+
+ updateSubscription(
+ url: string,
+ updates: Partial>
+ ) {
+ const subscriptionMap = this.getSubscriptionMap();
+ this.globalState.set(this.getStorageKey(), {
+ ...subscriptionMap,
+ [url]: { ...subscriptionMap[url], ...updates },
+ });
+ }
+
+ setSubscriptionCache(url: string, cache: string) {
+ return this.cacheStorage.set(this.getCacheKey(url), cache);
+ }
+}
diff --git a/packages/frontend/core/src/modules/storage/index.ts b/packages/frontend/core/src/modules/storage/index.ts
index 1e70f32304..369216fcde 100644
--- a/packages/frontend/core/src/modules/storage/index.ts
+++ b/packages/frontend/core/src/modules/storage/index.ts
@@ -1,4 +1,5 @@
export {
+ CacheStorage,
GlobalCache,
GlobalSessionState,
GlobalState,
diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json
index b6b37aaaa2..1b80bf2f95 100644
--- a/packages/frontend/i18n/src/i18n-completenesses.json
+++ b/packages/frontend/i18n/src/i18n-completenesses.json
@@ -5,13 +5,13 @@
"de": 99,
"el-GR": 99,
"en": 100,
- "es-AR": 100,
+ "es-AR": 99,
"es-CL": 100,
"es": 99,
"fa": 99,
"fr": 99,
"hi": 2,
- "it-IT": 100,
+ "it-IT": 99,
"it": 1,
"ja": 99,
"ko": 57,
diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts
index c21543b7de..4ee70257fe 100644
--- a/packages/frontend/i18n/src/i18n.gen.ts
+++ b/packages/frontend/i18n/src/i18n.gen.ts
@@ -7617,6 +7617,34 @@ export function useAFFiNEI18N(): {
* `Integration properties`
*/
["com.affine.integration.properties"](): string;
+ /**
+ * `Calendar`
+ */
+ ["com.affine.integration.calendar.name"](): string;
+ /**
+ * `New events will be scheduled in AFFiNE’s journal`
+ */
+ ["com.affine.integration.calendar.desc"](): string;
+ /**
+ * `Subscribe`
+ */
+ ["com.affine.integration.calendar.new-subscription"](): string;
+ /**
+ * `Unsubscribe`
+ */
+ ["com.affine.integration.calendar.unsubscribe"](): string;
+ /**
+ * `Add a calendar by URL`
+ */
+ ["com.affine.integration.calendar.new-title"](): string;
+ /**
+ * `Calendar URL`
+ */
+ ["com.affine.integration.calendar.new-url-label"](): string;
+ /**
+ * `An error occurred while adding the calendar`
+ */
+ ["com.affine.integration.calendar.new-error"](): string;
/**
* `Notes`
*/
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 87b59e3a26..fd767d9db5 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -1906,6 +1906,13 @@
"com.affine.integration.readwise-prop.created": "Created",
"com.affine.integration.readwise-prop.updated": "Updated",
"com.affine.integration.properties": "Integration properties",
+ "com.affine.integration.calendar.name": "Calendar",
+ "com.affine.integration.calendar.desc": "New events will be scheduled in AFFiNE’s journal",
+ "com.affine.integration.calendar.new-subscription": "Subscribe",
+ "com.affine.integration.calendar.unsubscribe": "Unsubscribe",
+ "com.affine.integration.calendar.new-title": "Add a calendar by URL",
+ "com.affine.integration.calendar.new-url-label": "Calendar URL",
+ "com.affine.integration.calendar.new-error": "An error occurred while adding the calendar",
"com.affine.audio.notes": "Notes",
"com.affine.audio.transcribing": "Transcribing",
"com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others",
diff --git a/yarn.lock b/yarn.lock
index e330507931..777b6ba987 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -442,6 +442,7 @@ __metadata:
graphemer: "npm:^1.4.0"
graphql: "npm:^16.9.0"
history: "npm:^5.3.0"
+ ical.js: "npm:^2.1.0"
idb: "npm:^8.0.0"
idb-keyval: "npm:^6.2.1"
image-blob-reduce: "npm:^4.1.0"
@@ -23272,6 +23273,13 @@ __metadata:
languageName: node
linkType: hard
+"ical.js@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "ical.js@npm:2.1.0"
+ checksum: 10/2c1ac836a3a87a6958ab5386f26965a70328ca5566dd84fc641726aef893adb43433aa35d7ea576b890e8461ce54995f9fa82bfa32ce8b186b9b9d4788a6f894
+ languageName: node
+ linkType: hard
+
"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24":
version: 0.4.24
resolution: "iconv-lite@npm:0.4.24"