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