From 90c1704f870aaafac7fb181e954b29740b39f7d1 Mon Sep 17 00:00:00 2001 From: Raul Benencia Date: Mon, 13 Apr 2026 08:11:52 -0700 Subject: org-agenda-shell Initial version of org-agenda-shell. Alpha. --- .../extension.js | 548 +++++++++++++++++++++ .../metadata.json | 11 + .../org-agenda-indicator@rbenencia.name/prefs.js | 33 ++ ...ell.extensions.org-agenda-indicator.gschema.xml | 14 + .../stylesheet.css | 38 ++ 5 files changed, 644 insertions(+) create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css (limited to '.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name') diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js new file mode 100644 index 0000000..b92048c --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js @@ -0,0 +1,548 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import Pango from 'gi://Pango'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +const SNAPSHOT_RELATIVE_PATH = '.cache/org-agenda-shell/today.json'; +const REFRESH_INTERVAL_SECONDS = 60; +const RELOAD_DELAY_MS = 200; +const PANEL_TITLE_MAX = 20; + +const InfoMenuItem = GObject.registerClass( +class InfoMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(primaryText, secondaryText = '', {overdue = false} = {}) { + super._init({reactive: false, can_focus: false}); + + const box = new St.BoxLayout({ + vertical: true, + x_expand: true, + style_class: 'org-agenda-indicator-row', + }); + + this._primaryLabel = new St.Label({ + text: primaryText, + x_expand: true, + style_class: 'org-agenda-indicator-row-title', + }); + this._primaryLabel.clutter_text.ellipsize = Pango.EllipsizeMode.END; + + if (overdue) + this._primaryLabel.add_style_class_name('org-agenda-indicator-overdue'); + + box.add_child(this._primaryLabel); + + if (secondaryText) { + this._secondaryLabel = new St.Label({ + text: secondaryText, + x_expand: true, + style_class: 'org-agenda-indicator-row-meta', + }); + this._secondaryLabel.clutter_text.ellipsize = Pango.EllipsizeMode.END; + box.add_child(this._secondaryLabel); + } + + this.add_child(box); + } +}); + +const SectionHeaderItem = GObject.registerClass( +class SectionHeaderItem extends PopupMenu.PopupBaseMenuItem { + _init(text) { + super._init({reactive: false, can_focus: false}); + + const label = new St.Label({ + text, + x_expand: true, + style_class: 'org-agenda-indicator-section-label', + }); + const box = new St.BoxLayout({ + x_expand: true, + style_class: 'org-agenda-indicator-section', + }); + + box.add_child(label); + this.add_child(box); + } +}); + +const Indicator = GObject.registerClass( +class Indicator extends PanelMenu.Button { + _init(settings) { + super._init(0.0, 'Org Agenda Indicator'); + + this._settings = settings; + this._snapshotPath = GLib.build_filenamev([ + GLib.get_home_dir(), + SNAPSHOT_RELATIVE_PATH, + ]); + this._snapshotDir = GLib.path_get_dirname(this._snapshotPath); + this._snapshotParentDir = GLib.path_get_dirname(this._snapshotDir); + this._snapshotDirBaseName = GLib.path_get_basename(this._snapshotDir); + this._snapshotBaseName = GLib.path_get_basename(this._snapshotPath); + this._directoryMonitor = null; + this._monitoredDirectory = null; + this._refreshSourceId = 0; + this._reloadSourceId = 0; + this._state = { + status: 'loading', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: null, + }; + + this._label = new St.Label({ + text: '...', + y_align: Clutter.ActorAlign.CENTER, + name: 'org-agenda-indicator-label', + }); + this._label.clutter_text.ellipsize = Pango.EllipsizeMode.END; + this.add_child(this._label); + + this._showClockedInChangedId = this._settings.connect( + 'changed::show-clocked-in-task', + () => { + this._syncLabel(); + this._rebuildMenu(); + }); + + this._refreshSourceId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + REFRESH_INTERVAL_SECONDS, + () => { + this._loadSnapshot(); + return GLib.SOURCE_CONTINUE; + }); + + this._startMonitoring(); + this._loadSnapshot(); + } + + destroy() { + if (this._reloadSourceId) { + GLib.source_remove(this._reloadSourceId); + this._reloadSourceId = 0; + } + + if (this._refreshSourceId) { + GLib.source_remove(this._refreshSourceId); + this._refreshSourceId = 0; + } + + this._directoryMonitor?.cancel(); + this._directoryMonitor = null; + if (this._showClockedInChangedId) { + this._settings.disconnect(this._showClockedInChangedId); + this._showClockedInChangedId = 0; + } + + super.destroy(); + } + + _startMonitoring() { + this._directoryMonitor?.cancel(); + this._directoryMonitor = null; + + const targetDirectory = GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR) + ? this._snapshotDir + : this._snapshotParentDir; + const directory = Gio.File.new_for_path(targetDirectory); + + try { + this._directoryMonitor = directory.monitor_directory( + Gio.FileMonitorFlags.WATCH_MOVES, + null); + this._monitoredDirectory = targetDirectory; + this._directoryMonitor.connect('changed', (_monitor, file, otherFile, eventType) => { + if (!this._eventTouchesSnapshot(file, otherFile, eventType)) + return; + + this._scheduleReload(); + }); + } catch (error) { + this._monitoredDirectory = null; + this._setState({ + status: 'error', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: `Unable to monitor ${this._snapshotDir}: ${error.message}`, + }); + } + } + + _eventTouchesSnapshot(file, otherFile, eventType) { + const relevantEvents = new Set([ + Gio.FileMonitorEvent.CHANGED, + Gio.FileMonitorEvent.CHANGES_DONE_HINT, + Gio.FileMonitorEvent.CREATED, + Gio.FileMonitorEvent.MOVED_IN, + Gio.FileMonitorEvent.MOVED_OUT, + Gio.FileMonitorEvent.DELETED, + Gio.FileMonitorEvent.ATTRIBUTE_CHANGED, + ]); + + if (!relevantEvents.has(eventType)) + return false; + + const names = [ + file?.get_basename(), + otherFile?.get_basename(), + ]; + + if (this._monitoredDirectory === this._snapshotParentDir) + return names.includes(this._snapshotDirBaseName); + + return names.includes(this._snapshotBaseName); + } + + _scheduleReload() { + if (this._reloadSourceId) + return; + + this._reloadSourceId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + RELOAD_DELAY_MS, + () => { + this._reloadSourceId = 0; + this._loadSnapshot(); + return GLib.SOURCE_REMOVE; + }); + } + + _loadSnapshot() { + const snapshotFile = Gio.File.new_for_path(this._snapshotPath); + + if ((this._monitoredDirectory === this._snapshotParentDir && + GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR)) || + (this._monitoredDirectory === this._snapshotDir && + !GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR))) + this._startMonitoring(); + + try { + if (!snapshotFile.query_exists(null)) { + this._setState({ + status: 'missing', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: null, + }); + return; + } + + const [, contents] = snapshotFile.load_contents(null); + const payload = JSON.parse(new TextDecoder().decode(contents)); + const currentDate = GLib.DateTime.new_now_local().format('%F'); + + if (payload.date !== currentDate) { + this._setState({ + status: 'stale', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: payload.date ?? null, + generatedAt: payload.generated_at ?? null, + generatedEpoch: payload.generated_epoch ?? null, + tasks: [], + clockedIn: payload.clocked_in ?? null, + error: null, + }); + return; + } + + const tasks = Array.isArray(payload.today_tasks) ? payload.today_tasks : []; + const overdueCount = Number(payload.overdue_count ?? tasks.filter(task => task.is_overdue).length); + const todayCount = Number(payload.today_count ?? tasks.length - overdueCount); + + this._setState({ + status: 'ready', + taskCount: Number(payload.task_count ?? tasks.length), + todayCount, + overdueCount, + date: payload.date ?? null, + generatedAt: payload.generated_at ?? null, + generatedEpoch: payload.generated_epoch ?? null, + tasks, + clockedIn: payload.clocked_in ?? null, + error: null, + }); + } catch (error) { + this._setState({ + status: 'error', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: error.message, + }); + } + } + + _setState(nextState) { + this._state = nextState; + this._syncLabel(); + this._rebuildMenu(); + } + + _syncLabel() { + switch (this._state.status) { + case 'ready': + this._label.set_text(this._panelText()); + break; + case 'loading': + this._label.set_text('...'); + break; + case 'missing': + this._label.set_text('-'); + break; + default: + this._label.set_text('?'); + break; + } + } + + _rebuildMenu() { + this.menu.removeAll(); + + this.menu.addMenuItem(new InfoMenuItem(this._summaryText(), this._summaryMetaText())); + + if (this._state.error) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.menu.addMenuItem(new InfoMenuItem('Unable to read agenda snapshot', this._state.error)); + return; + } + + if (this._state.status !== 'ready') + return; + + const todayTasks = this._state.tasks.filter(task => !task.is_overdue); + const overdueTasks = this._state.tasks.filter(task => task.is_overdue); + + if (this._showClockedInTask() && this._state.clockedIn) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.menu.addMenuItem(new SectionHeaderItem('Clocked In')); + this.menu.addMenuItem(this._clockedInItem(this._state.clockedIn)); + } + + if (todayTasks.length > 0 || overdueTasks.length > 0) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + } + + if (todayTasks.length > 0) { + this.menu.addMenuItem(new SectionHeaderItem('Today')); + for (const task of todayTasks) + this.menu.addMenuItem(this._taskItem(task)); + } + + if (overdueTasks.length > 0) { + if (todayTasks.length > 0) + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this.menu.addMenuItem(new SectionHeaderItem('Overdue')); + for (const task of overdueTasks) + this.menu.addMenuItem(this._taskItem(task)); + } + } + + _panelText() { + const compactCount = this._compactCountText(); + + if (this._showClockedInTask() && this._state.clockedIn?.title) { + const title = this._truncate(this._state.clockedIn.title, PANEL_TITLE_MAX); + return compactCount ? `● ${title} · ${compactCount}` : `● ${title}`; + } + + return compactCount; + } + + _compactCountText() { + const {todayCount, overdueCount} = this._state; + + if (overdueCount > 0 && todayCount > 0) + return `${todayCount}+${overdueCount}!`; + + if (overdueCount > 0) + return `${overdueCount}!`; + + return String(todayCount); + } + + _summaryText() { + switch (this._state.status) { + case 'ready': + if (this._state.taskCount === 0) + return this._state.clockedIn ? 'Clock is running' : 'No scheduled tasks'; + + return this._countPhrase(this._state.todayCount, 'today') + + (this._state.overdueCount > 0 ? `, ${this._countPhrase(this._state.overdueCount, 'overdue')}` : ''); + case 'missing': + return 'Agenda snapshot not found'; + case 'stale': + return `Agenda snapshot is stale (${this._state.date ?? 'unknown date'})`; + case 'loading': + return 'Loading org agenda snapshot'; + default: + return 'Unable to read agenda snapshot'; + } + } + + _summaryMetaText() { + const parts = []; + + if (this._showClockedInTask() && this._state.clockedIn) + parts.push(this._clockSummaryText(this._state.clockedIn)); + + if (this._state.generatedEpoch) + parts.push(`Updated ${this._timeOfDay(this._state.generatedEpoch)}`); + else if (this._state.generatedAt) + parts.push(`Updated ${this._state.generatedAt}`); + + return parts.join(' '); + } + + _showClockedInTask() { + return this._settings.get_boolean('show-clocked-in-task'); + } + + _countPhrase(count, label) { + if (count === 1) + return `1 ${label}`; + + return `${count} ${label}`; + } + + _clockedInItem(task) { + return new InfoMenuItem( + task.title ?? 'Clock running', + this._clockTaskMeta(task)); + } + + _taskItem(task) { + return new InfoMenuItem( + this._taskPrimaryText(task), + this._taskMetaText(task), + {overdue: task.is_overdue}); + } + + _taskPrimaryText(task) { + const pieces = []; + + if (task.time) + pieces.push(task.time); + + pieces.push(task.title ?? 'Untitled task'); + + return pieces.join(' '); + } + + _taskMetaText(task) { + const pieces = []; + + if (task.state) + pieces.push(task.state); + + if (task.category) + pieces.push(task.category); + + if (task.is_overdue && task.scheduled_for) + pieces.push(`scheduled ${this._friendlyDate(task.scheduled_for)}`); + + return pieces.join(' · '); + } + + _clockTaskMeta(task) { + const pieces = []; + + if (task.started_epoch) + pieces.push(`started ${this._timeOfDay(task.started_epoch)}`); + + const elapsed = this._elapsedClockText(task.started_epoch); + if (elapsed) + pieces.push(elapsed); + + if (task.state) + pieces.push(task.state); + + if (task.category) + pieces.push(task.category); + + return pieces.join(' · '); + } + + _clockSummaryText(task) { + const elapsed = this._elapsedClockText(task.started_epoch); + return elapsed ? `Clock ${elapsed}` : 'Clock running'; + } + + _elapsedClockText(startedEpoch) { + if (!startedEpoch) + return ''; + + const roundedMinutes = Math.max(0, Math.floor((Date.now() / 1000 - startedEpoch) / 60)); + const hours = Math.floor(roundedMinutes / 60); + const remainder = roundedMinutes % 60; + + if (hours > 0) + return `${hours}h ${remainder}m`; + + return `${roundedMinutes}m`; + } + + _friendlyDate(dateText) { + const date = GLib.DateTime.new_from_iso8601( + `${dateText}T00:00:00`, + GLib.TimeZone.new_local()); + return date ? date.format('%b %-d') : dateText; + } + + _timeOfDay(epoch) { + const date = GLib.DateTime.new_from_unix_local(epoch); + return date ? date.format('%H:%M') : ''; + } + + _truncate(text, maxLength) { + if (!text || text.length <= maxLength) + return text ?? ''; + + return `${text.slice(0, maxLength - 1)}…`; + } +}); + +export default class OrgAgendaIndicatorExtension extends Extension { + enable() { + this._indicator = new Indicator(this.getSettings()); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json new file mode 100644 index 0000000..de62ebd --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "org-agenda-indicator@rbenencia.name", + "extension-id": "org-agenda-indicator", + "name": "Org Agenda Indicator", + "description": "Show today's org-agenda tasks in the top bar.", + "settings-schema": "org.gnome.shell.extensions.org-agenda-indicator", + "shell-version": ["49"], + "session-modes": ["user"], + "url": "https://rbenencia.name", + "version": 1 +} diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js new file mode 100644 index 0000000..08db40d --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js @@ -0,0 +1,33 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; + +import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +export default class OrgAgendaIndicatorPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings('org.gnome.shell.extensions.org-agenda-indicator'); + const page = new Adw.PreferencesPage(); + const group = new Adw.PreferencesGroup({ + title: 'Display', + description: 'Configure what the indicator shows.', + }); + const row = new Adw.ActionRow({ + title: 'Show clocked-in task', + subtitle: 'Display the active Org clock in the panel and menu when one is running.', + }); + const toggle = new Gtk.Switch({ + active: settings.get_boolean('show-clocked-in-task'), + valign: Gtk.Align.CENTER, + }); + + toggle.connect('notify::active', widget => { + settings.set_boolean('show-clocked-in-task', widget.get_active()); + }); + + row.add_suffix(toggle); + row.activatable_widget = toggle; + group.add(row); + page.add(group); + window.add(page); + } +} diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml new file mode 100644 index 0000000..317b9a1 --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml @@ -0,0 +1,14 @@ + + + + + true + Show clocked-in task + + When enabled, the indicator displays the active Org clock in the panel + and in a dedicated menu section. + + + + diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css new file mode 100644 index 0000000..ed690bb --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css @@ -0,0 +1,38 @@ +#org-agenda-indicator-label { + margin: 0 6px; + max-width: 24em; +} + +.org-agenda-indicator-overdue { + font-weight: 600; +} + +.org-agenda-indicator-section { + padding: 6px 12px 2px; +} + +.org-agenda-indicator-section-label { + font-size: 0.82em; + font-weight: 700; + letter-spacing: 0.06em; + opacity: 0.7; + text-transform: uppercase; +} + +.org-agenda-indicator-row { + min-width: 22em; + padding: 3px 0; +} + +.org-agenda-indicator-row-title { + font-weight: 600; +} + +.org-agenda-indicator-row-meta { + font-size: 0.9em; + opacity: 0.75; +} + +.org-agenda-indicator-row-title.org-agenda-indicator-overdue { + color: #d97706; +} -- cgit v1.2.3