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
20 changed files with 779 additions and 14 deletions

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,