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