Compare commits

..

No commits in common. "29208d8b4d8512ecfe38a0f3947ec7712284b21b" and "bed3e47f8e21b912fc3ad66d1a1b4bb371ff285b" have entirely different histories.

10 changed files with 114 additions and 285 deletions

View file

@ -1,7 +0,0 @@
export default {
transform: {
'^.+\\.jsx?$': 'babel-jest',
},
moduleFileExtensions: ['js', 'jsx'],
testEnvironment: 'node',
};

9
package-lock.json generated
View file

@ -17,11 +17,8 @@
"supertest": "^7.0.0" "supertest": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0",
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/register": "^7.25.9", "@babel/register": "^7.25.9",
"babel-jest": "^29.7.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"rewire": "^7.0.0" "rewire": "^7.0.0"
} }
@ -3184,9 +3181,9 @@
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",

View file

@ -2,7 +2,7 @@
"name": "calendar-merger", "name": "calendar-merger",
"version": "1.1.0", "version": "1.1.0",
"scripts": { "scripts": {
"start": "node src/app.js", "start": "node app.js",
"test": "jest ./test" "test": "jest ./test"
}, },
"dependencies": { "dependencies": {
@ -15,15 +15,12 @@
}, },
"description": "calmerger", "description": "calmerger",
"main": "server.js", "main": "server.js",
"author": "Ryan",
"type": "module", "type": "module",
"author": "Ryan",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0",
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/register": "^7.25.9", "@babel/register": "^7.25.9",
"babel-jest": "^29.7.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"rewire": "^7.0.0" "rewire": "^7.0.0"
} }

View file

