mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 01:56:27 +08:00
feat(core): integrate google calendar sync (#14248)
fix #14170 fix #13893 fix #13673 fix #13543 fix #13308 fix #7607 #### PR Dependency Tree * **PR #14247** * **PR #14248** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Integrations panel in Account Settings to link/unlink calendar providers. * Collapsible settings wrapper for improved layout. * **Improvements** * Calendar system reworked: per-account calendar groups, simplified toggles with explicit Save, richer event display (multi-dot date indicators), improved event time/title handling across journal views. * **Localization** * Added calendar keys: save-error, no-journal, no-calendar; removed legacy duplicate-error keys. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
|
||||
import type { Server } from '../entities/server';
|
||||
|
||||
export class WorkspaceServerService extends Service {
|
||||
server: Server | null = null;
|
||||
readonly server$ = new LiveData<Server | null>(null);
|
||||
|
||||
get server() {
|
||||
return this.server$.value;
|
||||
}
|
||||
|
||||
bindServer(server: Server) {
|
||||
this.server = server;
|
||||
this.server$.setValue(server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import dayjs from 'dayjs';
|
||||
import ICAL from 'ical.js';
|
||||
import { EMPTY, mergeMap, switchMap, throttleTime } from 'rxjs';
|
||||
|
||||
import type {
|
||||
CalendarStore,
|
||||
CalendarSubscriptionConfig,
|
||||
} from '../store/calendar';
|
||||
import type { CalendarEvent, EventsByDateMap } from '../type';
|
||||
import { parseCalendarUrl } from '../utils/calendar-url-parser';
|
||||
import { isAllDay } from '../utils/is-all-day';
|
||||
|
||||
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$ = LiveData.computed(get => {
|
||||
const config = get(this.config$);
|
||||
if (config?.name !== undefined) {
|
||||
return config.name;
|
||||
}
|
||||
const content = get(this.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 '';
|
||||
}
|
||||
});
|
||||
|
||||
eventsByDateMap$ = LiveData.computed(get => {
|
||||
const content = get(this.content$);
|
||||
const config = get(this.config$);
|
||||
|
||||
const map: EventsByDateMap = new Map();
|
||||
|
||||
if (!content || !config?.showEvents) return map;
|
||||
|
||||
const jCal = ICAL.parse(content);
|
||||
const vCalendar = new ICAL.Component(jCal);
|
||||
const vEvents = vCalendar.getAllSubcomponents('vevent');
|
||||
|
||||
for (const vEvent of vEvents) {
|
||||
const event = new ICAL.Event(vEvent);
|
||||
const calendarEvent: CalendarEvent = {
|
||||
id: event.uid,
|
||||
url: this.url,
|
||||
title: event.summary,
|
||||
startAt: event.startDate,
|
||||
endAt: event.endDate,
|
||||
};
|
||||
|
||||
// create index for each day of the event
|
||||
if (event.startDate && event.endDate) {
|
||||
const start = dayjs(event.startDate.toJSDate());
|
||||
const end = dayjs(event.endDate.toJSDate());
|
||||
|
||||
let current = start;
|
||||
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
||||
if (
|
||||
current.isSame(end, 'day') &&
|
||||
end.hour() === 0 &&
|
||||
end.minute() === 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const todayEvent: CalendarEvent = { ...calendarEvent };
|
||||
const dateKey = current.format('YYYY-MM-DD');
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, []);
|
||||
}
|
||||
todayEvent.allDay = isAllDay(current, start, end);
|
||||
todayEvent.date = current;
|
||||
todayEvent.id = `${event.uid}-${dateKey}`;
|
||||
if (
|
||||
config.showAllDayEvents ||
|
||||
(!config.showAllDayEvents && !todayEvent.allDay)
|
||||
) {
|
||||
map.get(dateKey)?.push(todayEvent);
|
||||
}
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
} else {
|
||||
console.warn("event's start or end date is missing", event);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
url = this.props.url;
|
||||
loading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
update = effect(
|
||||
throttleTime(30 * 1000),
|
||||
switchMap(() =>
|
||||
fromPromise(async () => {
|
||||
const url = parseCalendarUrl(this.url);
|
||||
const response = await fetch(url);
|
||||
return await response.text();
|
||||
}).pipe(
|
||||
mergeMap(value => {
|
||||
this.store.setSubscriptionCache(this.url, value).catch(console.error);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.loading$.setValue(true)),
|
||||
onComplete(() => this.loading$.setValue(false))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
override dispose() {
|
||||
super.dispose();
|
||||
this.update.reset();
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,216 @@
|
||||
import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
|
||||
import { type Dayjs } from 'dayjs';
|
||||
import ICAL from 'ical.js';
|
||||
import { Observable, switchMap } from 'rxjs';
|
||||
|
||||
import type {
|
||||
CalendarStore,
|
||||
CalendarSubscriptionConfig,
|
||||
} from '../store/calendar';
|
||||
CalendarAccountCalendarsQuery,
|
||||
CalendarAccountsQuery,
|
||||
CalendarEventsQuery,
|
||||
WorkspaceCalendarItemInput,
|
||||
WorkspaceCalendarsQuery,
|
||||
} from '@affine/graphql';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
|
||||
import type { CalendarStore } from '../store/calendar';
|
||||
import type { CalendarEvent } from '../type';
|
||||
import { parseCalendarUrl } from '../utils/calendar-url-parser';
|
||||
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());
|
||||
};
|
||||
});
|
||||
})
|
||||
),
|
||||
accounts$ = new LiveData<CalendarAccountsQuery['calendarAccounts'][number][]>(
|
||||
[]
|
||||
);
|
||||
subscription$(url: string) {
|
||||
return this.subscriptions$.map(subscriptions =>
|
||||
subscriptions.find(sub => sub.url === url)
|
||||
);
|
||||
}
|
||||
eventsByDateMap$ = LiveData.computed(get => {
|
||||
return get(this.subscriptions$)
|
||||
.map(sub => get(sub.eventsByDateMap$))
|
||||
.reduce((acc, map) => {
|
||||
for (const [date, events] of map) {
|
||||
acc.set(
|
||||
date,
|
||||
acc.has(date) ? [...(acc.get(date) ?? []), ...events] : [...events]
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<string, CalendarEvent[]>());
|
||||
accountCalendars$ = new LiveData<
|
||||
Map<
|
||||
string,
|
||||
CalendarAccountCalendarsQuery['calendarAccountCalendars'][number][]
|
||||
>
|
||||
>(new Map());
|
||||
workspaceCalendars$ = new LiveData<
|
||||
WorkspaceCalendarsQuery['workspaceCalendars'][number][]
|
||||
>([]);
|
||||
readonly eventsByDateMap$ = new LiveData<
|
||||
Map<string, CalendarEventsQuery['calendarEvents'][number][]>
|
||||
>(new Map());
|
||||
readonly eventDates$ = LiveData.computed(get => {
|
||||
const eventsByDateMap = get(this.eventsByDateMap$);
|
||||
const dates = new Set<string>();
|
||||
for (const [date, events] of eventsByDateMap) {
|
||||
if (events.length > 0) {
|
||||
dates.add(date);
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
});
|
||||
|
||||
private readonly subscriptionInfoById$ = LiveData.computed(get => {
|
||||
const accountCalendars = get(this.accountCalendars$);
|
||||
const workspaceCalendars = get(this.workspaceCalendars$);
|
||||
const subscriptionInfo = new Map<
|
||||
string,
|
||||
{
|
||||
subscription: CalendarAccountCalendarsQuery['calendarAccountCalendars'][number];
|
||||
colorOverride?: string | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const calendars of accountCalendars.values()) {
|
||||
for (const calendar of calendars) {
|
||||
subscriptionInfo.set(calendar.id, { subscription: calendar });
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of workspaceCalendars[0]?.items ?? []) {
|
||||
const existing = subscriptionInfo.get(item.subscriptionId);
|
||||
if (!existing) continue;
|
||||
subscriptionInfo.set(item.subscriptionId, {
|
||||
...existing,
|
||||
colorOverride: item.colorOverride,
|
||||
});
|
||||
}
|
||||
|
||||
return subscriptionInfo;
|
||||
});
|
||||
|
||||
eventsByDate$(date: Dayjs) {
|
||||
return this.eventsByDateMap$.map(eventsByDateMap => {
|
||||
const dateKey = date.format('YYYY-MM-DD');
|
||||
const events = [...(eventsByDateMap.get(dateKey) || [])];
|
||||
const dateKey = date.format('YYYY-MM-DD');
|
||||
return LiveData.computed(get => {
|
||||
const subscriptionInfoById = get(this.subscriptionInfoById$);
|
||||
const eventsByDateMap = get(this.eventsByDateMap$);
|
||||
const events = eventsByDateMap.get(dateKey) ?? [];
|
||||
|
||||
// sort events by start time
|
||||
return events.sort((a, b) => {
|
||||
return (
|
||||
(a.startAt?.toJSDate().getTime() ?? 0) -
|
||||
(b.startAt?.toJSDate().getTime() ?? 0)
|
||||
return events
|
||||
.map(event => {
|
||||
const subscriptionInfo = subscriptionInfoById.get(
|
||||
event.subscriptionId
|
||||
);
|
||||
return {
|
||||
id: event.id,
|
||||
subscriptionId: event.subscriptionId,
|
||||
title: event.title ?? '',
|
||||
startAt: dayjs(event.startAtUtc),
|
||||
endAt: dayjs(event.endAtUtc),
|
||||
allDay: event.allDay,
|
||||
date,
|
||||
calendarName:
|
||||
subscriptionInfo?.subscription.displayName ??
|
||||
subscriptionInfo?.subscription.externalCalendarId ??
|
||||
'',
|
||||
calendarColor:
|
||||
subscriptionInfo?.colorOverride ??
|
||||
subscriptionInfo?.subscription.color ??
|
||||
undefined,
|
||||
} satisfies CalendarEvent;
|
||||
})
|
||||
.sort(
|
||||
(left, right) => left.startAt.valueOf() - right.startAt.valueOf()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async verifyUrl(_url: string) {
|
||||
const url = parseCalendarUrl(_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 loadAccountCalendars(signal?: AbortSignal) {
|
||||
const accounts = await this.store.fetchAccounts(signal);
|
||||
this.accounts$.setValue(accounts);
|
||||
|
||||
const calendarsByAccount = new Map<
|
||||
string,
|
||||
CalendarAccountCalendarsQuery['calendarAccountCalendars'][number][]
|
||||
>();
|
||||
|
||||
await Promise.all(
|
||||
accounts.map(async account => {
|
||||
try {
|
||||
const calendars = await this.store.fetchAccountCalendars(
|
||||
account.id,
|
||||
signal
|
||||
);
|
||||
calendarsByAccount.set(account.id, calendars);
|
||||
} catch (error) {
|
||||
console.error('Failed to load calendar subscriptions', error);
|
||||
calendarsByAccount.set(account.id, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.accountCalendars$.setValue(calendarsByAccount);
|
||||
return calendarsByAccount;
|
||||
}
|
||||
|
||||
async revalidateWorkspaceCalendars(signal?: AbortSignal) {
|
||||
const calendars = await this.store.fetchWorkspaceCalendars(signal);
|
||||
this.workspaceCalendars$.setValue(calendars);
|
||||
return calendars;
|
||||
}
|
||||
|
||||
async updateWorkspaceCalendars(items: WorkspaceCalendarItemInput[]) {
|
||||
const updated = await this.store.updateWorkspaceCalendars(items);
|
||||
const next = [...this.workspaceCalendars$.value];
|
||||
const index = next.findIndex(calendar => calendar.id === updated.id);
|
||||
if (index >= 0) {
|
||||
next[index] = updated;
|
||||
} else {
|
||||
next.push(updated);
|
||||
}
|
||||
this.workspaceCalendars$.setValue(next);
|
||||
return updated;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
getSubscription(url: string) {
|
||||
return this.store.getSubscription(url);
|
||||
}
|
||||
|
||||
deleteSubscription(url: string) {
|
||||
this.store.removeSubscription(url);
|
||||
}
|
||||
|
||||
updateSubscription(
|
||||
url: string,
|
||||
updates: Partial<Omit<CalendarSubscriptionConfig, 'url'>>
|
||||
async revalidateEventsRange(
|
||||
rangeStart: Dayjs,
|
||||
rangeEnd: Dayjs,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
this.store.updateSubscription(url, updates);
|
||||
const start = rangeStart.startOf('day');
|
||||
const end = rangeEnd.endOf('day');
|
||||
const workspaceCalendarId = this.workspaceCalendars$.value[0]?.id;
|
||||
const next = new Map(this.eventsByDateMap$.value);
|
||||
let cursor = start;
|
||||
while (cursor.isBefore(end, 'day') || cursor.isSame(end, 'day')) {
|
||||
next.set(cursor.format('YYYY-MM-DD'), []);
|
||||
cursor = cursor.add(1, 'day');
|
||||
}
|
||||
if (!workspaceCalendarId) {
|
||||
this.eventsByDateMap$.setValue(next);
|
||||
return [];
|
||||
}
|
||||
|
||||
const events = await this.store.fetchEvents(
|
||||
workspaceCalendarId,
|
||||
start.toISOString(),
|
||||
end.toISOString(),
|
||||
signal
|
||||
);
|
||||
for (const event of events) {
|
||||
const startAt = dayjs(event.startAtUtc);
|
||||
const endAt = dayjs(event.endAtUtc);
|
||||
let current = startAt.isBefore(start, 'day') ? start : startAt;
|
||||
const rangeEndDay = endAt.isAfter(end, 'day') ? end : endAt;
|
||||
|
||||
while (
|
||||
current.isBefore(rangeEndDay, 'day') ||
|
||||
current.isSame(rangeEndDay, 'day')
|
||||
) {
|
||||
if (
|
||||
current.isSame(endAt, 'day') &&
|
||||
endAt.hour() === 0 &&
|
||||
endAt.minute() === 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const dateKey = current.format('YYYY-MM-DD');
|
||||
const list = next.get(dateKey);
|
||||
if (list) {
|
||||
list.push(event);
|
||||
} else {
|
||||
next.set(dateKey, [event]);
|
||||
}
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
}
|
||||
this.eventsByDateMap$.setValue(next);
|
||||
return events;
|
||||
}
|
||||
|
||||
async revalidateEvents(date: Dayjs, signal?: AbortSignal) {
|
||||
return await this.revalidateEventsRange(date, date, signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import type { Framework } from '@toeverything/infra';
|
||||
import { WorkspaceServerService } from '../cloud';
|
||||
import { WorkspaceDBService } from '../db';
|
||||
import { DocScope, DocService, DocsService } from '../doc';
|
||||
import { CacheStorage, GlobalState } from '../storage';
|
||||
import { 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';
|
||||
@@ -19,7 +18,6 @@ import { ReadwiseStore } from './store/readwise';
|
||||
|
||||
export { IntegrationService };
|
||||
export { CalendarIntegration } from './entities/calendar';
|
||||
export { CalendarSubscription } from './entities/calendar-subscription';
|
||||
export type { CalendarEvent } from './type';
|
||||
export { IntegrationTypeIcon } from './views/icon';
|
||||
export { DocIntegrationPropertiesTable } from './views/properties-table';
|
||||
@@ -41,14 +39,8 @@ export function configureIntegrationModule(framework: Framework) {
|
||||
ReadwiseStore,
|
||||
DocsService,
|
||||
])
|
||||
.store(CalendarStore, [
|
||||
GlobalState,
|
||||
CacheStorage,
|
||||
WorkspaceService,
|
||||
WorkspaceServerService,
|
||||
])
|
||||
.store(CalendarStore, [WorkspaceService, WorkspaceServerService])
|
||||
.entity(CalendarIntegration, [CalendarStore])
|
||||
.entity(CalendarSubscription, [CalendarStore])
|
||||
.scope(DocScope)
|
||||
.service(IntegrationPropertyService, [DocService]);
|
||||
}
|
||||
|
||||
@@ -1,155 +1,107 @@
|
||||
import { LiveData, Store } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { exhaustMap, map } from 'rxjs';
|
||||
import {
|
||||
type CalendarAccountCalendarsQuery,
|
||||
calendarAccountCalendarsQuery,
|
||||
type CalendarAccountsQuery,
|
||||
calendarAccountsQuery,
|
||||
type CalendarEventsQuery,
|
||||
calendarEventsQuery,
|
||||
type UpdateWorkspaceCalendarsMutation,
|
||||
updateWorkspaceCalendarsMutation,
|
||||
type WorkspaceCalendarItemInput,
|
||||
type WorkspaceCalendarsQuery,
|
||||
workspaceCalendarsQuery,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import { AuthService, type WorkspaceServerService } from '../../cloud';
|
||||
import type { CacheStorage, GlobalState } from '../../storage';
|
||||
import type { WorkspaceServerService } from '../../cloud';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
export interface CalendarSubscriptionConfig {
|
||||
color: string;
|
||||
name?: 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 get gql() {
|
||||
return this.workspaceServerService.server?.gql;
|
||||
}
|
||||
|
||||
private _getKey(userId: string, workspaceId: string) {
|
||||
return `calendar:${userId}:${workspaceId}:subscriptions`;
|
||||
private get workspaceId() {
|
||||
return this.workspaceService.workspace.id;
|
||||
}
|
||||
|
||||
private _createSubscription() {
|
||||
return {
|
||||
showEvents: true,
|
||||
showAllDayEvents: true,
|
||||
color: this.getRandomColor(),
|
||||
};
|
||||
async fetchAccounts(signal?: AbortSignal) {
|
||||
const gql = this.gql;
|
||||
if (!gql) return [] satisfies CalendarAccountsQuery['calendarAccounts'];
|
||||
const data = await gql({
|
||||
query: calendarAccountsQuery,
|
||||
context: { signal },
|
||||
});
|
||||
return data.calendarAccounts;
|
||||
}
|
||||
|
||||
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__');
|
||||
async fetchAccountCalendars(accountId: string, signal?: AbortSignal) {
|
||||
const gql = this.gql;
|
||||
if (!gql) {
|
||||
return [] satisfies CalendarAccountCalendarsQuery['calendarAccountCalendars'];
|
||||
}
|
||||
const data = await gql({
|
||||
query: calendarAccountCalendarsQuery,
|
||||
variables: { accountId },
|
||||
context: { signal },
|
||||
});
|
||||
return data.calendarAccountCalendars;
|
||||
}
|
||||
|
||||
getStorageKey() {
|
||||
const workspaceId = this.workspaceService.workspace.id;
|
||||
return this._getKey(this.getUserId(), workspaceId);
|
||||
async fetchWorkspaceCalendars(signal?: AbortSignal) {
|
||||
const gql = this.gql;
|
||||
if (!gql) {
|
||||
return [] satisfies WorkspaceCalendarsQuery['workspaceCalendars'];
|
||||
}
|
||||
const data = await gql({
|
||||
query: workspaceCalendarsQuery,
|
||||
variables: { workspaceId: this.workspaceId },
|
||||
context: { signal },
|
||||
});
|
||||
return data.workspaceCalendars;
|
||||
}
|
||||
|
||||
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;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getSubscription(url: string) {
|
||||
return this.getSubscriptionMap()[url];
|
||||
}
|
||||
|
||||
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,
|
||||
async updateWorkspaceCalendars(items: WorkspaceCalendarItemInput[]) {
|
||||
const gql = this.gql;
|
||||
if (!gql) {
|
||||
throw new Error('No graphql service available');
|
||||
}
|
||||
const data = await gql({
|
||||
query: updateWorkspaceCalendarsMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: this.workspaceId,
|
||||
items,
|
||||
},
|
||||
},
|
||||
});
|
||||
return data.updateWorkspaceCalendars satisfies UpdateWorkspaceCalendarsMutation['updateWorkspaceCalendars'];
|
||||
}
|
||||
|
||||
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'>>
|
||||
async fetchEvents(
|
||||
workspaceCalendarId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const subscriptionMap = this.getSubscriptionMap();
|
||||
this.globalState.set(this.getStorageKey(), {
|
||||
...subscriptionMap,
|
||||
[url]: { ...subscriptionMap[url], ...updates },
|
||||
const gql = this.gql;
|
||||
if (!gql) return [] satisfies CalendarEventsQuery['calendarEvents'];
|
||||
const data = await gql({
|
||||
query: calendarEventsQuery,
|
||||
variables: {
|
||||
workspaceCalendarId,
|
||||
from,
|
||||
to,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
}
|
||||
|
||||
setSubscriptionCache(url: string, cache: string) {
|
||||
return this.cacheStorage.set(this.getCacheKey(url), cache);
|
||||
return data.calendarEvents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type ICAL from 'ical.js';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
import type { DocIntegrationRef } from '../db/schema/schema';
|
||||
@@ -103,12 +102,12 @@ export interface ReadwiseConfig {
|
||||
// ===============================
|
||||
export type CalendarEvent = {
|
||||
id: string;
|
||||
url: string;
|
||||
subscriptionId: string;
|
||||
title: string;
|
||||
startAt?: ICAL.Time;
|
||||
endAt?: ICAL.Time;
|
||||
allDay?: boolean;
|
||||
date?: Dayjs;
|
||||
startAt: Dayjs;
|
||||
endAt: Dayjs;
|
||||
allDay: boolean;
|
||||
date: Dayjs;
|
||||
calendarName?: string;
|
||||
calendarColor?: string;
|
||||
};
|
||||
|
||||
export type EventsByDateMap = Map<string, CalendarEvent[]>;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export const parseCalendarUrl = (_url: string) => {
|
||||
let url = _url;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.protocol === 'webcal:') {
|
||||
urlObj.protocol = 'https';
|
||||
}
|
||||
url = urlObj.toString();
|
||||
return url;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(`Invalid URL: "${url}"`);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
export const isAllDay = (current: Dayjs, start: Dayjs, end: Dayjs): boolean => {
|
||||
if (current.isSame(start, 'day')) {
|
||||
return (
|
||||
start.hour() === 0 && start.minute() === 0 && !current.isSame(end, 'day')
|
||||
);
|
||||
} else if (current.isSame(end, 'day')) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user