Personal noctalia plugins collection
1import QtQuick
2import Quickshell
3import qs.Commons
4import qs.Services.Location
5import qs.Services.UI
6import Quickshell.Io
7import qs.Services.System
8
9Item {
10 id: root
11 property var pluginApi: null
12 property date currentDate: new Date()
13 property ListModel eventsModel: ListModel {}
14 property ListModel allDayEventsModel: ListModel {}
15 property var overlappingEventsData: ({})
16 property bool isLoading: false
17 property bool hasLoadedOnce: false
18 property string syncStatus: ""
19
20 property real dayColumnWidth: 120 * Style.uiScaleRatio
21 property real allDaySectionHeight: 0 * Style.uiScaleRatio
22 property var allDayEventsWithLayout: []
23
24 property date weekStart: calculateWeekStart(currentDate, firstDayOfWeek)
25 property date weekEnd: calculateWeekEnd(weekStart)
26 property var weekDates: calculateWeekDates(weekStart)
27 property string monthRangeText: formatMonthRangeText(weekDates)
28
29 // Settings
30 property string panelModeSetting: pluginApi?.pluginSettings?.panelMode || "attached"
31 property string weekStartSetting: pluginApi?.pluginSettings?.weekStart || "1"
32 property string timeFormatSetting: pluginApi?.pluginSettings?.timeFormat || "24h"
33 property string lineColorTypeSetting: pluginApi?.pluginSettings?.lineColorType || "mOutline"
34 property real hourLineOpacitySetting: pluginApi?.pluginSettings?.hourLineOpacity ?? 0.5
35 property real dayLineOpacitySetting: pluginApi?.pluginSettings?.dayLineOpacity ?? 0.9
36
37 readonly property int firstDayOfWeek: weekStartSetting === "0" ? 0 :
38 weekStartSetting === "1" ? 1 :
39 weekStartSetting === "6" ? 6 : I18n.locale.firstDayOfWeek
40
41 readonly property bool use12hourFormat: timeFormatSetting === "12h" ? true :
42 timeFormatSetting === "24h" ? false :
43 Settings.data.location.use12hourFormat
44
45 readonly property color lineColor: lineColorTypeSetting === "mOnSurfaceVariant" ? Color.mOnSurfaceVariant : Color.mOutline
46
47 onWeekStartSettingChanged: if (hasLoadedOnce) Qt.callLater(loadEvents)
48 onTimeFormatSettingChanged: eventsModelChanged()
49 onCurrentDateChanged: Qt.callLater(loadEvents)
50 onLineColorTypeSettingChanged: eventsModelChanged()
51 onHourLineOpacitySettingChanged: eventsModelChanged()
52 onDayLineOpacitySettingChanged: eventsModelChanged()
53
54 // React to CalendarService signals (async event delivery)
55 Connections {
56 target: CalendarService
57 function onAvailableChanged() {
58 if (CalendarService.available) {
59 Qt.callLater(loadEvents)
60 } else {
61 isLoading = false
62 if (pluginApi) syncStatus = pluginApi.tr("panel.no_service")
63 }
64 }
65 function onEventsChanged() {
66 if (isLoading || hasLoadedOnce) {
67 Qt.callLater(updateEventsFromService)
68 }
69 }
70 function onLoadingChanged() {
71 if (!CalendarService.loading && isLoading) {
72 Qt.callLater(updateEventsFromService)
73 }
74 }
75 }
76
77 // Safety timeout: if CalendarService never signals back, stop spinning
78 Timer {
79 id: loadingTimeout
80 interval: 15000
81 repeat: false
82 onTriggered: {
83 if (isLoading) {
84 console.warn("[weekly-calendar] loading timeout, forcing update")
85 updateEventsFromService()
86 }
87 }
88 }
89
90 // IPC
91 IpcHandler {
92 target: "plugin:weekly-calendar"
93 function togglePanel() { pluginApi?.withCurrentScreen(s => pluginApi.togglePanel(s)) }
94 }
95
96 Component.onCompleted: {
97 initializePluginSettings()
98 if (CalendarService.available) Qt.callLater(loadEvents)
99 }
100
101 onPluginApiChanged: {
102 initializePluginSettings()
103 if (CalendarService.available) Qt.callLater(loadEvents)
104 }
105
106 function initializePluginSettings() {
107 if (!pluginApi) return
108 if (!pluginApi.pluginSettings.weekStart) {
109 pluginApi.pluginSettings = {
110 weekStart: "1",
111 timeFormat: "24h",
112 lineColorType: "mOutline",
113 hourLineOpacity: 0.5,
114 dayLineOpacity: 1.0
115 }
116 pluginApi.saveSettings()
117 }
118 }
119
120 function initializePlugin() {
121 console.log("[weekly-calendar] initializePlugin called, CalendarService.available=" + CalendarService.available)
122 loadEvents()
123 }
124
125 // Fetch events - kicks off async request, signals will deliver results
126 function loadEvents() {
127 if (!pluginApi) return
128 if (!CalendarService.available) {
129 syncStatus = pluginApi.tr("panel.no_service")
130 return
131 }
132
133 isLoading = true
134 syncStatus = pluginApi.tr("panel.loading")
135
136 var now = new Date()
137 var start = new Date(weekStart), end = new Date(weekEnd)
138 start.setDate(start.getDate() - 7)
139 end.setDate(end.getDate() + 14)
140
141 CalendarService.loadEvents(
142 Math.max(0, Math.ceil((end - now) / 86400000)),
143 Math.max(0, Math.ceil((now - start) / 86400000))
144 )
145
146 hasLoadedOnce = true
147 loadingTimeout.restart()
148
149 // If CalendarService already has events (cached), process them now
150 if (CalendarService.events && CalendarService.events.length > 0) {
151 Qt.callLater(updateEventsFromService)
152 }
153 }
154
155 function updateEventsFromService() {
156 if (!pluginApi) return
157 loadingTimeout.stop()
158 clearEventModels()
159
160 if (!CalendarService.available) {
161 syncStatus = pluginApi.tr("panel.no_service")
162 } else if (!CalendarService.events?.length) {
163 syncStatus = pluginApi.tr("panel.no_events")
164 } else {
165 var stats = processCalendarEvents(CalendarService.events)
166 syncStatus = stats.timedCount === 1
167 ? `${stats.timedCount} ${pluginApi.tr("panel.event")}, ${stats.allDayCount} ${pluginApi.tr("panel.allday")}`
168 : `${stats.timedCount} ${pluginApi.tr("panel.events")}, ${stats.allDayCount} ${pluginApi.tr("panel.allday")}`
169 }
170
171 isLoading = false
172 }
173
174 // Events generation & layout
175 function processCalendarEvents(events) {
176 var uniqueEvents = {}, uniqueAllDayEvents = {}
177 var timedCount = 0, allDayCount = 0
178 var newEvents = [], newAllDayEvents = []
179 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
180
181 for (var i = 0; i < events.length; i++) {
182 var event = events[i], eventObj = createEventObject(event, i)
183 var eventStart = new Date(eventObj.startTime), eventEnd = new Date(eventObj.endTime)
184 var overlapsWeek = eventStart < weekEndDate && eventEnd > weekStartDate
185
186 if (overlapsWeek) {
187 var key = event.uid + "-" + event.start + "-" + event.end + i
188 if (eventObj.allDay) {
189 if (!uniqueAllDayEvents[key]) {
190 uniqueAllDayEvents[key] = true
191 allDayCount++
192 newAllDayEvents.push(eventObj)
193 }
194 } else if (!uniqueEvents[key]) {
195 uniqueEvents[key] = true
196 timedCount++
197 processTimedEventIntoArray(eventObj, newEvents)
198 }
199 }
200 }
201
202 eventsModel.clear()
203 allDayEventsModel.clear()
204 newEvents.forEach(e => eventsModel.append(e))
205 newAllDayEvents.forEach(e => allDayEventsModel.append(e))
206
207 calculateAllDayEventLayout()
208 updateOverlappingEvents()
209 eventsModel.layoutChanged()
210 allDayEventsModel.layoutChanged()
211
212 return {timedCount: timedCount, allDayCount: allDayCount}
213 }
214
215 function clearEventModels() { eventsModel.clear(); allDayEventsModel.clear() }
216
217 function processTimedEventIntoArray(eventObj, target) {
218 var start = new Date(eventObj.startTime), end = new Date(eventObj.endTime)
219 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
220 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
221
222 if (startDay.getTime() === endDay.getTime()) {
223 if (start < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 0, start, end, startDay, 0, 1))
224 } else {
225 var firstEnd = new Date(startDay); firstEnd.setHours(24, 0, 0, 0)
226 var secondStart = new Date(endDay); secondStart.setHours(0, 0, 0, 0)
227 if (start < weekEnd && firstEnd > weekStart) target.push(createEventPart(eventObj, 0, start, firstEnd, startDay, 0, 2))
228 if (secondStart < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 1, secondStart, end, endDay, 1, 2))
229 }
230 }
231
232 function createEventObject(event, idx) {
233 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
234 var allDay = isAllDayEvent(event), multiDay = isMultiDayEvent(event)
235 var daySpan = calculateDaySpan(start, end, multiDay || allDay)
236 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
237 var id = event.uid + "-" + event.start + "-" + event.end + idx
238
239 return {
240 id: id, title: event.summary || "Untitled Event", description: event.description || "",
241 location: event.location || "", startTime: start, endTime: end, allDay: allDay, multiDay: multiDay,
242 daySpan: daySpan, rawStart: event.start, rawEnd: event.end, duration: (event.end - event.start) / 3600,
243 endsAtMidnight: endsMidnight
244 }
245 }
246
247 function createEventPart(event, partIdx, start, end, day, partNum, total) {
248 return {
249 id: event.id + "-part-" + partIdx, title: event.title, description: event.description,
250 location: event.location, startTime: start, endTime: end, allDay: false, multiDay: true,
251 daySpan: 1, fullStartTime: event.startTime, fullEndTime: event.endTime, isPart: true,
252 partDay: new Date(day), partIndex: partNum, totalParts: total
253 }
254 }
255
256 function getDayIndexForDate(date) {
257 if (!date || isNaN(date.getTime())) return -1
258 var diff = Math.floor((date - weekStart) / 86400000)
259 return diff >= 0 && diff < 7 ? diff : -1
260 }
261 function getDisplayDayIndexForDate(date) { return getDayIndexForDate(date) }
262
263 function calculateAllDaySpanForWeek(event) {
264 var start = new Date(event.startTime), end = new Date(event.endTime)
265 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
266 var adjEnd = endsMidnight ? new Date(end.getTime() - 1) : end
267 var startIdx = Math.max(0, getDayIndexForDate(start))
268 var endIdx = Math.min(6, Math.floor((Math.min(adjEnd, weekEnd) - weekStart) / 86400000))
269 return Math.max(1, endIdx - startIdx + 1)
270 }
271
272 function findAvailableLane(occupied, start, end) {
273 var lane = 0, found = false
274 while (!found) {
275 var conflict = false
276 for (var d = start; d <= end; d++) {
277 if (occupied[d]?.includes(lane)) { conflict = true; break }
278 }
279 if (!conflict) found = true
280 else lane++
281 }
282 return lane
283 }
284
285 function calculateAllDayEventLayout() {
286
287 var occupied = [[], [], [], [], [], [], []]
288 var eventsWithLayout = [], maxLanes = 0
289 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
290
291 for (var i = 0; i < allDayEventsModel.count; i++) {
292 var event = allDayEventsModel.get(i)
293 var start = new Date(event.startTime), end = new Date(event.endTime)
294 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
295 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
296
297 if (startDay < weekStartDate && endDay >= weekStartDate) {
298 var span = calculateAllDaySpanForWeek(event)
299 if (span > 0) {
300 var lane = findAvailableLane(occupied, 0, span - 1)
301 for (var d = 0; d < span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
302 maxLanes = Math.max(maxLanes, lane + 1)
303 eventsWithLayout.push(createLayoutEvent(event, 0, span, lane, true))
304 }
305 } else if (startDay >= weekStartDate && startDay < weekEndDate) {
306 var startIdx = getDayIndexForDate(start)
307 var span = calculateAllDaySpanForWeek(event)
308 if (span > 0) {
309 var lane = findAvailableLane(occupied, startIdx, startIdx + span - 1)
310 for (var d = startIdx; d < startIdx + span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
311 maxLanes = Math.max(maxLanes, lane + 1)
312 eventsWithLayout.push(createLayoutEvent(event, startIdx, span, lane, false))
313 }
314 }
315 }
316
317 eventsWithLayout.sort((a,b) => a.lane !== b.lane ? a.lane - b.lane : a.startDay - b.startDay)
318 allDayEventsWithLayout = eventsWithLayout
319 allDaySectionHeight = maxLanes === 0 ? 0 : maxLanes === 1 ? 25 : Math.max(30, maxLanes * 25)
320
321 return maxLanes
322 }
323
324 function createLayoutEvent(event, startDay, spanDays, lane, isCont) {
325 return {
326 id: event.id, title: event.title, description: event.description, location: event.location,
327 startTime: event.startTime, endTime: event.endTime, allDay: event.allDay, multiDay: event.multiDay,
328 daySpan: event.daySpan, rawStart: event.rawStart, rawEnd: event.rawEnd, duration: event.duration,
329 endsAtMidnight: event.endsAtMidnight, fullStartTime: event.fullStartTime, fullEndTime: event.fullEndTime,
330 startDay: startDay, spanDays: spanDays, lane: lane, isContinuation: isCont
331 }
332 }
333
334 function updateOverlappingEvents() {
335 var overlapData = {}
336 for (var day = 0; day < 7; day++) processDayEventsWithLanes(day, overlapData)
337 overlappingEventsData = overlapData
338 }
339
340 function processDayEventsWithLanes(day, data) {
341 var events = []
342 for (var i = 0; i < eventsModel.count; i++) {
343 var e = eventsModel.get(i)
344 if (getDisplayDayIndexForDate(e.startTime) === day) {
345 events.push({index: i, start: e.startTime.getTime(), end: e.endTime.getTime()})
346 }
347 }
348 if (events.length === 0) return
349
350 events.sort((a,b) => a.start === b.start ? (b.end - b.start) - (a.end - a.start) : a.start - b.start)
351 var groups = [], current = [], endTime = -1
352
353 events.forEach(e => {
354 if (e.start >= endTime) {
355 if (current.length > 0) groups.push({events: current, endTime: endTime})
356 current = [e]; endTime = e.end
357 } else {
358 current.push(e)
359 if (e.end > endTime) endTime = e.end
360 }
361 })
362 if (current.length > 0) groups.push({events: current, endTime: endTime})
363 groups.forEach(g => assignLanesToGroup(g.events, data))
364 }
365
366 function assignLanesToGroup(group, data) {
367 if (group.length === 0) return
368 var laneEnds = []
369 group.forEach(e => {
370 var placed = false
371 for (var lane = 0; lane < laneEnds.length; lane++) {
372 if (e.start >= laneEnds[lane]) {
373 laneEnds[lane] = e.end
374 e.lane = lane
375 placed = true
376 break
377 }
378 }
379 if (!placed) { e.lane = laneEnds.length; laneEnds.push(e.end) }
380 })
381
382 var total = laneEnds.length
383 group.forEach(e => {
384 data[e.index] = {
385 xOffset: (e.lane / total) * (dayColumnWidth +1),
386 width: (dayColumnWidth+1) / total,
387 lane: e.lane,
388 totalLanes: total
389 }
390 })
391 }
392
393 // Range & formatting of calendar
394 function calculateWeekStart(date, firstDay) {
395 var d = new Date(date)
396 var day = d.getDay()
397 var diff = (day - firstDay + 7) % 7
398 d.setDate(d.getDate() - diff)
399 d.setHours(0, 0, 0, 0)
400 return d
401 }
402
403 function calculateWeekDates(startDate) {
404 var dates = []
405 var start = new Date(startDate)
406
407 for (var i = 0; i < 7; i++) {
408 var d = new Date(start)
409 d.setDate(start.getDate() + i)
410 dates.push(d)
411 }
412
413 return dates
414 }
415
416 function calculateWeekEnd(startDate) {
417 var end = new Date(startDate)
418 end.setDate(end.getDate() + 7)
419 end.setHours(0, 0, 0, 0)
420 return end
421 }
422
423 function isSameDay(date1, date2) {
424 return date1.getDate() === date2.getDate() &&
425 date1.getMonth() === date2.getMonth() &&
426 date1.getFullYear() === date2.getFullYear()
427 }
428
429 function isToday(date) {
430 var today = new Date()
431 return isSameDay(date, today)
432 }
433
434 function isDateInRange(date, startDate, endDate) {
435 return date >= startDate && date < endDate
436 }
437
438 function formatMonthRangeText(dates) {
439 if (!dates || dates.length === 0) return ""
440 var start = dates[0], end = dates[6], locale = I18n.locale
441 return locale.toString(start, "yyyy-MM") === locale.toString(end, "yyyy-MM")
442 ? locale.toString(start, "MMM yyyy")
443 : start.getFullYear() === end.getFullYear()
444 ? locale.toString(start, "MMM") + " – " + locale.toString(end, "MMM") + " " + start.getFullYear()
445 : locale.toString(start, "MMM yyyy") + " – " + locale.toString(end, "MMM yyyy")
446 }
447
448 function isAllDayEvent(event) {
449 var dur = event.end - event.start
450 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
451 var startsMidnight = start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0
452 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
453 return (dur === 86400 && startsMidnight) || (dur >= 86400 && endsMidnight) || dur >= 86400
454 }
455
456 function isMultiDayEvent(event) {
457 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
458 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
459 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
460 var endDay = endsMidnight ? new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1) :
461 new Date(end.getFullYear(), end.getMonth(), end.getDate())
462 return startDay.getTime() !== endDay.getTime()
463 }
464
465 function calculateDaySpan(start, end, isMultiDay) {
466 if (!isMultiDay) return 1
467 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
468 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
469 var diff = Math.floor((endDay - startDay) / 86400000)
470 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
471 return Math.max(1, endsMidnight ? diff : diff + 1)
472 }
473
474 function formatTime(date) {
475 if (!date || isNaN(date.getTime())) return ""
476 return use12hourFormat ? I18n.locale.toString(date, "h:mm AP") : I18n.locale.toString(date, "HH:mm")
477 }
478
479 function formatDateTime(date) {
480 if (!date || isNaN(date.getTime())) return ""
481 return I18n.locale.monthName(date.getMonth(), Locale.ShortFormat) + ' ' +
482 date.getDate() + ', ' + date.getFullYear() + ' ' + formatTime(date)
483 }
484
485 function formatTimeRangeForDisplay(event) {
486 var start = event.fullStartTime || event.startTime
487 var end = event.fullEndTime || event.endTime
488 return formatTime(start) + " - " + formatTime(end)
489 }
490
491 // Interaction functions
492 function getEventTooltip(event) {
493 var start = event.fullStartTime || event.startTime
494 var end = event.fullEndTime || event.endTime
495 var tip = event.title + "\n" + formatDateTime(start) + " - " + formatDateTime(end)
496 if (event.location) tip += "\n⚲ " + event.location
497 if (event.description) tip += "\n🛈 " + event.description
498 return tip
499 }
500
501 function navigateWeek(days) {
502 var d = new Date(currentDate)
503 d.setDate(d.getDate() + days)
504 currentDate = d
505 }
506
507 function handleEventClick(event) {
508 const date = event.startTime || new Date();
509 const month = date.getMonth() + 1;
510 const day = date.getDate();
511 const year = date.getFullYear();
512 const dateWithSlashes = `${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year.toString().substring(2)}`;
513 if (ProgramCheckerService.gnomeCalendarAvailable) {
514 Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]);
515 }
516 }
517
518 function goToToday() { currentDate = new Date() }
519
520 // Event creation via EDS Python script
521 property string createEventStdout: ""
522 property string createEventStderr: ""
523
524 Process {
525 id: createEventProcess
526 onExited: function(exitCode, exitStatus) {
527 if (exitCode === 0) {
528 try {
529 var result = JSON.parse(createEventStdout)
530 if (result.success) {
531 console.log("Event created: " + result.uid)
532 Qt.callLater(loadEvents)
533 }
534 } catch(e) {
535 console.error("Failed to parse create-event output: " + createEventStdout)
536 }
537 } else {
538 console.error("create-event.py failed: " + createEventStderr)
539 }
540 createEventStdout = ""
541 createEventStderr = ""
542 }
543 stdout: SplitParser {
544 onRead: data => createEventStdout += data
545 }
546 stderr: SplitParser {
547 onRead: data => createEventStderr += data
548 }
549 }
550
551 function createEvent(calendarUid, summary, startTimestamp, endTimestamp, location, description) {
552 var scriptPath = pluginApi.pluginDir + "/scripts/create-event.py"
553 var args = ["python3", scriptPath,
554 "--calendar", calendarUid,
555 "--summary", summary,
556 "--start", String(startTimestamp),
557 "--end", String(endTimestamp)]
558 if (location) { args.push("--location"); args.push(location) }
559 if (description) { args.push("--description"); args.push(description) }
560 createEventProcess.command = args
561 createEventProcess.running = true
562 }
563}