feat(05-03): calendar providers (CalDAV, ICS, Exchange)
- Implement ICSProvider with fetch + node-ical parsing + RRULE expansion - Implement CalDAVProvider with tsdav DAVClient + time-range filtering - Implement ExchangeProvider dispatching on exchangeMode (graph vs ews) - Graph mode uses @microsoft/microsoft-graph-client /me/calendarView - EWS mode uses ews-javascript-api FindAppointments - Exchange gracefully degrades: returns empty array on failure (D-08) - No provider logs decrypted passwords (T-05-13)
This commit is contained in:
@@ -1,27 +1,137 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DAVClient } from 'tsdav';
|
||||||
|
import * as ical from 'node-ical';
|
||||||
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CalDAV calendar provider — uses tsdav for protocol handling.
|
* CalDAV calendar provider — uses tsdav for protocol handling.
|
||||||
* Full implementation in Task 2.
|
*
|
||||||
|
* Connects via Basic auth (username + decrypted password), fetches calendars,
|
||||||
|
* then fetchCalendarObjects with time-range filter (A2).
|
||||||
|
* Parses returned iCal data with node-ical and maps to CalendarEvent.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalDAVProvider implements CalendarProvider {
|
export class CalDAVProvider implements CalendarProvider {
|
||||||
private readonly logger = new Logger(CalDAVProvider.name);
|
private readonly logger = new Logger(CalDAVProvider.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events from a CalDAV server within the specified date range.
|
||||||
|
* Uses tsdav's time-range filter to only fetch relevant events (A2).
|
||||||
|
*/
|
||||||
async fetchEvents(
|
async fetchEvents(
|
||||||
source: { url: string; username?: string; password?: string; id: string; color?: string | null },
|
source: { url: string; username?: string; password?: string; id: string; color?: string | null },
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
): Promise<CalendarEvent[]> {
|
): Promise<CalendarEvent[]> {
|
||||||
// Stub — implemented in Task 2
|
const events: CalendarEvent[] = [];
|
||||||
return [];
|
|
||||||
|
try {
|
||||||
|
const client = await this.createClient(source);
|
||||||
|
const calendars = await client.fetchCalendars();
|
||||||
|
|
||||||
|
for (const calendar of calendars) {
|
||||||
|
const objects = await client.fetchCalendarObjects({
|
||||||
|
calendar,
|
||||||
|
timeRange: {
|
||||||
|
start: from.toISOString(),
|
||||||
|
end: to.toISOString(),
|
||||||
|
},
|
||||||
|
expand: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
if (!obj.data) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = ical.sync.parseICS(obj.data);
|
||||||
|
|
||||||
|
for (const key of Object.keys(parsed)) {
|
||||||
|
const component = parsed[key];
|
||||||
|
if (!component || component.type !== 'VEVENT') continue;
|
||||||
|
|
||||||
|
const vevent = component as ical.VEvent;
|
||||||
|
const start = new Date(vevent.start);
|
||||||
|
const end = vevent.end
|
||||||
|
? new Date(vevent.end)
|
||||||
|
: new Date(start.getTime() + 3600000);
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
id: `${source.id}-${vevent.uid || key}`,
|
||||||
|
sourceId: source.id,
|
||||||
|
title: extractString(vevent.summary),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allDay: vevent.datetype === 'date',
|
||||||
|
location: vevent.location
|
||||||
|
? extractString(vevent.location)
|
||||||
|
: undefined,
|
||||||
|
description: vevent.description
|
||||||
|
? extractString(vevent.description)
|
||||||
|
: undefined,
|
||||||
|
color: source.color ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to parse CalDAV object: ${(parseErr as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to fetch CalDAV events from ${source.url}: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests connection by attempting login and listing calendars.
|
||||||
|
*/
|
||||||
async testConnection(
|
async testConnection(
|
||||||
source: { url: string; username?: string; password?: string; id: string },
|
source: { url: string; username?: string; password?: string; id: string },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Stub — implemented in Task 2
|
try {
|
||||||
return false;
|
const client = await this.createClient(source);
|
||||||
|
const calendars = await client.fetchCalendars();
|
||||||
|
return calendars.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a tsdav DAVClient with Basic auth and logs in.
|
||||||
|
*/
|
||||||
|
private async createClient(
|
||||||
|
source: { url: string; username?: string; password?: string },
|
||||||
|
): Promise<DAVClient> {
|
||||||
|
const client = new DAVClient({
|
||||||
|
serverUrl: source.url,
|
||||||
|
credentials: {
|
||||||
|
username: source.username || '',
|
||||||
|
password: source.password || '',
|
||||||
|
},
|
||||||
|
authMethod: 'Basic',
|
||||||
|
defaultAccountType: 'caldav',
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.login();
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a plain string from a node-ical ParameterValue.
|
||||||
|
*/
|
||||||
|
function extractString(
|
||||||
|
value: ical.ParameterValue | string | undefined,
|
||||||
|
): string {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'object' && 'val' in value) return String(value.val);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,26 +3,225 @@ import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews').
|
* Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews').
|
||||||
* Uses @microsoft/microsoft-graph-client for Graph and ews-javascript-api for EWS.
|
*
|
||||||
* Full implementation in Task 2.
|
* - 'graph': Uses @microsoft/microsoft-graph-client for Exchange Online / M365
|
||||||
|
* - 'ews': Uses ews-javascript-api for on-premise Exchange Server
|
||||||
|
*
|
||||||
|
* Both modes gracefully degrade: on auth failure, returns empty array and
|
||||||
|
* surfaces a generic error (no credential details — Security V7 / T-05-13).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExchangeProvider implements CalendarProvider {
|
export class ExchangeProvider implements CalendarProvider {
|
||||||
private readonly logger = new Logger(ExchangeProvider.name);
|
private readonly logger = new Logger(ExchangeProvider.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events from Exchange, dispatching by exchangeMode.
|
||||||
|
* D-08: source TYPE is configurable and attempted — widget must not crash.
|
||||||
|
*/
|
||||||
async fetchEvents(
|
async fetchEvents(
|
||||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string; color?: string | null },
|
source: {
|
||||||
|
url: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
exchangeMode?: string | null;
|
||||||
|
id: string;
|
||||||
|
color?: string | null;
|
||||||
|
},
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
): Promise<CalendarEvent[]> {
|
): Promise<CalendarEvent[]> {
|
||||||
// Stub — implemented in Task 2
|
const mode = source.exchangeMode || 'graph';
|
||||||
return [];
|
|
||||||
|
try {
|
||||||
|
if (mode === 'graph') {
|
||||||
|
return await this.fetchViaGraph(source, from, to);
|
||||||
|
} else {
|
||||||
|
return await this.fetchViaEws(source, from, to);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Graceful degradation — T-05-13: no credential details in error
|
||||||
|
this.logger.error(
|
||||||
|
`Exchange (${mode}) fetch failed for source ${source.id}: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests connection to Exchange. Returns false on any auth/network failure.
|
||||||
|
*/
|
||||||
async testConnection(
|
async testConnection(
|
||||||
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string },
|
source: {
|
||||||
|
url: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
exchangeMode?: string | null;
|
||||||
|
id: string;
|
||||||
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Stub — implemented in Task 2
|
const mode = source.exchangeMode || 'graph';
|
||||||
return false;
|
|
||||||
|
try {
|
||||||
|
if (mode === 'graph') {
|
||||||
|
return await this.testGraphConnection(source);
|
||||||
|
} else {
|
||||||
|
return await this.testEwsConnection(source);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events via Microsoft Graph API (Exchange Online / M365).
|
||||||
|
* Uses @microsoft/microsoft-graph-client with /me/calendarView.
|
||||||
|
*/
|
||||||
|
private async fetchViaGraph(
|
||||||
|
source: {
|
||||||
|
url: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
id: string;
|
||||||
|
color?: string | null;
|
||||||
|
},
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
// Dynamic import to avoid loading Graph SDK when not needed
|
||||||
|
const { Client: GraphClient } = await import(
|
||||||
|
'@microsoft/microsoft-graph-client'
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = GraphClient.init({
|
||||||
|
authProvider: (done: (error: any, token: string) => void) => {
|
||||||
|
// Use the password as the access token (OAuth bearer token)
|
||||||
|
// Users configure their OAuth token in the password field for Graph API
|
||||||
|
done(null, source.password || '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client
|
||||||
|
.api('/me/calendarView')
|
||||||
|
.query({
|
||||||
|
startDateTime: from.toISOString(),
|
||||||
|
endDateTime: to.toISOString(),
|
||||||
|
})
|
||||||
|
.select('id,subject,start,end,isAllDay,location,bodyPreview')
|
||||||
|
.orderby('start/dateTime')
|
||||||
|
.top(100)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
if (result?.value) {
|
||||||
|
for (const item of result.value) {
|
||||||
|
events.push({
|
||||||
|
id: `${source.id}-${item.id}`,
|
||||||
|
sourceId: source.id,
|
||||||
|
title: item.subject || 'Untitled',
|
||||||
|
start: new Date(item.start?.dateTime + 'Z'),
|
||||||
|
end: new Date(item.end?.dateTime + 'Z'),
|
||||||
|
allDay: item.isAllDay || false,
|
||||||
|
location: item.location?.displayName || undefined,
|
||||||
|
description: item.bodyPreview || undefined,
|
||||||
|
color: source.color ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events via Exchange Web Services (on-premise Exchange).
|
||||||
|
* Uses ews-javascript-api with FindAppointments over a CalendarView.
|
||||||
|
*
|
||||||
|
* Note: ews-javascript-api has no TypeScript definitions;
|
||||||
|
* we use dynamic import + any casting for type safety.
|
||||||
|
*/
|
||||||
|
private async fetchViaEws(
|
||||||
|
source: {
|
||||||
|
url: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
id: string;
|
||||||
|
color?: string | null;
|
||||||
|
},
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
// Dynamic import — ews-javascript-api is JS-only, no .d.ts
|
||||||
|
const ews: any = await import('ews-javascript-api');
|
||||||
|
|
||||||
|
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
|
||||||
|
service.Url = new ews.Uri(source.url);
|
||||||
|
service.Credentials = new ews.WebCredentials(
|
||||||
|
source.username || '',
|
||||||
|
source.password || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// CalendarView constructor accepts JS Dates in ews-javascript-api
|
||||||
|
const calendarView = new ews.CalendarView(from, to, 100);
|
||||||
|
const findResults = await service.FindAppointments(
|
||||||
|
ews.WellKnownFolderName.Calendar,
|
||||||
|
calendarView,
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
for (const appointment of findResults.Items) {
|
||||||
|
events.push({
|
||||||
|
id: `${source.id}-${appointment.Id?.UniqueId || String(Date.now())}`,
|
||||||
|
sourceId: source.id,
|
||||||
|
title: appointment.Subject || 'Untitled',
|
||||||
|
start: appointment.Start ? new Date(String(appointment.Start)) : new Date(),
|
||||||
|
end: appointment.End ? new Date(String(appointment.End)) : new Date(),
|
||||||
|
allDay: appointment.IsAllDayEvent || false,
|
||||||
|
location: appointment.Location || undefined,
|
||||||
|
description: undefined, // Body requires separate load call
|
||||||
|
color: source.color ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests Graph API connection by requesting calendar list.
|
||||||
|
*/
|
||||||
|
private async testGraphConnection(
|
||||||
|
source: { url: string; password?: string },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { Client: GraphClient } = await import(
|
||||||
|
'@microsoft/microsoft-graph-client'
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = GraphClient.init({
|
||||||
|
authProvider: (done: (error: any, token: string) => void) => {
|
||||||
|
done(null, source.password || '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.api('/me/calendars').top(1).get();
|
||||||
|
return !!result?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests EWS connection by binding to the calendar folder.
|
||||||
|
*/
|
||||||
|
private async testEwsConnection(
|
||||||
|
source: { url: string; username?: string; password?: string },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const ews: any = await import('ews-javascript-api');
|
||||||
|
|
||||||
|
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
|
||||||
|
service.Url = new ews.Uri(source.url);
|
||||||
|
service.Credentials = new ews.WebCredentials(
|
||||||
|
source.username || '',
|
||||||
|
source.password || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,151 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as ical from 'node-ical';
|
||||||
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ICS calendar provider — fetches and parses .ics files via HTTPS.
|
* ICS calendar provider — fetches and parses .ics files via HTTPS.
|
||||||
* Uses node-ical for parsing. Full implementation in Task 2.
|
*
|
||||||
|
* Uses node-ical for parsing iCal data including recurring event expansion (A6).
|
||||||
|
* Applies date-range filtering and maps to normalized CalendarEvent format.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ICSProvider implements CalendarProvider {
|
export class ICSProvider implements CalendarProvider {
|
||||||
private readonly logger = new Logger(ICSProvider.name);
|
private readonly logger = new Logger(ICSProvider.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events from an ICS URL and filters to the given date range.
|
||||||
|
* Expands recurring events via node-ical's expandRecurringEvent (A6).
|
||||||
|
*/
|
||||||
async fetchEvents(
|
async fetchEvents(
|
||||||
source: { url: string; id: string; color?: string | null },
|
source: { url: string; id: string; color?: string | null },
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
): Promise<CalendarEvent[]> {
|
): Promise<CalendarEvent[]> {
|
||||||
// Stub — implemented in Task 2
|
const events: CalendarEvent[] = [];
|
||||||
return [];
|
|
||||||
|
try {
|
||||||
|
// Fetch with timeout (Pitfall 4) then parse
|
||||||
|
const icsText = await this.fetchIcsText(source.url);
|
||||||
|
const data = ical.sync.parseICS(icsText);
|
||||||
|
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
const component = data[key];
|
||||||
|
if (!component || component.type !== 'VEVENT') continue;
|
||||||
|
|
||||||
|
const vevent = component as ical.VEvent;
|
||||||
|
|
||||||
|
// Handle recurring events
|
||||||
|
if (vevent.rrule) {
|
||||||
|
try {
|
||||||
|
const instances = ical.expandRecurringEvent(vevent, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
includeOverrides: true,
|
||||||
|
excludeExdates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const instance of instances) {
|
||||||
|
events.push({
|
||||||
|
id: `${source.id}-${vevent.uid}-${instance.start.getTime()}`,
|
||||||
|
sourceId: source.id,
|
||||||
|
title: extractString(instance.summary ?? vevent.summary),
|
||||||
|
start: new Date(instance.start),
|
||||||
|
end: new Date(instance.end),
|
||||||
|
allDay: instance.isFullDay,
|
||||||
|
location: vevent.location ? extractString(vevent.location) : undefined,
|
||||||
|
description: vevent.description ? extractString(vevent.description) : undefined,
|
||||||
|
color: source.color ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback: if RRULE expansion fails, include the base event if in range
|
||||||
|
this.logger.warn(`RRULE expansion failed for ${vevent.uid}: ${err}`);
|
||||||
|
const mapped = this.mapSingleEvent(vevent, source, from, to);
|
||||||
|
if (mapped) events.push(mapped);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-recurring event — check if in range
|
||||||
|
const mapped = this.mapSingleEvent(vevent, source, from, to);
|
||||||
|
if (mapped) events.push(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch ICS from ${source.url}: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests connection by fetching the ICS URL and verifying it parses.
|
||||||
|
*/
|
||||||
async testConnection(
|
async testConnection(
|
||||||
source: { url: string; id: string },
|
source: { url: string; id: string },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Stub — implemented in Task 2
|
try {
|
||||||
return false;
|
const icsText = await this.fetchIcsText(source.url);
|
||||||
|
const data = ical.sync.parseICS(icsText);
|
||||||
|
return Object.keys(data).length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches raw ICS text from a URL with an 8-second timeout.
|
||||||
|
*/
|
||||||
|
private async fetchIcsText(url: string): Promise<string> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a single (non-recurring) VEvent to CalendarEvent if it falls within the date range.
|
||||||
|
*/
|
||||||
|
private mapSingleEvent(
|
||||||
|
vevent: ical.VEvent,
|
||||||
|
source: { id: string; color?: string | null },
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): CalendarEvent | null {
|
||||||
|
const start = new Date(vevent.start);
|
||||||
|
const end = vevent.end ? new Date(vevent.end) : new Date(start.getTime() + 3600000);
|
||||||
|
|
||||||
|
// Check if event overlaps with the requested range
|
||||||
|
if (end < from || start > to) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${source.id}-${vevent.uid}`,
|
||||||
|
sourceId: source.id,
|
||||||
|
title: extractString(vevent.summary),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allDay: vevent.datetype === 'date',
|
||||||
|
location: vevent.location ? extractString(vevent.location) : undefined,
|
||||||
|
description: vevent.description ? extractString(vevent.description) : undefined,
|
||||||
|
color: source.color ?? undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a plain string from a node-ical ParameterValue
|
||||||
|
* (which can be a string or an object with `val` property).
|
||||||
|
*/
|
||||||
|
function extractString(value: ical.ParameterValue | string | undefined): string {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'object' && 'val' in value) return String(value.val);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user