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