2024-11-26 03:09:49 +03:00
|
|
|
import ICAL from './lib/ical.timezones.js';
|
|
|
|
import fs from 'fs';
|
|
|
|
import path from 'path';
|
|
|
|
import axios from 'axios';
|
|
|
|
import logger from './logger.js';
|
2024-11-11 19:34:53 +03:00
|
|
|
|
|
|
|
export const MERGED_CALENDARS_DIR = path.join(process.cwd(), 'calendar');
|
|
|
|
|
|
|
|
// Ensure the merged calendars directory exists
|
|
|
|
fs.mkdirSync(MERGED_CALENDARS_DIR, { recursive: true });
|
|
|
|
|
|
|
|
// Utility to sanitize filenames
|
|
|
|
export const sanitizeFilename = (filename) => filename.replace(/[<>:"/\\|?* ]/g, '_');
|
|
|
|
|
|
|
|
// Fetch calendar data from URL or file
|
|
|
|
export async function fetchCalendarData(calendar) {
|
|
|
|
const isFilePath = !calendar.url.startsWith('http');
|
|
|
|
try {
|
2025-04-22 18:02:21 +03:00
|
|
|
if (isFilePath) {
|
|
|
|
// logger.debug(`Reading calendar from file: ${calendar.url}`);
|
|
|
|
return { data: fs.readFileSync(path.resolve(calendar.url), 'utf-8'), ...calendar };
|
|
|
|
} else {
|
|
|
|
// logger.debug(`Fetching calendar from URL: ${calendar.url}`);
|
|
|
|
const response = await axios.get(calendar.url);
|
|
|
|
return { data: response.data, ...calendar };
|
2024-11-11 19:34:53 +03:00
|
|
|
}
|
2025-04-22 18:02:21 +03:00
|
|
|
} catch (error) {
|
|
|
|
logger.error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
|
|
|
|
throw new Error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
|
2024-11-11 19:34:53 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create a top-level VCALENDAR component
|
|
|
|
export function createCalendarComponent(name) {
|
2024-11-26 03:09:49 +03:00
|
|
|
logger.info(`Creating calendar component with name: ${name}`);
|
2024-11-11 19:34:53 +03:00
|
|
|
const calendarComponent = new ICAL.Component(['vcalendar', [], []]);
|
|
|
|
calendarComponent.updatePropertyWithValue('name', name);
|
2024-11-19 15:24:16 +03:00
|
|
|
calendarComponent.updatePropertyWithValue('prodid', '-//CalMerge//Calendar Merger 1.0//EN');
|
2024-11-12 23:23:59 +03:00
|
|
|
calendarComponent.updatePropertyWithValue('version', '2.0');
|
2024-11-19 14:03:42 +03:00
|
|
|
calendarComponent.updatePropertyWithValue('calscale', 'GREGORIAN');
|
2024-11-11 19:34:53 +03:00
|
|
|
return calendarComponent;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add events to the calendar component
|
2024-11-25 20:57:32 +03:00
|
|
|
export function addEventsToCalendar(newCalendar, calendars) {
|
2024-11-19 19:03:06 +03:00
|
|
|
let defaultTimeZone = null; // To store the first found X-WR-TIMEZONE
|
|
|
|
|
2024-11-25 13:41:28 +01:00
|
|
|
calendars.forEach((calendarRaw) => {
|
2024-11-12 22:39:39 +03:00
|
|
|
try {
|
2025-07-23 15:08:55 +03:00
|
|
|
const { data, prefix, override, keepProperties = false } = calendarRaw; // Extract prefix, override and keepProperties
|
2024-11-25 13:41:28 +01:00
|
|
|
const calendar = new ICAL.Component(ICAL.parse(calendarRaw.data));
|
2024-11-19 19:03:06 +03:00
|
|
|
|
|
|
|
// Extract METHOD from the parsed data (if available)
|
2024-11-25 13:41:28 +01:00
|
|
|
const method = calendar.getFirstPropertyValue('method');
|
2024-11-19 19:03:06 +03:00
|
|
|
if (method) {
|
2024-11-26 03:09:49 +03:00
|
|
|
logger.info(`Extracted METHOD: ${method}`);
|
2024-11-19 19:03:06 +03:00
|
|
|
// Only add the METHOD property once
|
2024-11-25 13:41:28 +01:00
|
|
|
if (!newCalendar.getFirstPropertyValue('method')) {
|
|
|
|
newCalendar.updatePropertyWithValue('method', method.toUpperCase());
|
2024-11-19 19:03:06 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
// Extract X-WR-TIMEZONE if available
|
2024-11-25 13:41:28 +01:00
|
|
|
const wrTimeZone = calendar.getFirstPropertyValue('x-wr-timezone');
|
2024-11-19 19:03:06 +03:00
|
|
|
if (wrTimeZone) {
|
2024-11-26 03:09:49 +03:00
|
|
|
logger.info(`Extracted X-WR-TIMEZONE: ${wrTimeZone}`);
|
2024-11-19 19:03:06 +03:00
|
|
|
// Set it as the default if not already set
|
|
|
|
if (!defaultTimeZone) {
|
|
|
|
defaultTimeZone = wrTimeZone;
|
2024-11-25 13:41:28 +01:00
|
|
|
if (!newCalendar.getFirstPropertyValue('x-wr-timezone')) {
|
|
|
|
newCalendar.updatePropertyWithValue('x-wr-timezone', defaultTimeZone);
|
2024-11-19 19:03:06 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-11-19 15:59:23 +03:00
|
|
|
|
2024-11-18 23:40:41 +03:00
|
|
|
// Extract and add VTIMEZONE components
|
2024-11-25 13:41:28 +01:00
|
|
|
const timezones = calendar.getAllSubcomponents('vtimezone');
|
2024-11-18 23:40:41 +03:00
|
|
|
timezones.forEach((timezone) => {
|
|
|
|
const tzid = timezone.getFirstPropertyValue('tzid');
|
2024-11-25 13:41:28 +01:00
|
|
|
if (!newCalendar.getFirstSubcomponent((comp) => comp.name === 'vtimezone' && comp.getFirstPropertyValue('tzid') === tzid)) {
|
2024-11-26 03:09:49 +03:00
|
|
|
logger.debug(`Adding VTIMEZONE: ${tzid}`);
|
2024-11-25 13:41:28 +01:00
|
|
|
newCalendar.addSubcomponent(timezone);
|
2024-11-18 23:40:41 +03:00
|
|
|
}
|
|
|
|
});
|
2024-11-25 13:41:28 +01:00
|
|
|
|
2024-11-18 23:49:50 +03:00
|
|
|
// Process VEVENT components
|
2024-11-25 13:41:28 +01:00
|
|
|
calendar.getAllSubcomponents('vevent').forEach((vevent) => {
|
|
|
|
const event = new ICAL.Event(vevent);
|
|
|
|
const newEvent = new ICAL.Event();
|
2024-11-18 23:40:41 +03:00
|
|
|
|
2024-11-25 13:41:28 +01:00
|
|
|
newEvent.uid = event.uid;
|
2024-11-26 03:09:49 +03:00
|
|
|
newEvent.startDate = event.startDate;
|
|
|
|
newEvent.endDate = event.endDate;
|
2024-11-18 23:49:50 +03:00
|
|
|
|
2024-11-25 13:41:28 +01:00
|
|
|
const dtstamp = vevent.getFirstPropertyValue('dtstamp');
|
2024-11-25 13:32:47 +01:00
|
|
|
if (dtstamp) newEvent.component.updatePropertyWithValue('dtstamp', dtstamp);
|
2024-11-18 23:49:50 +03:00
|
|
|
|
2024-11-25 20:57:32 +03:00
|
|
|
if (override) {
|
|
|
|
newEvent.summary = prefix || 'Busy';
|
2024-11-19 13:21:22 +03:00
|
|
|
} else {
|
2024-11-25 20:57:32 +03:00
|
|
|
newEvent.summary = prefix ? `${prefix} ${event.summary}` : event.summary;
|
2025-07-23 15:08:55 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// Handle all properties based on keepProperties setting
|
|
|
|
if (keepProperties) {
|
|
|
|
// Preserve all available properties
|
|
|
|
if (event.location) newEvent.location = event.location;
|
|
|
|
if (event.description) newEvent.description = event.description;
|
|
|
|
|
|
|
|
// Copy additional properties from the original component
|
|
|
|
const originalComponent = vevent;
|
|
|
|
|
|
|
|
// Preserve URL (for call links)
|
|
|
|
const url = originalComponent.getFirstPropertyValue('url');
|
|
|
|
if (url) newEvent.component.updatePropertyWithValue('url', url);
|
|
|
|
|
|
|
|
// Preserve attendees
|
|
|
|
const attendees = originalComponent.getAllProperties('attendee');
|
|
|
|
attendees.forEach(attendee => {
|
|
|
|
newEvent.component.addProperty(attendee);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Preserve categories
|
|
|
|
const categories = originalComponent.getFirstPropertyValue('categories');
|
|
|
|
if (categories) newEvent.component.updatePropertyWithValue('categories', categories);
|
|
|
|
|
|
|
|
// Preserve organizer
|
|
|
|
const organizer = originalComponent.getFirstPropertyValue('organizer');
|
|
|
|
if (organizer) newEvent.component.updatePropertyWithValue('organizer', organizer);
|
|
|
|
|
|
|
|
// Preserve status
|
|
|
|
const status = originalComponent.getFirstPropertyValue('status');
|
|
|
|
if (status) newEvent.component.updatePropertyWithValue('status', status);
|
|
|
|
|
|
|
|
// Preserve class (privacy)
|
|
|
|
const eventClass = originalComponent.getFirstPropertyValue('class');
|
|
|
|
if (eventClass) newEvent.component.updatePropertyWithValue('class', eventClass);
|
|
|
|
} else if (!override) {
|
|
|
|
// Backward compatibility: only preserve location when not overriding
|
2024-11-25 13:41:28 +01:00
|
|
|
if (event.location) newEvent.location = event.location;
|
2024-11-19 13:21:22 +03:00
|
|
|
}
|
2024-11-25 20:57:32 +03:00
|
|
|
|
2024-11-25 13:41:28 +01:00
|
|
|
const rrule = vevent.getFirstPropertyValue('rrule');
|
2024-11-25 13:32:47 +01:00
|
|
|
if (rrule) newEvent.component.updatePropertyWithValue('rrule', rrule);
|
|
|
|
|
2024-11-18 23:49:50 +03:00
|
|
|
// Add the VEVENT to the calendar
|
2024-11-25 13:41:28 +01:00
|
|
|
newCalendar.addSubcomponent(newEvent.component);
|
2024-11-18 23:49:50 +03:00
|
|
|
});
|
2024-11-25 13:32:47 +01:00
|
|
|
} catch (error) {
|
2024-11-26 03:09:49 +03:00
|
|
|
logger.error(`Error processing calendar: ${error.message}`);
|
2024-11-12 22:39:39 +03:00
|
|
|
}
|
2024-11-11 19:34:53 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Save calendar data to file
|
|
|
|
export function saveCalendarFile(filename, content) {
|
2024-11-19 19:06:22 +03:00
|
|
|
const normalizedContent = content.replace(/\r?\n/g, '\r\n').trimEnd(); // Normalize to CRLF
|
2024-11-11 19:34:53 +03:00
|
|
|
const filePath = path.join(MERGED_CALENDARS_DIR, filename);
|
2024-11-26 03:09:49 +03:00
|
|
|
logger.info(`Saving calendar data to file: ${filePath}`);
|
2024-11-19 14:17:42 +03:00
|
|
|
fs.writeFileSync(filePath, normalizedContent);
|
2024-11-11 19:34:53 +03:00
|
|
|
return filePath;
|
|
|
|
}
|