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