feat(core): calendar integration storage (#11788)

close AF-2501, AF-2504
This commit is contained in:
CatsJuice 2025-04-23 07:57:23 +00:00
parent af69154f1c
commit 200015a811
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
20 changed files with 779 additions and 14 deletions

View File

@ -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",

View File

@ -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,
});

View File

@ -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>
</>
);
};

View File

@ -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',
});

View File

@ -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>
);
};

View File

@ -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: <IntegrationTypeIcon type="readwise" />,
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[];
});
}

View File

@ -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<string | null>(null);
const featureFlagService = useService(FeatureFlagService);
const integrationList = useLiveData(
useMemo(
() => getAllowedIntegrationList$(featureFlagService),
[featureFlagService]
)
);
return (
<>
<SettingHeader
@ -27,7 +38,7 @@ export const IntegrationSetting = () => {
}
/>
<ul className={list}>
{INTEGRATION_LIST.map(item => {
{integrationList.map(item => {
const title =
typeof item.name === 'string'
? t[item.name]()

View File

@ -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,

View File

@ -11,14 +11,16 @@ export const IntegrationSettingHeader = ({
name,
desc,
action,
divider = true,
}: {
icon: ReactNode;
name: string;
desc: string;
action?: ReactNode;
divider?: boolean;
}) => {
return (
<header className={styles.header}>
<header className={styles.header} data-divider={divider}>
<IntegrationCardIcon className={styles.headerIcon}>
{icon}
</IntegrationCardIcon>

View File

@ -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

View File

@ -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))
)
)
);
}

View File

@ -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);
}
}

View File

@ -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]);
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -1,4 +1,5 @@
export {
CacheStorage,
GlobalCache,
GlobalSessionState,
GlobalState,

View File

@ -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,

View File

@ -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 AFFiNEs 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`
*/

View File

@ -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 AFFiNEs 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",

View File

@ -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"