From 200015a811f3a3ef9c2c7d3df12c140ad3501441 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Wed, 23 Apr 2025 07:57:23 +0000 Subject: [PATCH] feat(core): calendar integration storage (#11788) close AF-2501, AF-2504 --- packages/frontend/core/package.json | 1 + .../integration/calendar/setting-panel.css.ts | 49 ++++++ .../integration/calendar/setting-panel.tsx | 123 ++++++++++++++ .../calendar/subscription-setting.css.ts | 84 ++++++++++ .../calendar/subscription-setting.tsx | 87 ++++++++++ .../integration/constants.tsx | 46 +++++- .../workspace-setting/integration/index.tsx | 17 +- .../integration/setting.css.ts | 8 +- .../workspace-setting/integration/setting.tsx | 4 +- .../core/src/modules/feature-flag/constant.ts | 7 + .../entities/calendar-subscription.ts | 59 +++++++ .../modules/integration/entities/calendar.ts | 93 +++++++++++ .../core/src/modules/integration/index.ts | 15 +- .../integration/services/integration.ts | 2 + .../src/modules/integration/store/calendar.ts | 150 ++++++++++++++++++ .../core/src/modules/storage/index.ts | 1 + .../i18n/src/i18n-completenesses.json | 4 +- packages/frontend/i18n/src/i18n.gen.ts | 28 ++++ packages/frontend/i18n/src/resources/en.json | 7 + yarn.lock | 8 + 20 files changed, 779 insertions(+), 14 deletions(-) create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx create mode 100644 packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts create mode 100644 packages/frontend/core/src/modules/integration/entities/calendar.ts create mode 100644 packages/frontend/core/src/modules/integration/store/calendar.ts 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 ( + <> + + +
+ + + +
+ {t['com.affine.integration.calendar.new-title']()} +
+
+ +
+
+ {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 ( + + ); +}; 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"