mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-23 20:18:42 +08:00
feat(core): calendar integration storage (#11788)
close AF-2501, AF-2504
This commit is contained in:
@@ -53,6 +53,7 @@
|
|||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"graphql": "^16.9.0",
|
"graphql": "^16.9.0",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"ical.js": "^2.1.0",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"image-blob-reduce": "^4.1.0",
|
"image-blob-reduce": "^4.1.0",
|
||||||
|
|||||||
+49
@@ -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,
|
||||||
|
});
|
||||||
+123
@@ -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 (
|
||||||
|
<>
|
||||||
|
<IntegrationSettingHeader
|
||||||
|
icon={<TodayIcon />}
|
||||||
|
name={t['com.affine.integration.calendar.name']()}
|
||||||
|
desc={t['com.affine.integration.calendar.desc']()}
|
||||||
|
divider={false}
|
||||||
|
/>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{subscriptions.map(subscription => (
|
||||||
|
<SubscriptionSetting
|
||||||
|
key={subscription.url}
|
||||||
|
subscription={subscription}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddSubscription />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
prefix={<PlusIcon />}
|
||||||
|
size="large"
|
||||||
|
onClick={handleOpen}
|
||||||
|
className={styles.newButton}
|
||||||
|
>
|
||||||
|
{t['com.affine.integration.calendar.new-subscription']()}
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
persistent
|
||||||
|
withoutCloseButton
|
||||||
|
contentOptions={{ className: styles.newDialog }}
|
||||||
|
>
|
||||||
|
<header className={styles.newDialogHeader}>
|
||||||
|
<IntegrationCardIcon>
|
||||||
|
<TodayIcon />
|
||||||
|
</IntegrationCardIcon>
|
||||||
|
<div className={styles.newDialogTitle}>
|
||||||
|
{t['com.affine.integration.calendar.new-title']()}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.newDialogContent}>
|
||||||
|
<div className={styles.newDialogLabel}>
|
||||||
|
{t['com.affine.integration.calendar.new-url-label']()}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="https://example.com/calendar.ics"
|
||||||
|
onEnter={handleAddSub}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={styles.newDialogFooter}>
|
||||||
|
<Button onClick={handleClose}>{t['Cancel']()}</Button>
|
||||||
|
<Button variant="primary" onClick={handleAddSub} loading={verifying}>
|
||||||
|
{t['com.affine.integration.calendar.new-subscription']()}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+84
@@ -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',
|
||||||
|
});
|
||||||
+87
@@ -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 (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Menu
|
||||||
|
rootOptions={{ open: menuOpen, onOpenChange: setMenuOpen }}
|
||||||
|
contentOptions={{ alignOffset: -6 }}
|
||||||
|
items={
|
||||||
|
<ColorPicker
|
||||||
|
activeColor={config.color}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.colorPickerTrigger}
|
||||||
|
style={{ color: config.color }}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
<div className={styles.name}>{name || t['Untitled']()}</div>
|
||||||
|
<Button variant="error" onClick={handleUnsubscribe}>
|
||||||
|
{t['com.affine.integration.calendar.unsubscribe']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColorPicker = ({
|
||||||
|
activeColor,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
activeColor: string;
|
||||||
|
}) => {
|
||||||
|
const calendar = useService(IntegrationService).calendar;
|
||||||
|
const colors = useMemo(() => calendar.colors, [calendar]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.colorPicker}>
|
||||||
|
{colors.map(color => (
|
||||||
|
<li
|
||||||
|
key={color}
|
||||||
|
onClick={() => onChange(color)}
|
||||||
|
data-active={color === activeColor}
|
||||||
|
className={styles.colorPickerItem}
|
||||||
|
style={{ color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
+41
-5
@@ -1,23 +1,59 @@
|
|||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { IntegrationTypeIcon } from '@affine/core/modules/integration';
|
import { IntegrationTypeIcon } from '@affine/core/modules/integration';
|
||||||
import type { I18nString } from '@affine/i18n';
|
import type { I18nString } from '@affine/i18n';
|
||||||
|
import { TodayIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { LiveData } from '@toeverything/infra';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { CalendarSettingPanel } from './calendar/setting-panel';
|
||||||
import { ReadwiseSettingPanel } from './readwise/setting-panel';
|
import { ReadwiseSettingPanel } from './readwise/setting-panel';
|
||||||
|
|
||||||
export type IntegrationCard = {
|
interface IntegrationCard {
|
||||||
id: string;
|
id: string;
|
||||||
name: I18nString;
|
name: I18nString;
|
||||||
desc: I18nString;
|
desc: I18nString;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
setting: ReactNode;
|
setting: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const INTEGRATION_LIST: IntegrationCard[] = [
|
const INTEGRATION_LIST = [
|
||||||
{
|
{
|
||||||
id: 'readwise',
|
id: 'readwise' as const,
|
||||||
name: 'com.affine.integration.readwise.name',
|
name: 'com.affine.integration.readwise.name',
|
||||||
desc: 'com.affine.integration.readwise.desc',
|
desc: 'com.affine.integration.readwise.desc',
|
||||||
icon: <IntegrationTypeIcon type="readwise" />,
|
icon: <IntegrationTypeIcon type="readwise" />,
|
||||||
setting: <ReadwiseSettingPanel />,
|
setting: <ReadwiseSettingPanel />,
|
||||||
},
|
},
|
||||||
];
|
BUILD_CONFIG.isElectron && {
|
||||||
|
id: 'calendar' as const,
|
||||||
|
name: 'com.affine.integration.calendar.name',
|
||||||
|
desc: 'com.affine.integration.calendar.desc',
|
||||||
|
icon: <TodayIcon />,
|
||||||
|
setting: <CalendarSettingPanel />,
|
||||||
|
},
|
||||||
|
] satisfies (IntegrationCard | false)[];
|
||||||
|
|
||||||
|
type IntegrationId = Exclude<
|
||||||
|
Extract<(typeof INTEGRATION_LIST)[number], {}>,
|
||||||
|
false
|
||||||
|
>['id'];
|
||||||
|
|
||||||
|
export type IntegrationItem = Exclude<IntegrationCard, 'id'> & {
|
||||||
|
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[];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+14
-3
@@ -1,6 +1,8 @@
|
|||||||
import { SettingHeader } from '@affine/component/setting-components';
|
import { SettingHeader } from '@affine/component/setting-components';
|
||||||
|
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { useI18n } from '@affine/i18n';
|
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 { SubPageProvider, useSubPageIsland } from '../../sub-page';
|
||||||
import {
|
import {
|
||||||
@@ -8,12 +10,21 @@ import {
|
|||||||
IntegrationCardContent,
|
IntegrationCardContent,
|
||||||
IntegrationCardHeader,
|
IntegrationCardHeader,
|
||||||
} from './card';
|
} from './card';
|
||||||
import { INTEGRATION_LIST } from './constants';
|
import { getAllowedIntegrationList$ } from './constants';
|
||||||
import { list } from './index.css';
|
import { list } from './index.css';
|
||||||
|
|
||||||
export const IntegrationSetting = () => {
|
export const IntegrationSetting = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [opened, setOpened] = useState<string | null>(null);
|
const [opened, setOpened] = useState<string | null>(null);
|
||||||
|
const featureFlagService = useService(FeatureFlagService);
|
||||||
|
|
||||||
|
const integrationList = useLiveData(
|
||||||
|
useMemo(
|
||||||
|
() => getAllowedIntegrationList$(featureFlagService),
|
||||||
|
[featureFlagService]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingHeader
|
<SettingHeader
|
||||||
@@ -27,7 +38,7 @@ export const IntegrationSetting = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ul className={list}>
|
<ul className={list}>
|
||||||
{INTEGRATION_LIST.map(item => {
|
{integrationList.map(item => {
|
||||||
const title =
|
const title =
|
||||||
typeof item.name === 'string'
|
typeof item.name === 'string'
|
||||||
? t[item.name]()
|
? t[item.name]()
|
||||||
|
|||||||
+6
-2
@@ -5,9 +5,13 @@ export const header = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
paddingBottom: 16,
|
|
||||||
borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
|
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
selectors: {
|
||||||
|
'&[data-divider="true"]': {
|
||||||
|
paddingBottom: 16,
|
||||||
|
borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
export const headerContent = style({
|
export const headerContent = style({
|
||||||
width: 0,
|
width: 0,
|
||||||
|
|||||||
+3
-1
@@ -11,14 +11,16 @@ export const IntegrationSettingHeader = ({
|
|||||||
name,
|
name,
|
||||||
desc,
|
desc,
|
||||||
action,
|
action,
|
||||||
|
divider = true,
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
name: string;
|
name: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
|
divider?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header} data-divider={divider}>
|
||||||
<IntegrationCardIcon className={styles.headerIcon}>
|
<IntegrationCardIcon className={styles.headerIcon}>
|
||||||
{icon}
|
{icon}
|
||||||
</IntegrationCardIcon>
|
</IntegrationCardIcon>
|
||||||
|
|||||||
@@ -276,6 +276,13 @@ export const AFFINE_FLAGS = {
|
|||||||
configurable: isCanaryBuild,
|
configurable: isCanaryBuild,
|
||||||
defaultState: false,
|
defaultState: false,
|
||||||
},
|
},
|
||||||
|
enable_calendar_integration: {
|
||||||
|
category: 'affine',
|
||||||
|
displayName: 'Enable Calendar Integration',
|
||||||
|
description: 'Enable calendar integration',
|
||||||
|
configurable: false,
|
||||||
|
defaultState: isCanaryBuild,
|
||||||
|
},
|
||||||
} satisfies { [key in string]: FlagInfo };
|
} satisfies { [key in string]: FlagInfo };
|
||||||
|
|
||||||
// oxlint-disable-next-line no-redeclare
|
// oxlint-disable-next-line no-redeclare
|
||||||
|
|||||||
@@ -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<any>(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))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CalendarSubscription[]>(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<Omit<CalendarSubscriptionConfig, 'url'>>
|
||||||
|
) {
|
||||||
|
this.store.updateSubscription(url, updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,23 @@ import type { Framework } from '@toeverything/infra';
|
|||||||
import { WorkspaceServerService } from '../cloud';
|
import { WorkspaceServerService } from '../cloud';
|
||||||
import { WorkspaceDBService } from '../db';
|
import { WorkspaceDBService } from '../db';
|
||||||
import { DocScope, DocService, DocsService } from '../doc';
|
import { DocScope, DocService, DocsService } from '../doc';
|
||||||
import { GlobalState } from '../storage';
|
import { CacheStorage, GlobalState } from '../storage';
|
||||||
import { TagService } from '../tag';
|
import { TagService } from '../tag';
|
||||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||||
|
import { CalendarIntegration } from './entities/calendar';
|
||||||
|
import { CalendarSubscription } from './entities/calendar-subscription';
|
||||||
import { ReadwiseIntegration } from './entities/readwise';
|
import { ReadwiseIntegration } from './entities/readwise';
|
||||||
import { ReadwiseCrawler } from './entities/readwise-crawler';
|
import { ReadwiseCrawler } from './entities/readwise-crawler';
|
||||||
import { IntegrationWriter } from './entities/writer';
|
import { IntegrationWriter } from './entities/writer';
|
||||||
import { IntegrationService } from './services/integration';
|
import { IntegrationService } from './services/integration';
|
||||||
import { IntegrationPropertyService } from './services/integration-property';
|
import { IntegrationPropertyService } from './services/integration-property';
|
||||||
|
import { CalendarStore } from './store/calendar';
|
||||||
import { IntegrationRefStore } from './store/integration-ref';
|
import { IntegrationRefStore } from './store/integration-ref';
|
||||||
import { ReadwiseStore } from './store/readwise';
|
import { ReadwiseStore } from './store/readwise';
|
||||||
|
|
||||||
export { IntegrationService };
|
export { IntegrationService };
|
||||||
|
export { CalendarIntegration } from './entities/calendar';
|
||||||
|
export { CalendarSubscription } from './entities/calendar-subscription';
|
||||||
export { IntegrationTypeIcon } from './views/icon';
|
export { IntegrationTypeIcon } from './views/icon';
|
||||||
export { DocIntegrationPropertiesTable } from './views/properties-table';
|
export { DocIntegrationPropertiesTable } from './views/properties-table';
|
||||||
|
|
||||||
@@ -35,6 +40,14 @@ export function configureIntegrationModule(framework: Framework) {
|
|||||||
ReadwiseStore,
|
ReadwiseStore,
|
||||||
DocsService,
|
DocsService,
|
||||||
])
|
])
|
||||||
|
.store(CalendarStore, [
|
||||||
|
GlobalState,
|
||||||
|
CacheStorage,
|
||||||
|
WorkspaceService,
|
||||||
|
WorkspaceServerService,
|
||||||
|
])
|
||||||
|
.entity(CalendarIntegration, [CalendarStore])
|
||||||
|
.entity(CalendarSubscription, [CalendarStore])
|
||||||
.scope(DocScope)
|
.scope(DocScope)
|
||||||
.service(IntegrationPropertyService, [DocService]);
|
.service(IntegrationPropertyService, [DocService]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LiveData, Service } from '@toeverything/infra';
|
import { LiveData, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { CalendarIntegration } from '../entities/calendar';
|
||||||
import { ReadwiseIntegration } from '../entities/readwise';
|
import { ReadwiseIntegration } from '../entities/readwise';
|
||||||
import { IntegrationWriter } from '../entities/writer';
|
import { IntegrationWriter } from '../entities/writer';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ export class IntegrationService extends Service {
|
|||||||
readwise = this.framework.createEntity(ReadwiseIntegration, {
|
readwise = this.framework.createEntity(ReadwiseIntegration, {
|
||||||
writer: this.writer,
|
writer: this.writer,
|
||||||
});
|
});
|
||||||
|
calendar = this.framework.createEntity(CalendarIntegration);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -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<string, CalendarSubscriptionConfig>;
|
||||||
|
|
||||||
|
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<CalendarSubscriptionStore>(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<string>(this.getCacheKey(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscriptionMap() {
|
||||||
|
return (
|
||||||
|
this.globalState.get<CalendarSubscriptionStore | undefined>(
|
||||||
|
this.getStorageKey()
|
||||||
|
) ?? {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubscription(url: string, config?: Partial<CalendarSubscriptionConfig>) {
|
||||||
|
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<Omit<CalendarSubscriptionConfig, 'url'>>
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
CacheStorage,
|
||||||
GlobalCache,
|
GlobalCache,
|
||||||
GlobalSessionState,
|
GlobalSessionState,
|
||||||
GlobalState,
|
GlobalState,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
"de": 99,
|
"de": 99,
|
||||||
"el-GR": 99,
|
"el-GR": 99,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 100,
|
"es-AR": 99,
|
||||||
"es-CL": 100,
|
"es-CL": 100,
|
||||||
"es": 99,
|
"es": 99,
|
||||||
"fa": 99,
|
"fa": 99,
|
||||||
"fr": 99,
|
"fr": 99,
|
||||||
"hi": 2,
|
"hi": 2,
|
||||||
"it-IT": 100,
|
"it-IT": 99,
|
||||||
"it": 1,
|
"it": 1,
|
||||||
"ja": 99,
|
"ja": 99,
|
||||||
"ko": 57,
|
"ko": 57,
|
||||||
|
|||||||
@@ -7617,6 +7617,34 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Integration properties`
|
* `Integration properties`
|
||||||
*/
|
*/
|
||||||
["com.affine.integration.properties"](): string;
|
["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`
|
* `Notes`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1906,6 +1906,13 @@
|
|||||||
"com.affine.integration.readwise-prop.created": "Created",
|
"com.affine.integration.readwise-prop.created": "Created",
|
||||||
"com.affine.integration.readwise-prop.updated": "Updated",
|
"com.affine.integration.readwise-prop.updated": "Updated",
|
||||||
"com.affine.integration.properties": "Integration properties",
|
"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.notes": "Notes",
|
||||||
"com.affine.audio.transcribing": "Transcribing",
|
"com.affine.audio.transcribing": "Transcribing",
|
||||||
"com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others",
|
"com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others",
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ __metadata:
|
|||||||
graphemer: "npm:^1.4.0"
|
graphemer: "npm:^1.4.0"
|
||||||
graphql: "npm:^16.9.0"
|
graphql: "npm:^16.9.0"
|
||||||
history: "npm:^5.3.0"
|
history: "npm:^5.3.0"
|
||||||
|
ical.js: "npm:^2.1.0"
|
||||||
idb: "npm:^8.0.0"
|
idb: "npm:^8.0.0"
|
||||||
idb-keyval: "npm:^6.2.1"
|
idb-keyval: "npm:^6.2.1"
|
||||||
image-blob-reduce: "npm:^4.1.0"
|
image-blob-reduce: "npm:^4.1.0"
|
||||||
@@ -23272,6 +23273,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24":
|
||||||
version: 0.4.24
|
version: 0.4.24
|
||||||
resolution: "iconv-lite@npm:0.4.24"
|
resolution: "iconv-lite@npm:0.4.24"
|
||||||
|
|||||||
Reference in New Issue
Block a user