Lucid: Show calendar event from Thunderbird
This commit is contained in:
5 files changed
+1226
No files matched your search
@@ -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: []
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"}))
|
||||||
@@ -170,6 +170,10 @@ in {
|
|||||||
fi
|
fi
|
||||||
$DRY_RUN_CMD install -m644 ${../../../modules/user/lucid-shell.qml} "$target/shell.qml"
|
$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/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}" \
|
$DRY_RUN_CMD mkdir -p "${wallpaperDir}" \
|
||||||
"${config.home.homeDirectory}/.config/hypr/scripts/wallpaper"
|
"${config.home.homeDirectory}/.config/hypr/scripts/wallpaper"
|
||||||
$DRY_RUN_CMD ${pkgs.rsync}/bin/rsync -rltp --chmod=Du+rwx,Fu+rw --ignore-existing \
|
$DRY_RUN_CMD ${pkgs.rsync}/bin/rsync -rltp --chmod=Du+rwx,Fu+rw --ignore-existing \
|
||||||
|
|||||||
Reference in new issue
Block a user