From e7c6c74964281788ecaa4dc5b48d6e5ce90ccadc Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Tue, 3 Nov 2026 19:54:26 +0300 Subject: [PATCH] Lucid: Show calendar event from Thunderbird --- modules/user/Agenda.qml | 310 ++++++++++++++++ modules/user/CalendarSource.qml | 98 +++++ modules/user/ClockCalendar.qml | 536 +++++++++++++++++++++++++++ modules/user/thunderbird-calendar.py | 278 ++++++++++++++ user/sweetbread/modules/lucid.nix | 4 + 5 files changed, 1226 insertions(+) create mode 100644 modules/user/Agenda.qml create mode 100644 modules/user/CalendarSource.qml create mode 100644 modules/user/ClockCalendar.qml create mode 100644 modules/user/thunderbird-calendar.py diff --git a/modules/user/Agenda.qml b/modules/user/Agenda.qml new file mode 100644 index 0000000..56d5364 --- /dev/null +++ b/modules/user/Agenda.qml @@ -0,0 +1,310 @@ +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton + +// reminders: what the clock rings for, what the calendar marks, and what the +// widgets and the lock screen list as coming up +Singleton { + id: root + + // [{ id, year, month, day, hour, minute, name, repeat }]; repeat is + // "" | "daily" | "weekdays" | "weekly", counted from the stored date + property alias items: store.items + property int tick: 0 + // reminders due and not yet dealt with, oldest first + property var ringing: [] + readonly property var firing: root.ringing.length > 0 ? root.ringing[0] : null + property var snoozedUntil: ({}) + property var dismissedAt: ({}) + property string checkedDay: "" + // when this copy of the shell started, so a reload does not ring out loud + readonly property real bornAt: Date.now() + // changes once a minute; what the countdown text needs, instead of every tick + property string minuteKey: "" + + readonly property var next: { + root.tick; + CalendarSource.tick; + return root.upcoming(1)[0] || null; + } + readonly property bool soon: { + var n = root.next; + return n !== null && n.at.getTime() - Loc.nowMs() <= 7 * 86400000; + } + + readonly property var repeatOptions: [ + { "key": "", "label": "Once" }, + { "key": "daily", "label": "Daily" }, + { "key": "weekdays", "label": "Weekdays" }, + { "key": "weekly", "label": "Weekly" } + ] + + function dayStart(y, m, d) { + return new Date(y, m, d, 0, 0, 0, 0); + } + + // does this reminder happen on the given day? + function occursOn(r, y, m, d) { + var day = root.dayStart(y, m, d); + var start = root.dayStart(r.year, r.month, r.day); + if (day < start) + return false; + + switch (r.repeat || "") { + case "daily": + return true; + case "weekdays": + return day.getDay() !== 0 && day.getDay() !== 6; + case "weekly": + return day.getDay() === start.getDay(); + } + return day.getTime() === start.getTime(); + } + + // the next time a reminder rings at or after `from` + // `next` re-reads this every second for every reminder, so it must not walk + // the calendar: a one-off happens on exactly its own date, and daily, + // weekdays and weekly all come round inside a week of whichever is later, + // today or the date the repeat starts from + function nextAt(r, from) { + if (!r.repeat) { + var at = new Date(r.year, r.month, r.day, r.hour, r.minute, 0, 0); + return at >= from ? at : null; + } + var fromDay = root.dayStart(from.getFullYear(), from.getMonth(), from.getDate()); + var startDay = root.dayStart(r.year, r.month, r.day); + var probe = startDay > fromDay ? startDay : fromDay; + for (var i = 0; i < 8; i++) { + if (root.occursOn(r, probe.getFullYear(), probe.getMonth(), probe.getDate())) { + var a = new Date(probe.getFullYear(), probe.getMonth(), probe.getDate(), r.hour, r.minute, 0, 0); + if (a >= from) + return a; + + } + probe.setDate(probe.getDate() + 1); + } + return null; + } + + function upcoming(limit) { + var now = Loc.now(); + var out = []; + for (var i = 0; i < root.items.length; i++) { + var at = root.nextAt(root.items[i], now); + if (at) + out.push({ "item": root.items[i], "at": at }); + + } + out = out.concat(CalendarSource.upcoming()); + out.sort((a, b) => { + return a.at - b.at; + }); + return out.slice(0, limit || out.length); + } + + function forDay(y, m, d) { + var local = root.items.filter((r) => { + return root.occursOn(r, y, m, d); + }); + var fromThunderbird = CalendarSource.forDay(y, m, d).map((event) => CalendarSource.asAgendaItem(event)); + return local.concat(fromThunderbird).sort((a, b) => { + return (a.hour * 60 + a.minute) - (b.hour * 60 + b.minute); + }); + } + + function hasOn(y, m, d) { + if (CalendarSource.hasOn(y, m, d)) + return true; + for (var i = 0; i < root.items.length; i++) { + if (root.occursOn(root.items[i], y, m, d)) + return true; + + } + return false; + } + + function add(y, m, d, hour, minute, name, repeat) { + root.items = root.items.concat([{ + "id": Date.now() + "-" + Math.random().toString(36).slice(2, 7), + "year": y, + "month": m, + "day": d, + "hour": hour, + "minute": minute, + "name": name, + "repeat": repeat || "" + }]); + root.tick++; + } + + function remove(id) { + root.items = root.items.filter((r) => { + return r.id !== id; + }); + root.ringing = root.ringing.filter((r) => { + return r.id !== id; + }); + root.tick++; + } + + function timeText(r) { + if (!r) + return ""; + + if (Prefs.clock24h) + return String(r.hour).padStart(2, "0") + ":" + String(r.minute).padStart(2, "0"); + + var h = r.hour % 12 === 0 ? 12 : r.hour % 12; + return h + ":" + String(r.minute).padStart(2, "0") + " " + (r.hour < 12 ? "AM" : "PM"); + } + + function untilText(at) { + if (!at) + return ""; + + var mins = Math.round((at - Loc.now()) / 60000); + if (mins < 1) + return "now"; + + if (mins < 60) + return "in " + mins + " min"; + + var hrs = Math.round(mins / 60); + if (hrs < 24) + return "in " + hrs + (hrs === 1 ? " hour" : " hours"); + + var days = Math.round(hrs / 24); + return days === 1 ? "tomorrow" : "in " + days + " days"; + } + + function repeatText(r) { + switch (r.repeat || "") { + case "daily": + return "Every day"; + case "weekdays": + return "Weekdays"; + case "weekly": + return "Every " + root.dayStart(r.year, r.month, r.day).toLocaleDateString(Qt.locale(), "dddd"); + } + return ""; + } + + function snooze(id, minutes) { + var m = Object.assign({}, root.snoozedUntil); + m[id] = Loc.nowMs() + minutes * 60000; + root.snoozedUntil = m; + root.ringing = root.ringing.filter((q) => { + return q.id !== id; + }); + } + + // a one-off goes once it has rung; a repeating one waits for its next turn + function dismiss(id) { + var m = Object.assign({}, root.dismissedAt); + m[id] = Loc.nowMs(); + root.dismissedAt = m; + root.ringing = root.ringing.filter((q) => { + return q.id !== id; + }); + var r = root.items.find((x) => { + return x.id === id; + }); + if (r && !r.repeat) + root.remove(id); + + } + + function prune(now) { + var today = root.dayStart(now.getFullYear(), now.getMonth(), now.getDate()); + var kept = root.items.filter((r) => { + return r.repeat || root.dayStart(r.year, r.month, r.day) >= today; + }); + if (kept.length !== root.items.length) + root.items = kept; + + } + + function check() { + var now = Loc.now(); + var key = now.getFullYear() + "-" + now.getMonth() + "-" + now.getDate(); + if (root.checkedDay !== key) { + root.prune(now); + root.checkedDay = key; + } + var mkey = key + "-" + now.getHours() + "-" + now.getMinutes(); + if (root.minuteKey !== mkey) + root.minuteKey = mkey; + + var fresh = false; + for (var i = 0; i < root.items.length; i++) { + var r = root.items[i]; + if (root.ringing.some((q) => { + return q.id === r.id; + })) + continue; + + var until = root.snoozedUntil[r.id]; + if (until && now.getTime() < until) + continue; + + // today's occurrence, if there is one and it has come round + if (!root.occursOn(r, now.getFullYear(), now.getMonth(), now.getDate())) + continue; + + var due = new Date(now.getFullYear(), now.getMonth(), now.getDate(), r.hour, r.minute, 0, 0); + if (now < due) + continue; + + var last = root.dismissedAt[r.id]; + if (last && last >= due.getTime()) + continue; + + // an hour late is too late to ring + if (!until && now.getTime() - due.getTime() > 3600000) + continue; + + root.ringing = root.ringing.concat([r]); + // a reload forgets dismissals, so one already overdue as the shell + // starts rings without a sound; after that, one that came due while + // the machine slept still gets it + if (until || Date.now() - root.bornAt > 5000 || now.getTime() - due.getTime() < 60000) + fresh = true; + + } + if (fresh && Prefs.reminderSound) + Sounds.play("reminder", 1); + + root.tick++; + } + + Timer { + interval: 1000 + repeat: true + running: true + onTriggered: root.check() + } + + Timer { + id: saveDebounce + + interval: 300 + onTriggered: file.writeAdapter() + } + + FileView { + id: file + + path: Quickshell.env("HOME") + "/.config/quickshell/lucidbar/clock_reminders.json" + blockLoading: true + onAdapterUpdated: saveDebounce.restart() + + adapter: JsonAdapter { + id: store + + property var items: [] + } + + } + +} diff --git a/modules/user/CalendarSource.qml b/modules/user/CalendarSource.qml new file mode 100644 index 0000000..59c9f0b --- /dev/null +++ b/modules/user/CalendarSource.qml @@ -0,0 +1,98 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs +pragma Singleton + +// Read-only events already synchronized by Thunderbird. This deliberately does +// not connect to DAV or store credentials; Thunderbird remains the DAV client. +Singleton { + id: source + + property var events: [] + property int tick: 0 + property string error: "" + property real updatedAt: 0 + + function refresh() { + if (reader.running) + return; + reader.command = ["python3", Quickshell.env("HOME") + "/.config/quickshell/thunderbird-calendar.py"]; + reader.running = true; + } + + function forDay(year, month, day) { + source.tick; + return source.events.filter((event) => { + const date = new Date(event.startMs); + return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day; + }); + } + + function upcoming(limit) { + source.tick; + const now = Loc.nowMs(); + return source.events.filter((event) => event.startMs >= now).slice(0, limit || source.events.length).map((event) => ({ + "item": source.asAgendaItem(event), + "at": new Date(event.startMs) + })); + } + + function hasOn(year, month, day) { + return source.forDay(year, month, day).length > 0; + } + + function asAgendaItem(event) { + const date = new Date(event.startMs); + return { + "id": "thunderbird:" + event.id, + "name": event.name, + "calendar": event.calendar, + "source": "thunderbird", + "startMs": event.startMs, + "endMs": event.endMs, + "hour": date.getHours(), + "minute": date.getMinutes(), + "repeat": "" + }; + } + + Process { + id: reader + + command: [] + + stdout: StdioCollector { + onStreamFinished: { + if (this.text.trim() === "") + return; + try { + const result = JSON.parse(this.text); + if (!result.error) { + source.events = result.events || []; + source.error = ""; + source.updatedAt = Date.now(); + source.tick++; + } else { + source.error = result.error; + } + } catch (e) { + source.error = "Could not read Thunderbird calendar cache"; + } + } + } + + onExited: (code) => { + if (code !== 0 && source.error === "") + source.error = "Could not read Thunderbird calendar cache"; + } + } + + Timer { + interval: 300000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: source.refresh() + } +} diff --git a/modules/user/ClockCalendar.qml b/modules/user/ClockCalendar.qml new file mode 100644 index 0000000..397316a --- /dev/null +++ b/modules/user/ClockCalendar.qml @@ -0,0 +1,536 @@ +import QtQuick +import qs +import qs.lucidui + +// the month on the left, the picked day's reminders and a way to add one on the right +Item { + id: page + + property var host: null + property var selected: { + const n = Loc.now(); + return { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() }; + } + readonly property date selectedDate: new Date(page.selected.year, page.selected.month, page.selected.day) + readonly property var dayItems: { + Agenda.checkedDay; + return Agenda.forDay(page.selected.year, page.selected.month, page.selected.day); + } + property int newHour: 9 + property int newMinute: 0 + // the hour is freshTime's next-hour guess, not one anybody chose + property bool hourGuessed: false + // am/pm was set by hand, so a typed hour keeps to it + property bool halfPicked: false + property string newRepeat: "" + readonly property bool pm: page.newHour >= 12 + readonly property int shownHour: Prefs.clock24h ? page.newHour : (page.newHour % 12 === 0 ? 12 : page.newHour % 12) + // a one-off set before now would never ring + readonly property bool past: { + Agenda.minuteKey; + return page.newRepeat === "" && page.gone(page.newHour, page.newMinute); + } + readonly property bool shown: page.visible && (!page.host || page.host.expanded) + + onShownChanged: if (page.shown) + page.freshTime() + + function selectToday() { + const n = Loc.now(); + page.selected = { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() }; + month.goToday(); + page.freshTime(); + } + + // earlier than the minute we are in, on the picked day + function gone(hour, minute) { + const n = Loc.now(); + return new Date(page.selected.year, page.selected.month, page.selected.day, hour, minute) < new Date(n.getFullYear(), n.getMonth(), n.getDate(), n.getHours(), n.getMinutes()); + } + + function isToday() { + return page.gone(0, 0) && !page.gone(23, 59); + } + + // every opening of the clock starts on today, unless a reminder is half written + function startVisit() { + if (nameField.text !== "") + return ; + + const n = Loc.now(); + page.selected = { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() }; + if (!month.isCurrent) + month.goToday(); + + page.freshTime(); + } + + // 9:00, or the next hour once 9:00 has gone today; a time already set for a name stays + function freshTime() { + if (nameField.text !== "") + return ; + + page.newHour = 9; + page.newMinute = 0; + page.hourGuessed = false; + page.halfPicked = false; + const next = Loc.now().getHours() + 1; + if (page.gone(9, 0) && next < 24 && !page.gone(next, 0)) { + page.newHour = next; + page.hourGuessed = true; + } + } + + // a typed 12-hour time means the half still to come: 12:04 at noon is pm + function preferUpcoming() { + if (Prefs.clock24h || !page.past) + return ; + + const flipped = (page.newHour + 12) % 24; + if (!page.gone(flipped, page.newMinute)) + page.newHour = flipped; + + } + + function focusName() { + nameField.input.forceActiveFocus(); + } + + function submit() { + const name = nameField.text.trim(); + if (name === "" || page.past) + return ; + + Agenda.add(page.selected.year, page.selected.month, page.selected.day, page.newHour, page.newMinute, name, page.newRepeat); + nameField.text = ""; + page.newRepeat = ""; + } + + function step(minutes) { + page.hourGuessed = false; + let t = (page.newHour * 60 + page.newMinute + minutes + 1440) % 1440; + page.newHour = Math.floor(t / 60); + page.newMinute = t % 60; + } + + // typed hours are 0-23, or 1-12 next to the am/pm toggle; on today a + // 12-hour one is its next turn, not the half the guessed hour left behind + function typeHour(h) { + page.hourGuessed = false; + if (Prefs.clock24h) { + page.newHour = Math.min(23, h); + } else if (page.isToday() && !page.halfPicked) { + // the minutes are still to come, so only an hour wholly gone is past + const h12 = Math.min(12, h) % 12; + page.newHour = page.gone(h12, 59) ? h12 + 12 : h12; + } else { + page.newHour = Math.min(12, h) % 12 + (page.pm ? 12 : 0); + page.preferUpcoming(); + } + } + + // minutes typed under the guessed hour mean the next time the clock shows + // them: at 12:19, 20 is 12:20 rather than 1:20 + function typeMinute(m) { + page.newMinute = Math.min(59, m); + if (page.hourGuessed && page.isToday()) { + const h = Loc.now().getHours(); + if (!page.gone(h, page.newMinute)) + page.newHour = h; + else if (h + 1 < 24) + page.newHour = h + 1; + } + page.preferUpcoming(); + } + + function setPm(wantPm) { + page.hourGuessed = false; + page.halfPicked = true; + page.newHour = page.newHour % 12 + (wantPm ? 12 : 0); + } + + // two digits you type into, each holding its own value until focus leaves + component TimeDigit: Item { + id: digit + + property int value: 0 + property int span: 60 + property Item nextDigit: null + readonly property bool typing: field.activeFocus + + signal typed(int v) + signal meridiem(bool wantPm) + signal accepted() + + function show() { + field.text = String(digit.value).padStart(2, "0"); + } + + function take() { + field.forceActiveFocus(); + field.selectAll(); + } + + function nudge(by) { + digit.typed((digit.value + by + digit.span) % digit.span); + digit.show(); + } + + width: Theme.dp(30) + height: Theme.dp(30) + onValueChanged: if (!field.activeFocus) + digit.show() + Component.onCompleted: digit.show() + + TextInput { + id: field + + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + maximumLength: 2 + inputMethodHints: Qt.ImhDigitsOnly + selectByMouse: true + color: Theme.text + selectionColor: Theme.alpha(Theme.primary, 0.35) + selectedTextColor: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.typeSize("labelLarge") + font.variableAxes: Theme.axes(Theme.typeSize("labelLarge"), 640, 0) + font.features: ({ + "tnum": 1 + }) + onTextEdited: { + if (field.text !== "") + digit.typed(parseInt(field.text, 10)); + + if (field.text.length === 2 && digit.nextDigit) + digit.nextDigit.take(); + + } + onActiveFocusChanged: { + if (field.activeFocus) + Qt.callLater(field.selectAll); + else + digit.show(); + } + onAccepted: digit.accepted() + Keys.onUpPressed: digit.nudge(1) + Keys.onDownPressed: digit.nudge(-1) + Keys.onTabPressed: digit.nextDigit ? digit.nextDigit.take() : digit.show() + Keys.onPressed: (e) => { + const c = e.text.toLowerCase(); + if (c === "a" || c === "p") + digit.meridiem(c === "p"); + + } + + validator: RegularExpressionValidator { + regularExpression: /[0-9]{0,2}/ + } + + } + + Rectangle { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - Theme.dp(6) + height: Theme.dp(2) + radius: 1 + color: digit.typing ? Theme.primary : Theme.alpha(Theme.subtext, 0.4) + + Behavior on color { + ColorAnimation { + duration: Theme.durFastEffects + } + + } + + } + + } + + implicitHeight: Theme.dp(404) + + Connections { + function onExpandedChanged() { + if (page.host.expanded) + page.startVisit(); + + } + + target: page.host + } + + Rectangle { + id: monthCard + + width: Theme.dp(312) + height: parent.height + radius: Theme.shapeXl + color: Theme.withBlur(Theme.surfaceHigh) + + MonthView { + id: month + + anchors.centerIn: parent + cell: Theme.dp(40) + selected: page.selected + onPicked: (y, m, d) => { + page.selected = { "year": y, "month": m, "day": d }; + if (m !== month.monthIndex) + month.page(y * 12 + m > month.year * 12 + month.monthIndex ? 1 : -1); + + } + } + + } + + Rectangle { + anchors.left: monthCard.right + anchors.leftMargin: Theme.dp(12) + anchors.right: parent.right + height: parent.height + radius: Theme.shapeXl + color: Theme.withBlur(Theme.surfaceHigh) + + Item { + anchors.fill: parent + anchors.margins: Theme.dp(18) + + Column { + id: dayHead + + width: parent.width + spacing: 0 + + LText { + role: "titleLarge" + weight: 560 + text: page.selectedDate.toLocaleDateString(Qt.locale(), "dddd") + } + + LText { + role: "bodyMedium" + color: Theme.subtext + text: page.selectedDate.toLocaleDateString(Qt.locale(), "d MMMM yyyy") + (page.dayItems.length ? " · " + page.dayItems.length + (page.dayItems.length === 1 ? " reminder" : " reminders") : "") + } + + } + + Flickable { + id: list + + anchors.top: dayHead.bottom + anchors.topMargin: Theme.dp(12) + anchors.bottom: form.top + anchors.bottomMargin: Theme.dp(12) + width: parent.width + contentHeight: items.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: items + + width: list.width + spacing: Theme.dp(2) + + Repeater { + model: page.dayItems + + ListItem { + id: row + + required property var modelData + required property int index + + width: items.width + first: row.index === 0 + last: row.index === page.dayItems.length - 1 + containerColor: Theme.withBlur(Theme.surfaceHighest) + clickable: false + headline: row.modelData.name + supporting: Agenda.timeText(row.modelData) + (row.modelData.repeat ? " · " + Agenda.repeatText(row.modelData) : "") + (row.modelData.source === "thunderbird" ? " · " + row.modelData.calendar : "") + icon: row.modelData.source === "thunderbird" ? "event" : (row.modelData.repeat ? "repeat" : "notifications") + iconColor: Theme.primary + trailing: [ + IconButton { + visible: row.modelData.source !== "thunderbird" + icon: "delete" + size: "xs" + onClicked: Agenda.remove(row.modelData.id) + } + ] + } + + } + + } + + Column { + visible: page.dayItems.length === 0 + anchors.horizontalCenter: parent.horizontalCenter + y: Theme.dp(24) + spacing: Theme.dp(8) + + Icon { + anchors.horizontalCenter: parent.horizontalCenter + name: "event_available" + size: Theme.dp(32) + color: Theme.subtextDim + } + + LText { + anchors.horizontalCenter: parent.horizontalCenter + role: "bodyMedium" + color: Theme.subtext + text: "A free day" + } + + } + + } + + Column { + id: form + + anchors.bottom: parent.bottom + width: parent.width + spacing: Theme.dp(10) + + TextField { + id: nameField + + width: parent.width + label: "New reminder" + placeholder: "What should it say?" + supporting: page.past && nameField.text.trim() !== "" ? (page.gone(0, 0) && !page.gone(23, 59) ? Agenda.timeText({ "hour": page.newHour, "minute": page.newMinute }) + " has already gone by today" : "That day has already gone by") : "" + containerColor: Theme.withBlur(Theme.surfaceHighest) + onAccepted: page.submit() + } + + ButtonGroup { + width: parent.width + size: "xs" + options: Agenda.repeatOptions + current: page.newRepeat + onPicked: (k) => { + return page.newRepeat = k; + } + } + + Row { + width: parent.width + spacing: Theme.dp(8) + + Rectangle { + id: timeBox + + width: timeRow.implicitWidth + Theme.dp(20) + height: Theme.dp(36) + radius: Theme.dp(18) + color: Theme.withBlur(Theme.surfaceHighest) + + Row { + id: timeRow + + anchors.centerIn: parent + spacing: Theme.dp(2) + + TimeDigit { + id: hourDigit + + anchors.verticalCenter: parent.verticalCenter + value: page.shownHour + span: Prefs.clock24h ? 24 : 12 + nextDigit: minuteDigit + onTyped: (v) => { + return page.typeHour(v); + } + onMeridiem: (wantPm) => { + return page.setPm(wantPm); + } + onAccepted: page.submit() + } + + LText { + anchors.verticalCenter: parent.verticalCenter + role: "labelLarge" + weight: 640 + color: Theme.subtext + text: ":" + } + + TimeDigit { + id: minuteDigit + + anchors.verticalCenter: parent.verticalCenter + value: page.newMinute + span: 60 + onTyped: (v) => { + return page.typeMinute(v); + } + onMeridiem: (wantPm) => { + return page.setPm(wantPm); + } + onAccepted: page.submit() + } + + Item { + visible: !Prefs.clock24h + anchors.verticalCenter: parent.verticalCenter + width: Theme.dp(32) + height: Theme.dp(30) + + LText { + anchors.centerIn: parent + role: "labelMedium" + weight: 640 + color: Theme.primary + text: page.pm ? "PM" : "AM" + } + + StateLayer { + radius: Theme.rad(8) + onClicked: page.setPm(!page.pm) + } + + } + + } + + // the wheel moves a minute at a time + WheelHandler { + onWheel: (e) => { + return page.step(e.angleDelta.y > 0 ? 1 : -1); + } + } + + } + + Item { + width: parent.width - timeBox.width - addBtn.width - Theme.dp(16) + height: 1 + } + + Button { + id: addBtn + + variant: "filled" + size: "xs" + icon: "add" + text: "Add" + disabled: nameField.text.trim() === "" || page.past + onClicked: page.submit() + } + + } + + } + + } + + } + +} diff --git a/modules/user/thunderbird-calendar.py b/modules/user/thunderbird-calendar.py new file mode 100644 index 0000000..a5ae09d --- /dev/null +++ b/modules/user/thunderbird-calendar.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Read-only bridge from Thunderbird's synchronized CalDAV event cache.""" + +import configparser +import datetime as dt +import json +import re +import shutil +import sqlite3 +import tempfile +import time +from pathlib import Path + + +HOME = Path.home() +DB_NAME = "cache.sqlite" +WINDOW_START = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=30) +WINDOW_END = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=400) + + +def active_profile(): + ini = configparser.ConfigParser(interpolation=None) + ini.read(HOME / ".thunderbird" / "profiles.ini", encoding="utf-8") + for section in ini.sections(): + if not section.startswith("Profile") or ini.get(section, "Default", fallback="0") != "1": + continue + profile = Path(ini.get(section, "Path")) + if ini.get(section, "IsRelative", fallback="1") == "1": + profile = HOME / ".thunderbird" / profile + return profile + raise FileNotFoundError("No default Thunderbird profile") + + +def calendar_registry(profile): + prefs = profile / "prefs.js" + pattern = re.compile(r'^user_pref\("calendar\.registry\.([^.]+)\.([^\"]+)",\s*(.*)\);$') + registry = {} + if not prefs.exists(): + return registry + with prefs.open(encoding="utf-8", errors="replace") as source: + for line in source: + match = pattern.match(line.strip()) + if not match: + continue + calendar_id, key, raw = match.groups() + try: + value = json.loads(raw) + except json.JSONDecodeError: + continue + registry.setdefault(calendar_id, {})[key] = value + return { + calendar_id: data + for calendar_id, data in registry.items() + if data.get("type") == "caldav" and not data.get("disabled", False) + } + + +def stable_snapshot(database, target_dir): + """Copy DB and WAL together; retry if Thunderbird writes during the copy.""" + target = target_dir / DB_NAME + sidecars = [Path(str(database) + suffix) for suffix in ("", "-wal", "-shm")] + for _ in range(4): + before = [] + for path in sidecars: + try: + stat = path.stat() + before.append((stat.st_size, stat.st_mtime_ns)) + except FileNotFoundError: + before.append(None) + for path, state in zip(sidecars, before): + dest = Path(str(target) + str(path)[len(str(database)):]) + if state is None: + dest.unlink(missing_ok=True) + else: + shutil.copy2(path, dest) + after = [] + for path in sidecars: + try: + stat = path.stat() + after.append((stat.st_size, stat.st_mtime_ns)) + except FileNotFoundError: + after.append(None) + if before == after: + return target + time.sleep(0.05) + raise RuntimeError("Thunderbird calendar cache changed during snapshot") + + +def parse_until(value): + if not value: + return None + value = value.strip() + try: + if re.fullmatch(r"\d{8}", value): + return dt.datetime.strptime(value, "%Y%m%d").replace(tzinfo=dt.timezone.utc) + return dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S").replace(tzinfo=dt.timezone.utc) + except ValueError: + return None + + +def parse_recurrence(text): + rules = [] + excluded = set() + for line in text.replace("\r", "").split("\n"): + if line.startswith("RRULE:"): + rules.append(dict(part.split("=", 1) for part in line[6:].split(";") if "=" in part)) + elif line.startswith("EXDATE") and ":" in line: + values = line.split(":", 1)[1].split(",") + for value in values: + parsed = parse_until(value) + if parsed: + excluded.add(int(parsed.timestamp() * 1000)) + return rules, excluded + + +def month_dates(year, month, base_day, rule): + import calendar + + month_days = int(calendar.monthrange(year, month)[1]) + if "BYMONTHDAY" in rule: + days = [] + for raw in rule["BYMONTHDAY"].split(","): + value = int(raw) + day = value if value > 0 else month_days + value + 1 + if 1 <= day <= month_days: + days.append(day) + return sorted(set(days)) + if "BYDAY" not in rule: + return [base_day] if base_day <= month_days else [] + + weekdays = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6} + result = [] + for entry in rule["BYDAY"].split(","): + match = re.fullmatch(r"([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)", entry) + if not match: + continue + ordinal, name = match.groups() + weekday = weekdays[name] + matching = [day for day in range(1, month_days + 1) if dt.date(year, month, day).weekday() == weekday] + if ordinal: + index = int(ordinal) + if index < 0: + index = len(matching) + index + 1 + if 1 <= index <= len(matching): + result.append(matching[index - 1]) + else: + result.extend(matching) + return sorted(set(result)) + + +def occurrences(start, rule, excluded): + freq = rule.get("FREQ", "").upper() + interval = max(1, int(rule.get("INTERVAL", "1"))) + count_limit = int(rule["COUNT"]) if rule.get("COUNT", "").isdigit() else None + until = parse_until(rule.get("UNTIL")) + candidates = [] + base_date = start.date() + + if freq == "DAILY": + day = base_date + while day <= WINDOW_END.date(): + delta = (day - base_date).days + if delta % interval == 0: + candidates.append(dt.datetime.combine(day, start.timetz())) + day += dt.timedelta(days=1) + elif freq == "WEEKLY": + days = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6} + weekdays = sorted({days[x] for x in rule.get("BYDAY", "").split(",") if x in days}) or [base_date.weekday()] + week = base_date - dt.timedelta(days=base_date.weekday()) + while week <= WINDOW_END.date(): + weeks = (week - (base_date - dt.timedelta(days=base_date.weekday()))).days // 7 + if weeks % interval == 0: + for weekday in weekdays: + day = week + dt.timedelta(days=weekday) + if day >= base_date: + candidates.append(dt.datetime.combine(day, start.timetz())) + week += dt.timedelta(days=7) + elif freq == "MONTHLY": + month_index = base_date.year * 12 + base_date.month - 1 + last_index = WINDOW_END.year * 12 + WINDOW_END.month - 1 + for index in range(month_index, last_index + 1, interval): + year, month0 = divmod(index, 12) + for day in month_dates(year, month0 + 1, base_date.day, rule): + date = dt.date(year, month0 + 1, day) + if date >= base_date: + candidates.append(dt.datetime.combine(date, start.timetz())) + elif freq == "YEARLY": + for year in range(base_date.year, WINDOW_END.year + 1, interval): + months = [int(x) for x in rule.get("BYMONTH", str(base_date.month)).split(",")] + for month in months: + for day in month_dates(year, month, base_date.day, rule): + date = dt.date(year, month, day) + if date >= base_date: + candidates.append(dt.datetime.combine(date, start.timetz())) + + candidates.sort() + result = [] + for ordinal, occurrence in enumerate(candidates): + if count_limit is not None and ordinal >= count_limit: + break + if until and occurrence > until: + break + stamp = int(occurrence.timestamp() * 1000) + if WINDOW_START.timestamp() * 1000 <= stamp <= WINDOW_END.timestamp() * 1000 and stamp not in excluded: + result.append(stamp) + return result + + +def main(): + profile = active_profile() + registry = calendar_registry(profile) + database = profile / "calendar-data" / DB_NAME + events = [] + if registry and database.exists(): + with tempfile.TemporaryDirectory(prefix="lucid-tb-calendar-") as td: + snapshot = stable_snapshot(database, Path(td)) + db = sqlite3.connect(snapshot) + db.row_factory = sqlite3.Row + recurrence = {} + for row in db.execute("SELECT cal_id, item_id, icalString FROM cal_recurrence"): + recurrence[(row["cal_id"], row["item_id"])] = parse_recurrence(row["icalString"] or "") + + rows = list(db.execute( + "SELECT cal_id, id, title, event_start, event_end, recurrence_id, ical_status " + "FROM cal_events WHERE event_start IS NOT NULL" + )) + overrides = set() + for row in rows: + if row["recurrence_id"] is not None: + try: + overrides.add((row["cal_id"], row["id"], int(row["recurrence_id"]))) + except (TypeError, ValueError): + pass + + for row in rows: + cal_id = row["cal_id"] + if cal_id not in registry or (row["ical_status"] or "").upper() == "CANCELLED": + continue + try: + start_us = int(row["event_start"]) + except (TypeError, ValueError): + continue + start_ms = start_us // 1000 + duration_ms = max(0, int(row["event_end"] or row["event_start"]) // 1000 - start_ms) + title = (row["title"] or "(untitled event)").strip() + cal_name = registry[cal_id].get("name") or "CalDAV" + rule_data = recurrence.get((cal_id, row["id"])) + starts = [start_ms] + if rule_data: + rules, excluded = rule_data + if rules: + base = dt.datetime.fromtimestamp(start_ms / 1000, dt.timezone.utc) + starts = occurrences(base, rules[0], excluded) + for occurrence_ms in starts: + if (cal_id, row["id"], occurrence_ms * 1000) in overrides: + continue + if not (WINDOW_START.timestamp() * 1000 <= occurrence_ms <= WINDOW_END.timestamp() * 1000): + continue + events.append({ + "id": f"{cal_id}:{row['id']}:{occurrence_ms}", + "name": title, + "calendar": cal_name, + "startMs": occurrence_ms, + "endMs": occurrence_ms + duration_ms, + }) + db.close() + + events.sort(key=lambda event: (event["startMs"], event["name"].casefold())) + print(json.dumps({"events": events}, ensure_ascii=False, separators=(",", ":"))) + + +if __name__ == "__main__": + try: + main() + except Exception: + # Keep refresh failures out of Lucid's console: current calendar events + # remain visible until the next successful sync. + print(json.dumps({"events": [], "error": "Thunderbird calendar cache unavailable"})) diff --git a/user/sweetbread/modules/lucid.nix b/user/sweetbread/modules/lucid.nix index 83b1b53..c18195c 100644 --- a/user/sweetbread/modules/lucid.nix +++ b/user/sweetbread/modules/lucid.nix @@ -170,6 +170,10 @@ in { fi $DRY_RUN_CMD install -m644 ${../../../modules/user/lucid-shell.qml} "$target/shell.qml" $DRY_RUN_CMD install -m644 ${../../../modules/user/LucidBar.qml} "$target/LucidBar.qml" + $DRY_RUN_CMD install -m644 ${../../../modules/user/Agenda.qml} "$target/Agenda.qml" + $DRY_RUN_CMD install -m644 ${../../../modules/user/CalendarSource.qml} "$target/CalendarSource.qml" + $DRY_RUN_CMD install -m644 ${../../../modules/user/ClockCalendar.qml} "$target/lucidbar/ClockCalendar.qml" + $DRY_RUN_CMD install -m644 ${../../../modules/user/thunderbird-calendar.py} "$target/thunderbird-calendar.py" $DRY_RUN_CMD mkdir -p "${wallpaperDir}" \ "${config.home.homeDirectory}/.config/hypr/scripts/wallpaper" $DRY_RUN_CMD ${pkgs.rsync}/bin/rsync -rltp --chmod=Du+rwx,Fu+rw --ignore-existing \