From f5ac0aee972dfc6bc4536e87981391d776c574cc Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Fri, 25 Apr 2025 02:37:51 +0000 Subject: [PATCH] feat(core): events list in journal calendar (#11873) close AF-2505 ## Summary by CodeRabbit - **New Features** - Introduced calendar event integration in the journal detail page, allowing users to view and interact with calendar events for a selected date. - Added the ability to create new linked documents from calendar events directly within the journal interface. - **Improvements** - Enhanced calendar integration with support for all-day and timed events, improved event grouping by date, and real-time updates. - Added new English localization entries for calendar event labels. - **Bug Fixes** - Improved URL handling for calendar subscriptions, ensuring better compatibility and error feedback. - **Style** - Added new styles for calendar event components to ensure a consistent and user-friendly appearance. --- packages/frontend/core/package.json | 2 +- .../tabs/journal/calendar-events.css.ts | 71 +++++++++ .../tabs/journal/calendar-events.tsx | 140 ++++++++++++++++++ .../detail-page/tabs/journal/index.tsx | 2 + .../entities/calendar-subscription.ts | 19 ++- .../modules/integration/entities/calendar.ts | 116 +++++++++++++-- .../core/src/modules/integration/index.ts | 1 + .../integration/utils/calendar-url-parser.ts | 14 ++ packages/frontend/i18n/src/i18n.gen.ts | 8 + packages/frontend/i18n/src/resources/en.json | 2 + yarn.lock | 4 +- 11 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.css.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx create mode 100644 packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 34fc18f3f..53ef0f021 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -19,7 +19,7 @@ "@affine/templates": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", - "@blocksuite/icons": "^2.2.12", + "@blocksuite/icons": "^2.2.13", "@dotlottie/player-component": "^2.7.12", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.css.ts b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.css.ts new file mode 100644 index 000000000..a8bca4378 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.css.ts @@ -0,0 +1,71 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { createVar, style } from '@vanilla-extract/css'; + +export const primaryColor = createVar('calendar-event-primary'); + +export const list = style({ + display: 'flex', + flexDirection: 'column', + gap: 2, + padding: '0px 16px 10px 16px', +}); + +export const event = style({ + display: 'flex', + gap: 8, + alignItems: 'center', + padding: '5px 4px', + borderRadius: 4, + cursor: 'pointer', + selectors: { + '&:hover': { + backgroundColor: cssVarV2.layer.background.hoverOverlay, + }, + }, +}); + +export const eventIcon = style({ + width: 20, + height: 20, + color: primaryColor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 20, +}); + +export const eventTitle = style({ + width: 0, + flex: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: 14, + fontWeight: 400, + lineHeight: '22px', + color: cssVarV2.text.primary, +}); + +export const eventCaption = style({ + fontSize: 12, + lineHeight: '20px', + color: cssVarV2.text.secondary, +}); + +export const eventTime = style({ + display: 'flex', + selectors: { + [`${event}:hover &`]: { + display: 'none', + }, + }, +}); +export const eventNewDoc = style({ + display: 'none', + gap: 4, + selectors: { + [`${event}:hover &`]: { + display: 'flex', + }, + }, +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx new file mode 100644 index 000000000..0cdac94b4 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx @@ -0,0 +1,140 @@ +import { Loading, toast } from '@affine/component'; +import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { DocsService } from '@affine/core/modules/doc'; +import { + type CalendarEvent, + IntegrationService, +} from '@affine/core/modules/integration'; +import { JournalService } from '@affine/core/modules/journal'; +import { GuardService } from '@affine/core/modules/permissions'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { useI18n } from '@affine/i18n'; +import { FullDayIcon, PeriodIcon, PlusIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import type { Dayjs } from 'dayjs'; +import type ICAL from 'ical.js'; +import { useEffect, useMemo, useState } from 'react'; + +import * as styles from './calendar-events.css'; + +const pad = (val?: number) => (val ?? 0).toString().padStart(2, '0'); + +function formatTime(start?: ICAL.Time, end?: ICAL.Time) { + const from = `${pad(start?.hour)}:${pad(start?.minute)}`; + const to = `${pad(end?.hour)}:${pad(end?.minute)}`; + return from === to ? from : `${from} - ${to}`; +} + +export const CalendarEvents = ({ date }: { date: Dayjs }) => { + const calendar = useService(IntegrationService).calendar; + const events = useLiveData( + useMemo(() => calendar.eventsByDate$(date), [calendar, date]) + ); + + useEffect(() => { + const update = () => { + calendar.subscriptions$.value.forEach(sub => sub.update()); + }; + update(); + const interval = setInterval(update, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [calendar]); + + return ( + + ); +}; + +const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => { + const t = useI18n(); + const { url, title, startAt, endAt, allDay, date } = event; + const [loading, setLoading] = useState(false); + const calendar = useService(IntegrationService).calendar; + const docsService = useService(DocsService); + const guardService = useService(GuardService); + const journalService = useService(JournalService); + const workspaceService = useService(WorkspaceService); + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + const subscription = useLiveData( + useMemo(() => calendar.subscription$(url), [calendar, url]) + ); + const config = useLiveData( + useMemo(() => subscription?.config$, [subscription?.config$]) + ); + const color = config?.color || cssVarV2.button.primary; + + const handleClick = useAsyncCallback(async () => { + if (!date || loading) return; + const docs = journalService.journalsByDate$( + date.format('YYYY-MM-DD') + ).value; + if (docs.length === 0) return; + + setLoading(true); + + try { + for (const doc of docs) { + const canEdit = await guardService.can('Doc_Update', doc.id); + if (!canEdit) { + toast(t['com.affine.no-permission']()); + continue; + } + + const newDoc = createPage(); + await docsService.changeDocTitle(newDoc.id, title); + await docsService.addLinkedDoc(doc.id, newDoc.id); + } + } finally { + setLoading(false); + } + }, [ + createPage, + date, + docsService, + guardService, + journalService, + loading, + t, + title, + ]); + + return ( +
  • +
    + {allDay ? : } +
    +
    {title}
    + {loading ? ( + + ) : ( +
    + + {allDay + ? t['com.affine.integration.calendar.all-day']() + : formatTime(startAt, endAt)} + + + + {t['com.affine.integration.calendar.new-doc']()} + +
    + )} +
  • + ); +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx index 6af375742..a2ced6da2 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx @@ -32,6 +32,7 @@ import dayjs from 'dayjs'; import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; +import { CalendarEvents } from './calendar-events'; import * as styles from './journal.css'; import { JournalTemplateOnboarding } from './template-onboarding'; import { JournalTemplateSetting } from './template-setting'; @@ -166,6 +167,7 @@ export const EditorJournalPanel = () => { {journalDate ? ( <> + ) : ( 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 89e4e5d22..cca2ca540 100644 --- a/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts +++ b/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts @@ -8,12 +8,13 @@ import { onStart, } from '@toeverything/infra'; import ICAL from 'ical.js'; -import { switchMap } from 'rxjs'; +import { EMPTY, mergeMap, switchMap, throttleTime } from 'rxjs'; import type { CalendarStore, CalendarSubscriptionConfig, } from '../store/calendar'; +import { parseCalendarUrl } from '../utils/calendar-url-parser'; export class CalendarSubscription extends Entity<{ url: string }> { constructor(private readonly store: CalendarStore) { @@ -44,16 +45,26 @@ export class CalendarSubscription extends Entity<{ url: string }> { error$ = new LiveData(null); update = effect( + throttleTime(30 * 1000), switchMap(() => fromPromise(async () => { - const response = await fetch(this.url); - const cache = await response.text(); - this.store.setSubscriptionCache(this.url, cache).catch(console.error); + 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(); + } } diff --git a/packages/frontend/core/src/modules/integration/entities/calendar.ts b/packages/frontend/core/src/modules/integration/entities/calendar.ts index d6419c657..ffade89aa 100644 --- a/packages/frontend/core/src/modules/integration/entities/calendar.ts +++ b/packages/frontend/core/src/modules/integration/entities/calendar.ts @@ -1,4 +1,5 @@ import { Entity, LiveData, ObjectPool } from '@toeverything/infra'; +import dayjs, { type Dayjs } from 'dayjs'; import ICAL from 'ical.js'; import { Observable, switchMap } from 'rxjs'; @@ -6,8 +7,33 @@ import type { CalendarStore, CalendarSubscriptionConfig, } from '../store/calendar'; +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(); @@ -45,19 +71,87 @@ export class CalendarIntegration extends Entity { ), [] ); + subscription$(url: string) { + return this.subscriptions$.map(subscriptions => + 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 eventsByDate; + }); + eventsByDate$(date: Dayjs) { + return this.eventsByDateMap$.map(eventsByDateMap => { + const dateKey = date.format('YYYY-MM-DD'); + 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) + ); + }); + }); + } 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'); - } + const url = parseCalendarUrl(_url); try { const response = await fetch(url); const content = await response.text(); diff --git a/packages/frontend/core/src/modules/integration/index.ts b/packages/frontend/core/src/modules/integration/index.ts index 2c91583f1..9413c9520 100644 --- a/packages/frontend/core/src/modules/integration/index.ts +++ b/packages/frontend/core/src/modules/integration/index.ts @@ -18,6 +18,7 @@ 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 { IntegrationTypeIcon } from './views/icon'; diff --git a/packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts b/packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts new file mode 100644 index 000000000..e8fec3361 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts @@ -0,0 +1,14 @@ +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}"`); + } +}; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 74ae3672d..a29bfc84d 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -7653,6 +7653,14 @@ export function useAFFiNEI18N(): { * `An error occurred while adding the calendar` */ ["com.affine.integration.calendar.new-error"](): string; + /** + * `All day` + */ + ["com.affine.integration.calendar.all-day"](): string; + /** + * `New doc` + */ + ["com.affine.integration.calendar.new-doc"](): string; /** * `Notes` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 1dd3ae2d2..484b1406c 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1915,6 +1915,8 @@ "com.affine.integration.calendar.new-title": "Add a calendar by URL", "com.affine.integration.calendar.new-url-label": "Calendar URL", "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.audio.notes": "Notes", "com.affine.audio.transcribing": "Transcribing", "com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others", diff --git a/yarn.lock b/yarn.lock index f7c87715b..1ca0a8282 100644 --- a/yarn.lock +++ b/yarn.lock @@ -404,7 +404,7 @@ __metadata: "@affine/templates": "workspace:*" "@affine/track": "workspace:*" "@blocksuite/affine": "workspace:*" - "@blocksuite/icons": "npm:^2.2.12" + "@blocksuite/icons": "npm:^2.2.13" "@dotlottie/player-component": "npm:^2.7.12" "@emotion/cache": "npm:^11.14.0" "@emotion/react": "npm:^11.14.0" @@ -3986,7 +3986,7 @@ __metadata: languageName: unknown linkType: soft -"@blocksuite/icons@npm:^2.2.12": +"@blocksuite/icons@npm:^2.2.12, @blocksuite/icons@npm:^2.2.13": version: 2.2.13 resolution: "@blocksuite/icons@npm:2.2.13" peerDependencies: