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:
CatsJuice
2025-04-25 02:37:51 +00:00
parent 4b7fddc32c
commit f5ac0aee97
11 changed files with 361 additions and 18 deletions

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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';

View File

@@ -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}"`);
}
};