refactor(calendar): extract ICS parser into shared ics-parser.js module
This commit is contained in:
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.20.35] - 2026-04-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Extracted ICS parser functions (`unfoldLines`, `parseICS`, `formatICSDate`, `tzLocalToUTC`, `applyDuration`) from `apple-calendar.js` into a new shared module `server/services/ics-parser.js`, plus a new `expandRRULE` helper — pure refactor, no logic changes
|
||||||
|
- Added `test:ics-parser` test suite covering line unfolding, all-day/UTC event parsing, and RRULE expansion
|
||||||
|
|
||||||
## [0.20.34] - 2026-04-20
|
## [0.20.34] - 2026-04-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.20.34",
|
"version": "0.20.35",
|
||||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
"test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js",
|
"test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js",
|
||||||
"test:reminders": "node --experimental-sqlite test-reminders.js",
|
"test:reminders": "node --experimental-sqlite test-reminders.js",
|
||||||
"test:api": "node test-api.js",
|
"test:api": "node test-api.js",
|
||||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api"
|
"test:ics-parser": "node test-ics-parser.js",
|
||||||
|
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && node test-ics-parser.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { createLogger } from '../logger.js';
|
|||||||
const log = createLogger('Apple');
|
const log = createLogger('Apple');
|
||||||
|
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
|
import { unfoldLines, parseICS, formatICSDate, tzLocalToUTC, applyDuration } from './ics-parser.js';
|
||||||
|
|
||||||
const APPLE_COLOR = '#FC3C44';
|
const APPLE_COLOR = '#FC3C44';
|
||||||
|
|
||||||
@@ -101,191 +102,6 @@ async function testConnection() {
|
|||||||
return { ok: true, calendarCount: calendars.length };
|
return { ok: true, calendarCount: calendars.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
|
||||||
// Minimaler ICS-Parser
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entfaltet ICS-Zeilenfortsetzungen (RFC 5545 §3.1).
|
|
||||||
* @param {string} ics
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function unfoldLines(ics) {
|
|
||||||
return ics.replace(/\r?\n[ \t]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extrahiert alle VEVENT-Blöcke aus einem ICS-String.
|
|
||||||
* @param {string} ics
|
|
||||||
* @returns {Array<{uid, summary, description, location, dtstart, dtend, rrule, allDay}>}
|
|
||||||
*/
|
|
||||||
function parseICS(ics) {
|
|
||||||
const unfolded = unfoldLines(ics);
|
|
||||||
const events = [];
|
|
||||||
const vEventRe = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/g;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = vEventRe.exec(unfolded)) !== null) {
|
|
||||||
const block = match[1];
|
|
||||||
const get = (prop) => {
|
|
||||||
const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im');
|
|
||||||
const m = re.exec(block);
|
|
||||||
return m ? m[1].trim() : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const uid = get('UID');
|
|
||||||
const summary = get('SUMMARY') || '(kein Titel)';
|
|
||||||
const description = get('DESCRIPTION') || null;
|
|
||||||
const location = get('LOCATION') || null;
|
|
||||||
const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null;
|
|
||||||
|
|
||||||
// DTSTART / DTEND - extract value and optional TZID parameter
|
|
||||||
const parseDTLine = (prop) => {
|
|
||||||
const re = new RegExp(`^${prop}((?:;[^:]*)*):(.*)$`, 'im');
|
|
||||||
const m = block.match(re);
|
|
||||||
if (!m) return { value: null, tzid: null };
|
|
||||||
const params = m[1];
|
|
||||||
const value = m[2].trim();
|
|
||||||
const tzMatch = params.match(/;TZID=([^;:]+)/i);
|
|
||||||
return { value, tzid: tzMatch ? tzMatch[1].trim() : null };
|
|
||||||
};
|
|
||||||
|
|
||||||
const dtStartLine = parseDTLine('DTSTART');
|
|
||||||
const dtEndLine = parseDTLine('DTEND');
|
|
||||||
const dtStartRaw = dtStartLine.value;
|
|
||||||
const dtEndRaw = dtEndLine.value;
|
|
||||||
|
|
||||||
const allDay = /^DTSTART;VALUE=DATE:/im.test(block);
|
|
||||||
const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay, dtStartLine.tzid) : null;
|
|
||||||
let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay, dtEndLine.tzid) : null;
|
|
||||||
|
|
||||||
// RFC 5545: DTEND for VALUE=DATE is exclusive - subtract one day
|
|
||||||
if (allDay && dtend) {
|
|
||||||
const d = new Date(dtend + 'T00:00:00');
|
|
||||||
d.setDate(d.getDate() - 1);
|
|
||||||
dtend = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DURATION fallback when DTEND is missing (e.g. DURATION:P3D)
|
|
||||||
if (!dtend && dtstart) {
|
|
||||||
const durMatch = /^DURATION(?:;[^:]*)?:(.*)$/im.exec(block);
|
|
||||||
if (durMatch) {
|
|
||||||
dtend = applyDuration(dtstart, durMatch[1].trim(), allDay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uid || !dtstart) continue;
|
|
||||||
|
|
||||||
events.push({ uid, summary, description, location, dtstart, dtend, rrule, allDay });
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a local datetime string in a named timezone to UTC ISO-8601.
|
|
||||||
* Uses the Intl API (Node.js 12+) without external dependencies.
|
|
||||||
* @param {string} localStr - "YYYY-MM-DDTHH:MM:SS"
|
|
||||||
* @param {string} tzid - IANA timezone name e.g. "Europe/Helsinki"
|
|
||||||
* @returns {string} UTC ISO string ending with 'Z', or localStr on error
|
|
||||||
*/
|
|
||||||
function tzLocalToUTC(localStr, tzid) {
|
|
||||||
try {
|
|
||||||
// Treat the local time as if it were UTC to create a reference point
|
|
||||||
const fakeUTC = new Date(localStr + 'Z');
|
|
||||||
|
|
||||||
// Find out what fakeUTC looks like in the target timezone
|
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
|
||||||
timeZone: tzid,
|
|
||||||
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
||||||
hour: 'numeric', minute: 'numeric', second: 'numeric',
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(fakeUTC);
|
|
||||||
|
|
||||||
const get = (type) => {
|
|
||||||
const part = parts.find(p => p.type === type);
|
|
||||||
const v = part ? part.value : '0';
|
|
||||||
return v === '24' ? 0 : parseInt(v, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute offset: how much the timezone differs from what we fed in
|
|
||||||
const tzDisplayedAsUTC = Date.UTC(
|
|
||||||
get('year'), get('month') - 1, get('day'),
|
|
||||||
get('hour'), get('minute'), get('second')
|
|
||||||
);
|
|
||||||
const offsetMs = fakeUTC.getTime() - tzDisplayedAsUTC;
|
|
||||||
|
|
||||||
const trueUTC = new Date(fakeUTC.getTime() + offsetMs);
|
|
||||||
return trueUTC.toISOString().replace('.000Z', 'Z');
|
|
||||||
} catch {
|
|
||||||
return localStr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Konvertiert ICS-Datumswert in ISO-8601-String.
|
|
||||||
* Unterstützt: DATE (20240101), DATE-TIME lokal (20240101T120000),
|
|
||||||
* DATE-TIME UTC (20240101T120000Z), DATE-TIME mit TZID (konvertiert zu UTC).
|
|
||||||
* @param {string} val
|
|
||||||
* @param {boolean} allDay
|
|
||||||
* @param {string|null} tzid - IANA timezone from TZID parameter, if present
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function formatICSDate(val, allDay, tzid) {
|
|
||||||
if (allDay || /^\d{8}$/.test(val)) {
|
|
||||||
// DATE: YYYYMMDD → YYYY-MM-DD
|
|
||||||
return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`;
|
|
||||||
}
|
|
||||||
// DATE-TIME: YYYYMMDDTHHMMSS[Z]
|
|
||||||
const y = val.slice(0, 4);
|
|
||||||
const mo = val.slice(4, 6);
|
|
||||||
const d = val.slice(6, 8);
|
|
||||||
const h = val.slice(9, 11);
|
|
||||||
const mi = val.slice(11, 13);
|
|
||||||
const s = val.slice(13, 15) || '00';
|
|
||||||
|
|
||||||
if (val.endsWith('Z')) {
|
|
||||||
// Already UTC
|
|
||||||
return `${y}-${mo}-${d}T${h}:${mi}:${s}Z`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tzid) {
|
|
||||||
// Convert from named timezone to UTC
|
|
||||||
return tzLocalToUTC(`${y}-${mo}-${d}T${h}:${mi}:${s}`, tzid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floating local time - store without timezone suffix
|
|
||||||
return `${y}-${mo}-${d}T${h}:${mi}:${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Berechnet ein Enddatum aus Start + ICS-DURATION (P-Format, Subset: PnW, PnD, PnDTnHnMnS).
|
|
||||||
* Für all-day Events gibt es YYYY-MM-DD zurück (inklusive, bereits um 1 Tag reduziert),
|
|
||||||
* für timed Events einen ISO-DateTime-String.
|
|
||||||
*/
|
|
||||||
function applyDuration(dtstart, dur, allDay) {
|
|
||||||
const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(dur);
|
|
||||||
if (!m) return null;
|
|
||||||
|
|
||||||
const weeks = parseInt(m[1] || '0', 10);
|
|
||||||
const days = parseInt(m[2] || '0', 10);
|
|
||||||
const hours = parseInt(m[3] || '0', 10);
|
|
||||||
const mins = parseInt(m[4] || '0', 10);
|
|
||||||
const secs = parseInt(m[5] || '0', 10);
|
|
||||||
|
|
||||||
const base = new Date(dtstart.includes('T') ? dtstart : dtstart + 'T00:00:00');
|
|
||||||
base.setDate(base.getDate() + weeks * 7 + days);
|
|
||||||
base.setHours(base.getHours() + hours, base.getMinutes() + mins, base.getSeconds() + secs);
|
|
||||||
|
|
||||||
if (allDay) {
|
|
||||||
// Duration end is exclusive for DATE values - subtract one day for inclusive storage
|
|
||||||
base.setDate(base.getDate() - 1);
|
|
||||||
return `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return base.toISOString().replace('.000Z', 'Z');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Minimaler ICS-Builder
|
// Minimaler ICS-Builder
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { nextOccurrence } from './recurrence.js';
|
||||||
|
|
||||||
|
function unfoldLines(ics) {
|
||||||
|
return ics.replace(/\r?\n[ \t]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseICS(ics) {
|
||||||
|
const unfolded = unfoldLines(ics);
|
||||||
|
const events = [];
|
||||||
|
const vEventRe = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/g;
|
||||||
|
let match;
|
||||||
|
while ((match = vEventRe.exec(unfolded)) !== null) {
|
||||||
|
const block = match[1];
|
||||||
|
const get = (prop) => {
|
||||||
|
const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im');
|
||||||
|
const m = re.exec(block);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
};
|
||||||
|
const uid = get('UID');
|
||||||
|
const summary = get('SUMMARY') || '(kein Titel)';
|
||||||
|
const description = get('DESCRIPTION') || null;
|
||||||
|
const location = get('LOCATION') || null;
|
||||||
|
const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null;
|
||||||
|
const parseDTLine = (prop) => {
|
||||||
|
const re = new RegExp(`^${prop}((?:;[^:]*)*):(.*)$`, 'im');
|
||||||
|
const m = block.match(re);
|
||||||
|
if (!m) return { value: null, tzid: null };
|
||||||
|
const params = m[1];
|
||||||
|
const value = m[2].trim();
|
||||||
|
const tzMatch = params.match(/;TZID=([^;:]+)/i);
|
||||||
|
return { value, tzid: tzMatch ? tzMatch[1].trim() : null };
|
||||||
|
};
|
||||||
|
const dtStartLine = parseDTLine('DTSTART');
|
||||||
|
const dtEndLine = parseDTLine('DTEND');
|
||||||
|
const dtStartRaw = dtStartLine.value;
|
||||||
|
const dtEndRaw = dtEndLine.value;
|
||||||
|
const allDay = /^DTSTART;VALUE=DATE:/im.test(block);
|
||||||
|
const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay, dtStartLine.tzid) : null;
|
||||||
|
let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay, dtEndLine.tzid) : null;
|
||||||
|
if (allDay && dtend) {
|
||||||
|
const d = new Date(dtend + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
dtend = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (!dtend && dtstart) {
|
||||||
|
const durMatch = /^DURATION(?:;[^:]*)?:(.*)$/im.exec(block);
|
||||||
|
if (durMatch) dtend = applyDuration(dtstart, durMatch[1].trim(), allDay);
|
||||||
|
}
|
||||||
|
if (!uid || !dtstart) continue;
|
||||||
|
events.push({ uid, summary, description, location, dtstart, dtend, rrule, allDay });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tzLocalToUTC(localStr, tzid) {
|
||||||
|
try {
|
||||||
|
const fakeUTC = new Date(localStr + 'Z');
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tzid, year: 'numeric', month: 'numeric', day: 'numeric',
|
||||||
|
hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false,
|
||||||
|
}).formatToParts(fakeUTC);
|
||||||
|
const get = (type) => {
|
||||||
|
const part = parts.find(p => p.type === type);
|
||||||
|
const v = part ? part.value : '0';
|
||||||
|
return v === '24' ? 0 : parseInt(v, 10);
|
||||||
|
};
|
||||||
|
const tzDisplayedAsUTC = Date.UTC(
|
||||||
|
get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')
|
||||||
|
);
|
||||||
|
const offsetMs = fakeUTC.getTime() - tzDisplayedAsUTC;
|
||||||
|
return new Date(fakeUTC.getTime() + offsetMs).toISOString().replace('.000Z', 'Z');
|
||||||
|
} catch { return localStr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatICSDate(val, allDay, tzid) {
|
||||||
|
if (allDay || /^\d{8}$/.test(val)) {
|
||||||
|
return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
const y = val.slice(0, 4), mo = val.slice(4, 6), d = val.slice(6, 8);
|
||||||
|
const h = val.slice(9, 11), mi = val.slice(11, 13), s = val.slice(13, 15) || '00';
|
||||||
|
if (val.endsWith('Z')) return `${y}-${mo}-${d}T${h}:${mi}:${s}Z`;
|
||||||
|
if (tzid) return tzLocalToUTC(`${y}-${mo}-${d}T${h}:${mi}:${s}`, tzid);
|
||||||
|
return `${y}-${mo}-${d}T${h}:${mi}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDuration(dtstart, dur, allDay) {
|
||||||
|
const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(dur);
|
||||||
|
if (!m) return null;
|
||||||
|
const weeks = parseInt(m[1] || '0', 10), days = parseInt(m[2] || '0', 10);
|
||||||
|
const hours = parseInt(m[3] || '0', 10), mins = parseInt(m[4] || '0', 10);
|
||||||
|
const secs = parseInt(m[5] || '0', 10);
|
||||||
|
const base = new Date(dtstart.includes('T') ? dtstart : dtstart + 'T00:00:00');
|
||||||
|
base.setDate(base.getDate() + weeks * 7 + days);
|
||||||
|
base.setHours(base.getHours() + hours, base.getMinutes() + mins, base.getSeconds() + secs);
|
||||||
|
if (allDay) {
|
||||||
|
base.setDate(base.getDate() - 1);
|
||||||
|
return `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return base.toISOString().replace('.000Z', 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandRRULE(vevent, windowStart, windowEnd) {
|
||||||
|
if (!vevent.rrule) return [];
|
||||||
|
const results = [];
|
||||||
|
const startDate = vevent.dtstart.slice(0, 10);
|
||||||
|
const timeSuffix = vevent.allDay ? '' : (vevent.dtstart.slice(10) || '');
|
||||||
|
let durationMs = null;
|
||||||
|
if (vevent.dtend) {
|
||||||
|
const s = new Date(vevent.allDay ? vevent.dtstart + 'T00:00:00Z' : vevent.dtstart);
|
||||||
|
const e = new Date(vevent.allDay ? vevent.dtend + 'T00:00:00Z' : vevent.dtend);
|
||||||
|
if (!isNaN(s) && !isNaN(e)) durationMs = e - s;
|
||||||
|
}
|
||||||
|
let current = startDate, iterations = 0;
|
||||||
|
const MAX_ITER = 1500;
|
||||||
|
while (current <= windowEnd && iterations < MAX_ITER) {
|
||||||
|
iterations++;
|
||||||
|
if (current >= windowStart) {
|
||||||
|
const occStart = current + timeSuffix;
|
||||||
|
let occEnd = null;
|
||||||
|
if (durationMs !== null) {
|
||||||
|
if (vevent.allDay) {
|
||||||
|
const d = new Date(current + 'T00:00:00Z');
|
||||||
|
d.setUTCMilliseconds(d.getUTCMilliseconds() + durationMs);
|
||||||
|
occEnd = d.toISOString().slice(0, 10);
|
||||||
|
} else {
|
||||||
|
occEnd = new Date(new Date(occStart).getTime() + durationMs)
|
||||||
|
.toISOString().replace('.000Z', 'Z');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
uid: `${vevent.uid}__${current}`, summary: vevent.summary,
|
||||||
|
description: vevent.description, location: vevent.location,
|
||||||
|
dtstart: occStart, dtend: occEnd, rrule: null, allDay: vevent.allDay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const next = nextOccurrence(current, vevent.rrule);
|
||||||
|
if (!next || next <= current) break;
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { unfoldLines, parseICS, formatICSDate, tzLocalToUTC, applyDuration, expandRRULE };
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { unfoldLines, parseICS, expandRRULE } from './server/services/ics-parser.js';
|
||||||
|
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||||
|
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||||
|
}
|
||||||
|
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
|
||||||
|
|
||||||
|
console.log('\n[ICS-Parser-Test]\n');
|
||||||
|
|
||||||
|
test('unfoldLines entfaltet Zeilenfortsetzungen', () => {
|
||||||
|
const result = unfoldLines('SUMMARY:Hallo\r\n Welt');
|
||||||
|
assert(result === 'SUMMARY:HalloWelt', `got: ${result}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseICS: einfaches Ganztags-Event', () => {
|
||||||
|
const ics = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:test-1@x\r\nSUMMARY:Geburtstag\r\nDTSTART;VALUE=DATE:20260501\r\nDTEND;VALUE=DATE:20260502\r\nEND:VEVENT\r\nEND:VCALENDAR';
|
||||||
|
const events = parseICS(ics);
|
||||||
|
assert(events.length === 1, `expected 1, got ${events.length}`);
|
||||||
|
assert(events[0].uid === 'test-1@x', 'uid');
|
||||||
|
assert(events[0].dtstart === '2026-05-01', `dtstart: ${events[0].dtstart}`);
|
||||||
|
assert(events[0].dtend === '2026-05-01', `dtend: ${events[0].dtend}`);
|
||||||
|
assert(events[0].allDay === true, 'allDay');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseICS: Event ohne UID wird übersprungen', () => {
|
||||||
|
const ics = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Ohne UID\r\nDTSTART:20260601T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR';
|
||||||
|
assert(parseICS(ics).length === 0, 'should skip event without UID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseICS: UTC datetime', () => {
|
||||||
|
const ics = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:utc@x\r\nSUMMARY:Meeting\r\nDTSTART:20260615T140000Z\r\nDTEND:20260615T150000Z\r\nEND:VEVENT\r\nEND:VCALENDAR';
|
||||||
|
const [ev] = parseICS(ics);
|
||||||
|
assert(ev.dtstart === '2026-06-15T14:00:00Z', `dtstart: ${ev.dtstart}`);
|
||||||
|
assert(ev.allDay === false, 'allDay');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expandRRULE: WEEKLY 3-Wochen-Fenster', () => {
|
||||||
|
const vevent = {
|
||||||
|
uid: 'weekly@x', summary: 'Wöchentlich', description: null, location: null,
|
||||||
|
dtstart: '2026-04-13', dtend: '2026-04-13', rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO', allDay: true,
|
||||||
|
};
|
||||||
|
const occ = expandRRULE(vevent, '2026-04-13', '2026-05-04');
|
||||||
|
assert(occ.length >= 3, `expected >=3, got ${occ.length}`);
|
||||||
|
assert(occ[0].uid === 'weekly@x__2026-04-13', `uid: ${occ[0].uid}`);
|
||||||
|
assert(occ[0].rrule === null, 'expanded events have null rrule');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expandRRULE: null rrule → leeres Array', () => {
|
||||||
|
const v = { uid: 'x', summary: 'x', description: null, location: null,
|
||||||
|
dtstart: '2026-04-20', dtend: null, rrule: null, allDay: true };
|
||||||
|
assert(expandRRULE(v, '2026-01-01', '2026-12-31').length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
Reference in New Issue
Block a user