var GRID_DAYS = 42; var WEEK_DAYS = 7; var MAX_EVENT_DAYS = 400; // --- XML entity decode (named subset + numeric) --- function decodeXmlEntities(s) { return String(s == null ? "" : s).replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, function (m, body) { if (body[0] === '#') { var code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); return isNaN(code) ? m : String.fromCharCode(code); } var map = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; return map.hasOwnProperty(body) ? map[body] : m; }); } // --- RFC 5545 §3.1 line unfolding: split CR/LF/CRLF, merge lines that // start with ONE space or tab (strip exactly one WS char, keep the rest). --- function unfold(text) { var raw = String(text == null ? "" : text).split(/\r\n|\r|\n/); var out = []; for (var i = 0; i < raw.length; i++) { var line = raw[i]; while (i + 1 < raw.length && (raw[i + 1][0] === ' ' || raw[i + 1][0] === '\t')) { line += raw[i + 1].slice(1); i++; } out.push(line); } return out; } // --- RFC 5545 §3.3.11 TEXT unescape: single left-to-right pass. --- function unescapeText(v) { return String(v == null ? "" : v).replace(/\\(.)/g, function (_, c) { return (c === 'n' || c === 'N') ? '\n' : c; }); } // --- split "NAME;params:value" on the first UNQUOTED colon --- function splitLine(line) { var colon = -1, inQuote = false; for (var i = 0; i < line.length; i++) { var ch = line[i]; if (ch === '"') inQuote = !inQuote; else if (ch === ':' && !inQuote) { colon = i; break; } } if (colon < 0) return { name: line, params: "", value: "" }; var head = line.slice(0, colon); var value = line.slice(colon + 1); var semi = head.indexOf(';'); if (semi < 0) return { name: head, params: "", value: value }; return { name: head.slice(0, semi), params: head.slice(semi + 1), value: value }; } function pad2(n) { return (n < 10 ? "0" : "") + n; } // "20260602" -> "2026-06-02" (plain date string, NO timezone math) function dateStr(yyyymmdd) { var s = String(yyyymmdd).slice(0, 8); return s.slice(0, 4) + "-" + s.slice(4, 6) + "-" + s.slice(6, 8); } // "20260622T152500Z" -> UTC epoch ms function utcMs(v) { var m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/.exec(v); if (!m) return null; return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]); } // add one day to a "YYYY-MM-DD" string (for exclusive all-day end math callers) function addDays(dateString, n) { var p = dateString.split("-"); var d = new Date(+p[0], +p[1] - 1, +p[2]); d.setDate(d.getDate() + n); return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()); } // parse a single VEVENT/VTODO block (already unfolded array of lines) function parseBlock(lines, kind, cal, color) { var obj = { uid: "", summary: "", cal: cal, color: color, kind: kind === "VTODO" ? "task" : "event", allDay: false, startMs: null, endMs: null, date: null, endDate: null, done: false }; for (var i = 0; i < lines.length; i++) { var p = splitLine(lines[i]); var name = p.name.toUpperCase(); var isDate = /VALUE=DATE(;|$)/i.test(p.params) || /^\d{8}$/.test(p.value); if (name === "UID") obj.uid = unescapeText(p.value); else if (name === "SUMMARY") obj.summary = unescapeText(p.value); else if (name === "DTSTART") { if (isDate) { obj.allDay = true; obj.date = dateStr(p.value); } else obj.startMs = utcMs(p.value); } else if (name === "DTEND") { if (isDate) obj.endDate = dateStr(p.value); else obj.endMs = utcMs(p.value); } else if (name === "DUE") { if (isDate) { obj.allDay = true; obj.date = dateStr(p.value); } else { obj.startMs = utcMs(p.value); } } else if (name === "STATUS") { if (p.value.toUpperCase() === "COMPLETED") obj.done = true; } else if (name === "PERCENT-COMPLETE") { if (parseInt(p.value, 10) >= 100) obj.done = true; } } // task with a timed DUE: derive its local date for grid placement if (obj.kind === "task" && !obj.date && obj.startMs != null) { var d = new Date(obj.startMs); obj.date = d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()); } return obj; } function parseCalendarData(text, cal, color, events) { var lines = unfold(text); var cur = null; var curKind = null; for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (line.indexOf("BEGIN:VEVENT") === 0) { cur = []; curKind = "VEVENT"; continue; } if (line.indexOf("BEGIN:VTODO") === 0) { cur = []; curKind = "VTODO"; continue; } if (line.indexOf("END:VEVENT") === 0 || line.indexOf("END:VTODO") === 0) { if (cur) events.push(parseBlock(cur, curKind, cal, color)); cur = null; curKind = null; continue; } if (cur) cur.push(line); } } function parseCalendarChunk(chunk, events) { var nl = chunk.indexOf("\n"); if (nl < 0) return; var header = chunk.slice(0, nl).split("\t"); if (header.length < 3) return; var cal = header[0]; var color = header[1]; var body = decodeXmlEntities(chunk.slice(nl + 1)); var re = /<[^>]*calendar-data[^>]*>([\s\S]*?)<\/[^>]*calendar-data>/g; var match; while ((match = re.exec(body)) !== null) parseCalendarData(match[1], cal, color, events); } // parse the whole concatenated XML (delimited by "@@CAL href\tcolor\tkind" lines) function parseIcs(rawXml) { var events = []; var chunks = String(rawXml == null ? "" : rawXml).split(/^@@CAL /m); for (var c = 1; c < chunks.length; c++) parseCalendarChunk(chunks[c], events); return events; } function localDateStr(ms) { var d = new Date(ms); return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()); } function coveredDates(ev) { if (ev.allDay) { var start = ev.date; if (!start) return []; var endExclusive = ev.endDate || addDays(start, 1); var out = [], cur = start; for (var i = 0; i < MAX_EVENT_DAYS && cur < endExclusive; i++) { out.push(cur); cur = addDays(cur, 1); } return out; } if (ev.startMs == null) return ev.date ? [ev.date] : []; var a = localDateStr(ev.startMs); var b = ev.endMs != null ? localDateStr(ev.endMs) : a; var dates = [a], c2 = a; for (var j = 0; j < MAX_EVENT_DAYS && c2 < b; j++) { c2 = addDays(c2, 1); dates.push(c2); } return dates; } function isMultiDay(ev) { return coveredDates(ev).length > 1; } function buildSegments(events, gridStartDateStr) { // index every grid cell date -> absolute day number 0..41 var dayIndex = {}; for (var i = 0; i < GRID_DAYS; i++) dayIndex[addDays(gridStartDateStr, i)] = i; // multi-day events as absolute [first,last] ranges clamped to the grid var spans = []; for (var e = 0; e < events.length; e++) { var ev = events[e]; var dates = coveredDates(ev); if (dates.length < 2) continue; // single-day handled by dots/pills, not bands var idxs = dates.map(function (d) { return dayIndex[d]; }).filter(function (x) { return x !== undefined; }); if (!idxs.length) continue; var first = Math.min.apply(null, idxs), last = Math.max.apply(null, idxs); spans.push({ first: first, last: last, color: ev.color, summary: ev.summary, trueStart: dayIndex[dates[0]], trueEnd: dayIndex[dates[dates.length - 1]] }); } // greedy lane assignment over absolute ranges spans.sort(function (a, b) { return a.first - b.first || (b.last - b.first) - (a.last - a.first); }); var laneEnds = []; // last occupied abs-day per lane for (var s2 = 0; s2 < spans.length; s2++) { var sp = spans[s2], lane = 0; while (lane < laneEnds.length && laneEnds[lane] >= sp.first) lane++; laneEnds[lane] = sp.last; sp.lane = lane; } // split each span at week boundaries into row segments var segments = []; for (var s3 = 0; s3 < spans.length; s3++) { var span = spans[s3]; for (var abs = span.first; abs <= span.last;) { var row = Math.floor(abs / WEEK_DAYS); var col = abs % WEEK_DAYS; var rowEndAbs = row * WEEK_DAYS + WEEK_DAYS - 1; var segEndAbs = Math.min(span.last, rowEndAbs); segments.push({ row: row, colStart: col, colEnd: segEndAbs % WEEK_DAYS, lane: span.lane, color: span.color, summary: span.summary, roundLeft: abs === span.trueStart, roundRight: segEndAbs === span.trueEnd }); abs = segEndAbs + 1; } } return { segments: segments, laneCount: laneEnds.length }; } if (typeof module !== 'undefined' && module.exports) { module.exports = { GRID_DAYS: GRID_DAYS, WEEK_DAYS: WEEK_DAYS, MAX_EVENT_DAYS: MAX_EVENT_DAYS, decodeXmlEntities: decodeXmlEntities, unfold: unfold, unescapeText: unescapeText, splitLine: splitLine, parseIcs: parseIcs, utcMs: utcMs, dateStr: dateStr, addDays: addDays, coveredDates: coveredDates, isMultiDay: isMultiDay, buildSegments: buildSegments }; }