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\nBEGIN:VEVENT\nUID:u\nSUMMARY:X\nDTSTART:20260601T090000Z\nEND:VEVENT')[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 = '/p/personal/Mine#5b8cff'; 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 \'\' ' + '-K - "$CALDAV_URL" <= 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 ; 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="";' + 'else ' + 'BODY="";' + 'fi;' + 'curl -sS -X REPORT -H "Depth: 1" -H "Content-Type: application/xml" --data "$BODY" -K - "$CALDAV_BASE$HREF" <= 0) { root.status = "needs-setup"; return; } let parsed = []; try { parsed = Ical.parseIcs(t); } catch (e) { parsed = []; } // a successful REPORT is a 207 ; 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" } }