forked from ryanmwangi/CalMerger
chore: Merge branch 'master' of forge.ftt.gmbh:janek/CalMerger
This commit is contained in:
commit
d6022eeb62
|
@ -1,7 +1,7 @@
|
||||||
import ICAL from './lib/ical.timezones.js';
|
import ICAL from './lib/ical.timezones.js'
|
||||||
import fs from 'fs';
|
import fs from 'fs'
|
||||||
import path from 'path';
|
import path from 'path'
|
||||||
import axios from 'axios';
|
import axios from 'axios'
|
||||||
|
|
||||||
export const MERGED_CALENDARS_DIR = path.join(process.cwd(), 'calendar');
|
export const MERGED_CALENDARS_DIR = path.join(process.cwd(), 'calendar');
|
||||||
|
|
||||||
|
@ -26,81 +26,6 @@ export async function fetchCalendarData(calendar) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to check if TZID exists in the raw property string
|
|
||||||
function hasTZID(rawProperty) {
|
|
||||||
return rawProperty.includes('TZID=');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to process DTSTART/DTEND
|
|
||||||
function processDateTimeProperty(event, propertyName, newEvent) {
|
|
||||||
const rawProperty = event.getFirstProperty(propertyName)?.toICALString();
|
|
||||||
if (!rawProperty) {
|
|
||||||
console.log(`No raw property found for ${propertyName}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Raw property: ${rawProperty}`); // Log the raw property
|
|
||||||
|
|
||||||
// Check if it's a date-based event (VALUE=DATE)
|
|
||||||
if (rawProperty.includes('VALUE=DATE')) {
|
|
||||||
console.log(`Date-based event detected for ${propertyName}: ${rawProperty}`);
|
|
||||||
|
|
||||||
// Split to get the date part (should be in the format YYYYMMDD)
|
|
||||||
const dateOnly = rawProperty.split(':')[1]; // e.g., "20231225"
|
|
||||||
console.log(`Extracted date string: ${dateOnly}`);
|
|
||||||
|
|
||||||
if (!dateOnly) {
|
|
||||||
console.error(`Error: Could not extract date from ${rawProperty}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the date string is valid (no dashes, just YYYYMMDD)
|
|
||||||
const year = dateOnly.slice(0, 4);
|
|
||||||
const month = dateOnly.slice(4, 6);
|
|
||||||
const day = dateOnly.slice(6, 8);
|
|
||||||
|
|
||||||
console.log(`Parsed date: ${year}-${month}-${day}`);
|
|
||||||
|
|
||||||
// Check if the date is valid
|
|
||||||
if (!year || !month || !day || isNaN(new Date(`${year}-${month}-${day}`))) {
|
|
||||||
console.error(`Invalid date parsed from raw property: ${rawProperty}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedDate = dateOnly; // Use the date string as is (YYYYMMDD format)
|
|
||||||
console.log(`Formatted date: ${formattedDate}`);
|
|
||||||
|
|
||||||
// Log before adding the property to ensure it's correct
|
|
||||||
console.log(`Adding date-based property with value: ${propertyName};VALUE=DATE:${formattedDate}`);
|
|
||||||
|
|
||||||
// Correct property name usage (DTSTART not dtstart)
|
|
||||||
const property = new ICAL.Property(propertyName.toUpperCase(), newEvent); // Use uppercase "DTSTART"
|
|
||||||
property.setValue(`VALUE=DATE:${formattedDate}`);
|
|
||||||
|
|
||||||
// Log the property object before adding it to ensure everything is correct
|
|
||||||
console.log(`Property to add:`, property);
|
|
||||||
|
|
||||||
newEvent.addProperty(property);
|
|
||||||
} else {
|
|
||||||
console.log(`Time-based event detected for ${propertyName}: ${rawProperty}`);
|
|
||||||
|
|
||||||
// Time-based event processing (existing logic)
|
|
||||||
const dateTime = event.getFirstPropertyValue(propertyName);
|
|
||||||
const dateTimeString = dateTime.toString();
|
|
||||||
|
|
||||||
const property = new ICAL.Property(propertyName, newEvent);
|
|
||||||
property.setValue(dateTimeString);
|
|
||||||
|
|
||||||
if (hasTZID(rawProperty)) {
|
|
||||||
// If raw property includes TZID, add it
|
|
||||||
property.setParameter('TZID', dateTime.zone.tzid);
|
|
||||||
}
|
|
||||||
|
|
||||||
newEvent.addProperty(property);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Create a top-level VCALENDAR component
|
// Create a top-level VCALENDAR component
|
||||||
export function createCalendarComponent(name) {
|
export function createCalendarComponent(name) {
|
||||||
const calendarComponent = new ICAL.Component(['vcalendar', [], []]);
|
const calendarComponent = new ICAL.Component(['vcalendar', [], []]);
|
||||||
|
@ -112,94 +37,71 @@ export function createCalendarComponent(name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add events to the calendar component
|
// Add events to the calendar component
|
||||||
export function addEventsToCalendar(calendarComponent, results, overrideFlag = false) {
|
export function addEventsToCalendar(newCalendar, calendars, overrideFlag = false) {
|
||||||
let defaultTimeZone = null; // To store the first found X-WR-TIMEZONE
|
let defaultTimeZone = null; // To store the first found X-WR-TIMEZONE
|
||||||
|
|
||||||
results.forEach((result) => {
|
calendars.forEach((calendarRaw) => {
|
||||||
try {
|
try {
|
||||||
const parsed = ICAL.parse(result.data);
|
const calendar = new ICAL.Component(ICAL.parse(calendarRaw.data));
|
||||||
const component = new ICAL.Component(parsed);
|
|
||||||
|
|
||||||
// Extract METHOD from the parsed data (if available)
|
// Extract METHOD from the parsed data (if available)
|
||||||
const method = component.getFirstPropertyValue('method');
|
const method = calendar.getFirstPropertyValue('method');
|
||||||
if (method) {
|
if (method) {
|
||||||
console.log(`Extracted METHOD: ${method}`);
|
console.log(`Extracted METHOD: ${method}`);
|
||||||
// Only add the METHOD property once
|
// Only add the METHOD property once
|
||||||
if (!calendarComponent.getFirstPropertyValue('method')) {
|
if (!newCalendar.getFirstPropertyValue('method')) {
|
||||||
calendarComponent.updatePropertyWithValue('method', method.toUpperCase());
|
newCalendar.updatePropertyWithValue('method', method.toUpperCase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Extract X-WR-TIMEZONE if available
|
// Extract X-WR-TIMEZONE if available
|
||||||
const wrTimeZone = component.getFirstPropertyValue('x-wr-timezone');
|
const wrTimeZone = calendar.getFirstPropertyValue('x-wr-timezone');
|
||||||
if (wrTimeZone) {
|
if (wrTimeZone) {
|
||||||
console.log(`Extracted X-WR-TIMEZONE: ${wrTimeZone}`);
|
console.log(`Extracted X-WR-TIMEZONE: ${wrTimeZone}`);
|
||||||
// Set it as the default if not already set
|
// Set it as the default if not already set
|
||||||
if (!defaultTimeZone) {
|
if (!defaultTimeZone) {
|
||||||
defaultTimeZone = wrTimeZone;
|
defaultTimeZone = wrTimeZone;
|
||||||
if (!calendarComponent.getFirstPropertyValue('x-wr-timezone')) {
|
if (!newCalendar.getFirstPropertyValue('x-wr-timezone')) {
|
||||||
calendarComponent.updatePropertyWithValue('x-wr-timezone', defaultTimeZone);
|
newCalendar.updatePropertyWithValue('x-wr-timezone', defaultTimeZone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and add VTIMEZONE components
|
// Extract and add VTIMEZONE components
|
||||||
const timezones = component.getAllSubcomponents('vtimezone');
|
const timezones = calendar.getAllSubcomponents('vtimezone');
|
||||||
timezones.forEach((timezone) => {
|
timezones.forEach((timezone) => {
|
||||||
const tzid = timezone.getFirstPropertyValue('tzid');
|
const tzid = timezone.getFirstPropertyValue('tzid');
|
||||||
if (!calendarComponent.getFirstSubcomponent((comp) => comp.name === 'vtimezone' && comp.getFirstPropertyValue('tzid') === tzid)) {
|
if (!newCalendar.getFirstSubcomponent((comp) => comp.name === 'vtimezone' && comp.getFirstPropertyValue('tzid') === tzid)) {
|
||||||
calendarComponent.addSubcomponent(timezone);
|
newCalendar.addSubcomponent(timezone);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process VEVENT components
|
// Process VEVENT components
|
||||||
component.getAllSubcomponents('vevent').forEach((event) => {
|
calendar.getAllSubcomponents('vevent').forEach((vevent) => {
|
||||||
const vevent = new ICAL.Event(event);
|
const event = new ICAL.Event(vevent);
|
||||||
const newEvent = new ICAL.Component('vevent');
|
const newEvent = new ICAL.Event();
|
||||||
|
|
||||||
// 1. Add DTSTART
|
newEvent.uid = event.uid;
|
||||||
processDateTimeProperty(event, 'dtstart', newEvent);
|
newEvent.startDate = event.startDate
|
||||||
|
newEvent.endDate = event.endDate
|
||||||
// 2. Add DTEND
|
|
||||||
processDateTimeProperty(event, 'dtend', newEvent);
|
|
||||||
|
|
||||||
// 3. Copy DTSTAMP
|
const dtstamp = vevent.getFirstPropertyValue('dtstamp');
|
||||||
const dtstamp = event.getFirstPropertyValue('dtstamp');
|
if (dtstamp) newEvent.component.updatePropertyWithValue('dtstamp', dtstamp);
|
||||||
if (dtstamp) newEvent.updatePropertyWithValue('dtstamp', dtstamp);
|
|
||||||
|
|
||||||
// 4. Copy UID
|
if (overrideFlag) {
|
||||||
newEvent.updatePropertyWithValue('uid', vevent.uid);
|
newEvent.summary = 'Busy'
|
||||||
|
|
||||||
// 5. Add LOCATION (conditionally included)
|
|
||||||
if (!overrideFlag && vevent.location) {
|
|
||||||
newEvent.updatePropertyWithValue('location', vevent.location);
|
|
||||||
} else if (overrideFlag && vevent.location) {
|
|
||||||
// Modify SUMMARY if override is set
|
|
||||||
const modifiedSummary = `${vevent.summary.trim()} (Location omitted)`;
|
|
||||||
newEvent.updatePropertyWithValue('summary', modifiedSummary);
|
|
||||||
} else {
|
} else {
|
||||||
newEvent.updatePropertyWithValue('summary', vevent.summary.trim());
|
newEvent.summary = event.summary;
|
||||||
|
if (event.location) newEvent.location = event.location;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Copy Recurrence Rules (RRULE) and Recurrence ID
|
const rrule = vevent.getFirstPropertyValue('rrule');
|
||||||
const rrule = event.getFirstPropertyValue('rrule');
|
if (rrule) newEvent.component.updatePropertyWithValue('rrule', rrule);
|
||||||
if (rrule) newEvent.updatePropertyWithValue('rrule', rrule);
|
|
||||||
|
|
||||||
const recurrenceId = event.getFirstPropertyValue('recurrence-id');
|
|
||||||
if (recurrenceId) newEvent.updatePropertyWithValue('recurrence-id', recurrenceId);
|
|
||||||
|
|
||||||
// 7. Copy SUMMARY
|
|
||||||
newEvent.updatePropertyWithValue('summary', vevent.summary.trim());
|
|
||||||
|
|
||||||
// 8. Add SEQUENCE (if available or default to 0)
|
|
||||||
const sequence = event.getFirstPropertyValue('sequence') || 0;
|
|
||||||
newEvent.updatePropertyWithValue('sequence', sequence);
|
|
||||||
|
|
||||||
// Add the VEVENT to the calendar
|
// Add the VEVENT to the calendar
|
||||||
calendarComponent.addSubcomponent(newEvent);
|
newCalendar.addSubcomponent(newEvent.component);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
console.log(`Processed VEVENT components for calendar: ${result.name}`);
|
console.error(`Error processing calendar:`, calendarRaw, error);
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing calendar data:', error.message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,9 +47,9 @@ async function refreshCalendarData(calendarName) {
|
||||||
// Read the JSON file to get the source URL and other details
|
// Read the JSON file to get the source URL and other details
|
||||||
const { calendars } = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8'));
|
const { calendars } = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8'));
|
||||||
|
|
||||||
const results = await Promise.all(calendars.map(fetchCalendarData));
|
const calendarResults = await Promise.all(calendars.map(fetchCalendarData));
|
||||||
const calendarComponent = createCalendarComponent(calendarName);
|
const calendarComponent = createCalendarComponent(calendarName);
|
||||||
addEventsToCalendar(calendarComponent, results);
|
addEventsToCalendar(calendarComponent, calendarResults);
|
||||||
|
|
||||||
saveCalendarFile(`${calendarName}.ics`, calendarComponent.toString());
|
saveCalendarFile(`${calendarName}.ics`, calendarComponent.toString());
|
||||||
console.log('Calendar data refreshed and saved.');
|
console.log('Calendar data refreshed and saved.');
|
||||||
|
|
|
@ -2,13 +2,13 @@ import express from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import routes from './routes.js';
|
import routes from './routes.js';
|
||||||
|
|
||||||
|
console.log(`Starting server in ${process.cwd()}`);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|
||||||
// Serve static files from the 'public' directory
|
// Serve static files from the 'public' directory
|
||||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
app.use('/', routes);
|
app.use('/', routes);
|
||||||
|
|
||||||
|
export default app;
|
||||||
export default app;
|
|
|
@ -1,8 +1,6 @@
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import express from 'express';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { jest } from '@jest/globals';
|
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
// ESM equivalent of __dirname
|
// ESM equivalent of __dirname
|
||||||
|
@ -15,20 +13,18 @@ const EXPECTED_OUTPUTS_DIR = path.join(__dirname, 'expected_outputs');
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
process.chdir(__dirname)
|
process.chdir(__dirname)
|
||||||
console.log(process.cwd());
|
const app = await import('../src/server');
|
||||||
import app from '../src/server.js';
|
|
||||||
|
|
||||||
const normalizeLineEndings = (str) => str.replace(/\r\n/g, '\r\n').trimEnd(); // Normalize to CRLF
|
|
||||||
|
|
||||||
describe('Calendar Merging API', () => {
|
describe('Calendar Merging API', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Start the server
|
|
||||||
server = app.listen(0);
|
|
||||||
// Clean up the merged calendars directory before tests
|
// Clean up the merged calendars directory before tests
|
||||||
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
|
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
|
||||||
if (fs.existsSync(CALENDARS_DIR)) {
|
if (fs.existsSync(CALENDARS_DIR)) {
|
||||||
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
|
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
// Start the server
|
||||||
|
server = app.default.listen(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -71,12 +67,8 @@ describe('Calendar Merging API', () => {
|
||||||
const expectedOutput = fs.readFileSync(input, 'utf8');
|
const expectedOutput = fs.readFileSync(input, 'utf8');
|
||||||
const actualOutput = fs.readFileSync(filePath, 'utf8');
|
const actualOutput = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
// Normalize line endings
|
|
||||||
const normalizedActual = normalizeLineEndings(actualOutput);
|
|
||||||
const normalizedExpected = normalizeLineEndings(expectedOutput);
|
|
||||||
|
|
||||||
//compare
|
//compare
|
||||||
expect(normalizedActual).toBe(normalizedExpected);
|
expect(actualOutput).toBe(expectedOutput);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Preserve google calendar', async () => {
|
test('Preserve google calendar', async () => {
|
||||||
|
@ -103,12 +95,8 @@ describe('Calendar Merging API', () => {
|
||||||
const expectedOutput = fs.readFileSync(input, 'utf8');
|
const expectedOutput = fs.readFileSync(input, 'utf8');
|
||||||
const actualOutput = fs.readFileSync(filePath, 'utf8');
|
const actualOutput = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
// Normalize line endings
|
|
||||||
const normalizedActual = normalizeLineEndings(actualOutput);
|
|
||||||
const normalizedExpected = normalizeLineEndings(expectedOutput);
|
|
||||||
|
|
||||||
//compare
|
//compare
|
||||||
expect(normalizedActual).toBe(normalizedExpected);
|
expect(actualOutput).toBe(expectedOutput);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
NAME:US Holidays
|
NAME:US Holidays
|
||||||
|
PRODID:-//CalMerge//Calendar Merger 1.0//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
UID:20231225T000000-001@example.com
|
UID:20231225T000000-001@example.com
|
||||||
DTSTAMP:20231225T000000Z
|
|
||||||
DTSTART;VALUE=DATE:20231225
|
DTSTART;VALUE=DATE:20231225
|
||||||
DTEND;VALUE=DATE:20231226
|
DTEND;VALUE=DATE:20231226
|
||||||
|
DTSTAMP:20231225T000000Z
|
||||||
SUMMARY:Christmas Day
|
SUMMARY:Christmas Day
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
END:VCALENDAR
|
|
@ -6,11 +6,10 @@ CALSCALE:GREGORIAN
|
||||||
METHOD:PUBLISH
|
METHOD:PUBLISH
|
||||||
X-WR-TIMEZONE:Africa/Nairobi
|
X-WR-TIMEZONE:Africa/Nairobi
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
|
UID:6tbrvsitniuu72li7kk15gou2b@google.com
|
||||||
DTSTART:20241003T190000Z
|
DTSTART:20241003T190000Z
|
||||||
DTEND:20241003T200000Z
|
DTEND:20241003T200000Z
|
||||||
DTSTAMP:20241119T115316Z
|
DTSTAMP:20241119T115316Z
|
||||||
UID:6tbrvsitniuu72li7kk15gou2b@google.com
|
|
||||||
SUMMARY:progodessey
|
SUMMARY:progodessey
|
||||||
SEQUENCE:0
|
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
END:VCALENDAR
|
|
@ -30,10 +30,10 @@ SUMMARY:progodessey
|
||||||
TRANSP:OPAQUE
|
TRANSP:OPAQUE
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
|
UID:7l7n9nltrudluv65gfgll2q930@google.com
|
||||||
DTSTART:20241009T173000Z
|
DTSTART:20241009T173000Z
|
||||||
DTEND:20241009T183000Z
|
DTEND:20241009T183000Z
|
||||||
DTSTAMP:20241119T115316Z
|
DTSTAMP:20241119T115316Z
|
||||||
UID:7l7n9nltrudluv65gfgll2q930@google.com
|
|
||||||
CREATED:20241010T123337Z
|
CREATED:20241010T123337Z
|
||||||
LAST-MODIFIED:20241010T123337Z
|
LAST-MODIFIED:20241010T123337Z
|
||||||
SEQUENCE:0
|
SEQUENCE:0
|
||||||
|
@ -41,4 +41,4 @@ STATUS:CONFIRMED
|
||||||
SUMMARY:do
|
SUMMARY:do
|
||||||
TRANSP:OPAQUE
|
TRANSP:OPAQUE
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
END:VCALENDAR
|
|
@ -21,13 +21,12 @@ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||||
END:STANDARD
|
END:STANDARD
|
||||||
END:VTIMEZONE
|
END:VTIMEZONE
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
|
UID:5f4ad965-16a8-48eb-8233-78bf93a8b35e
|
||||||
DTSTART;TZID=Europe/Berlin:20241120T211500
|
DTSTART;TZID=Europe/Berlin:20241120T211500
|
||||||
DTEND;TZID=Europe/Berlin:20241120T215000
|
DTEND;TZID=Europe/Berlin:20241120T215000
|
||||||
DTSTAMP:20241113T212909Z
|
DTSTAMP:20241113T212909Z
|
||||||
UID:5f4ad965-16a8-48eb-8233-78bf93a8b35e
|
SUMMARY:JR Weekly Check-In
|
||||||
LOCATION:FaceTime
|
LOCATION:FaceTime
|
||||||
RRULE:FREQ=WEEKLY;BYDAY=WE
|
RRULE:FREQ=WEEKLY;BYDAY=WE
|
||||||
SUMMARY:JR Weekly Check-In
|
|
||||||
SEQUENCE:0
|
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
END:VCALENDAR
|
Loading…
Reference in New Issue