refactor(calendar): extract ICS parser into shared ics-parser.js module
This commit is contained in:
@@ -16,6 +16,7 @@ import { createLogger } from '../logger.js';
|
||||
const log = createLogger('Apple');
|
||||
|
||||
import * as db from '../db.js';
|
||||
import { unfoldLines, parseICS, formatICSDate, tzLocalToUTC, applyDuration } from './ics-parser.js';
|
||||
|
||||
const APPLE_COLOR = '#FC3C44';
|
||||
|
||||
@@ -101,191 +102,6 @@ async function testConnection() {
|
||||
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
|
||||
// --------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user