Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

quickshell/nuguland/CalDav.qml

Raw
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import "caldav.js" as Ical

Item {
    id: root

    readonly property string configPath: Quickshell.statePath("caldav.json")
    // config is the JsonAdapter below (auto-loads + auto-persists, like Usage.qml).
    property alias config: cfgAdapter
    property var events: []
    property bool stale: false
    // needs-setup until config has url+username AND a fetch has succeeded/failed
    property string status: (root.config.url && root.config.username) ? "loading" : "needs-setup"

    readonly property string host: {
        const u = root.config.url ? String(root.config.url) : "";
        const m = /^https?:\/\/([^\/:]+)/.exec(u);
        return m ? m[1] : "";
    }

    // scheme+host only; PROPFIND hrefs are absolute from the host root, so the
    // dav path must NOT be prepended again when building fetch URLs.
    readonly property string origin: {
        const u = root.config.url ? String(root.config.url) : "";
        const m = /^(https?:\/\/[^\/]+)/.exec(u);
        return m ? m[1] : "";
    }

    function stateJson() {
        return JSON.stringify({
            backend: "CalDav.qml",
            status: status,
            stale: stale,
            secretError: secretError,
            host: host,
            username: config ? config.username : "",
            setupDraftUrl: setupUrlDraft,
            setupDraftUsername: setupUsernameDraft,
            setupDraftHasPassword: setupPasswordDraft.length > 0,
            calendarCount: config && config.calendars ? config.calendars.length : 0,
            eventCount: events ? events.length : 0
        });
    }

    function selfTest() {
        const cases = [];
        function ok(name, cond) {
            cases.push({
                name: name,
                pass: !!cond
            });
        }
        try {
            ok("unescape-adjacent", Ical.unescapeText("lit\\\\nslash") === "lit\\nslash");
            ok("entity-decode", Ical.decodeXmlEntities("a"b") === 'a"b');
            const ev = Ical.parseIcs('@@CAL /p/\t#5b8cff\tVEVENT\n<c:calendar-data>BEGIN:VEVENT\nUID:u\nSUMMARY:X\nDTSTART:20260601T090000Z\nEND:VEVENT</c:calendar-data>')[0];
            ok("parse-timed", ev && ev.summary === "X" && ev.allDay === false);
            const seg = Ical.buildSegments([
                {
                    allDay: true,
                    date: "2026-06-10",
                    endDate: "2026-06-13",
                    color: "#fff",
                    summary: "U"
                }
            ], "2026-06-08");
            ok("segments", seg.segments.length === 1 && seg.segments[0].colStart === 2 && seg.segments[0].colEnd === 4);
            const propfind = '<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/"><d:response><d:href>/p/personal/</d:href><d:propstat><d:prop><d:displayname>Mine</d:displayname><ic:calendar-color>#5b8cff</ic:calendar-color><d:resourcetype><cal:calendar/></d:resourcetype><cal:supported-calendar-component-set><cal:comp name="VEVENT"/></cal:supported-calendar-component-set></d:prop></d:propstat></d:response></d:multistatus>';
            const cals = root.parseCalendars(propfind);
            ok("discover", cals.length === 1 && cals[0].displayName === "Mine" && cals[0].color === "#5b8cff" && cals[0].components.indexOf("VEVENT") >= 0);
            const w = root.windowUtc("2026-06-08"); // Monday grid start
            const shape = /^\d{8}T\d{6}Z$/;
            const back = new Date(Date.UTC(+w.start.slice(0, 4), +w.start.slice(4, 6) - 1, +w.start.slice(6, 8), +w.start.slice(9, 11), +w.start.slice(11, 13), +w.start.slice(13, 15)));
            const spanDays = (Date.UTC(+w.end.slice(0, 4), +w.end.slice(4, 6) - 1, +w.end.slice(6, 8), +w.end.slice(9, 11)) - Date.UTC(+w.start.slice(0, 4), +w.start.slice(4, 6) - 1, +w.start.slice(6, 8), +w.start.slice(9, 11))) / 86400000;
            ok("window", shape.test(w.start) && shape.test(w.end) && back.getFullYear() === 2026 && back.getMonth() === 5 && back.getDate() === 8 && back.getHours() === 0 && spanDays === 42); // local-midnight round-trip + 42-day span, timezone-independent
            const oldUrlDraft = root.setupUrlDraft;
            const oldUserDraft = root.setupUsernameDraft;
            const oldPwDraft = root.setupPasswordDraft;
            try {
                root.setSetupDraft("https://dav.example", "alice", "pw");
                ok("setup-draft", root.setupUrlDraft === "https://dav.example" && root.setupUsernameDraft === "alice" && root.setupPasswordDraft === "pw");
            } finally {
                root.setSetupDraft(oldUrlDraft, oldUserDraft, oldPwDraft);
            }
        } catch (e) {
            ok("threw:" + e, false);
        }
        const fail = cases.filter(c => !c.pass).length;
        return JSON.stringify({
            pass: cases.length - fail,
            fail: fail,
            cases: cases
        });
    }

    function loadConfig() {
        let data = {};
        try {
            data = JSON.parse(cfgFile.text() || "{}");
        } catch (error) {
            data = {};
        }
        root.config.url = String(data.url || "");
        root.config.username = String(data.username || "");
        root.config.calendars = data.calendars || [];
        if (!root.setupUrlDraft)
            root.setupUrlDraft = root.config.url;
        if (!root.setupUsernameDraft)
            root.setupUsernameDraft = root.config.username;
    }

    function persistConfig() {
        cfgFile.setText(JSON.stringify({
            "url": root.config.url,
            "username": root.config.username,
            "calendars": root.config.calendars || []
        }, null, 2));
    }

    // --- discovery: parse a PROPFIND multistatus into calendar descriptors ---
    function parseCalendars(xml) {
        const out = [];
        const text = String(xml || "");
        const respRe = /<[^>]*:?response[^>]*>([\s\S]*?)<\/[^>]*:?response>/g;
        let m;
        while ((m = respRe.exec(text)) !== null) {
            const body = m[1];
            const href = (/<[^>]*:?href[^>]*>([\s\S]*?)<\/[^>]*:?href>/.exec(body) || [])[1];
            const name = (/<[^>]*:?displayname[^>]*>([\s\S]*?)<\/[^>]*:?displayname>/.exec(body) || [])[1];
            const color = (/<[^>]*calendar-color[^>]*>([\s\S]*?)<\/[^>]*calendar-color>/.exec(body) || [])[1];
            const isCal = /<[^>]*:?calendar\b[^>]*\/?>/.test(body);
            if (!href || !name || !isCal)
                continue; // skip inbox/outbox/trashbin (no displayname) + non-calendars
            const comps = [];
            const compRe = /<[^>]*:?comp[^>]*name="([^"]+)"/g;
            let cm;
            while ((cm = compRe.exec(body)) !== null)
                comps.push(cm[1]);
            out.push({
                href: Ical.decodeXmlEntities(href).trim(),
                displayName: Ical.decodeXmlEntities(name).trim(),
                color: (color ? String(color).trim().slice(0, 7) : "#888888"),
                components: comps,
                enabled: true
            });
        }
        return out;
    }

    function discoverAndFetch() {
        if (!root.host || !root.config.username) {
            root.status = "needs-setup";
            return;
        }
        root.status = "loading";
        discoverProc.exec({
            command: ["sh", "-c", root.discoverScript()],
            environment: {
                CALDAV_HOST: root.host,
                CALDAV_USER: String(root.config.username),
                CALDAV_URL: String(root.config.url).replace(/\/+$/, "") + "/calendars/" + encodeURIComponent(String(root.config.username)) + "/"
            }
        });
    }

    function discoverScript() {
        // password read here, fed to curl via -K - on stdin; never in argv
        return 'PW="$(secret-tool lookup service caldav host "$CALDAV_HOST" 2>/dev/null)";' + 'if [ -z "$PW" ]; then echo __NO_SECRET__; exit 0; fi;' + 'curl -sS -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" ' + '--data \'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:i="http://apple.com/ns/ical/"><d:prop><d:resourcetype/><d:displayname/><i:calendar-color/><c:supported-calendar-component-set/></d:prop></d:propfind>\' ' + '-K - "$CALDAV_URL" <<EOF\nuser = "$CALDAV_USER:$PW"\nEOF';
    }

    function applyDiscovery(text) {
        const t = String(text || "");
        if (t.indexOf("__NO_SECRET__") >= 0) {
            root.status = "needs-setup";
            return;
        }
        const cals = root.parseCalendars(t);
        if (!cals.length) {
            root.status = "offline";
            root.stale = true;
            return;
        }
        root.config.calendars = cals;
        root.persistConfig();
        root.refresh();
    }

    // --- fetch window math ---
    // Monday on-or-before the 1st of the currently displayed month, as "YYYY-MM-DD"
    function gridStartDate() {
        const now = new Date();
        const first = new Date(now.getFullYear(), now.getMonth(), 1);
        const dow = (first.getDay() + 6) % 7; // 0=Mon
        first.setDate(first.getDate() - dow);
        function p(n) {
            return (n < 10 ? "0" : "") + n;
        }
        return first.getFullYear() + "-" + p(first.getMonth() + 1) + "-" + p(first.getDate());
    }

    // window from a "YYYY-MM-DD" local grid start: [start, start+42d] as UTC ICAL stamps
    function windowUtc(gridStart) {
        const p = gridStart.split("-");
        const s = new Date(+p[0], +p[1] - 1, +p[2], 0, 0, 0);     // local midnight
        const e = new Date(s.getTime());
        e.setDate(e.getDate() + 42);
        function fmt(d) {
            function z(n) {
                return (n < 10 ? "0" : "") + n;
            }
            return d.getUTCFullYear() + z(d.getUTCMonth() + 1) + z(d.getUTCDate()) + "T" + z(d.getUTCHours()) + z(d.getUTCMinutes()) + z(d.getUTCSeconds()) + "Z";
        }
        return {
            start: fmt(s),
            end: fmt(e)
        };
    }

    function refresh() {
        if (!root.host || !root.config.username) {
            root.status = "needs-setup";
            return;
        }
        if (!root.config.calendars || !root.config.calendars.length) {
            root.discoverAndFetch();
            return;
        }
        root.status = "loading";
        const gs = root.gridStartDate();
        const w = root.windowUtc(gs);
        const lines = [];
        const cals = root.config.calendars.filter(c => c.enabled !== false);
        for (let i = 0; i < cals.length; i++) {
            const isTask = (cals[i].components || []).indexOf("VTODO") >= 0 && (cals[i].components || []).indexOf("VEVENT") < 0;
            lines.push([cals[i].href, cals[i].color, isTask ? "VTODO" : "VEVENT"].join("\t"));
        }
        fetchProc.exec({
            command: ["sh", "-c", root.fetchScript()],
            environment: {
                CALDAV_HOST: root.host,
                CALDAV_USER: String(root.config.username),
                CALDAV_BASE: root.origin,
                CALDAV_WIN_START: w.start,
                CALDAV_WIN_END: w.end,
                CALDAV_CALS: lines.join("\n")
            }
        });
    }

    function fetchScript() {
        // For each "href\tcolor\tkind" line: print delimiter, then curl REPORT.
        // VEVENT uses <c:expand>; VTODO must NOT (Nextcloud #45333 drops tasks).
        return 'PW="$(secret-tool lookup service caldav host "$CALDAV_HOST" 2>/dev/null)";' + 'if [ -z "$PW" ]; then echo __NO_SECRET__; exit 0; fi;' + 'printf %s "$CALDAV_CALS" | while IFS="\t" read -r HREF COLOR KIND; do ' + '[ -z "$HREF" ] && continue;' + 'printf "@@CAL %s\\t%s\\t%s\\n" "$HREF" "$COLOR" "$KIND";' + 'if [ "$KIND" = "VTODO" ]; then ' + 'BODY="<c:calendar-query xmlns:d=\\"DAV:\\" xmlns:c=\\"urn:ietf:params:xml:ns:caldav\\"><d:prop><c:calendar-data/></d:prop><c:filter><c:comp-filter name=\\"VCALENDAR\\"><c:comp-filter name=\\"VTODO\\"/></c:comp-filter></c:filter></c:calendar-query>";' + 'else ' + 'BODY="<c:calendar-query xmlns:d=\\"DAV:\\" xmlns:c=\\"urn:ietf:params:xml:ns:caldav\\"><d:prop><c:calendar-data><c:expand start=\\"$CALDAV_WIN_START\\" end=\\"$CALDAV_WIN_END\\"/></c:calendar-data></d:prop><c:filter><c:comp-filter name=\\"VCALENDAR\\"><c:comp-filter name=\\"VEVENT\\"><c:time-range start=\\"$CALDAV_WIN_START\\" end=\\"$CALDAV_WIN_END\\"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>";' + 'fi;' + 'curl -sS -X REPORT -H "Depth: 1" -H "Content-Type: application/xml" --data "$BODY" -K - "$CALDAV_BASE$HREF" <<EOF\nuser = "$CALDAV_USER:$PW"\nEOF\n' + 'printf "\\n";' + 'done';
    }

    function applyFetch(text) {
        const t = String(text || "");
        if (t.indexOf("__NO_SECRET__") >= 0) {
            root.status = "needs-setup";
            return;
        }
        let parsed = [];
        try {
            parsed = Ical.parseIcs(t);
        } catch (e) {
            parsed = [];
        }
        // a successful REPORT is a 207 <d:multistatus>; its absence means a
        // transport/auth failure (curl prints @@CAL headers but no data), so
        // t is non-empty yet useless — keep last-good events and flag stale.
        // (an empty month still returns multistatus, so this won't false-trip.)
        if (t.indexOf("multistatus") < 0) {
            root.status = "offline";
            root.stale = true;
            return;
        }
        // client-side window filter for tasks (VTODO has no server time-range)
        const gs = root.gridStartDate();
        const winStart = gs, winEnd = Ical.addDays(gs, 42);
        root.events = parsed.filter(function (ev) {
            if (ev.kind !== "task")
                return true;
            return ev.date && ev.date >= winStart && ev.date < winEnd;
        });
        root.status = "ok";
        root.stale = false;
    }

    // non-empty when the last keyring write failed; shown in the setup form
    property string secretError: ""
    property string setupUrlDraft: ""
    property string setupUsernameDraft: ""
    property string setupPasswordDraft: ""

    function setSetupDraft(url, username, password) {
        root.setupUrlDraft = String(url || "");
        root.setupUsernameDraft = String(username || "");
        root.setupPasswordDraft = String(password || "");
    }

    // Persist url+username, store the password in gnome-keyring, then discover.
    // Password reaches secret-tool via stdin (printf is a shell builtin, so it
    // never appears in argv/ps); it travels QML->sh only as a transient env var.
    function saveSetup(url, username, password) {
        root.secretError = "";
        root.setSetupDraft(url, username, password);
        const u = String(url || "").trim().replace(/\/+$/, "");
        const user = String(username || "").trim();
        if (!u || !user || !password) {
            root.secretError = "URL, Benutzer und Passwort nötig";
            return;
        }
        const m = /^https?:\/\/([^\/:]+)/.exec(u);
        const h = m ? m[1] : "";
        if (!h) {
            root.secretError = "URL ungültig (https://… erwartet)";
            return;
        }
        root.setupUrlDraft = u;
        root.setupUsernameDraft = user;
        root.config.url = u;
        root.config.username = user;
        root.config.calendars = [];
        root.persistConfig();
        root.status = "loading";
        storeProc.exec({
            command: ["sh", "-c", root.storeScript()],
            environment: {
                CALDAV_HOST: h,
                CALDAV_USER: user,
                CALDAV_NEWPW: String(password)
            }
        });
    }

    function storeScript() {
        return 'printf %s "$CALDAV_NEWPW" | secret-tool store --label="nuguland caldav" service caldav host "$CALDAV_HOST" account "$CALDAV_USER"';
    }

    Process {
        id: discoverProc
        stdout: StdioCollector {
            waitForEnd: true
            onStreamFinished: root.applyDiscovery(this.text)
        }
        stderr: StdioCollector {
            waitForEnd: true
            onStreamFinished: if (this.text.trim())
                root.stale = true
        }
    }

    Process {
        id: fetchProc
        stdout: StdioCollector {
            waitForEnd: true
            onStreamFinished: root.applyFetch(this.text)
        }
        stderr: StdioCollector {
            waitForEnd: true
            onStreamFinished: if (this.text.trim()) {
                root.stale = true;
                if (root.status === "loading")
                    root.status = "offline";
            }
        }
    }

    Process {
        id: storeProc
        stderr: StdioCollector {
            waitForEnd: true
            onStreamFinished: if (this.text.trim())
                root.secretError = this.text.trim()
        }
        function onExited(code) {
            if (code === 0) {
                root.secretError = "";
                root.setupPasswordDraft = "";
                root.discoverAndFetch();
            } else {
                root.status = "needs-setup";
                if (!root.secretError)
                    root.secretError = "Keyring-Fehler (" + code + ")";
            }
        }
    }

    Timer {
        interval: 15 * 60 * 1000
        running: true
        repeat: true
        onTriggered: root.refresh()
    }

    QtObject {
        id: cfgAdapter

        property string url: ""
        property string username: ""
        property var calendars: []
    }

    FileView {
        id: cfgFile
        path: root.configPath
        // config is read async; refresh only once url/username are populated
        onLoaded: {
            root.loadConfig();
            Qt.callLater(root.refresh);
        }
        onLoadFailed: root.status = "needs-setup"
    }
}