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