mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +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:
@@ -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