From 4c5e3a875e57806d5c3d2298746b292436fd8010 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Fri, 25 Apr 2025 02:37:52 +0000 Subject: [PATCH] feat(core): calendar integration setting (#11882) close AF-2503 --- .../calendar/subscription-setting.css.ts | 23 ++++- .../calendar/subscription-setting.tsx | 73 ++++++++++++-- .../integration/setting.css.ts | 6 ++ .../workspace-setting/integration/setting.tsx | 6 +- .../entities/calendar-subscription.ts | 62 ++++++++++++ .../modules/integration/entities/calendar.ts | 94 +++---------------- .../core/src/modules/integration/index.ts | 2 +- .../core/src/modules/integration/type.ts | 17 ++++ .../modules/integration/utils/is-all-day.ts | 13 +++ packages/frontend/i18n/src/i18n.gen.ts | 18 ++++ packages/frontend/i18n/src/resources/en.json | 4 + 11 files changed, 223 insertions(+), 95 deletions(-) create mode 100644 packages/frontend/core/src/modules/integration/utils/is-all-day.ts diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts index 9484f1461a..497234b8eb 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts @@ -6,13 +6,11 @@ export const card = style({ 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', + margin: '4px 0', alignItems: 'center', justifyContent: 'center', ':before': { @@ -26,6 +24,7 @@ export const header = style({ display: 'flex', alignItems: 'center', gap: 8, + padding: '7px 0', }); export const colorPickerTrigger = style({ width: 24, @@ -82,3 +81,21 @@ export const name = style({ overflow: 'hidden', whiteSpace: 'nowrap', }); + +export const allDayEventsContainer = style({ + overflow: 'hidden', + display: 'grid', + gridTemplateRows: '1fr', + transition: + 'grid-template-rows 0.4s cubic-bezier(.07,.83,.46,1), opacity 0.4s ease', + selectors: { + '&[data-collapsed="true"]': { + gridTemplateRows: '0fr', + opacity: 0, + }, + }, +}); + +export const allDayEventsContent = style({ + overflow: 'hidden', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx index e323352c78..ce2c52987e 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx @@ -1,4 +1,4 @@ -import { Button, Menu } from '@affine/component'; +import { Button, Menu, useConfirmModal } from '@affine/component'; import { type CalendarSubscription, IntegrationService, @@ -7,6 +7,7 @@ import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo, useState } from 'react'; +import { IntegrationSettingToggle } from '../setting'; import * as styles from './subscription-setting.css'; export const SubscriptionSetting = ({ @@ -18,7 +19,7 @@ export const SubscriptionSetting = ({ const [menuOpen, setMenuOpen] = useState(false); const calendar = useService(IntegrationService).calendar; const config = useLiveData(subscription.config$); - const name = useLiveData(subscription.name$); + const name = useLiveData(subscription.name$) || t['Untitled'](); const handleColorChange = useCallback( (color: string) => { @@ -28,9 +29,17 @@ export const SubscriptionSetting = ({ [calendar, subscription.url] ); - const handleUnsubscribe = useCallback(() => { - calendar.deleteSubscription(subscription.url); - }, [calendar, subscription.url]); + const toggleShowEvents = useCallback(() => { + calendar.updateSubscription(subscription.url, { + showEvents: !config?.showEvents, + }); + }, [calendar, subscription.url, config?.showEvents]); + + const toggleShowAllDayEvents = useCallback(() => { + calendar.updateSubscription(subscription.url, { + showAllDayEvents: !config?.showAllDayEvents, + }); + }, [calendar, subscription.url, config?.showAllDayEvents]); if (!config) return null; @@ -52,15 +61,61 @@ export const SubscriptionSetting = ({ style={{ color: config.color }} /> -
{name || t['Untitled']()}
- +
{name}
+ + +
+ +
+
+
+ +
); }; +const UnsubscribeButton = ({ url, name }: { url: string; name: string }) => { + const t = useI18n(); + const calendar = useService(IntegrationService).calendar; + const { openConfirmModal } = useConfirmModal(); + + const handleUnsubscribe = useCallback(() => { + openConfirmModal({ + title: t['com.affine.integration.calendar.unsubscribe'](), + children: t.t('com.affine.integration.calendar.unsubscribe-content', { + name, + }), + onConfirm: () => { + calendar.deleteSubscription(url); + }, + confirmText: t['com.affine.integration.calendar.unsubscribe'](), + confirmButtonOptions: { + variant: 'error', + }, + }); + }, [calendar, name, openConfirmModal, t, url]); + + return ( + + ); +}; + const ColorPicker = ({ activeColor, onChange, diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts index a74949055d..41df039051 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.css.ts @@ -42,6 +42,11 @@ export const settingItem = style({ alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, + selectors: { + '&[data-has-desc="false"]': { + padding: '5px 0', + }, + }, }); export const settingName = style({ @@ -56,6 +61,7 @@ export const settingDesc = style({ lineHeight: '20px', fontWeight: 400, color: cssVarV2.text.secondary, + marginTop: 2, }); export const textRadioGroup = style({ diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx index c630b42150..ffdd6c8eb6 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/setting.tsx @@ -47,7 +47,11 @@ export const IntegrationSettingItem = ({ ...props }: IntegrationSettingItemProps) => { return ( -
+
{name &&
{name}
} {desc &&

{desc}

} diff --git a/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts b/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts index cca2ca5403..fd99a9d20c 100644 --- a/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts +++ b/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts @@ -7,6 +7,7 @@ import { onComplete, onStart, } from '@toeverything/infra'; +import dayjs from 'dayjs'; import ICAL from 'ical.js'; import { EMPTY, mergeMap, switchMap, throttleTime } from 'rxjs'; @@ -14,7 +15,9 @@ 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) { @@ -39,6 +42,65 @@ export class CalendarSubscription extends Entity<{ url: string }> { 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); diff --git a/packages/frontend/core/src/modules/integration/entities/calendar.ts b/packages/frontend/core/src/modules/integration/entities/calendar.ts index ffade89aaf..8d9e432bce 100644 --- a/packages/frontend/core/src/modules/integration/entities/calendar.ts +++ b/packages/frontend/core/src/modules/integration/entities/calendar.ts @@ -1,5 +1,5 @@ import { Entity, LiveData, ObjectPool } from '@toeverything/infra'; -import dayjs, { type Dayjs } from 'dayjs'; +import { type Dayjs } from 'dayjs'; import ICAL from 'ical.js'; import { Observable, switchMap } from 'rxjs'; @@ -7,33 +7,10 @@ import type { CalendarStore, CalendarSubscriptionConfig, } from '../store/calendar'; +import type { CalendarEvent } from '../type'; import { parseCalendarUrl } from '../utils/calendar-url-parser'; import { CalendarSubscription } from './calendar-subscription'; -export type CalendarEvent = { - id: string; - url: string; - title: string; - startAt?: ICAL.Time; - endAt?: ICAL.Time; - allDay?: boolean; - date?: Dayjs; -}; - -type EventsByDateMap = Map; - -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; - } -}; - export class CalendarIntegration extends Entity { constructor(private readonly store: CalendarStore) { super(); @@ -76,65 +53,20 @@ export class CalendarIntegration extends Entity { subscriptions.find(sub => sub.url === url) ); } - contents$ = LiveData.computed(get => { - const subscriptions = get(this.subscriptions$); - return subscriptions.map(sub => ({ - url: sub.url, - content: get(sub.content$), - })); - }); eventsByDateMap$ = LiveData.computed(get => { - const contents = get(this.contents$); - const eventsByDate: EventsByDateMap = new Map(); - - for (const { content, url } of contents) { - if (!content) continue; - 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, - 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 (!eventsByDate.has(dateKey)) { - eventsByDate.set(dateKey, []); - } - todayEvent.allDay = isAllDay(current, start, end); - todayEvent.date = current; - todayEvent.id = `${event.uid}-${dateKey}`; - eventsByDate.get(dateKey)?.push(todayEvent); - current = current.add(1, 'day'); - } - } else { - console.warn("event's start or end date is missing", event); + 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 eventsByDate; + return acc; + }, new Map()); }); + eventsByDate$(date: Dayjs) { return this.eventsByDateMap$.map(eventsByDateMap => { const dateKey = date.format('YYYY-MM-DD'); diff --git a/packages/frontend/core/src/modules/integration/index.ts b/packages/frontend/core/src/modules/integration/index.ts index 9413c95201..bc6fe47277 100644 --- a/packages/frontend/core/src/modules/integration/index.ts +++ b/packages/frontend/core/src/modules/integration/index.ts @@ -18,9 +18,9 @@ import { IntegrationRefStore } from './store/integration-ref'; import { ReadwiseStore } from './store/readwise'; export { IntegrationService }; -export type { CalendarEvent } from './entities/calendar'; 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'; diff --git a/packages/frontend/core/src/modules/integration/type.ts b/packages/frontend/core/src/modules/integration/type.ts index 0ef9c85fa9..883b2e7971 100644 --- a/packages/frontend/core/src/modules/integration/type.ts +++ b/packages/frontend/core/src/modules/integration/type.ts @@ -1,4 +1,6 @@ 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'; @@ -96,3 +98,18 @@ export interface ReadwiseConfig { // Zotero // =============================== // TODO + +// =============================== +// Calendar +// =============================== +export type CalendarEvent = { + id: string; + url: string; + title: string; + startAt?: ICAL.Time; + endAt?: ICAL.Time; + allDay?: boolean; + date?: Dayjs; +}; + +export type EventsByDateMap = Map; diff --git a/packages/frontend/core/src/modules/integration/utils/is-all-day.ts b/packages/frontend/core/src/modules/integration/utils/is-all-day.ts new file mode 100644 index 0000000000..1afc8c3a54 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/utils/is-all-day.ts @@ -0,0 +1,13 @@ +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; + } +}; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index a29bfc84dc..2becd71bbc 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -7661,6 +7661,24 @@ export function useAFFiNEI18N(): { * `New doc` */ ["com.affine.integration.calendar.new-doc"](): string; + /** + * `Show calendar events` + */ + ["com.affine.integration.calendar.show-events"](): string; + /** + * `Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE` + */ + ["com.affine.integration.calendar.show-events-desc"](): string; + /** + * `Show all day event` + */ + ["com.affine.integration.calendar.show-all-day-events"](): string; + /** + * `Are you sure you want to unsubscribe "{{name}}"? Unsubscribing this account will remove its data from Journal.` + */ + ["com.affine.integration.calendar.unsubscribe-content"](options: { + readonly name: string; + }): string; /** * `Notes` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 484b1406ce..e91270956e 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1917,6 +1917,10 @@ "com.affine.integration.calendar.new-error": "An error occurred while adding the calendar", "com.affine.integration.calendar.all-day": "All day", "com.affine.integration.calendar.new-doc": "New doc", + "com.affine.integration.calendar.show-events": "Show calendar events", + "com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE", + "com.affine.integration.calendar.show-all-day-events": "Show all day event", + "com.affine.integration.calendar.unsubscribe-content": "Are you sure you want to unsubscribe \"{{name}}\"? Unsubscribing this account will remove its data from Journal.", "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",