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