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