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 property string syncStatus: "" 19 property int lastKnownEventCount: 0 20 21 property real dayColumnWidth: 120 * Style.uiScaleRatio 22 property real allDaySectionHeight: 0 * Style.uiScaleRatio 23 property var allDayEventsWithLayout: [] 24 25 property date weekStart: calculateWeekStart(currentDate, firstDayOfWeek) 26 property date weekEnd: calculateWeekEnd(weekStart) 27 property var weekDates: calculateWeekDates(weekStart) 28 property string monthRangeText: formatMonthRangeText(weekDates) 29 30 // Settings 31 property string panelModeSetting: pluginApi?.pluginSettings?.panelMode || "attached" 32 property string weekStartSetting: pluginApi?.pluginSettings?.weekStart || "1" 33 property string timeFormatSetting: pluginApi?.pluginSettings?.timeFormat || "24h" 34 property string lineColorTypeSetting: pluginApi?.pluginSettings?.lineColorType || "mOutline" 35 property real hourLineOpacitySetting: pluginApi?.pluginSettings?.hourLineOpacity ?? 0.5 36 property real dayLineOpacitySetting: pluginApi?.pluginSettings?.dayLineOpacity ?? 0.9 37 38 readonly property int firstDayOfWeek: weekStartSetting === "0" ? 0 : 39 weekStartSetting === "1" ? 1 : 40 weekStartSetting === "6" ? 6 : I18n.locale.firstDayOfWeek 41 42 readonly property bool use12hourFormat: timeFormatSetting === "12h" ? true : 43 timeFormatSetting === "24h" ? false : 44 Settings.data.location.use12hourFormat 45 46 readonly property color lineColor: lineColorTypeSetting === "mOnSurfaceVariant" ? Color.mOnSurfaceVariant : Color.mOutline 47 48 onWeekStartSettingChanged: if (hasLoadedOnce) Qt.callLater(refreshView) 49 onTimeFormatSettingChanged: eventsModelChanged() 50 onCurrentDateChanged: Qt.callLater(refreshView) 51 onLineColorTypeSettingChanged: eventsModelChanged() 52 onHourLineOpacitySettingChanged: eventsModelChanged() 53 onDayLineOpacitySettingChanged: eventsModelChanged() 54 55 // React to CalendarService signals (async event delivery) 56 Connections { 57 target: CalendarService 58 function onAvailableChanged() { 59 if (CalendarService.available) { 60 Qt.callLater(loadEvents) 61 } else { 62 isLoading = false 63 if (pluginApi) syncStatus = pluginApi.tr("panel.no_service") 64 } 65 } 66 function onEventsChanged() { 67 var count = CalendarService.events ? CalendarService.events.length : 0 68 // If auto-refresh dropped event count significantly, re-request wide range 69 if (hasLoadedOnce && !isLoading && lastKnownEventCount > 10 && count < lastKnownEventCount * 0.5) { 70 console.log("[weekly-calendar] Auto-refresh narrowed events (" + count + " vs " + lastKnownEventCount + "), re-requesting wide range") 71 Qt.callLater(loadEvents) 72 return 73 } 74 lastKnownEventCount = Math.max(lastKnownEventCount, count) 75 Qt.callLater(updateEventsFromService) 76 } 77 function onLoadingChanged() { 78 if (!CalendarService.loading && isLoading) { 79 Qt.callLater(updateEventsFromService) 80 } 81 } 82 } 83 84 // Safety timeout: if CalendarService never signals back, stop spinning 85 Timer { 86 id: loadingTimeout 87 interval: 15000 88 repeat: false 89 onTriggered: { 90 if (isLoading) { 91 console.warn("[weekly-calendar] loading timeout, forcing update") 92 updateEventsFromService() 93 } 94 } 95 } 96 97 // IPC 98 IpcHandler { 99 target: "plugin:weekly-calendar" 100 function togglePanel() { pluginApi?.withCurrentScreen(s => pluginApi.togglePanel(s)) } 101 } 102 103 Component.onCompleted: { 104 initializePluginSettings() 105 // Process any cached events immediately 106 if (CalendarService.events && CalendarService.events.length > 0) { 107 Qt.callLater(updateEventsFromService) 108 } 109 if (CalendarService.available) Qt.callLater(loadEvents) 110 } 111 112 onPluginApiChanged: { 113 initializePluginSettings() 114 if (CalendarService.events && CalendarService.events.length > 0) { 115 Qt.callLater(updateEventsFromService) 116 } 117 if (CalendarService.available) Qt.callLater(loadEvents) 118 } 119 120 function initializePluginSettings() { 121 if (!pluginApi) return 122 if (!pluginApi.pluginSettings.weekStart) { 123 pluginApi.pluginSettings = { 124 weekStart: "1", 125 timeFormat: "24h", 126 lineColorType: "mOutline", 127 hourLineOpacity: 0.5, 128 dayLineOpacity: 1.0 129 } 130 pluginApi.saveSettings() 131 } 132 } 133 134 function initializePlugin() { 135 console.log("[weekly-calendar] initializePlugin called, CalendarService.available=" + CalendarService.available) 136 if (!hasLoadedOnce && CalendarService.available) { 137 loadEvents() 138 } else { 139 refreshView() 140 } 141 } 142 143 // Re-filter existing events for the current week view (no new fetch) 144 function refreshView() { 145 if (!pluginApi) return 146 if (CalendarService.events && CalendarService.events.length > 0) { 147 updateEventsFromService() 148 } else if (hasLoadedOnce) { 149 clearEventModels() 150 syncStatus = pluginApi.tr("panel.no_events") 151 } 152 } 153 154 // Fetch events from EDS - requests a wide date range to cover past/future navigation 155 function loadEvents() { 156 if (!pluginApi) return 157 if (!CalendarService.available) { 158 syncStatus = pluginApi.tr("panel.no_service") 159 console.log("[weekly-calendar] loadEvents: service not available") 160 return 161 } 162 163 isLoading = true 164 syncStatus = pluginApi.tr("panel.loading") 165 166 // Request a wide range: 180 days behind, 60 days ahead 167 // This covers ~6 months of past events and ~2 months of future events 168 var daysAhead = 60 169 var daysBehind = 180 170 171 CalendarService.loadEvents(daysAhead, daysBehind) 172 173 hasLoadedOnce = true 174 loadingTimeout.restart() 175 176 // If CalendarService already has events (cached), display them now 177 if (CalendarService.events && CalendarService.events.length > 0) { 178 Qt.callLater(updateEventsFromService) 179 } 180 } 181 182 function updateEventsFromService() { 183 if (!pluginApi) return 184 loadingTimeout.stop() 185 clearEventModels() 186 187 if (!CalendarService.available) { 188 syncStatus = pluginApi.tr("panel.no_service") 189 } else if (!CalendarService.events?.length) { 190 syncStatus = pluginApi.tr("panel.no_events") 191 } else { 192 var stats = processCalendarEvents(CalendarService.events) 193 syncStatus = stats.timedCount === 1 194 ? `${stats.timedCount} ${pluginApi.tr("panel.event")}, ${stats.allDayCount} ${pluginApi.tr("panel.allday")}` 195 : `${stats.timedCount} ${pluginApi.tr("panel.events")}, ${stats.allDayCount} ${pluginApi.tr("panel.allday")}` 196 } 197 198 isLoading = false 199 } 200 201 // Events generation & layout 202 function processCalendarEvents(events) { 203 var uniqueEvents = {}, uniqueAllDayEvents = {} 204 var timedCount = 0, allDayCount = 0 205 var newEvents = [], newAllDayEvents = [] 206 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd) 207 208 for (var i = 0; i < events.length; i++) { 209 var event = events[i], eventObj = createEventObject(event, i) 210 var eventStart = new Date(eventObj.startTime), eventEnd = new Date(eventObj.endTime) 211 var overlapsWeek = eventStart < weekEndDate && eventEnd > weekStartDate 212 213 if (overlapsWeek) { 214 var key = event.uid + "-" + event.start + "-" + event.end + i 215 if (eventObj.allDay) { 216 if (!uniqueAllDayEvents[key]) { 217 uniqueAllDayEvents[key] = true 218 allDayCount++ 219 newAllDayEvents.push(eventObj) 220 } 221 } else if (!uniqueEvents[key]) { 222 uniqueEvents[key] = true 223 timedCount++ 224 processTimedEventIntoArray(eventObj, newEvents) 225 } 226 } 227 } 228 229 eventsModel.clear() 230 allDayEventsModel.clear() 231 newEvents.forEach(e => eventsModel.append(e)) 232 newAllDayEvents.forEach(e => allDayEventsModel.append(e)) 233 234 calculateAllDayEventLayout() 235 updateOverlappingEvents() 236 eventsModel.layoutChanged() 237 allDayEventsModel.layoutChanged() 238 239 return {timedCount: timedCount, allDayCount: allDayCount} 240 } 241 242 function clearEventModels() { eventsModel.clear(); allDayEventsModel.clear() } 243 244 function processTimedEventIntoArray(eventObj, target) { 245 var start = new Date(eventObj.startTime), end = new Date(eventObj.endTime) 246 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate()) 247 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()) 248 249 if (startDay.getTime() === endDay.getTime()) { 250 if (start < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 0, start, end, startDay, 0, 1)) 251 } else { 252 var firstEnd = new Date(startDay); firstEnd.setHours(24, 0, 0, 0) 253 var secondStart = new Date(endDay); secondStart.setHours(0, 0, 0, 0) 254 if (start < weekEnd && firstEnd > weekStart) target.push(createEventPart(eventObj, 0, start, firstEnd, startDay, 0, 2)) 255 if (secondStart < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 1, secondStart, end, endDay, 1, 2)) 256 } 257 } 258 259 function createEventObject(event, idx) { 260 var start = new Date(event.start * 1000), end = new Date(event.end * 1000) 261 var allDay = isAllDayEvent(event), multiDay = isMultiDayEvent(event) 262 var daySpan = calculateDaySpan(start, end, multiDay || allDay) 263 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 264 var id = event.uid + "-" + event.start + "-" + event.end + idx 265 266 return { 267 id: id, title: event.summary || "Untitled Event", description: event.description || "", 268 location: event.location || "", startTime: start, endTime: end, allDay: allDay, multiDay: multiDay, 269 daySpan: daySpan, rawStart: event.start, rawEnd: event.end, duration: (event.end - event.start) / 3600, 270 endsAtMidnight: endsMidnight 271 } 272 } 273 274 function createEventPart(event, partIdx, start, end, day, partNum, total) { 275 return { 276 id: event.id + "-part-" + partIdx, title: event.title, description: event.description, 277 location: event.location, startTime: start, endTime: end, allDay: false, multiDay: true, 278 daySpan: 1, fullStartTime: event.startTime, fullEndTime: event.endTime, isPart: true, 279 partDay: new Date(day), partIndex: partNum, totalParts: total 280 } 281 } 282 283 function getDayIndexForDate(date) { 284 if (!date || isNaN(date.getTime())) return -1 285 var diff = Math.floor((date - weekStart) / 86400000) 286 return diff >= 0 && diff < 7 ? diff : -1 287 } 288 function getDisplayDayIndexForDate(date) { return getDayIndexForDate(date) } 289 290 function calculateAllDaySpanForWeek(event) { 291 var start = new Date(event.startTime), end = new Date(event.endTime) 292 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 293 var adjEnd = endsMidnight ? new Date(end.getTime() - 1) : end 294 var startIdx = Math.max(0, getDayIndexForDate(start)) 295 var endIdx = Math.min(6, Math.floor((Math.min(adjEnd, weekEnd) - weekStart) / 86400000)) 296 return Math.max(1, endIdx - startIdx + 1) 297 } 298 299 function findAvailableLane(occupied, start, end) { 300 var lane = 0, found = false 301 while (!found) { 302 var conflict = false 303 for (var d = start; d <= end; d++) { 304 if (occupied[d]?.includes(lane)) { conflict = true; break } 305 } 306 if (!conflict) found = true 307 else lane++ 308 } 309 return lane 310 } 311 312 function calculateAllDayEventLayout() { 313 314 var occupied = [[], [], [], [], [], [], []] 315 var eventsWithLayout = [], maxLanes = 0 316 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd) 317 318 for (var i = 0; i < allDayEventsModel.count; i++) { 319 var event = allDayEventsModel.get(i) 320 var start = new Date(event.startTime), end = new Date(event.endTime) 321 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate()) 322 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()) 323 324 if (startDay < weekStartDate && endDay >= weekStartDate) { 325 var span = calculateAllDaySpanForWeek(event) 326 if (span > 0) { 327 var lane = findAvailableLane(occupied, 0, span - 1) 328 for (var d = 0; d < span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) } 329 maxLanes = Math.max(maxLanes, lane + 1) 330 eventsWithLayout.push(createLayoutEvent(event, 0, span, lane, true)) 331 } 332 } else if (startDay >= weekStartDate && startDay < weekEndDate) { 333 var startIdx = getDayIndexForDate(start) 334 var span = calculateAllDaySpanForWeek(event) 335 if (span > 0) { 336 var lane = findAvailableLane(occupied, startIdx, startIdx + span - 1) 337 for (var d = startIdx; d < startIdx + span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) } 338 maxLanes = Math.max(maxLanes, lane + 1) 339 eventsWithLayout.push(createLayoutEvent(event, startIdx, span, lane, false)) 340 } 341 } 342 } 343 344 eventsWithLayout.sort((a,b) => a.lane !== b.lane ? a.lane - b.lane : a.startDay - b.startDay) 345 allDayEventsWithLayout = eventsWithLayout 346 allDaySectionHeight = maxLanes === 0 ? 0 : maxLanes === 1 ? 25 : Math.max(30, maxLanes * 25) 347 348 return maxLanes 349 } 350 351 function createLayoutEvent(event, startDay, spanDays, lane, isCont) { 352 return { 353 id: event.id, title: event.title, description: event.description, location: event.location, 354 startTime: event.startTime, endTime: event.endTime, allDay: event.allDay, multiDay: event.multiDay, 355 daySpan: event.daySpan, rawStart: event.rawStart, rawEnd: event.rawEnd, duration: event.duration, 356 endsAtMidnight: event.endsAtMidnight, fullStartTime: event.fullStartTime, fullEndTime: event.fullEndTime, 357 startDay: startDay, spanDays: spanDays, lane: lane, isContinuation: isCont 358 } 359 } 360 361 function updateOverlappingEvents() { 362 var overlapData = {} 363 for (var day = 0; day < 7; day++) processDayEventsWithLanes(day, overlapData) 364 overlappingEventsData = overlapData 365 } 366 367 function processDayEventsWithLanes(day, data) { 368 var events = [] 369 for (var i = 0; i < eventsModel.count; i++) { 370 var e = eventsModel.get(i) 371 if (getDisplayDayIndexForDate(e.startTime) === day) { 372 events.push({index: i, start: e.startTime.getTime(), end: e.endTime.getTime()}) 373 } 374 } 375 if (events.length === 0) return 376 377 events.sort((a,b) => a.start === b.start ? (b.end - b.start) - (a.end - a.start) : a.start - b.start) 378 var groups = [], current = [], endTime = -1 379 380 events.forEach(e => { 381 if (e.start >= endTime) { 382 if (current.length > 0) groups.push({events: current, endTime: endTime}) 383 current = [e]; endTime = e.end 384 } else { 385 current.push(e) 386 if (e.end > endTime) endTime = e.end 387 } 388 }) 389 if (current.length > 0) groups.push({events: current, endTime: endTime}) 390 groups.forEach(g => assignLanesToGroup(g.events, data)) 391 } 392 393 function assignLanesToGroup(group, data) { 394 if (group.length === 0) return 395 var laneEnds = [] 396 group.forEach(e => { 397 var placed = false 398 for (var lane = 0; lane < laneEnds.length; lane++) { 399 if (e.start >= laneEnds[lane]) { 400 laneEnds[lane] = e.end 401 e.lane = lane 402 placed = true 403 break 404 } 405 } 406 if (!placed) { e.lane = laneEnds.length; laneEnds.push(e.end) } 407 }) 408 409 var total = laneEnds.length 410 group.forEach(e => { 411 data[e.index] = { 412 xOffset: (e.lane / total) * (dayColumnWidth +1), 413 width: (dayColumnWidth+1) / total, 414 lane: e.lane, 415 totalLanes: total 416 } 417 }) 418 } 419 420 // Range & formatting of calendar 421 function calculateWeekStart(date, firstDay) { 422 var d = new Date(date) 423 var day = d.getDay() 424 var diff = (day - firstDay + 7) % 7 425 d.setDate(d.getDate() - diff) 426 d.setHours(0, 0, 0, 0) 427 return d 428 } 429 430 function calculateWeekDates(startDate) { 431 var dates = [] 432 var start = new Date(startDate) 433 434 for (var i = 0; i < 7; i++) { 435 var d = new Date(start) 436 d.setDate(start.getDate() + i) 437 dates.push(d) 438 } 439 440 return dates 441 } 442 443 function calculateWeekEnd(startDate) { 444 var end = new Date(startDate) 445 end.setDate(end.getDate() + 7) 446 end.setHours(0, 0, 0, 0) 447 return end 448 } 449 450 function isSameDay(date1, date2) { 451 return date1.getDate() === date2.getDate() && 452 date1.getMonth() === date2.getMonth() && 453 date1.getFullYear() === date2.getFullYear() 454 } 455 456 function isToday(date) { 457 var today = new Date() 458 return isSameDay(date, today) 459 } 460 461 function isDateInRange(date, startDate, endDate) { 462 return date >= startDate && date < endDate 463 } 464 465 function formatMonthRangeText(dates) { 466 if (!dates || dates.length === 0) return "" 467 var start = dates[0], end = dates[6], locale = I18n.locale 468 return locale.toString(start, "yyyy-MM") === locale.toString(end, "yyyy-MM") 469 ? locale.toString(start, "MMM yyyy") 470 : start.getFullYear() === end.getFullYear() 471 ? locale.toString(start, "MMM") + " – " + locale.toString(end, "MMM") + " " + start.getFullYear() 472 : locale.toString(start, "MMM yyyy") + " – " + locale.toString(end, "MMM yyyy") 473 } 474 475 function isAllDayEvent(event) { 476 var dur = event.end - event.start 477 var start = new Date(event.start * 1000), end = new Date(event.end * 1000) 478 var startsMidnight = start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0 479 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 480 return (dur === 86400 && startsMidnight) || (dur >= 86400 && endsMidnight) || dur >= 86400 481 } 482 483 function isMultiDayEvent(event) { 484 var start = new Date(event.start * 1000), end = new Date(event.end * 1000) 485 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 486 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate()) 487 var endDay = endsMidnight ? new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1) : 488 new Date(end.getFullYear(), end.getMonth(), end.getDate()) 489 return startDay.getTime() !== endDay.getTime() 490 } 491 492 function calculateDaySpan(start, end, isMultiDay) { 493 if (!isMultiDay) return 1 494 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate()) 495 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()) 496 var diff = Math.floor((endDay - startDay) / 86400000) 497 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 498 return Math.max(1, endsMidnight ? diff : diff + 1) 499 } 500 501 function formatTime(date) { 502 if (!date || isNaN(date.getTime())) return "" 503 return use12hourFormat ? I18n.locale.toString(date, "h:mm AP") : I18n.locale.toString(date, "HH:mm") 504 } 505 506 function formatDateTime(date) { 507 if (!date || isNaN(date.getTime())) return "" 508 return I18n.locale.monthName(date.getMonth(), Locale.ShortFormat) + ' ' + 509 date.getDate() + ', ' + date.getFullYear() + ' ' + formatTime(date) 510 } 511 512 function formatTimeRangeForDisplay(event) { 513 var start = event.fullStartTime || event.startTime 514 var end = event.fullEndTime || event.endTime 515 return formatTime(start) + " - " + formatTime(end) 516 } 517 518 // Interaction functions 519 function getEventTooltip(event) { 520 var start = event.fullStartTime || event.startTime 521 var end = event.fullEndTime || event.endTime 522 var tip = event.title + "\n" + formatDateTime(start) + " - " + formatDateTime(end) 523 if (event.location) tip += "\n⚲ " + event.location 524 if (event.description) tip += "\n🛈 " + event.description 525 return tip 526 } 527 528 function navigateWeek(days) { 529 var d = new Date(currentDate) 530 d.setDate(d.getDate() + days) 531 currentDate = d 532 } 533 534 function handleEventClick(event) { 535 const date = event.startTime || new Date(); 536 const month = date.getMonth() + 1; 537 const day = date.getDate(); 538 const year = date.getFullYear(); 539 const dateWithSlashes = `${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year.toString().substring(2)}`; 540 if (ProgramCheckerService.gnomeCalendarAvailable) { 541 Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]); 542 } 543 } 544 545 function goToToday() { currentDate = new Date() } 546 547 // Event creation via EDS Python script 548 property string createEventStdout: "" 549 property string createEventStderr: "" 550 551 Process { 552 id: createEventProcess 553 onExited: function(exitCode, exitStatus) { 554 if (exitCode === 0) { 555 try { 556 var result = JSON.parse(createEventStdout) 557 if (result.success) { 558 console.log("Event created: " + result.uid) 559 Qt.callLater(loadEvents) 560 } 561 } catch(e) { 562 console.error("Failed to parse create-event output: " + createEventStdout) 563 } 564 } else { 565 console.error("create-event.py failed: " + createEventStderr) 566 } 567 createEventStdout = "" 568 createEventStderr = "" 569 } 570 stdout: SplitParser { 571 onRead: data => createEventStdout += data 572 } 573 stderr: SplitParser { 574 onRead: data => createEventStderr += data 575 } 576 } 577 578 function createEvent(calendarUid, summary, startTimestamp, endTimestamp, location, description) { 579 var scriptPath = pluginApi.pluginDir + "/scripts/create-event.py" 580 var args = ["python3", scriptPath, 581 "--calendar", calendarUid, 582 "--summary", summary, 583 "--start", String(startTimestamp), 584 "--end", String(endTimestamp)] 585 if (location) { args.push("--location"); args.push(location) } 586 if (description) { args.push("--description"); args.push(description) } 587 createEventProcess.command = args 588 createEventProcess.running = true 589 } 590}