mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat(core): events list in journal calendar (#11873)
close AF-2505 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<ul className={styles.list}>
|
||||
{events.map(event => (
|
||||
<CalendarEventRenderer key={event.id} event={event} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<li
|
||||
style={assignInlineVars({
|
||||
[styles.primaryColor]: color,
|
||||
})}
|
||||
className={styles.event}
|
||||
data-all-day={allDay}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className={styles.eventIcon}>
|
||||
{allDay ? <FullDayIcon /> : <PeriodIcon />}
|
||||
</div>
|
||||
<div className={styles.eventTitle}>{title}</div>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className={styles.eventCaption}>
|
||||
<span className={styles.eventTime}>
|
||||
{allDay
|
||||
? t['com.affine.integration.calendar.all-day']()
|
||||
: formatTime(startAt, endAt)}
|
||||
</span>
|
||||
<span className={styles.eventNewDoc}>
|
||||
<PlusIcon style={{ fontSize: 18 }} />
|
||||
{t['com.affine.integration.calendar.new-doc']()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<JournalConflictBlock date={journalDate} />
|
||||
<CalendarEvents date={journalDate} />
|
||||
<JournalDailyCountBlock date={journalDate} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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<any>(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, CalendarEvent[]>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user