diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d6cd2..2ced471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Fixed diff --git a/package.json b/package.json index 5a1aaa3..7f67458 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "main": "server/index.js", "type": "module", @@ -22,7 +22,8 @@ "test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js", "test:reminders": "node --experimental-sqlite test-reminders.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": { "bcrypt": "^6.0.0", diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js index 94bbf62..a21c66a 100644 --- a/server/services/apple-calendar.js +++ b/server/services/apple-calendar.js @@ -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 // -------------------------------------------------------- diff --git a/server/services/ics-parser.js b/server/services/ics-parser.js new file mode 100644 index 0000000..c87d065 --- /dev/null +++ b/server/services/ics-parser.js @@ -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 }; diff --git a/test-ics-parser.js b/test-ics-parser.js new file mode 100644 index 0000000..f1db3fc --- /dev/null +++ b/test-ics-parser.js @@ -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);