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