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 // Render timed todos as short deadline markers (~8–10 px tall)
298 var endDate = isDueAllDay ? new Date(dueDate.getTime() + 86400000)
299 : new Date(dueDate.getTime() + 5 * 60000)
300
301 var todoEvent = {
302 id: "todo-" + todo.uid,
303 title: todo.summary,
304 description: todo.description || "",
305 location: "",
306 startTime: dueDate,
307 endTime: endDate,
308 allDay: isDueAllDay,
309 multiDay: false,
310 daySpan: 1,
311 isTodo: true,
312 todoUid: todo.uid,
313 calendarUid: todo.calendarUid,
314 todoStatus: todo.status,
315 todoPriority: todo.priority,
316 // Helper flags for compact rendering in Panel.qml
317 isDeadlineMarker: !isDueAllDay
318 }
319
320 if (isDueAllDay) {
321 allDayEventsModel.append(todoEvent)
322 } else {
323 eventsModel.append(todoEvent)
324 }
325 count++
326 }
327
328 // Recalculate layouts after adding todos
329 if (count > 0) {
330 calculateAllDayEventLayout()
331 updateOverlappingEvents()
332 eventsModel.layoutChanged()
333 allDayEventsModel.layoutChanged()
334 }
335
336 return { count: count }
337 }
338
339 function clearEventModels() { eventsModel.clear(); allDayEventsModel.clear() }
340
341 function processTimedEventIntoArray(eventObj, target) {
342 var start = new Date(eventObj.startTime), end = new Date(eventObj.endTime)
343 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
344 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
345
346 if (startDay.getTime() === endDay.getTime()) {
347 if (start < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 0, start, end, startDay, 0, 1))
348 } else {
349 var firstEnd = new Date(startDay); firstEnd.setHours(24, 0, 0, 0)
350 var secondStart = new Date(endDay); secondStart.setHours(0, 0, 0, 0)
351 if (start < weekEnd && firstEnd > weekStart) target.push(createEventPart(eventObj, 0, start, firstEnd, startDay, 0, 2))
352 if (secondStart < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 1, secondStart, end, endDay, 1, 2))
353 }
354 }
355
356 function createEventObject(event, idx) {
357 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
358 var allDay = isAllDayEvent(event), multiDay = isMultiDayEvent(event)
359 var daySpan = calculateDaySpan(start, end, multiDay || allDay)
360 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
361 var id = event.uid + "-" + event.start + "-" + event.end + idx
362
363 return {
364 id: id, title: event.summary || "Untitled Event", description: event.description || "",
365 location: event.location || "", startTime: start, endTime: end, allDay: allDay, multiDay: multiDay,
366 daySpan: daySpan, rawStart: event.start, rawEnd: event.end, duration: (event.end - event.start) / 3600,
367 endsAtMidnight: endsMidnight, isTodo: false, todoUid: "", calendarUid: "", todoStatus: "", todoPriority: 0
368 }
369 }
370
371 function createEventPart(event, partIdx, start, end, day, partNum, total) {
372 return {
373 id: event.id + "-part-" + partIdx, title: event.title, description: event.description,
374 location: event.location, startTime: start, endTime: end, allDay: false, multiDay: true,
375 daySpan: 1, fullStartTime: event.startTime, fullEndTime: event.endTime, isPart: true,
376 partDay: new Date(day), partIndex: partNum, totalParts: total,
377 isTodo: false, todoUid: "", calendarUid: "", todoStatus: "", todoPriority: 0
378 }
379 }
380
381 function getDayIndexForDate(date) {
382 if (!date || isNaN(date.getTime())) return -1
383 var diff = Math.floor((date - weekStart) / 86400000)
384 return diff >= 0 && diff < 7 ? diff : -1
385 }
386 function getDisplayDayIndexForDate(date) { return getDayIndexForDate(date) }
387
388 function calculateAllDaySpanForWeek(event) {
389 var start = new Date(event.startTime), end = new Date(event.endTime)
390 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
391 var adjEnd = endsMidnight ? new Date(end.getTime() - 1) : end
392 var startIdx = Math.max(0, getDayIndexForDate(start))
393 var endIdx = Math.min(6, Math.floor((Math.min(adjEnd, weekEnd) - weekStart) / 86400000))
394 return Math.max(1, endIdx - startIdx + 1)
395 }
396
397 function findAvailableLane(occupied, start, end) {
398 var lane = 0, found = false
399 while (!found) {
400 var conflict = false
401 for (var d = start; d <= end; d++) {
402 if (occupied[d]?.includes(lane)) { conflict = true; break }
403 }
404 if (!conflict) found = true
405 else lane++
406 }
407 return lane
408 }
409
410 function calculateAllDayEventLayout() {
411
412 var occupied = [[], [], [], [], [], [], []]
413 var eventsWithLayout = [], maxLanes = 0
414 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
415
416 for (var i = 0; i < allDayEventsModel.count; i++) {
417 var event = allDayEventsModel.get(i)
418 var start = new Date(event.startTime), end = new Date(event.endTime)
419 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
420 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
421
422 if (startDay < weekStartDate && endDay >= weekStartDate) {
423 var span = calculateAllDaySpanForWeek(event)
424 if (span > 0) {
425 var lane = findAvailableLane(occupied, 0, span - 1)
426 for (var d = 0; d < span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
427 maxLanes = Math.max(maxLanes, lane + 1)
428 eventsWithLayout.push(createLayoutEvent(event, 0, span, lane, true))
429 }
430 } else if (startDay >= weekStartDate && startDay < weekEndDate) {
431 var startIdx = getDayIndexForDate(start)
432 var span = calculateAllDaySpanForWeek(event)
433 if (span > 0) {
434 var lane = findAvailableLane(occupied, startIdx, startIdx + span - 1)
435 for (var d = startIdx; d < startIdx + span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
436 maxLanes = Math.max(maxLanes, lane + 1)
437 eventsWithLayout.push(createLayoutEvent(event, startIdx, span, lane, false))
438 }
439 }
440 }
441
442 eventsWithLayout.sort((a,b) => a.lane !== b.lane ? a.lane - b.lane : a.startDay - b.startDay)
443 allDayEventsWithLayout = eventsWithLayout
444 allDaySectionHeight = maxLanes === 0 ? 0 : maxLanes === 1 ? 25 : Math.max(30, maxLanes * 25)
445
446 return maxLanes
447 }
448
449 function createLayoutEvent(event, startDay, spanDays, lane, isCont) {
450 return {
451 id: event.id, title: event.title, description: event.description, location: event.location,
452 startTime: event.startTime, endTime: event.endTime, allDay: event.allDay, multiDay: event.multiDay,
453 daySpan: event.daySpan, rawStart: event.rawStart, rawEnd: event.rawEnd, duration: event.duration,
454 endsAtMidnight: event.endsAtMidnight, fullStartTime: event.fullStartTime, fullEndTime: event.fullEndTime,
455 startDay: startDay, spanDays: spanDays, lane: lane, isContinuation: isCont,
456 isTodo: event.isTodo || false, todoUid: event.todoUid || "", calendarUid: event.calendarUid || "",
457 todoStatus: event.todoStatus || "", todoPriority: event.todoPriority || 0
458 }
459 }
460
461 function updateOverlappingEvents() {
462 var overlapData = {}
463 for (var day = 0; day < 7; day++) processDayEventsWithLanes(day, overlapData)
464 overlappingEventsData = overlapData
465 }
466
467 function processDayEventsWithLanes(day, data) {
468 var events = []
469 for (var i = 0; i < eventsModel.count; i++) {
470 var e = eventsModel.get(i)
471 if (getDisplayDayIndexForDate(e.startTime) === day) {
472 events.push({index: i, start: e.startTime.getTime(), end: e.endTime.getTime()})
473 }
474 }
475 if (events.length === 0) return
476
477 events.sort((a,b) => a.start === b.start ? (b.end - b.start) - (a.end - a.start) : a.start - b.start)
478 var groups = [], current = [], endTime = -1
479
480 events.forEach(e => {
481 if (e.start >= endTime) {
482 if (current.length > 0) groups.push({events: current, endTime: endTime})
483 current = [e]; endTime = e.end
484 } else {
485 current.push(e)
486 if (e.end > endTime) endTime = e.end
487 }
488 })
489 if (current.length > 0) groups.push({events: current, endTime: endTime})
490 groups.forEach(g => assignLanesToGroup(g.events, data))
491 }
492
493 function assignLanesToGroup(group, data) {
494 if (group.length === 0) return
495 var laneEnds = []
496 group.forEach(e => {
497 var placed = false
498 for (var lane = 0; lane < laneEnds.length; lane++) {
499 if (e.start >= laneEnds[lane]) {
500 laneEnds[lane] = e.end
501 e.lane = lane
502 placed = true
503 break
504 }
505 }
506 if (!placed) { e.lane = laneEnds.length; laneEnds.push(e.end) }
507 })
508
509 var total = laneEnds.length
510 group.forEach(e => {
511 data[e.index] = {
512 xOffset: (e.lane / total) * (dayColumnWidth +1),
513 width: (dayColumnWidth+1) / total,
514 lane: e.lane,
515 totalLanes: total
516 }
517 })
518 }
519
520 // Range & formatting of calendar
521 function calculateWeekStart(date, firstDay) {
522 var d = new Date(date)
523 var day = d.getDay()
524 var diff = (day - firstDay + 7) % 7
525 d.setDate(d.getDate() - diff)
526 d.setHours(0, 0, 0, 0)
527 return d
528 }
529
530 function calculateWeekDates(startDate) {
531 var dates = []
532 var start = new Date(startDate)
533
534 for (var i = 0; i < 7; i++) {
535 var d = new Date(start)
536 d.setDate(start.getDate() + i)
537 dates.push(d)
538 }
539
540 return dates
541 }
542
543 function calculateWeekEnd(startDate) {
544 var end = new Date(startDate)
545 end.setDate(end.getDate() + 7)
546 end.setHours(0, 0, 0, 0)
547 return end
548 }
549
550 function isSameDay(date1, date2) {
551 return date1.getDate() === date2.getDate() &&
552 date1.getMonth() === date2.getMonth() &&
553 date1.getFullYear() === date2.getFullYear()
554 }
555
556 function isToday(date) {
557 var today = new Date()
558 return isSameDay(date, today)
559 }
560
561 function isDateInRange(date, startDate, endDate) {
562 return date >= startDate && date < endDate
563 }
564
565 function formatMonthRangeText(dates) {
566 if (!dates || dates.length === 0) return ""
567 var start = dates[0], end = dates[6], locale = I18n.locale
568 return locale.toString(start, "yyyy-MM") === locale.toString(end, "yyyy-MM")
569 ? locale.toString(start, "MMM yyyy")
570 : start.getFullYear() === end.getFullYear()
571 ? locale.toString(start, "MMM") + " – " + locale.toString(end, "MMM") + " " + start.getFullYear()
572 : locale.toString(start, "MMM yyyy") + " – " + locale.toString(end, "MMM yyyy")
573 }
574
575 function isAllDayEvent(event) {
576 var dur = event.end - event.start
577 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
578 var startsMidnight = start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0
579 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
580 return (dur === 86400 && startsMidnight) || (dur >= 86400 && endsMidnight) || dur >= 86400
581 }
582
583 function isMultiDayEvent(event) {
584 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
585 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
586 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
587 var endDay = endsMidnight ? new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1) :
588 new Date(end.getFullYear(), end.getMonth(), end.getDate())
589 return startDay.getTime() !== endDay.getTime()
590 }
591
592 function calculateDaySpan(start, end, isMultiDay) {
593 if (!isMultiDay) return 1
594 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
595 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
596 var diff = Math.floor((endDay - startDay) / 86400000)
597 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
598 return Math.max(1, endsMidnight ? diff : diff + 1)
599 }
600
601 function formatTime(date) {
602 if (!date || isNaN(date.getTime())) return ""
603 return use12hourFormat ? I18n.locale.toString(date, "h:mm AP") : I18n.locale.toString(date, "HH:mm")
604 }
605
606 function formatDateTime(date) {
607 if (!date || isNaN(date.getTime())) return ""
608 return I18n.locale.monthName(date.getMonth(), Locale.ShortFormat) + ' ' +
609 date.getDate() + ', ' + date.getFullYear() + ' ' + formatTime(date)
610 }
611
612 function formatTimeRangeForDisplay(event) {
613 var start = event.fullStartTime || event.startTime
614 var end = event.fullEndTime || event.endTime
615 return formatTime(start) + " - " + formatTime(end)
616 }
617
618 // Interaction functions
619 function getEventTooltip(event) {
620 var start = event.fullStartTime || event.startTime
621 var end = event.fullEndTime || event.endTime
622 var tip = event.title + "\n" + formatDateTime(start) + " - " + formatDateTime(end)
623 if (event.location) tip += "\n⚲ " + event.location
624 if (event.description) tip += "\n🛈 " + event.description
625 return tip
626 }
627
628 function navigateWeek(days) {
629 var d = new Date(currentDate)
630 d.setDate(d.getDate() + days)
631 currentDate = d
632 }
633
634 function handleEventClick(event) {
635 const date = event.startTime || new Date();
636 const month = date.getMonth() + 1;
637 const day = date.getDate();
638 const year = date.getFullYear();
639 const dateWithSlashes = `${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year.toString().substring(2)}`;
640 if (ProgramCheckerService.gnomeCalendarAvailable) {
641 Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]);
642 }
643 }
644
645 function goToToday() { currentDate = new Date() }
646
647 // Event creation via EDS Python script
648 property string createEventStdout: ""
649 property string createEventStderr: ""
650
651 Process {
652 id: createEventProcess
653 onExited: function(exitCode, exitStatus) {
654 if (exitCode === 0) {
655 try {
656 var result = JSON.parse(createEventStdout)
657 if (result.success) {
658 console.log("Event created: " + result.uid)
659 Qt.callLater(loadEvents)
660 }
661 } catch(e) {
662 console.error("Failed to parse create-event output: " + createEventStdout)
663 }
664 } else {
665 console.error("create-event.py failed: " + createEventStderr)
666 }
667 createEventStdout = ""
668 createEventStderr = ""
669 }
670 stdout: SplitParser {
671 onRead: data => createEventStdout += data
672 }
673 stderr: SplitParser {
674 onRead: data => createEventStderr += data
675 }
676 }
677
678 function createEvent(calendarUid, summary, startTimestamp, endTimestamp, location, description) {
679 var scriptPath = pluginApi.pluginDir + "/scripts/create-event.py"
680 var args = ["python3", scriptPath,
681 "--calendar", calendarUid,
682 "--summary", summary,
683 "--start", String(startTimestamp),
684 "--end", String(endTimestamp)]
685 if (location) { args.push("--location"); args.push(location) }
686 if (description) { args.push("--description"); args.push(description) }
687 createEventProcess.command = args
688 createEventProcess.running = true
689 }
690
691 // === Todo support ===
692
693 property string listTaskListsStdout: ""
694 property string listTaskListsStderr: ""
695
696 Process {
697 id: listTaskListsProcess
698 onExited: function(exitCode, exitStatus) {
699 if (exitCode === 0) {
700 try {
701 var result = JSON.parse(listTaskListsStdout)
702 if (Array.isArray(result)) {
703 taskLists = result.filter(function(tl) { return tl.enabled })
704 }
705 } catch(e) {
706 console.error("[weekly-calendar] Failed to parse task lists: " + listTaskListsStdout)
707 }
708 } else {
709 console.error("[weekly-calendar] list-task-lists.py failed: " + listTaskListsStderr)
710 }
711 listTaskListsStdout = ""
712 listTaskListsStderr = ""
713 }
714 stdout: SplitParser { onRead: data => listTaskListsStdout += data }
715 stderr: SplitParser { onRead: data => listTaskListsStderr += data }
716 }
717
718 property string listTodosStdout: ""
719 property string listTodosStderr: ""
720
721 Process {
722 id: listTodosProcess
723 onExited: function(exitCode, exitStatus) {
724 todosLoading = false
725 if (exitCode === 0) {
726 try {
727 var result = JSON.parse(listTodosStdout)
728 if (Array.isArray(result)) {
729 todosModel.clear()
730 for (var i = 0; i < result.length; i++) {
731 todosModel.append(result[i])
732 }
733 // Re-process events to include updated todos on the calendar
734 Qt.callLater(updateEventsFromService)
735 }
736 } catch(e) {
737 console.error("[weekly-calendar] Failed to parse todos: " + listTodosStdout)
738 }
739 } else {
740 console.error("[weekly-calendar] list-todos.py failed: " + listTodosStderr)
741 }
742 listTodosStdout = ""
743 listTodosStderr = ""
744 }
745 stdout: SplitParser { onRead: data => listTodosStdout += data }
746 stderr: SplitParser { onRead: data => listTodosStderr += data }
747 }
748
749 property string createTodoStdout: ""
750 property string createTodoStderr: ""
751
752 Process {
753 id: createTodoProcess
754 onExited: function(exitCode, exitStatus) {
755 if (exitCode === 0) {
756 try {
757 var result = JSON.parse(createTodoStdout)
758 if (result.success) {
759 console.log("[weekly-calendar] Todo created: " + result.uid)
760 Qt.callLater(loadTodos)
761 }
762 } catch(e) {
763 console.error("[weekly-calendar] Failed to parse create-todo output: " + createTodoStdout)
764 }
765 } else {
766 console.error("[weekly-calendar] create-todo.py failed: " + createTodoStderr)
767 }
768 createTodoStdout = ""
769 createTodoStderr = ""
770 }
771 stdout: SplitParser { onRead: data => createTodoStdout += data }
772 stderr: SplitParser { onRead: data => createTodoStderr += data }
773 }
774
775 property string updateTodoStdout: ""
776 property string updateTodoStderr: ""
777
778 Process {
779 id: updateTodoProcess
780 onExited: function(exitCode, exitStatus) {
781 if (exitCode === 0) {
782 try {
783 var result = JSON.parse(updateTodoStdout)
784 if (result.success) {
785 console.log("[weekly-calendar] Todo updated")
786 Qt.callLater(loadTodos)
787 }
788 } catch(e) {
789 console.error("[weekly-calendar] Failed to parse update-todo output: " + updateTodoStdout)
790 }
791 } else {
792 console.error("[weekly-calendar] update-todo.py failed: " + updateTodoStderr)
793 }
794 updateTodoStdout = ""
795 updateTodoStderr = ""
796 }
797 stdout: SplitParser { onRead: data => updateTodoStdout += data }
798 stderr: SplitParser { onRead: data => updateTodoStderr += data }
799 }
800
801 function loadTaskLists() {
802 if (!pluginApi) return
803 var scriptPath = pluginApi.pluginDir + "/scripts/list-task-lists.py"
804 listTaskListsProcess.command = ["python3", scriptPath]
805 listTaskListsProcess.running = true
806 }
807
808 function loadTodos() {
809 if (!pluginApi) return
810 todosLoading = true
811 todoSyncStatus = pluginApi.tr("panel.loading")
812 var scriptPath = pluginApi.pluginDir + "/scripts/list-todos.py"
813 var args = ["python3", scriptPath]
814 if (showCompletedTodos) args.push("--include-completed")
815 listTodosProcess.command = args
816 listTodosProcess.running = true
817 }
818
819 function createTodo(taskListUid, summary, due, priority, description) {
820 if (!pluginApi) return
821 var scriptPath = pluginApi.pluginDir + "/scripts/create-todo.py"
822 var args = ["python3", scriptPath,
823 "--task-list", taskListUid,
824 "--summary", summary]
825 if (due > 0) { args.push("--due"); args.push(String(due)) }
826 if (priority > 0) { args.push("--priority"); args.push(String(priority)) }
827 if (description) { args.push("--description"); args.push(description) }
828 createTodoProcess.command = args
829 createTodoProcess.running = true
830 }
831
832 function completeTodo(taskListUid, todoUid) {
833 if (!pluginApi) return
834 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
835 updateTodoProcess.command = ["python3", scriptPath,
836 "--task-list", taskListUid, "--uid", todoUid, "--action", "complete"]
837 updateTodoProcess.running = true
838 }
839
840 function uncompleteTodo(taskListUid, todoUid) {
841 if (!pluginApi) return
842 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
843 updateTodoProcess.command = ["python3", scriptPath,
844 "--task-list", taskListUid, "--uid", todoUid, "--action", "uncomplete"]
845 updateTodoProcess.running = true
846 }
847
848 function deleteTodo(taskListUid, todoUid) {
849 if (!pluginApi) return
850 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
851 updateTodoProcess.command = ["python3", scriptPath,
852 "--task-list", taskListUid, "--uid", todoUid, "--action", "delete"]
853 updateTodoProcess.running = true
854 }
855}