@ -26,128 +26,77 @@ 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) return;
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', [], []]);
calendarComponent.updatePropertyWithValue('name', name); calendarComponent.updatePropertyWithValue('name', name);
calendarComponent.updatePropertyWithValue('prodid', '-//CalMerge//Calendar Merger 1.0//EN');
calendarComponent.updatePropertyWithValue('version', '2.0'); calendarComponent.updatePropertyWithValue('version', '2.0');
calendarComponent.updatePropertyWithValue('calscale', 'GREGORIAN');
return calendarComponent; return calendarComponent;
} }
// Add events to the calendar component // Add events to the calendar component
export function addEventsToCalendar(calendarComponent, results, overrideFlag = false) { export function addEventsToCalendar(calendarComponent, results) {
let defaultTimeZone = null; // To store the first found X-WR-TIMEZONE
results.forEach((result) => { results.forEach((result) => {
try { try {
const parsed = ICAL.parse(result.data); const parsed = ICAL.parse(result.data);
const component = new ICAL.Component(parsed); const component = new ICAL.Component(parsed);
// Extract METHOD from the parsed data (if available)
const method = component.getFirstPropertyValue('method');
if (method) {
console.log(`Extracted METHOD: ${method}`);
// Only add the METHOD property once
if (!calendarComponent.getFirstPropertyValue('method')) {
calendarComponent.updatePropertyWithValue('method', method.toUpperCase());
}
}
// Extract X-WR-TIMEZONE if available
const wrTimeZone = component.getFirstPropertyValue('x-wr-timezone');
if (wrTimeZone) {
console.log(`Extracted X-WR-TIMEZONE: ${wrTimeZone}`);
// Set it as the default if not already set
if (!defaultTimeZone) {
defaultTimeZone = wrTimeZone;
if (!calendarComponent.getFirstPropertyValue('x-wr-timezone')) {
calendarComponent.updatePropertyWithValue('x-wr-timezone', defaultTimeZone);
}
}
}
// Extract and add VTIMEZONE components
const timezones = component.getAllSubcomponents('vtimezone');
timezones.forEach((timezone) => {
const tzid = timezone.getFirstPropertyValue('tzid');
if (!calendarComponent.getFirstSubcomponent((comp) => comp.name === 'vtimezone' && comp.getFirstPropertyValue('tzid') === tzid)) {
calendarComponent.addSubcomponent(timezone);
}
});
// Process VEVENT components
component.getAllSubcomponents('vevent').forEach((event) => { component.getAllSubcomponents('vevent').forEach((event) => {
const vevent = new ICAL.Event(event); const vevent = new ICAL.Event(event);
const newEvent = new ICAL.Component('vevent'); const newEvent = new ICAL.Component('vevent');
// 1. Add DTSTART // Use ICAL.Time to handle dates correctly
processDateTimeProperty(event, 'dtstart', newEvent); const startDate = vevent.startDate;
const endDate = vevent.endDate;
// 2. Add DTEND // Create new ICAL.Time objects for start and end dates
processDateTimeProperty(event, 'dtend', newEvent); const startTime = new ICAL.Time({
year: startDate.year,
month: startDate.month,
day: startDate.day,
hour: startDate.isDate ? null : startDate.hour,
minute: startDate.isDate ? null : startDate.minute,
second: startDate.isDate ? null : startDate.second,
zone: startDate.zone
});
startTime.isDate = startDate.isDate;
// 3. Copy DTSTAMP const endTime = new ICAL.Time({
const dtstamp = event.getFirstPropertyValue('dtstamp'); year: endDate.year,
if (dtstamp) newEvent.updatePropertyWithValue('dtstamp', dtstamp); month: endDate.month,
day: endDate.day,
hour: endDate.isDate ? null : endDate.hour,
minute: endDate.isDate ? null : endDate.minute,
second: endDate.isDate ? null : endDate.second,
zone: endDate.zone
});
endTime.isDate = endDate.isDate;
// 4. Copy UID // Retain the existing DTSTAMP from vevent
const dtstampProperty = event.getFirstProperty('dtstamp');
const dtstamp = dtstampProperty ? dtstampProperty.getFirstValue() : null;
// Add properties to the new event
newEvent.updatePropertyWithValue('uid', vevent.uid); newEvent.updatePropertyWithValue('uid', vevent.uid);
if (dtstamp) {
// 5. Add LOCATION (conditionally included) newEvent.updatePropertyWithValue('dtstamp', dtstamp); // Retain the existing DTSTAMP
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()); console.warn('DTSTAMP not found in the original event.'); // Warn if DTSTAMP is missing
} }
// 6. Copy Recurrence Rules (RRULE) and Recurrence ID // Set the dtstart and dtend properties using ICAL.Time
const rrule = event.getFirstPropertyValue('rrule'); newEvent.updatePropertyWithValue('dtstart', startTime);
if (rrule) newEvent.updatePropertyWithValue('rrule', rrule); newEvent.updatePropertyWithValue('dtend', endTime);
const recurrenceId = event.getFirstPropertyValue('recurrence-id');
if (recurrenceId) newEvent.updatePropertyWithValue('recurrence-id', recurrenceId);
// 7. Copy SUMMARY
newEvent.updatePropertyWithValue('summary', vevent.summary.trim()); 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 new event to the calendar component
calendarComponent.addSubcomponent(newEvent); calendarComponent.addSubcomponent(newEvent);
}); });
console.log(`Processed VEVENT components for calendar: ${result.name}`); // Log the added events for debugging
} catch (error) { console.log('Added events:', calendarComponent.toString());
} catch (error) {
console.error('Error processing calendar data:', error.message); console.error('Error processing calendar data:', error.message);
} }
}); });
@ -155,9 +104,8 @@ export function addEventsToCalendar(calendarComponent, results, overrideFlag = f
// Save calendar data to file // Save calendar data to file
export function saveCalendarFile(filename, content) { export function saveCalendarFile(filename, content) {
const normalizedContent = content.replace(/\r?\n/g, '\r\n').trimEnd(); // Normalize to CRLF
const filePath = path.join(MERGED_CALENDARS_DIR, filename); const filePath = path.join(MERGED_CALENDARS_DIR, filename);
// console.log(`Saving calendar data to file: ${filePath}`); console.log(`Saving calendar data to file: ${filePath}`);
fs.writeFileSync(filePath, normalizedContent); fs.writeFileSync(filePath, content);
return filePath; return filePath;
} }

View file

@ -1,7 +1,4 @@
import { createRequire } from 'module'; const ICAL = require('./ical.js');
const require = createRequire(import.meta.url);
const ICAL = require('./ical.cjs');
(function() { (function() {
function register(tzdata) { ICAL.TimezoneService.register(ICAL.Component.fromString("BEGIN:VTIMEZONE\r\n" + tzdata + "\r\nEND:VTIMEZONE")) }; function register(tzdata) { ICAL.TimezoneService.register(ICAL.Component.fromString("BEGIN:VTIMEZONE\r\n" + tzdata + "\r\nEND:VTIMEZONE")) };
ICAL.TimezoneService.IANA_TZDB_VERSION = "2024b"; ICAL.TimezoneService.IANA_TZDB_VERSION = "2024b";

View file

@ -9,11 +9,9 @@ const EXPECTED_OUTPUTS_DIR = path.join(__dirname, 'expected_outputs');
let server; let server;
process.chdir(__dirname) process.chdir(__dirname)
// console.log(process.cwd()); console.log(process.cwd());
const app = require('../src/server').default; const app = require('../src/server').default;
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 // Start the server
@ -53,27 +51,20 @@ describe('Calendar Merging API', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
// Check if the file was created in the test directory // Check if the file was created in the test directory
const filePath = path.join(CALENDARS_DIR, 'nextcloud-minimal.ics'); const filePath = path.join(CALENDARS_DIR, 'nextcloud-minimal.ics');
// console.log('Checking if file exists at:', filePath); console.log('Checking if file exists at:', filePath);
expect(fs.existsSync(filePath)).toBe(true); expect(fs.existsSync(filePath)).toBe(true);
// Load expected output and compare
// Load expected output
const expectedOutput = fs.readFileSync(input, 'utf8'); const expectedOutput = fs.readFileSync(input, 'utf8');
const actualOutput = fs.readFileSync(filePath, 'utf8'); const actualOutput = fs.readFileSync(filePath, 'utf8');
expect(actualOutput).toBe(expectedOutput);
// Normalize line endings
const normalizedActual = normalizeLineEndings(actualOutput);
const normalizedExpected = normalizeLineEndings(expectedOutput);
//compare
expect(normalizedActual).toBe(normalizedExpected);
}); });
test('Preserve google calendar', async () => { test('Preserve date-based calendar', async () => {
const input = getTestCalendarFilename('google-calendar-minimal.ics'); const input = getTestCalendarFilename('US_Holidays.ics');
const response = await request(server) const response = await request(server)
.post('/merge') .post('/merge')
.send({ .send({
linkGroupName: 'google-calendar-minimal', linkGroupName: 'US Holidays',
calendars: [ calendars: [
{ {
url: input, url: input,
@ -84,48 +75,15 @@ describe('Calendar Merging API', () => {
}); });
expect(response.status).toBe(200); expect(response.status).toBe(200);
// Check if the file was created in the test directory // Check if the file was created in the test directory
const filePath = path.join(CALENDARS_DIR, 'google-calendar-minimal.ics'); const filePath = path.join(CALENDARS_DIR, 'US_Holidays.ics');
// console.log('Checking if file exists at:', filePath); console.log('Checking if file exists at:', filePath);
expect(fs.existsSync(filePath)).toBe(true); expect(fs.existsSync(filePath)).toBe(true);
// Load expected output and compare
// Load expected output
const expectedOutput = fs.readFileSync(input, 'utf8'); const expectedOutput = fs.readFileSync(input, 'utf8');
const actualOutput = fs.readFileSync(filePath, 'utf8'); const actualOutput = fs.readFileSync(filePath, 'utf8');
expect(actualOutput).toBe(expectedOutput);
// Normalize line endings
const normalizedActual = normalizeLineEndings(actualOutput);
const normalizedExpected = normalizeLineEndings(expectedOutput);
//compare
expect(normalizedActual).toBe(normalizedExpected);
}); });
// test('Preserve date-based calendar', async () => {
// const input = getTestCalendarFilename('US_Holidays.ics');
// const response = await request(server)
// .post('/merge')
// .send({
// linkGroupName: 'US Holidays',
// calendars: [
// {
// url: input,
// prefix: '',
// override: false,
// },
// ],
// });
// expect(response.status).toBe(200);
// // Check if the file was created in the test directory
// const filePath = path.join(CALENDARS_DIR, 'US_Holidays.ics');
// console.log('Checking if file exists at:', filePath);
// expect(fs.existsSync(filePath)).toBe(true);
// // Load expected output and compare
// const expectedOutput = fs.readFileSync(input, 'utf8');
// const actualOutput = fs.readFileSync(filePath, 'utf8');
// expect(actualOutput).toBe(expectedOutput);
// });
// test('Merge date-based calendar', async () => { // test('Merge date-based calendar', async () => {
// const response = await request(server) // const response = await request(server)
// .post('/merge') // .post('/merge')
@ -158,61 +116,61 @@ describe('Calendar Merging API', () => {
// expect(actualOutput).toBe(expectedOutput); // expect(actualOutput).toBe(expectedOutput);
// }); // });
// test('Merge time-based calendar', async () => { test('Merge time-based calendar', async () => {
// const input = getTestCalendarFilename('work_task_calendar.ics'); const input = getTestCalendarFilename('work_task_calendar.ics');
// const response = await request(server) const response = await request(server)
// .post('/merge') .post('/merge')
// .send({ .send({
// linkGroupName: 'Time Based Calendar', linkGroupName: 'Time Based Calendar',
// calendars: [ calendars: [
// { {
// url: input, url: input,
// prefix: 'work_task', prefix: 'work_task',
// override: false, override: false,
// }, },
// ], ],
// }); });
// expect(response.status).toBe(200); expect(response.status).toBe(200);
// expect(response.body.url).toMatch(/calendar\/Time_Based_Calendar/); expect(response.body.url).toMatch(/calendar\/Time_Based_Calendar/);
// // Check if the file was created in the test directory // Check if the file was created in the test directory
// const filePath = path.join(CALENDARS_DIR, 'Time_Based_Calendar.ics'); const filePath = path.join(CALENDARS_DIR, 'Time_Based_Calendar.ics');
// expect(fs.existsSync(filePath)).toBe(true); expect(fs.existsSync(filePath)).toBe(true);
// // Load expected output and compare // Load expected output and compare
// const expectedOutput = fs.readFileSync(input, 'utf8'); const expectedOutput = fs.readFileSync(input, 'utf8');
// const actualOutput = fs.readFileSync(filePath, 'utf8'); const actualOutput = fs.readFileSync(filePath, 'utf8');
// expect(actualOutput).toBe(expectedOutput); expect(actualOutput).toBe(expectedOutput);
// }); });
// test('EAT Event', async () => { test('EAT Event', async () => {
// const input = getTestCalendarFilename('eat_time_zone_event.ics'); const input = getTestCalendarFilename('eat_time_zone_event.ics');
// const response = await request(server) const response = await request(server)
// .post('/merge') .post('/merge')
// .send({ .send({
// linkGroupName: 'EAT Event', linkGroupName: 'EAT Event',
// calendars: [ calendars: [
// { {
// url: input, url: input,
// prefix: 'EAT Event', prefix: 'EAT Event',
// override: false, override: false,
// }, },
// ], ],
// }); });
// expect(response.status).toBe(200); expect(response.status).toBe(200);
// expect(response.body.url).toMatch(/calendar\/EAT_Event/); expect(response.body.url).toMatch(/calendar\/EAT_Event/);
// // Check if the file was created in the test directory // Check if the file was created in the test directory
// const filePath = path.join(CALENDARS_DIR, 'EAT_Event.ics'); const filePath = path.join(CALENDARS_DIR, 'EAT_Event.ics');
// expect(fs.existsSync(filePath)).toBe(true); expect(fs.existsSync(filePath)).toBe(true);
// // Load expected output and compare // Load expected output and compare
// const expectedOutput = fs.readFileSync(input, 'utf8'); const expectedOutput = fs.readFileSync(input, 'utf8');
// const actualOutput = fs.readFileSync(filePath, 'utf8'); const actualOutput = fs.readFileSync(filePath, 'utf8');
// expect(actualOutput).toBe(expectedOutput); expect(actualOutput).toBe(expectedOutput);
// }); });
// test('Merge calendar without prefix', async () => { // test('Merge calendar without prefix', async () => {
// const response = await request(server) // const response = await request(server)

View file

@ -1,16 +0,0 @@
BEGIN:VCALENDAR
NAME:google-calendar-minimal
PRODID:-//CalMerge//Calendar Merger 1.0//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:Africa/Nairobi
BEGIN:VEVENT
DTSTART:20241003T190000Z
DTEND:20241003T200000Z
DTSTAMP:20241119T115316Z
UID:6tbrvsitniuu72li7kk15gou2b@google.com
SUMMARY:progodessey
SEQUENCE:0
END:VEVENT
END:VCALENDAR

View file

@ -1,44 +0,0 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:work
X-WR-TIMEZONE:Africa/Nairobi
BEGIN:VEVENT
DTSTART:20240930T113000Z
DTEND:20240930T123000Z
DTSTAMP:20241119T115316Z
UID:0d0p2hp0l26ebuk2r0kb1q9kuo@google.com
CREATED:20240930T111532Z
LAST-MODIFIED:20240930T111532Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:other work
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTART:20241003T190000Z
DTEND:20241003T200000Z
DTSTAMP:20241119T115316Z
UID:6tbrvsitniuu72li7kk15gou2b@google.com
CREATED:20241001T194455Z
LAST-MODIFIED:20241001T194455Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:progodessey
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTART:20241009T173000Z
DTEND:20241009T183000Z
DTSTAMP:20241119T115316Z
UID:7l7n9nltrudluv65gfgll2q930@google.com
CREATED:20241010T123337Z
LAST-MODIFIED:20241010T123337Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:do
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR

View file

@ -1,8 +1,7 @@
BEGIN:VCALENDAR BEGIN:VCALENDAR
NAME:nextcloud-minimal
PRODID:-//CalMerge//Calendar Merger 1.0//EN
VERSION:2.0 VERSION:2.0
CALSCALE:GREGORIAN CALSCALE:GREGORIAN
PRODID:-//SabreDAV//SabreDAV//EN
BEGIN:VTIMEZONE BEGIN:VTIMEZONE
TZID:Europe/Berlin TZID:Europe/Berlin
BEGIN:DAYLIGHT BEGIN:DAYLIGHT
@ -21,13 +20,13 @@ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD END:STANDARD
END:VTIMEZONE END:VTIMEZONE
BEGIN:VEVENT BEGIN:VEVENT
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 DTSTART;TZID=Europe/Berlin:20241120T211500
LOCATION:FaceTime LOCATION:FaceTime
RRULE:FREQ=WEEKLY;BYDAY=WE RRULE:FREQ=WEEKLY;BYDAY=WE
SUMMARY:JR Weekly Check-In
SEQUENCE:0 SEQUENCE:0
SUMMARY:JR Weekly Check-In
UID:5f4ad965-16a8-48eb-8233-78bf93a8b35e
END:VEVENT END:VEVENT
END:VCALENDAR END:VCALENDAR