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"
}
}