Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

quickshell/nuguland/caldav.js

Raw
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 };
}