repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
quickshell/nuguland/caldav.js
Rawvar 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 };
}