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}