mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(core): calendar integration storage (#11788)
close AF-2501, AF-2504
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 { 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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
150
packages/frontend/core/src/modules/integration/store/calendar.ts
Normal file
150
packages/frontend/core/src/modules/integration/store/calendar.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
CacheStorage,
|
||||
GlobalCache,
|
||||
GlobalSessionState,
|
||||
GlobalState,
|
||||
|
||||
Reference in New Issue
Block a user