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