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 // Todo support
22 property ListModel todosModel: ListModel {}
23 property var taskLists: []
24 property bool todosLoading: false
25 property bool showCompletedTodos: false
26
27 property real dayColumnWidth: 120 * Style.uiScaleRatio
28 property real allDaySectionHeight: 0 * Style.uiScaleRatio
29 property var allDayEventsWithLayout: []
30
31 property date weekStart: calculateWeekStart(currentDate, firstDayOfWeek)
32 property date weekEnd: calculateWeekEnd(weekStart)
33 property var weekDates: calculateWeekDates(weekStart)
34 property string monthRangeText: formatMonthRangeText(weekDates)
35
36 // Settings
37 property string panelModeSetting: pluginApi?.pluginSettings?.panelMode || "attached"
38 property string weekStartSetting: pluginApi?.pluginSettings?.weekStart || "1"
39 property string timeFormatSetting: pluginApi?.pluginSettings?.timeFormat || "24h"
40 property string lineColorTypeSetting: pluginApi?.pluginSettings?.lineColorType || "mOutline"
41 property real hourLineOpacitySetting: pluginApi?.pluginSettings?.hourLineOpacity ?? 0.5
42 property real dayLineOpacitySetting: pluginApi?.pluginSettings?.dayLineOpacity ?? 0.9
43
44 readonly property int firstDayOfWeek: weekStartSetting === "0" ? 0 :
45 weekStartSetting === "1" ? 1 :
46 weekStartSetting === "6" ? 6 : I18n.locale.firstDayOfWeek
47
48 readonly property bool use12hourFormat: timeFormatSetting === "12h" ? true :
49 timeFormatSetting === "24h" ? false :
50 Settings.data.location.use12hourFormat
51
52 readonly property color lineColor: lineColorTypeSetting === "mOnSurfaceVariant" ? Color.mOnSurfaceVariant : Color.mOutline
53
54 onWeekStartSettingChanged: if (hasLoadedOnce) Qt.callLater(refreshView)
55 onTimeFormatSettingChanged: eventsModelChanged()
56 onCurrentDateChanged: Qt.callLater(refreshView)
57 onLineColorTypeSettingChanged: eventsModelChanged()
58 onHourLineOpacitySettingChanged: eventsModelChanged()
59 onDayLineOpacitySettingChanged: eventsModelChanged()
60
61 // React to CalendarService signals (async event delivery)
62 Connections {
63 target: CalendarService
64 function onAvailableChanged() {
65 if (CalendarService.available) {
66 Qt.callLater(loadEvents)
67 Qt.callLater(loadTaskLists)
68 Qt.callLater(loadTodos)
69 } else {
70 isLoading = false
71 if (pluginApi) syncStatus = pluginApi.tr("panel.no_service")
72 }
73 }
74 function onEventsChanged() {
75 var count = CalendarService.events ? CalendarService.events.length : 0
76 // If auto-refresh dropped event count significantly, re-request wide range
77 if (hasLoadedOnce && !isLoading && lastKnownEventCount > 10 && count < lastKnownEventCount * 0.5) {
78 console.log("[weekly-calendar] Auto-refresh narrowed events (" + count + " vs " + lastKnownEventCount + "), re-requesting wide range")
79 Qt.callLater(loadEvents)
80 return
81 }
82 lastKnownEventCount = Math.max(lastKnownEventCount, count)
83 Qt.callLater(updateEventsFromService)
84 }
85 function onLoadingChanged() {
86 if (!CalendarService.loading && isLoading) {
87 Qt.callLater(updateEventsFromService)
88 }
89 }
90 }
91
92 // Safety timeout: if CalendarService never signals back, stop spinning
93 Timer {
94 id: loadingTimeout
95 interval: 15000
96 repeat: false
97 onTriggered: {
98 if (isLoading) {
99 console.warn("[weekly-calendar] loading timeout, forcing update")
100 updateEventsFromService()
101 }
102 }
103 }
104
105 // IPC
106 IpcHandler {
107 target: "plugin:weekly-calendar"
108 function togglePanel() { pluginApi?.withCurrentScreen(s => pluginApi.togglePanel(s)) }
109 }
110
111 Component.onCompleted: {
112 initializePluginSettings()
113 // Process any cached events immediately
114 if (CalendarService.events && CalendarService.events.length > 0) {
115 Qt.callLater(updateEventsFromService)
116 }
117 if (CalendarService.available) {
118 Qt.callLater(loadEvents)
119 Qt.callLater(loadTaskLists)
120 Qt.callLater(loadTodos)
121 }
122 }
123
124 onPluginApiChanged: {
125 initializePluginSettings()
126 if (CalendarService.events && CalendarService.events.length > 0) {
127 Qt.callLater(updateEventsFromService)
128 }
129 if (CalendarService.available) {
130 Qt.callLater(loadEvents)
131 Qt.callLater(loadTaskLists)
132 Qt.callLater(loadTodos)
133 }
134 }
135
136 function initializePluginSettings() {
137 if (!pluginApi) return
138 if (!pluginApi.pluginSettings.weekStart) {
139 pluginApi.pluginSettings = {
140 weekStart: "1",
141 timeFormat: "24h",
142 lineColorType: "mOutline",
143 hourLineOpacity: 0.5,
144 dayLineOpacity: 1.0
145 }
146 pluginApi.saveSettings()
147 }
148 }
149
150 function initializePlugin() {
151 console.log("[weekly-calendar] initializePlugin called, CalendarService.available=" + CalendarService.available)
152 if (!hasLoadedOnce && CalendarService.available) {
153 loadEvents()
154 } else {
155 refreshView()
156 }
157 }
158
159 // Re-filter existing events for the current week view (no new fetch)
160 function refreshView() {
161 if (!pluginApi) return
162 if (CalendarService.events && CalendarService.events.length > 0) {
163 updateEventsFromService()
164 } else if (hasLoadedOnce) {
165 clearEventModels()
166 syncStatus = pluginApi.tr("panel.no_events")
167 }
168 }
169
170 // Fetch events from EDS - requests a wide date range to cover past/future navigation
171 function loadEvents() {
172 if (!pluginApi) return
173 if (!CalendarService.available) {
174 syncStatus = pluginApi.tr("panel.no_service")
175 console.log("[weekly-calendar] loadEvents: service not available")
176 return
177 }
178
179 isLoading = true
180 syncStatus = pluginApi.tr("panel.loading")
181
182 // Request a wide range: 180 days behind, 60 days ahead
183 // This covers ~6 months of past events and ~2 months of future events
184 var daysAhead = 60
185 var daysBehind = 180
186
187 CalendarService.loadEvents(daysAhead, daysBehind)
188
189 hasLoadedOnce = true
190 loadingTimeout.restart()
191
192 // If CalendarService already has events (cached), display them now
193 if (CalendarService.events && CalendarService.events.length > 0) {
194 Qt.callLater(updateEventsFromService)
195 }
196 }
197
198 function updateEventsFromService() {
199 if (!pluginApi) return
200 loadingTimeout.stop()
201 clearEventModels()
202
203 if (!CalendarService.available) {
204 syncStatus = pluginApi.tr("panel.no_service")
205 } else if (!CalendarService.events?.length) {
206 var todoStats = processTodosForWeek()
207 if (todoStats.count > 0) {
208 syncStatus = pluginApi.tr("panel.no_events") + ", " +
209 todoStats.count + " " + (todoStats.count === 1 ? pluginApi.tr("panel.task") : pluginApi.tr("panel.tasks"))
210 } else {
211 syncStatus = pluginApi.tr("panel.no_events")
212 }
213 } else {
214 var stats = processCalendarEvents(CalendarService.events)
215 var todoStats = processTodosForWeek()
216 var parts = []
217 parts.push(stats.timedCount === 1
218 ? `${stats.timedCount} ${pluginApi.tr("panel.event")}`
219 : `${stats.timedCount} ${pluginApi.tr("panel.events")}`)
220 parts.push(`${stats.allDayCount} ${pluginApi.tr("panel.allday")}`)
221 if (todoStats.count > 0)
222 parts.push(todoStats.count + " " + (todoStats.count === 1 ? pluginApi.tr("panel.task") : pluginApi.tr("panel.tasks")))
223 syncStatus = parts.join(", ")
224 }
225
226 isLoading = false
227 }
228
229 // Events generation & layout
230 function processCalendarEvents(events) {
231 var uniqueEvents = {}, uniqueAllDayEvents = {}
232 var timedCount = 0, allDayCount = 0
233 var newEvents = [], newAllDayEvents = []
234 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
235
236 for (var i = 0; i < events.length; i++) {
237 var event = events[i], eventObj = createEventObject(event, i)
238 var eventStart = new Date(eventObj.startTime), eventEnd = new Date(eventObj.endTime)
239 var overlapsWeek = eventStart < weekEndDate && eventEnd > weekStartDate
240
241 if (overlapsWeek) {
242 var key = event.uid + "-" + event.start + "-" + event.end + i
243 if (eventObj.allDay) {
244 if (!uniqueAllDayEvents[key]) {
245 uniqueAllDayEvents[key] = true
246 allDayCount++
247 newAllDayEvents.push(eventObj)
248 }
249 } else if (!uniqueEvents[key]) {
250 uniqueEvents[key] = true
251 timedCount++
252 processTimedEventIntoArray(eventObj, newEvents)
253 }
254 }
255 }
256
257 eventsModel.clear()
258 allDayEventsModel.clear()
259 newEvents.forEach(e => eventsModel.append(e))
260 newAllDayEvents.forEach(e => allDayEventsModel.append(e))
261
262 calculateAllDayEventLayout()
263 updateOverlappingEvents()
264 eventsModel.layoutChanged()
265 allDayEventsModel.layoutChanged()
266
267 return {timedCount: timedCount, allDayCount: allDayCount}
268 }
269
270 function processTodosForWeek() {
271 var weekStartDate = new Date(weekStart)
272 var weekEndDate = new Date(weekEnd)
273 var count = 0
274
275 for (var i = 0; i < todosModel.count; i++) {
276 var todo = todosModel.get(i)
277 if (!todo.due) continue
278
279 var dueDate = new Date(todo.due)
280 if (isNaN(dueDate.getTime())) continue
281 if (dueDate < weekStartDate || dueDate >= weekEndDate) continue
282 if (!showCompletedTodos && todo.status === "COMPLETED") continue
283
284 var isDueAllDay = (dueDate.getHours() === 0 && dueDate.getMinutes() === 0)
285 var endDate = isDueAllDay ? new Date(dueDate.getTime() + 86400000)
286 : new Date(dueDate.getTime() + 1800000)
287
288 var todoEvent = {
289 id: "todo-" + todo.uid,
290 title: todo.summary,
291 description: todo.description || "",
292 location: "",
293 startTime: dueDate,
294 endTime: endDate,
295 allDay: isDueAllDay,
296 multiDay: false,
297 daySpan: 1,
298 isTodo: true,
299 todoUid: todo.uid,
300 calendarUid: todo.calendarUid,
301 todoStatus: todo.status,
302 todoPriority: todo.priority,
303 }
304
305 if (isDueAllDay) {
306 allDayEventsModel.append(todoEvent)
307 } else {
308 eventsModel.append(todoEvent)
309 }
310 count++
311 }
312
313 // Recalculate layouts after adding todos
314 if (count > 0) {
315 calculateAllDayEventLayout()
316 updateOverlappingEvents()
317 eventsModel.layoutChanged()
318 allDayEventsModel.layoutChanged()
319 }
320
321 return { count: count }
322 }
323
324 function clearEventModels() { eventsModel.clear(); allDayEventsModel.clear() }
325
326 function processTimedEventIntoArray(eventObj, target) {
327 var start = new Date(eventObj.startTime), end = new Date(eventObj.endTime)
328 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
329 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
330
331 if (startDay.getTime() === endDay.getTime()) {
332 if (start < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 0, start, end, startDay, 0, 1))
333 } else {
334 var firstEnd = new Date(startDay); firstEnd.setHours(24, 0, 0, 0)
335 var secondStart = new Date(endDay); secondStart.setHours(0, 0, 0, 0)
336 if (start < weekEnd && firstEnd > weekStart) target.push(createEventPart(eventObj, 0, start, firstEnd, startDay, 0, 2))
337 if (secondStart < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 1, secondStart, end, endDay, 1, 2))
338 }
339 }
340
341 function createEventObject(event, idx) {
342 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
343 var allDay = isAllDayEvent(event), multiDay = isMultiDayEvent(event)
344 var daySpan = calculateDaySpan(start, end, multiDay || allDay)
345 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
346 var id = event.uid + "-" + event.start + "-" + event.end + idx
347
348 return {
349 id: id, title: event.summary || "Untitled Event", description: event.description || "",
350 location: event.location || "", startTime: start, endTime: end, allDay: allDay, multiDay: multiDay,
351 daySpan: daySpan, rawStart: event.start, rawEnd: event.end, duration: (event.end - event.start) / 3600,
352 endsAtMidnight: endsMidnight, isTodo: false, todoUid: "", calendarUid: "", todoStatus: "", todoPriority: 0
353 }
354 }
355
356 function createEventPart(event, partIdx, start, end, day, partNum, total) {
357 return {
358 id: event.id + "-part-" + partIdx, title: event.title, description: event.description,
359 location: event.location, startTime: start, endTime: end, allDay: false, multiDay: true,
360 daySpan: 1, fullStartTime: event.startTime, fullEndTime: event.endTime, isPart: true,
361 partDay: new Date(day), partIndex: partNum, totalParts: total,
362 isTodo: false, todoUid: "", calendarUid: "", todoStatus: "", todoPriority: 0
363 }
364 }
365
366 function getDayIndexForDate(date) {
367 if (!date || isNaN(date.getTime())) return -1
368 var diff = Math.floor((date - weekStart) / 86400000)
369 return diff >= 0 && diff < 7 ? diff : -1
370 }
371 function getDisplayDayIndexForDate(date) { return getDayIndexForDate(date) }
372
373 function calculateAllDaySpanForWeek(event) {
374 var start = new Date(event.startTime), end = new Date(event.endTime)
375 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
376 var adjEnd = endsMidnight ? new Date(end.getTime() - 1) : end
377 var startIdx = Math.max(0, getDayIndexForDate(start))
378 var endIdx = Math.min(6, Math.floor((Math.min(adjEnd, weekEnd) - weekStart) / 86400000))
379 return Math.max(1, endIdx - startIdx + 1)
380 }
381
382 function findAvailableLane(occupied, start, end) {
383 var lane = 0, found = false
384 while (!found) {
385 var conflict = false
386 for (var d = start; d <= end; d++) {
387 if (occupied[d]?.includes(lane)) { conflict = true; break }
388 }
389 if (!conflict) found = true
390 else lane++
391 }
392 return lane
393 }
394
395 function calculateAllDayEventLayout() {
396
397 var occupied = [[], [], [], [], [], [], []]
398 var eventsWithLayout = [], maxLanes = 0
399 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
400
401 for (var i = 0; i < allDayEventsModel.count; i++) {
402 var event = allDayEventsModel.get(i)
403 var start = new Date(event.startTime), end = new Date(event.endTime)
404 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
405 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
406
407 if (startDay < weekStartDate && endDay >= weekStartDate) {
408 var span = calculateAllDaySpanForWeek(event)
409 if (span > 0) {
410 var lane = findAvailableLane(occupied, 0, span - 1)
411 for (var d = 0; d < span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
412 maxLanes = Math.max(maxLanes, lane + 1)
413 eventsWithLayout.push(createLayoutEvent(event, 0, span, lane, true))
414 }
415 } else if (startDay >= weekStartDate && startDay < weekEndDate) {
416 var startIdx = getDayIndexForDate(start)
417 var span = calculateAllDaySpanForWeek(event)
418 if (span > 0) {
419 var lane = findAvailableLane(occupied, startIdx, startIdx + span - 1)
420 for (var d = startIdx; d < startIdx + span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
421 maxLanes = Math.max(maxLanes, lane + 1)
422 eventsWithLayout.push(createLayoutEvent(event, startIdx, span, lane, false))
423 }
424 }
425 }
426
427 eventsWithLayout.sort((a,b) => a.lane !== b.lane ? a.lane - b.lane : a.startDay - b.startDay)
428 allDayEventsWithLayout = eventsWithLayout
429 allDaySectionHeight = maxLanes === 0 ? 0 : maxLanes === 1 ? 25 : Math.max(30, maxLanes * 25)
430
431 return maxLanes
432 }
433
434 function createLayoutEvent(event, startDay, spanDays, lane, isCont) {
435 return {
436 id: event.id, title: event.title, description: event.description, location: event.location,
437 startTime: event.startTime, endTime: event.endTime, allDay: event.allDay, multiDay: event.multiDay,
438 daySpan: event.daySpan, rawStart: event.rawStart, rawEnd: event.rawEnd, duration: event.duration,
439 endsAtMidnight: event.endsAtMidnight, fullStartTime: event.fullStartTime, fullEndTime: event.fullEndTime,
440 startDay: startDay, spanDays: spanDays, lane: lane, isContinuation: isCont,
441 isTodo: event.isTodo || false, todoUid: event.todoUid || "", calendarUid: event.calendarUid || "",
442 todoStatus: event.todoStatus || "", todoPriority: event.todoPriority || 0
443 }
444 }
445
446 function updateOverlappingEvents() {
447 var overlapData = {}
448 for (var day = 0; day < 7; day++) processDayEventsWithLanes(day, overlapData)
449 overlappingEventsData = overlapData
450 }
451
452 function processDayEventsWithLanes(day, data) {
453 var events = []
454 for (var i = 0; i < eventsModel.count; i++) {
455 var e = eventsModel.get(i)
456 if (getDisplayDayIndexForDate(e.startTime) === day) {
457 events.push({index: i, start: e.startTime.getTime(), end: e.endTime.getTime()})
458 }
459 }
460 if (events.length === 0) return
461
462 events.sort((a,b) => a.start === b.start ? (b.end - b.start) - (a.end - a.start) : a.start - b.start)
463 var groups = [], current = [], endTime = -1
464
465 events.forEach(e => {
466 if (e.start >= endTime) {
467 if (current.length > 0) groups.push({events: current, endTime: endTime})
468 current = [e]; endTime = e.end
469 } else {
470 current.push(e)
471 if (e.end > endTime) endTime = e.end
472 }
473 })
474 if (current.length > 0) groups.push({events: current, endTime: endTime})
475 groups.forEach(g => assignLanesToGroup(g.events, data))
476 }
477
478 function assignLanesToGroup(group, data) {
479 if (group.length === 0) return
480 var laneEnds = []
481 group.forEach(e => {
482 var placed = false
483 for (var lane = 0; lane < laneEnds.length; lane++) {
484 if (e.start >= laneEnds[lane]) {
485 laneEnds[lane] = e.end
486 e.lane = lane
487 placed = true
488 break
489 }
490 }
491 if (!placed) { e.lane = laneEnds.length; laneEnds.push(e.end) }
492 })
493
494 var total = laneEnds.length
495 group.forEach(e => {
496 data[e.index] = {
497 xOffset: (e.lane / total) * (dayColumnWidth +1),
498 width: (dayColumnWidth+1) / total,
499 lane: e.lane,
500 totalLanes: total
501 }
502 })
503 }
504
505 // Range & formatting of calendar
506 function calculateWeekStart(date, firstDay) {
507 var d = new Date(date)
508 var day = d.getDay()
509 var diff = (day - firstDay + 7) % 7
510 d.setDate(d.getDate() - diff)
511 d.setHours(0, 0, 0, 0)
512 return d
513 }
514
515 function calculateWeekDates(startDate) {
516 var dates = []
517 var start = new Date(startDate)
518
519 for (var i = 0; i < 7; i++) {
520 var d = new Date(start)
521 d.setDate(start.getDate() + i)
522 dates.push(d)
523 }
524
525 return dates
526 }
527
528 function calculateWeekEnd(startDate) {
529 var end = new Date(startDate)
530 end.setDate(end.getDate() + 7)
531 end.setHours(0, 0, 0, 0)
532 return end
533 }
534
535 function isSameDay(date1, date2) {
536 return date1.getDate() === date2.getDate() &&
537 date1.getMonth() === date2.getMonth() &&
538 date1.getFullYear() === date2.getFullYear()
539 }
540
541 function isToday(date) {
542 var today = new Date()
543 return isSameDay(date, today)
544 }
545
546 function isDateInRange(date, startDate, endDate) {
547 return date >= startDate && date < endDate
548 }
549
550 function formatMonthRangeText(dates) {
551 if (!dates || dates.length === 0) return ""
552 var start = dates[0], end = dates[6], locale = I18n.locale
553 return locale.toString(start, "yyyy-MM") === locale.toString(end, "yyyy-MM")
554 ? locale.toString(start, "MMM yyyy")
555 : start.getFullYear() === end.getFullYear()
556 ? locale.toString(start, "MMM") + " – " + locale.toString(end, "MMM") + " " + start.getFullYear()
557 : locale.toString(start, "MMM yyyy") + " – " + locale.toString(end, "MMM yyyy")
558 }
559
560 function isAllDayEvent(event) {
561 var dur = event.end - event.start
562 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
563 var startsMidnight = start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0
564 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
565 return (dur === 86400 && startsMidnight) || (dur >= 86400 && endsMidnight) || dur >= 86400
566 }
567
568 function isMultiDayEvent(event) {
569 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
570 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
571 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
572 var endDay = endsMidnight ? new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1) :
573 new Date(end.getFullYear(), end.getMonth(), end.getDate())
574 return startDay.getTime() !== endDay.getTime()
575 }
576
577 function calculateDaySpan(start, end, isMultiDay) {
578 if (!isMultiDay) return 1
579 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
580 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
581 var diff = Math.floor((endDay - startDay) / 86400000)
582 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
583 return Math.max(1, endsMidnight ? diff : diff + 1)
584 }
585
586 function formatTime(date) {
587 if (!date || isNaN(date.getTime())) return ""
588 return use12hourFormat ? I18n.locale.toString(date, "h:mm AP") : I18n.locale.toString(date, "HH:mm")
589 }
590
591 function formatDateTime(date) {
592 if (!date || isNaN(date.getTime())) return ""
593 return I18n.locale.monthName(date.getMonth(), Locale.ShortFormat) + ' ' +
594 date.getDate() + ', ' + date.getFullYear() + ' ' + formatTime(date)
595 }
596
597 function formatTimeRangeForDisplay(event) {
598 var start = event.fullStartTime || event.startTime
599 var end = event.fullEndTime || event.endTime
600 return formatTime(start) + " - " + formatTime(end)
601 }
602
603 // Interaction functions
604 function getEventTooltip(event) {
605 var start = event.fullStartTime || event.startTime
606 var end = event.fullEndTime || event.endTime
607 var tip = event.title + "\n" + formatDateTime(start) + " - " + formatDateTime(end)
608 if (event.location) tip += "\n⚲ " + event.location
609 if (event.description) tip += "\n🛈 " + event.description
610 return tip
611 }
612
613 function navigateWeek(days) {
614 var d = new Date(currentDate)
615 d.setDate(d.getDate() + days)
616 currentDate = d
617 }
618
619 function handleEventClick(event) {
620 const date = event.startTime || new Date();
621 const month = date.getMonth() + 1;
622 const day = date.getDate();
623 const year = date.getFullYear();
624 const dateWithSlashes = `${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year.toString().substring(2)}`;
625 if (ProgramCheckerService.gnomeCalendarAvailable) {
626 Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]);
627 }
628 }
629
630 function goToToday() { currentDate = new Date() }
631
632 // Event creation via EDS Python script
633 property string createEventStdout: ""
634 property string createEventStderr: ""
635
636 Process {
637 id: createEventProcess
638 onExited: function(exitCode, exitStatus) {
639 if (exitCode === 0) {
640 try {
641 var result = JSON.parse(createEventStdout)
642 if (result.success) {
643 console.log("Event created: " + result.uid)
644 Qt.callLater(loadEvents)
645 }
646 } catch(e) {
647 console.error("Failed to parse create-event output: " + createEventStdout)
648 }
649 } else {
650 console.error("create-event.py failed: " + createEventStderr)
651 }
652 createEventStdout = ""
653 createEventStderr = ""
654 }
655 stdout: SplitParser {
656 onRead: data => createEventStdout += data
657 }
658 stderr: SplitParser {
659 onRead: data => createEventStderr += data
660 }
661 }
662
663 function createEvent(calendarUid, summary, startTimestamp, endTimestamp, location, description) {
664 var scriptPath = pluginApi.pluginDir + "/scripts/create-event.py"
665 var args = ["python3", scriptPath,
666 "--calendar", calendarUid,
667 "--summary", summary,
668 "--start", String(startTimestamp),
669 "--end", String(endTimestamp)]
670 if (location) { args.push("--location"); args.push(location) }
671 if (description) { args.push("--description"); args.push(description) }
672 createEventProcess.command = args
673 createEventProcess.running = true
674 }
675
676 // === Todo support ===
677
678 property string listTaskListsStdout: ""
679 property string listTaskListsStderr: ""
680
681 Process {
682 id: listTaskListsProcess
683 onExited: function(exitCode, exitStatus) {
684 if (exitCode === 0) {
685 try {
686 var result = JSON.parse(listTaskListsStdout)
687 if (Array.isArray(result)) {
688 taskLists = result.filter(function(tl) { return tl.enabled })
689 }
690 } catch(e) {
691 console.error("[weekly-calendar] Failed to parse task lists: " + listTaskListsStdout)
692 }
693 } else {
694 console.error("[weekly-calendar] list-task-lists.py failed: " + listTaskListsStderr)
695 }
696 listTaskListsStdout = ""
697 listTaskListsStderr = ""
698 }
699 stdout: SplitParser { onRead: data => listTaskListsStdout += data }
700 stderr: SplitParser { onRead: data => listTaskListsStderr += data }
701 }
702
703 property string listTodosStdout: ""
704 property string listTodosStderr: ""
705
706 Process {
707 id: listTodosProcess
708 onExited: function(exitCode, exitStatus) {
709 todosLoading = false
710 if (exitCode === 0) {
711 try {
712 var result = JSON.parse(listTodosStdout)
713 if (Array.isArray(result)) {
714 todosModel.clear()
715 for (var i = 0; i < result.length; i++) {
716 todosModel.append(result[i])
717 }
718 // Re-process events to include updated todos on the calendar
719 Qt.callLater(updateEventsFromService)
720 }
721 } catch(e) {
722 console.error("[weekly-calendar] Failed to parse todos: " + listTodosStdout)
723 }
724 } else {
725 console.error("[weekly-calendar] list-todos.py failed: " + listTodosStderr)
726 }
727 listTodosStdout = ""
728 listTodosStderr = ""
729 }
730 stdout: SplitParser { onRead: data => listTodosStdout += data }
731 stderr: SplitParser { onRead: data => listTodosStderr += data }
732 }
733
734 property string createTodoStdout: ""
735 property string createTodoStderr: ""
736
737 Process {
738 id: createTodoProcess
739 onExited: function(exitCode, exitStatus) {
740 if (exitCode === 0) {
741 try {
742 var result = JSON.parse(createTodoStdout)
743 if (result.success) {
744 console.log("[weekly-calendar] Todo created: " + result.uid)
745 Qt.callLater(loadTodos)
746 }
747 } catch(e) {
748 console.error("[weekly-calendar] Failed to parse create-todo output: " + createTodoStdout)
749 }
750 } else {
751 console.error("[weekly-calendar] create-todo.py failed: " + createTodoStderr)
752 }
753 createTodoStdout = ""
754 createTodoStderr = ""
755 }
756 stdout: SplitParser { onRead: data => createTodoStdout += data }
757 stderr: SplitParser { onRead: data => createTodoStderr += data }
758 }
759
760 property string updateTodoStdout: ""
761 property string updateTodoStderr: ""
762
763 Process {
764 id: updateTodoProcess
765 onExited: function(exitCode, exitStatus) {
766 if (exitCode === 0) {
767 try {
768 var result = JSON.parse(updateTodoStdout)
769 if (result.success) {
770 console.log("[weekly-calendar] Todo updated")
771 Qt.callLater(loadTodos)
772 }
773 } catch(e) {
774 console.error("[weekly-calendar] Failed to parse update-todo output: " + updateTodoStdout)
775 }
776 } else {
777 console.error("[weekly-calendar] update-todo.py failed: " + updateTodoStderr)
778 }
779 updateTodoStdout = ""
780 updateTodoStderr = ""
781 }
782 stdout: SplitParser { onRead: data => updateTodoStdout += data }
783 stderr: SplitParser { onRead: data => updateTodoStderr += data }
784 }
785
786 function loadTaskLists() {
787 if (!pluginApi) return
788 var scriptPath = pluginApi.pluginDir + "/scripts/list-task-lists.py"
789 listTaskListsProcess.command = ["python3", scriptPath]
790 listTaskListsProcess.running = true
791 }
792
793 function loadTodos() {
794 if (!pluginApi) return
795 todosLoading = true
796 todoSyncStatus = pluginApi.tr("panel.loading")
797 var scriptPath = pluginApi.pluginDir + "/scripts/list-todos.py"
798 var args = ["python3", scriptPath]
799 if (showCompletedTodos) args.push("--include-completed")
800 listTodosProcess.command = args
801 listTodosProcess.running = true
802 }
803
804 function createTodo(taskListUid, summary, due, priority, description) {
805 if (!pluginApi) return
806 var scriptPath = pluginApi.pluginDir + "/scripts/create-todo.py"
807 var args = ["python3", scriptPath,
808 "--task-list", taskListUid,
809 "--summary", summary]
810 if (due > 0) { args.push("--due"); args.push(String(due)) }
811 if (priority > 0) { args.push("--priority"); args.push(String(priority)) }
812 if (description) { args.push("--description"); args.push(description) }
813 createTodoProcess.command = args
814 createTodoProcess.running = true
815 }
816
817 function completeTodo(taskListUid, todoUid) {
818 if (!pluginApi) return
819 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
820 updateTodoProcess.command = ["python3", scriptPath,
821 "--task-list", taskListUid, "--uid", todoUid, "--action", "complete"]
822 updateTodoProcess.running = true
823 }
824
825 function uncompleteTodo(taskListUid, todoUid) {
826 if (!pluginApi) return
827 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
828 updateTodoProcess.command = ["python3", scriptPath,
829 "--task-list", taskListUid, "--uid", todoUid, "--action", "uncomplete"]
830 updateTodoProcess.running = true
831 }
832
833 function deleteTodo(taskListUid, todoUid) {
834 if (!pluginApi) return
835 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
836 updateTodoProcess.command = ["python3", scriptPath,
837 "--task-list", taskListUid, "--uid", todoUid, "--action", "delete"]
838 updateTodoProcess.running = true
839 }
840}