Skip to content

Commit fd6dde7

Browse files
committed
feat: add Google Calendar integration via gws CLI
Add GCalendar service that fetches events using gws CLI, displays dot indicators on calendar days with events, shows upcoming events in a dedicated dashboard row, and sends desktop notifications before events start. Events are cached to disk for instant display on restart. Configurable via services.calendar in shell.json (disabled by default).
1 parent 9fa8895 commit fd6dde7

5 files changed

Lines changed: 312 additions & 2 deletions

File tree

config/Config.qml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,15 @@ Singleton {
385385
defaultPlayer: services.defaultPlayer,
386386
playerAliases: services.playerAliases,
387387
showLyrics: services.showLyrics,
388-
lyricsBackend: services.lyricsBackend
388+
lyricsBackend: services.lyricsBackend,
389+
calendar: {
390+
enabled: services.calendar.enabled,
391+
command: services.calendar.command,
392+
agendaDays: services.calendar.agendaDays,
393+
upcomingHours: services.calendar.upcomingHours,
394+
reminderMinutes: services.calendar.reminderMinutes,
395+
refreshInterval: services.calendar.refreshInterval
396+
}
389397
};
390398
}
391399

config/ServiceConfig.qml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,14 @@ JsonObject {
2121
]
2222
property bool showLyrics: false
2323
property string lyricsBackend: "Auto"
24+
property GCalendarConfig calendar: GCalendarConfig {}
25+
26+
component GCalendarConfig: JsonObject {
27+
property bool enabled: false // Requires gws CLI in PATH
28+
property string command: "gws" // Path or name of the gws CLI binary
29+
property int agendaDays: 30 // How many days ahead to fetch events
30+
property int upcomingHours: 24 // Hours ahead to show in upcoming list
31+
property int reminderMinutes: 10 // Minutes before event to send notification, 0 to disable
32+
property int refreshInterval: 900 // Refresh interval in seconds
33+
}
2434
}

modules/dashboard/Dash.qml

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
pragma ComponentBehavior: Bound
2+
13
import "dash"
24
import QtQuick.Layouts
35
import qs.components
@@ -86,7 +88,7 @@ GridLayout {
8688
Rect {
8789
Layout.row: 0
8890
Layout.column: 5
89-
Layout.rowSpan: 2
91+
Layout.rowSpan: GCalendar.upcoming.length > 0 ? 3 : 2
9092
Layout.preferredWidth: media.implicitWidth
9193
Layout.fillHeight: true
9294

@@ -97,6 +99,86 @@ GridLayout {
9799
}
98100
}
99101

102+
// Upcoming events
103+
Rect {
104+
Layout.row: 2
105+
Layout.column: 0
106+
Layout.columnSpan: 5
107+
Layout.fillWidth: true
108+
Layout.preferredHeight: eventsCol.implicitHeight
109+
110+
visible: GCalendar.upcoming.length > 0
111+
radius: Appearance.rounding.large
112+
113+
ColumnLayout {
114+
id: eventsCol
115+
116+
anchors.left: parent.left
117+
anchors.right: parent.right
118+
anchors.margins: Appearance.padding.large
119+
spacing: Appearance.spacing.small
120+
121+
StyledText {
122+
Layout.topMargin: Appearance.padding.small
123+
text: qsTr("Upcoming")
124+
color: Colours.palette.m3primary
125+
font.pointSize: Appearance.font.size.small
126+
font.weight: 600
127+
}
128+
129+
Repeater {
130+
model: GCalendar.upcoming
131+
132+
RowLayout {
133+
id: eventRow
134+
135+
required property var modelData
136+
137+
Layout.fillWidth: true
138+
spacing: Appearance.spacing.small
139+
140+
Rectangle {
141+
width: 3
142+
Layout.fillHeight: true
143+
radius: 1.5
144+
color: Colours.palette.m3tertiary
145+
}
146+
147+
ColumnLayout {
148+
Layout.fillWidth: true
149+
spacing: 0
150+
151+
StyledText {
152+
Layout.fillWidth: true
153+
text: eventRow.modelData.summary
154+
color: Colours.palette.m3onSurface
155+
font.pointSize: Appearance.font.size.small
156+
font.weight: 500
157+
elide: Text.ElideRight
158+
}
159+
160+
StyledText {
161+
Layout.fillWidth: true
162+
text: {
163+
let line = GCalendar.formatEventTime(eventRow.modelData);
164+
if (eventRow.modelData.location)
165+
line += ` · ${eventRow.modelData.location}`;
166+
return line;
167+
}
168+
color: Colours.palette.m3onSurfaceVariant
169+
font.pointSize: Appearance.font.size.small * 0.9
170+
elide: Text.ElideRight
171+
}
172+
}
173+
}
174+
}
175+
176+
Item {
177+
Layout.preferredHeight: Appearance.padding.small
178+
}
179+
}
180+
}
181+
100182
component Rect: StyledRect {
101183
color: Colours.tPalette.m3surfaceContainer
102184
}

modules/dashboard/dash/Calendar.qml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ CustomMouseArea {
166166

167167
required property var model
168168

169+
readonly property bool hasEvent: GCalendar.hasEvent(model.date)
170+
169171
implicitWidth: implicitHeight
170172
implicitHeight: text.implicitHeight + Appearance.padding.small * 2
171173

@@ -187,6 +189,18 @@ CustomMouseArea {
187189
font.pointSize: Appearance.font.size.normal
188190
font.weight: 500
189191
}
192+
193+
Rectangle {
194+
anchors.horizontalCenter: parent.horizontalCenter
195+
anchors.bottom: parent.bottom
196+
anchors.bottomMargin: 1
197+
width: 4
198+
height: 4
199+
radius: 2
200+
color: Colours.palette.m3tertiary
201+
visible: dayItem.hasEvent
202+
opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4
203+
}
190204
}
191205
}
192206

services/GCalendar.qml

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)