pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Io import qs.Common import qs.Services Item { id: root readonly property var log: Log.scoped("CalendarDankBackend") property bool enabled: false property string socketPath: "" readonly property bool socketFound: socketPath.length > 0 property bool connected: false readonly property bool binaryExists: launchKind.length > 0 property bool binaryChecked: false // "dcal" for a host install, "flatpak" for a sandboxed one, "" for neither. property string launchKind: "" property var calendars: [] property var events: [] property var eventsByDate: ({}) property var tasks: [] property var tasksByDate: ({}) property var _pendingTaskState: ({}) property string lastError: "" property date focusDate: new Date() property var _loadedFrom: null property var _loadedTo: null property var pendingRequests: ({}) property int requestCounter: 0 readonly property var fallbackPalette: ["#7287fd", "#f38ba8", "#a6e3a1", "#fab387", "#cba6f7", "#94e2d5", "#f9e2af", "#89dceb"] signal eventsUpdated onEnabledChanged: { if (enabled) { if (!connected) discoverProcess.running = true; return; } requestSocket.connected = false; subscribeSocket.connected = false; socketPath = ""; connected = false; } Component.onCompleted: { binaryCheck.running = true; discoverProcess.running = true; } Process { id: binaryCheck command: ["sh", "-c", "if command -v dcal >/dev/null 2>&1; then echo dcal; elif command -v flatpak >/dev/null 2>&1 && flatpak info com.danklinux.dankcalendar >/dev/null 2>&1; then echo flatpak; fi"] running: false stdout: StdioCollector { onStreamFinished: { root.launchKind = text.trim(); root.binaryChecked = true; } } } Process { id: discoverProcess running: false command: ["sh", "-c", "s=\"${DANKCAL_SOCKET:-}\"; if [ -S \"$s\" ]; then echo \"$s\"; exit 0; fi; for f in \"${XDG_RUNTIME_DIR:-/tmp}\"/app/com.danklinux.dankcalendar/dankcal-*.sock; do if [ -S \"$f\" ]; then echo \"$f\"; exit 0; fi; done; for f in \"${XDG_RUNTIME_DIR:-/tmp}\"/dankcal-*.sock /tmp/dankcal-*.sock; do [ -S \"$f\" ] || continue; p=$(basename \"$f\" .sock); p=${p#dankcal-}; if kill -0 \"$p\" 2>/dev/null; then echo \"$f\"; exit 0; fi; done"] stdout: StdioCollector { onStreamFinished: { const path = text.trim().split('\n')[0] || ""; if (path.length > 0) { root._applySocketPath(path); return; } if (!root.connected) { if (root.socketPath !== "") root.log.info("dankcal socket gone, waiting for daemon"); requestSocket.connected = false; subscribeSocket.connected = false; root.socketPath = ""; } } } } Timer { id: rediscoverTimer interval: 3000 repeat: true running: root.enabled && !root.connected onTriggered: { if (!discoverProcess.running) discoverProcess.running = true; } } function launch() { switch (launchKind) { case "dcal": Quickshell.execDetached(["dcal", "run", "-d", "--hidden"]); break; case "flatpak": Quickshell.execDetached(["flatpak", "run", "com.danklinux.dankcalendar", "run", "-d", "--hidden"]); break; default: return; } if (enabled && !connected) discoverProcess.running = true; } function _applySocketPath(path) { const changed = path !== socketPath; if (changed) log.info("dankcal socket discovered:", path); if (!changed && connected) return; socketPath = path; _reconnect(); } function _reconnect() { requestSocket.connected = false; subscribeSocket.connected = false; Qt.callLater(() => requestSocket.connected = true); } DankSocket { id: requestSocket path: root.socketPath connected: false onConnectionStateChanged: { if (linkUp) { root.connected = true; subscribeSocket.connected = true; root.log.info("connected to dankcal:", root.socketPath); root.refreshCalendars(); root.reloadEvents(); root.reloadTasks(); return; } if (!root.connected && !root.socketFound) return; root.connected = false; root._flushPending(); requestSocket.connected = false; subscribeSocket.connected = false; root.log.info("dankcal disconnected, rediscovering"); if (root.enabled) discoverProcess.running = true; } parser: SplitParser { onRead: line => { if (!line || line.length === 0) return; let response; try { response = JSON.parse(line); } catch (e) { return; } root._handleResponse(response); } } } DankSocket { id: subscribeSocket path: root.socketPath connected: false onConnectionStateChanged: { if (linkUp) root._sendSubscribe(); } parser: SplitParser { onRead: line => { if (!line || line.length === 0) return; let event; try { event = JSON.parse(line); } catch (e) { return; } root._handleEvent(event); } } } Timer { id: refreshDebounce interval: 400 repeat: false onTriggered: { root.refreshCalendars(); root.reloadEvents(); } } Timer { id: tasksDebounce interval: 400 repeat: false onTriggered: root.reloadTasks() } function _sendSubscribe() { subscribeSocket.send({ "id": _nextId(), "method": "subscribe", "params": { "topics": ["accounts", "calendars", "events", "tasks", "sync"] } }); } function _nextId() { requestCounter++; return Date.now() + requestCounter; } function _flushPending() { const ids = Object.keys(pendingRequests); for (const id of ids) { const cb = pendingRequests[id]; delete pendingRequests[id]; if (cb) cb({ "error": "disconnected" }); } } function _handleResponse(response) { if (response.event) { _handleEvent(response); return; } const id = response.id; if (!id) return; const cb = pendingRequests[id]; if (cb) { delete pendingRequests[id]; cb(response); } } function _handleEvent(event) { switch (event.event) { case "accounts": case "calendars": refreshCalendars(); refreshDebounce.restart(); break; case "events": refreshDebounce.restart(); break; case "tasks": tasksDebounce.restart(); break; case "sync": refreshDebounce.restart(); tasksDebounce.restart(); break; } } function sendRequest(method, params, callback) { if (!connected) { if (callback) callback({ "error": "not connected to dankcal socket" }); return; } const id = _nextId(); const req = { "id": id, "method": method }; if (params) req.params = params; if (callback) pendingRequests[id] = callback; requestSocket.send(req); } function refreshCalendars() { sendRequest("calendars.list", null, response => { if (response.error) { lastError = response.error; return; } const list = response.result || []; for (let i = 0; i < list.length; i++) { if (!list[i].color) list[i].color = fallbackPalette[i % fallbackPalette.length]; } calendars = list; _rebuildEventsByDate(); _rebuildTasksByDate(); }); } function calendarById(id) { for (let i = 0; i < calendars.length; i++) { if (calendars[i].id === id) return calendars[i]; } return null; } function writableCalendars() { return calendars.filter(c => !c.readOnly); } function defaultCalendar() { const writable = writableCalendars().filter(c => !c.hidden); return writable.length > 0 ? writable[0] : null; } function loadEvents(startDate, endDate) { const mid = new Date((startDate.getTime() + endDate.getTime()) / 2); focusDate = mid; _ensureWindow(); } function _ensureWindow() { if (!connected) return; if (!_loadedFrom || !_loadedTo) { reloadEvents(); return; } const margin = 14 * 86400000; const t = focusDate.getTime(); if (t < _loadedFrom.getTime() + margin || t > _loadedTo.getTime() - margin) reloadEvents(); else _rebuildEventsByDate(); } function reloadEvents() { if (!connected) return; const from = new Date(focusDate.getTime() - 60 * 86400000); const to = new Date(focusDate.getTime() + 90 * 86400000); sendRequest("events.list", { "from": from.toISOString(), "to": to.toISOString(), "limit": 5000 }, response => { if (response.error) { lastError = response.error; return; } _loadedFrom = from; _loadedTo = to; const raw = (response.result || {}).events || []; events = raw.map(e => _normalizeEvent(e)); _rebuildEventsByDate(); }); } function _dayBoundary(iso) { const d = new Date(iso); return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } function _normalizeEvent(e) { const allDay = !!e.allDay; const id = e.id || ""; if (id.startsWith("task_") || id.startsWith("vtodo_")) log.warn("daemon event id collides with task prefix:", id); return { "id": id, "calendarId": e.calendarId || "", "title": e.summary || "(untitled)", "description": e.description || "", "location": e.location || "", "url": e.url || "", "meetingUrl": e.meetingUrl || "", "start": allDay ? _dayBoundary(e.start) : new Date(e.start), "end": allDay ? _dayBoundary(e.end) : new Date(e.end), "allDay": allDay, "status": e.status || "confirmed", "recurringId": e.recurringId || "", "attendees": e.attendees || [], "organizer": e.organizer || null, "reminders": e.reminders || [] }; } function decorateEvent(ev) { const cal = calendarById(ev.calendarId); const out = Object.assign({}, ev); out.color = cal ? cal.color : fallbackPalette[0]; out.calendar = cal ? cal.name : ""; out.account = cal ? (cal.accountName || cal.accountId || "") : ""; out.readOnly = cal ? !!cal.readOnly : false; out.isMultiDay = ev.start.toDateString() !== ev.end.toDateString(); return out; } function _hiddenCalendarIds() { const hidden = {}; for (let i = 0; i < calendars.length; i++) { if (calendars[i].hidden) hidden[calendars[i].id] = true; } return hidden; } function _clampForDay(ev, cur, endDay) { const out = Object.assign({}, ev); const dayStart = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate()); const startDay = new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate()); if (dayStart.getTime() === startDay.getTime()) { out.start = new Date(ev.start); } else { out.start = new Date(dayStart); if (!ev.allDay) out.start.setHours(0, 0, 0, 0); } if (dayStart.getTime() === endDay.getTime()) { out.end = new Date(ev.end); } else { out.end = new Date(dayStart); if (!ev.allDay) out.end.setHours(23, 59, 59, 999); } return out; } function _rebuildEventsByDate() { const hidden = _hiddenCalendarIds(); const map = {}; for (const raw of events) { if (raw.status === "cancelled") continue; if (hidden[raw.calendarId]) continue; const ev = decorateEvent(raw); const lastInstant = ev.allDay ? new Date(ev.end.getTime() - 1) : ev.end; let cur = new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate()); let endDay = new Date(lastInstant.getFullYear(), lastInstant.getMonth(), lastInstant.getDate()); if (endDay < cur) endDay = new Date(cur); while (cur <= endDay) { const key = Qt.formatDate(cur, "yyyy-MM-dd"); if (!map[key]) map[key] = []; if (!map[key].some(e => e.id === ev.id)) map[key].push(_clampForDay(ev, cur, endDay)); cur.setDate(cur.getDate() + 1); } } eventsByDate = map; eventsUpdated(); } function createEvent(fields, callback) { sendRequest("events.create", fields, response => { if (response.error) lastError = response.error; else reloadEvents(); if (callback) callback(response); }); } function updateEvent(id, fields, callback) { const params = Object.assign({ "id": id }, fields); sendRequest("events.update", params, response => { if (response.error) lastError = response.error; else reloadEvents(); if (callback) callback(response); }); } function deleteEvent(id, callback) { sendRequest("events.delete", { "id": id }, response => { if (response.error) lastError = response.error; else reloadEvents(); if (callback) callback(response); }); } function reloadTasks() { if (!connected) return; sendRequest("tasks.list", { "includeCompleted": true, "limit": 5000 }, response => { if (response.error) { lastError = response.error; return; } const raw = (response.result || {}).tasks || []; tasks = raw.map(t => _applyPendingState(_normalizeTask(t))); _rebuildTasksByDate(); }); } // A completion toggle is applied optimistically; slow providers can serve a // reload that predates the write, so the desired state wins over a // disagreeing reload until the daemon confirms it or the hold expires. function _applyPendingState(t) { const pending = _pendingTaskState[t.id]; if (!pending) return t; if (t.completed === pending.completed || Date.now() > pending.expires) { delete _pendingTaskState[t.id]; return t; } return Object.assign({}, t, { "completed": pending.completed, "status": pending.completed ? "completed" : "needs_action" }); } function _normalizeTask(t) { const allDay = !!t.allDay; return { "id": t.id || "", "calendarId": t.calendarId || "", "title": t.summary || "(untitled)", "description": t.description || "", "location": t.location || "", "status": t.status || "needs_action", "completed": t.status === "completed", "priority": t.priority || 0, "due": t.due ? (allDay ? _dayBoundary(t.due) : new Date(t.due)) : null, "allDay": allDay }; } function taskById(id) { for (let i = 0; i < tasks.length; i++) { if (tasks[i].id === id) return tasks[i]; } return null; } function taskCalendars() { return calendars.filter(c => c.holdsTasks && !c.readOnly); } function defaultTaskCalendar() { const writable = taskCalendars().filter(c => !c.hidden); return writable.length > 0 ? writable[0] : null; } function _taskAsEvent(t) { const cal = calendarById(t.calendarId); return { "id": "vtodo_" + t.id, "title": t.title, "description": t.description, "location": t.location, "url": "", "calendar": cal ? cal.name : "", "color": cal ? cal.color : fallbackPalette[0], "readOnly": cal ? !!cal.readOnly : false, "start": t.due, "end": t.due, "allDay": t.allDay, "completed": t.completed, "status": t.status, "isMultiDay": false }; } function _rebuildTasksByDate() { const hidden = _hiddenCalendarIds(); const map = {}; for (const t of tasks) { if (!t.due || t.status === "cancelled" || hidden[t.calendarId]) continue; const key = Qt.formatDate(t.due, "yyyy-MM-dd"); if (!map[key]) map[key] = []; map[key].push(_taskAsEvent(t)); } tasksByDate = map; } function _patchTask(id, changes) { const next = tasks.slice(); for (let i = 0; i < next.length; i++) { if (next[i].id !== id) continue; next[i] = Object.assign({}, next[i], changes); break; } tasks = next; _rebuildTasksByDate(); } function createTask(fields, callback) { sendRequest("tasks.create", fields, response => { if (response.error) lastError = response.error; reloadTasks(); if (callback) callback(response); }); } function updateTask(id, fields, callback) { const params = Object.assign({ "id": id }, fields); sendRequest("tasks.update", params, response => { if (response.error) lastError = response.error; reloadTasks(); if (callback) callback(response); }); } function completeTask(id, completed, callback) { _pendingTaskState[id] = { "completed": completed, "expires": Date.now() + 15000 }; _patchTask(id, { "completed": completed, "status": completed ? "completed" : "needs_action" }); sendRequest("tasks.complete", { "id": id, "completed": completed }, response => { if (response.error) { lastError = response.error; delete _pendingTaskState[id]; reloadTasks(); } if (callback) callback(response); }); } function deleteTask(id, callback) { sendRequest("tasks.delete", { "id": id }, response => { if (response.error) lastError = response.error; reloadTasks(); if (callback) callback(response); }); } }