|
| 1 | +pragma Singleton |
| 2 | + |
| 3 | +import qs.config |
| 4 | +import qs.utils |
| 5 | +import Quickshell |
| 6 | +import Quickshell.Io |
| 7 | +import QtQuick |
| 8 | + |
| 9 | +Singleton { |
| 10 | + id: root |
| 11 | + |
| 12 | + readonly property bool enabled: Config.services.calendar.enabled |
| 13 | + |
| 14 | + property list<var> events: [] |
| 15 | + readonly property var eventDateSet: { |
| 16 | + const s = new Set(); |
| 17 | + for (const ev of events) |
| 18 | + s.add(ev.dateKey); |
| 19 | + return s; |
| 20 | + } |
| 21 | + readonly property list<var> upcoming: { |
| 22 | + const now = Date.now(); |
| 23 | + const cutoff = now + Config.services.calendar.upcomingHours * 3600000; |
| 24 | + return events.filter(ev => { |
| 25 | + const t = ev.startTime; |
| 26 | + return t >= now && t < cutoff; |
| 27 | + }); |
| 28 | + } |
| 29 | + |
| 30 | + property var notifiedSet: new Set() |
| 31 | + |
| 32 | + function hasEvent(date: date): bool { |
| 33 | + if (!enabled) |
| 34 | + return false; |
| 35 | + const y = date.getUTCFullYear(); |
| 36 | + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); |
| 37 | + const d = String(date.getUTCDate()).padStart(2, "0"); |
| 38 | + return eventDateSet.has(`${y}-${m}-${d}`); |
| 39 | + } |
| 40 | + |
| 41 | + function formatTime(isoStr: string): string { |
| 42 | + if (!isoStr || isoStr.length === 10) |
| 43 | + return qsTr("All day"); |
| 44 | + const d = new Date(isoStr); |
| 45 | + return Config.services.useTwelveHourClock |
| 46 | + ? Qt.formatDateTime(d, "h:mm AP") |
| 47 | + : Qt.formatDateTime(d, "hh:mm"); |
| 48 | + } |
| 49 | + |
| 50 | + function formatEventTime(ev: var): string { |
| 51 | + let parts = []; |
| 52 | + if (Config.services.calendar.upcomingHours > 24) { |
| 53 | + const d = new Date(ev.start); |
| 54 | + const target = ev.isAllDay ? new Date(ev.start + "T00:00:00") : d; |
| 55 | + const now = new Date(); |
| 56 | + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); |
| 57 | + const dayAfter = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2); |
| 58 | + if (target >= now && target < tomorrow) |
| 59 | + parts.push(qsTr("Today")); |
| 60 | + else if (target >= tomorrow && target < dayAfter) |
| 61 | + parts.push(qsTr("Tomorrow")); |
| 62 | + else |
| 63 | + parts.push(Qt.formatDateTime(target, "ddd, MMM d")); |
| 64 | + } |
| 65 | + parts.push(formatTime(ev.start)); |
| 66 | + return parts.join(" · "); |
| 67 | + } |
| 68 | + |
| 69 | + function parseEvents(data: var): list<var> { |
| 70 | + const parsed = []; |
| 71 | + for (const ev of data) { |
| 72 | + const startStr = ev.start ?? ""; |
| 73 | + const startDate = new Date(startStr); |
| 74 | + const isAllDay = startStr.length === 10; |
| 75 | + let dateKey; |
| 76 | + if (isAllDay) { |
| 77 | + dateKey = startStr; |
| 78 | + } else { |
| 79 | + const y = startDate.getFullYear(); |
| 80 | + const m = String(startDate.getMonth() + 1).padStart(2, "0"); |
| 81 | + const d = String(startDate.getDate()).padStart(2, "0"); |
| 82 | + dateKey = `${y}-${m}-${d}`; |
| 83 | + } |
| 84 | + parsed.push({ |
| 85 | + summary: ev.summary ?? "", |
| 86 | + start: startStr, |
| 87 | + end: ev.end ?? "", |
| 88 | + location: ev.location ?? "", |
| 89 | + calendar: ev.calendar ?? "", |
| 90 | + startTime: startDate.getTime(), |
| 91 | + dateKey: dateKey, |
| 92 | + isAllDay: isAllDay |
| 93 | + }); |
| 94 | + } |
| 95 | + parsed.sort((a, b) => a.startTime - b.startTime); |
| 96 | + return parsed; |
| 97 | + } |
| 98 | + |
| 99 | + function saveCache(): void { |
| 100 | + cache.setText(JSON.stringify(root.events.map(ev => ({ |
| 101 | + summary: ev.summary, |
| 102 | + start: ev.start, |
| 103 | + end: ev.end, |
| 104 | + location: ev.location, |
| 105 | + calendar: ev.calendar |
| 106 | + })))); |
| 107 | + } |
| 108 | + |
| 109 | + function fetch(): void { |
| 110 | + if (!enabled) |
| 111 | + return; |
| 112 | + fetchProc.running = true; |
| 113 | + } |
| 114 | + |
| 115 | + // Load cached events on startup |
| 116 | + FileView { |
| 117 | + id: cache |
| 118 | + |
| 119 | + path: `${Paths.state}/gcalendar.json` |
| 120 | + onLoaded: { |
| 121 | + try { |
| 122 | + const data = JSON.parse(text()); |
| 123 | + if (root.events.length === 0) |
| 124 | + root.events = root.parseEvents(data); |
| 125 | + } catch (e) { |
| 126 | + // Ignore corrupt cache |
| 127 | + } |
| 128 | + } |
| 129 | + onLoadFailed: err => { |
| 130 | + if (err === FileViewError.FileNotFound) |
| 131 | + setText("[]"); |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + Process { |
| 136 | + id: fetchProc |
| 137 | + |
| 138 | + command: [ |
| 139 | + Config.services.calendar.command, "calendar", "+agenda", |
| 140 | + "--days", String(Config.services.calendar.agendaDays), |
| 141 | + "--format", "json" |
| 142 | + ] |
| 143 | + stdout: StdioCollector { |
| 144 | + onStreamFinished: { |
| 145 | + try { |
| 146 | + const json = JSON.parse(text); |
| 147 | + root.events = root.parseEvents(json.events ?? []); |
| 148 | + root.saveCache(); |
| 149 | + } catch (e) { |
| 150 | + console.warn("GCalendar: failed to parse gws output:", e); |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + // Check for reminders |
| 157 | + Timer { |
| 158 | + interval: 30000 |
| 159 | + running: root.enabled && Config.services.calendar.reminderMinutes > 0 |
| 160 | + repeat: true |
| 161 | + onTriggered: { |
| 162 | + const now = Date.now(); |
| 163 | + const reminderMs = Config.services.calendar.reminderMinutes * 60000; |
| 164 | + for (const ev of root.events) { |
| 165 | + if (ev.isAllDay) |
| 166 | + continue; |
| 167 | + const diff = ev.startTime - now; |
| 168 | + if (diff > 0 && diff <= reminderMs + 60000 && diff > reminderMs - 60000) { |
| 169 | + const key = ev.summary + ev.start; |
| 170 | + if (!root.notifiedSet.has(key)) { |
| 171 | + root.notifiedSet.add(key); |
| 172 | + const timeStr = root.formatTime(ev.start); |
| 173 | + Quickshell.execDetached([ |
| 174 | + "notify-send", "-a", "caelestia-shell", |
| 175 | + "-u", "normal", |
| 176 | + "-i", "calendar", |
| 177 | + ev.summary, |
| 178 | + `Starts at ${timeStr}${ev.location ? " - " + ev.location : ""}` |
| 179 | + ]); |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + // Periodic refresh |
| 187 | + Timer { |
| 188 | + interval: Config.services.calendar.refreshInterval * 1000 |
| 189 | + running: root.enabled |
| 190 | + repeat: true |
| 191 | + onTriggered: root.fetch() |
| 192 | + } |
| 193 | + |
| 194 | + // Initial fetch (refreshes cache in background) |
| 195 | + Component.onCompleted: fetch() |
| 196 | +} |
0 commit comments