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:
DarkSky
2026-01-13 02:38:16 +08:00
committed by GitHub
parent 89f0430242
commit 279b7bb64f
50 changed files with 1349 additions and 1002 deletions
@@ -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;
}
};