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",