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 (
+
+ {events.map(event => (
+
+ ))}
+
+ );
+};
+
+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 (
+
+
+ {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: