A Process's exited and streamFinished signals aren't guaranteed to fire in order, and several services decided an outcome on whichever fired first: KdeConnect could report a successful file transfer as failed if exited landed before the real stdout payload; Clipboard could present a failed history query as an empty-but-healthy one; Brightness could strand the last queued write of a drag; SoundFeedback and SystemLocale could drop or misapply a rapid second toggle/click because re-arming an already-running Process is a no-op. All five now wait for both signals and let the authoritative one decide, matching the pattern HomeAssistantConfig.qml already used correctly. Health's "copy report" never enabled stdin, so it copied nothing while claiming success. Capture announced every recording as saved regardless of the recorder's actual exit code. Connectivity never restarted Bluetooth discovery when the adapter was enabled from an already-open page. CalendarAgenda left the UI in "loading" forever if its helper died at startup, and the helper itself could crash unguarded instead of reporting unavailable. Geocoding silently dropped a query typed while the previous one was still in flight. Notifs leaked tracked-but-undisplayed notifications under Do Not Disturb, and dismissAll() skipped them. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
323 lines
13 KiB
QML
323 lines
13 KiB
QML
pragma Singleton
|
|
|
|
// Calendar state shared by the Daybook and the quiet bar capsule. GNOME's
|
|
// Evolution Data Server owns sync and credentials; the helper only presents a
|
|
// small, local JSON view of enabled event sources.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/calendar-agenda"
|
|
|
|
// "loading" | "ready" | "degraded" | "unavailable"
|
|
property string phase: "loading"
|
|
readonly property bool available: (phase === "ready" || phase === "degraded") && sources.length > 0
|
|
property var sources: []
|
|
property var events: []
|
|
property var errors: []
|
|
property int generatedAt: 0
|
|
|
|
property date selectedDate: new Date()
|
|
readonly property string selectedDateKey: root.dateKey(selectedDate)
|
|
readonly property var selectedEvents: root.eventsForDate(selectedDate)
|
|
readonly property var nextEventForSelectedDate: root._nextForDate(selectedDate)
|
|
|
|
property int rangeStart: 0
|
|
property int rangeEnd: 0
|
|
property bool fixtureMode: false
|
|
property int fixtureNow: 0
|
|
property bool watchWanted: false
|
|
|
|
// Counts watch attempts that exit without ever emitting a snapshot, so a
|
|
// helper that is missing or crashes on launch (e.g. no evolution-data-server)
|
|
// is reported instead of retried forever in silence.
|
|
property int consecutiveWatchFailures: 0
|
|
property bool watchProducedSnapshot: false
|
|
readonly property int maxConsecutiveWatchFailures: 3
|
|
|
|
readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000)
|
|
readonly property var nextEvent: {
|
|
const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch);
|
|
candidates.sort((a, b) => Number(a.start) - Number(b.start));
|
|
return candidates.length > 0 ? candidates[0] : null;
|
|
}
|
|
readonly property int nextEventOffset: nextEvent ? Number(nextEvent.start) - nowEpoch : 999999
|
|
readonly property bool capsuleVisible: nextEvent !== null && nextEventOffset <= 900 && nextEventOffset >= -300
|
|
readonly property string capsuleText: {
|
|
if (!root.capsuleVisible)
|
|
return "";
|
|
if (root.nextEventOffset <= 0)
|
|
return "Now";
|
|
return Math.ceil(root.nextEventOffset / 60) + "m";
|
|
}
|
|
|
|
SystemClock {
|
|
id: clock
|
|
precision: SystemClock.Minutes
|
|
}
|
|
|
|
function dateKey(date: date): string {
|
|
return Qt.formatDate(date, "yyyy-MM-dd");
|
|
}
|
|
|
|
function dateFromKey(value: string): date {
|
|
const parts = value.split("-").map(Number);
|
|
if (parts.length !== 3 || parts.some(part => !Number.isFinite(part)))
|
|
return new Date(NaN);
|
|
return new Date(parts[0], parts[1] - 1, parts[2]);
|
|
}
|
|
|
|
function selectDate(date: date): void {
|
|
if (!date || Number.isNaN(date.getTime()))
|
|
return;
|
|
root.selectedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
}
|
|
|
|
function selectIsoDate(value: string): void {
|
|
root.selectDate(root.dateFromKey(value));
|
|
}
|
|
|
|
function sourceForId(sourceId: string): var {
|
|
return root.sources.find(source => source.id === sourceId) ?? null;
|
|
}
|
|
|
|
function eventsForDate(date: date): var {
|
|
if (!date || Number.isNaN(date.getTime()))
|
|
return [];
|
|
const start = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000);
|
|
const end = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime() / 1000);
|
|
const matches = root.events.filter(event => Number(event.start) < end && Number(event.end) > start);
|
|
matches.sort((a, b) => {
|
|
if (!!a.allDay !== !!b.allDay)
|
|
return a.allDay ? -1 : 1;
|
|
if (Number(a.start) !== Number(b.start))
|
|
return Number(a.start) - Number(b.start);
|
|
return String(a.id).localeCompare(String(b.id));
|
|
});
|
|
return matches;
|
|
}
|
|
|
|
function markersForDate(date: date): var {
|
|
const seen = ({});
|
|
const markers = [];
|
|
for (const event of root.eventsForDate(date)) {
|
|
if (seen[event.sourceId])
|
|
continue;
|
|
seen[event.sourceId] = true;
|
|
const source = root.sourceForId(event.sourceId);
|
|
markers.push({ sourceId: event.sourceId, color: source?.color ?? "#82aaff" });
|
|
}
|
|
return markers;
|
|
}
|
|
|
|
function _nextForDate(date: date): var {
|
|
const isToday = root.dateKey(date) === root.dateKey(clock.date);
|
|
const candidates = root.eventsForDate(date).filter(event => {
|
|
if (event.allDay)
|
|
return false;
|
|
return !isToday || Number(event.end) > root.nowEpoch;
|
|
});
|
|
return candidates.length > 0 ? candidates[0] : null;
|
|
}
|
|
|
|
function setVisibleMonth(year: int, month: int): void {
|
|
const requestedStart = Math.floor(new Date(year, month, -6).getTime() / 1000);
|
|
const requestedEnd = Math.floor(new Date(year, month, 43).getTime() / 1000);
|
|
if (requestedStart >= root.rangeStart && requestedEnd <= root.rangeEnd)
|
|
return;
|
|
root.rangeStart = requestedStart;
|
|
root.rangeEnd = requestedEnd;
|
|
root._restartWatch();
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (!root.fixtureMode)
|
|
root._restartWatch();
|
|
}
|
|
|
|
function _restartWatch(): void {
|
|
if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart)
|
|
return;
|
|
// Once the helper has been declared unavailable, don't flash back to
|
|
// "loading" on every retry -- only a real snapshot should clear it.
|
|
if (root.phase !== "unavailable")
|
|
root.phase = root.events.length > 0 ? root.phase : "loading";
|
|
root.watchWanted = false;
|
|
root.watchProducedSnapshot = false;
|
|
startTimer.restart();
|
|
}
|
|
|
|
function consumeSnapshot(data: string): void {
|
|
if (root.fixtureMode || data.trim() === "")
|
|
return;
|
|
root.watchProducedSnapshot = true;
|
|
root.consecutiveWatchFailures = 0;
|
|
try {
|
|
const snapshot = JSON.parse(data);
|
|
if (snapshot.ok !== true) {
|
|
root.phase = "unavailable";
|
|
root.errors = snapshot.errors ?? [];
|
|
return;
|
|
}
|
|
root.sources = Array.isArray(snapshot.sources) ? snapshot.sources : [];
|
|
root.events = Array.isArray(snapshot.events) ? snapshot.events : [];
|
|
root.errors = Array.isArray(snapshot.errors) ? snapshot.errors : [];
|
|
root.generatedAt = Number(snapshot.generatedAt) || 0;
|
|
root.phase = root.errors.length > 0 ? "degraded" : "ready";
|
|
} catch (error) {
|
|
root.phase = "unavailable";
|
|
root.errors = [{ code: "invalid-snapshot" }];
|
|
}
|
|
}
|
|
|
|
function openEvent(event: var): void {
|
|
if (!event?.uid)
|
|
return;
|
|
Quickshell.execDetached(["gnome-calendar", "--uuid", String(event.uid)]);
|
|
}
|
|
|
|
function openCalendar(date: date): void {
|
|
const target = date && !Number.isNaN(date.getTime()) ? date : root.selectedDate;
|
|
// GNOME Calendar passes this value to g_date_time_new_from_iso8601(),
|
|
// which requires a time and timezone rather than a bare YYYY-MM-DD.
|
|
Quickshell.execDetached(["gnome-calendar", "--date", target.toISOString()]);
|
|
}
|
|
|
|
function join(event: var): void {
|
|
const url = String(event?.joinUrl ?? "");
|
|
if (url.startsWith("https://"))
|
|
Quickshell.execDetached(["xdg-open", url]);
|
|
}
|
|
|
|
function applyFixture(name: string): void {
|
|
if (name !== "daybook" && name !== "upcoming" && name !== "outside-window")
|
|
return;
|
|
|
|
root.watchWanted = false;
|
|
root.fixtureMode = true;
|
|
root.fixtureNow = Math.floor(new Date(2026, 7, 17, 11, 6).getTime() / 1000);
|
|
|
|
if (name === "upcoming" || name === "outside-window") {
|
|
const offset = name === "upcoming" ? 12 * 60 : 20 * 60;
|
|
root.sources = [{ id: "fixture-work", name: "Fixture Work", color: "#82aaff" }];
|
|
root.events = [
|
|
{
|
|
id: "fixture-work:timed:1", sourceId: "fixture-work", uid: "fixture-timed",
|
|
summary: "Fixture appointment", start: root.fixtureNow + offset,
|
|
end: root.fixtureNow + offset + 30 * 60, allDay: false,
|
|
location: "", joinUrl: ""
|
|
},
|
|
{
|
|
id: "fixture-work:all-day:1", sourceId: "fixture-work", uid: "fixture-all-day",
|
|
summary: "Fixture all day", start: Math.floor(new Date(2026, 7, 17).getTime() / 1000),
|
|
end: Math.floor(new Date(2026, 7, 18).getTime() / 1000), allDay: true,
|
|
location: "", joinUrl: ""
|
|
}
|
|
];
|
|
root.errors = [];
|
|
root.generatedAt = root.fixtureNow;
|
|
root.phase = "ready";
|
|
root.selectIsoDate("2026-08-17");
|
|
return;
|
|
}
|
|
|
|
root.sources = [
|
|
{ id: "fixture-work", name: "Fixture Work", color: "#82aaff" },
|
|
{ id: "fixture-cloud", name: "Fixture Cloud", color: "#c3e88d" },
|
|
{ id: "fixture-personal", name: "Fixture Personal", color: "#fca7ea" }
|
|
];
|
|
root.events = [
|
|
{
|
|
id: "fixture-work:standup:1", sourceId: "fixture-work", uid: "fixture-standup",
|
|
summary: "Fixture standup", start: Math.floor(new Date(2026, 7, 17, 11, 30).getTime() / 1000),
|
|
end: Math.floor(new Date(2026, 7, 17, 12, 0).getTime() / 1000), allDay: false,
|
|
location: "Fixture Meet", joinUrl: "https://meet.google.com/abc-defg-hij"
|
|
},
|
|
{
|
|
id: "fixture-work:planning:1", sourceId: "fixture-work", uid: "fixture-planning",
|
|
summary: "Fixture planning", start: Math.floor(new Date(2026, 7, 17, 13, 0).getTime() / 1000),
|
|
end: Math.floor(new Date(2026, 7, 17, 13, 45).getTime() / 1000), allDay: false,
|
|
location: "", joinUrl: ""
|
|
},
|
|
{
|
|
id: "fixture-cloud:focus:1", sourceId: "fixture-cloud", uid: "fixture-focus",
|
|
summary: "Fixture focus", start: Math.floor(new Date(2026, 7, 17, 14, 30).getTime() / 1000),
|
|
end: Math.floor(new Date(2026, 7, 17, 16, 0).getTime() / 1000), allDay: false,
|
|
location: "", joinUrl: ""
|
|
},
|
|
{
|
|
id: "fixture-personal:dinner:1", sourceId: "fixture-personal", uid: "fixture-dinner",
|
|
summary: "Fixture dinner", start: Math.floor(new Date(2026, 7, 17, 18, 30).getTime() / 1000),
|
|
end: Math.floor(new Date(2026, 7, 17, 20, 0).getTime() / 1000), allDay: false,
|
|
location: "Fixture place", joinUrl: ""
|
|
},
|
|
{
|
|
id: "fixture-cloud:tomorrow:1", sourceId: "fixture-cloud", uid: "fixture-tomorrow",
|
|
summary: "Fixture tomorrow", start: Math.floor(new Date(2026, 7, 18, 9, 0).getTime() / 1000),
|
|
end: Math.floor(new Date(2026, 7, 18, 10, 0).getTime() / 1000), allDay: false,
|
|
location: "", joinUrl: ""
|
|
}
|
|
];
|
|
root.errors = [];
|
|
root.generatedAt = root.fixtureNow;
|
|
root.phase = "ready";
|
|
root.selectIsoDate("2026-08-17");
|
|
}
|
|
|
|
function clearFixture(): void {
|
|
root.fixtureMode = false;
|
|
root.fixtureNow = 0;
|
|
root.sources = [];
|
|
root.events = [];
|
|
root.errors = [];
|
|
root.selectedDate = new Date();
|
|
root.phase = "loading";
|
|
root.consecutiveWatchFailures = 0;
|
|
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
|
|
root._restartWatch();
|
|
}
|
|
|
|
Process {
|
|
id: watchProc
|
|
command: [root.helperPath, "watch", String(root.rangeStart), String(root.rangeEnd)]
|
|
running: root.watchWanted && !root.fixtureMode && root.rangeStart > 0
|
|
stdout: SplitParser {
|
|
onRead: data => root.consumeSnapshot(data)
|
|
}
|
|
onExited: (code, status) => {
|
|
if (!root.watchProducedSnapshot) {
|
|
root.consecutiveWatchFailures += 1;
|
|
// The helper died before ever emitting a snapshot, repeatedly --
|
|
// stop pretending this is still loading and surface the same
|
|
// "unavailable" state probe()/collect_snapshot() report.
|
|
if (root.consecutiveWatchFailures >= root.maxConsecutiveWatchFailures) {
|
|
root.phase = "unavailable";
|
|
root.errors = [{ code: "eds-unavailable" }];
|
|
}
|
|
}
|
|
if (root.watchWanted && !root.fixtureMode)
|
|
restartTimer.restart();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: startTimer
|
|
interval: 40
|
|
onTriggered: root.watchWanted = !root.fixtureMode
|
|
}
|
|
|
|
Timer {
|
|
id: restartTimer
|
|
interval: 10000
|
|
onTriggered: root._restartWatch()
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth());
|
|
}
|
|
}
|