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