aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.emacs.d/rul-lisp/packages/org-agenda-shell.el214
-rw-r--r--.emacs.d/rul-lisp/packages/rul-org.el2
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js548
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json11
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js33
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml14
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css38
-rwxr-xr-xbin/gnome-set-config38
8 files changed, 891 insertions, 7 deletions
diff --git a/.emacs.d/rul-lisp/packages/org-agenda-shell.el b/.emacs.d/rul-lisp/packages/org-agenda-shell.el
new file mode 100644
index 0000000..4c4c493
--- /dev/null
+++ b/.emacs.d/rul-lisp/packages/org-agenda-shell.el
@@ -0,0 +1,214 @@
+;;; org-agenda-shell.el --- Export Org agenda state for shell integrations -*- lexical-binding: t; -*-
+
+(require 'cl-lib)
+(require 'json)
+(require 'org)
+(require 'org-clock)
+(require 'seq)
+
+(defgroup org-agenda-shell nil
+ "Export Org agenda data for desktop integrations."
+ :group 'org)
+
+(defcustom org-agenda-shell-snapshot-path "~/.cache/org-agenda-shell/today.json"
+ "Path to the JSON snapshot consumed by external shell integrations."
+ :type 'file)
+
+(defcustom org-agenda-shell-export-idle-delay 2
+ "Idle delay, in seconds, before exporting the agenda snapshot after changes."
+ :type 'number)
+
+(defvar org-agenda-shell--export-idle-timer nil
+ "Pending idle timer for agenda snapshot exports.")
+
+(defun org-agenda-shell--time-epoch (time)
+ "Return TIME as an integer Unix epoch."
+ (truncate (float-time time)))
+
+(defun org-agenda-shell--json-bool (value)
+ "Return VALUE encoded as a JSON boolean."
+ (if value t :json-false))
+
+(defun org-agenda-shell--today-days ()
+ "Return today's date as an absolute day count."
+ (time-to-days (current-time)))
+
+(defun org-agenda-shell--open-todo-p ()
+ "Return non-nil when the current heading is an open TODO item."
+ (let ((state (org-get-todo-state)))
+ (and state
+ (not (member state org-done-keywords)))))
+
+(defun org-agenda-shell--scheduled-clock-string (scheduled)
+ "Return the HH:MM component extracted from SCHEDULED, if present."
+ (when (and scheduled
+ (string-match "\\([0-9]\\{1,2\\}:[0-9]\\{2\\}\\)" scheduled))
+ (match-string 1 scheduled)))
+
+(defun org-agenda-shell--task-record ()
+ "Return the current heading as an export task alist, or nil."
+ (let* ((scheduled (org-entry-get nil "SCHEDULED"))
+ (scheduled-time (and scheduled (org-time-string-to-time scheduled)))
+ (scheduled-days (and scheduled-time (time-to-days scheduled-time)))
+ (today-days (org-agenda-shell--today-days)))
+ (when (and scheduled
+ scheduled-time
+ scheduled-days
+ (<= scheduled-days today-days)
+ (org-agenda-shell--open-todo-p))
+ (let* ((file (buffer-file-name (buffer-base-buffer)))
+ (begin (point))
+ (task-id (or (org-entry-get nil "ID")
+ (format "%s::%d" file begin)))
+ (scheduled-for (format-time-string "%F" scheduled-time))
+ (clock-time (org-agenda-shell--scheduled-clock-string scheduled))
+ (title (org-get-heading t t t t))
+ (state (org-get-todo-state)))
+ `((id . ,task-id)
+ (title . ,title)
+ (time . ,clock-time)
+ (state . ,state)
+ (category . ,(org-get-category))
+ (scheduled_for . ,scheduled-for)
+ (is_today . ,(org-agenda-shell--json-bool
+ (= scheduled-days today-days)))
+ (is_overdue . ,(org-agenda-shell--json-bool
+ (< scheduled-days today-days)))
+ (source_file . ,file)
+ (_sort_days . ,scheduled-days)
+ (_sort_time . ,(or clock-time "")))))))
+
+(defun org-agenda-shell--task< (left right)
+ "Return non-nil when LEFT should sort before RIGHT."
+ (let ((left-days (alist-get '_sort_days left))
+ (right-days (alist-get '_sort_days right))
+ (left-time (alist-get '_sort_time left))
+ (right-time (alist-get '_sort_time right))
+ (left-title (alist-get 'title left))
+ (right-title (alist-get 'title right)))
+ (or (< left-days right-days)
+ (and (= left-days right-days)
+ (or (string< left-time right-time)
+ (and (string= left-time right-time)
+ (string< left-title right-title)))))))
+
+(defun org-agenda-shell--public-task (task)
+ "Return TASK without exporter-only sort keys."
+ (seq-remove
+ (lambda (pair)
+ (memq (car pair) '(_sort_days _sort_time)))
+ task))
+
+(defun org-agenda-shell--collect-tasks ()
+ "Return agenda tasks scheduled for today and overdue scheduled items."
+ (let (tasks)
+ (dolist (file (org-agenda-files))
+ (when (file-readable-p file)
+ (let ((create-lockfiles nil))
+ (with-current-buffer (find-file-noselect file)
+ (org-with-wide-buffer
+ (org-map-entries
+ (lambda ()
+ (let ((task (org-agenda-shell--task-record)))
+ (when task
+ (push task tasks))))
+ nil
+ 'file))))))
+ (sort tasks #'org-agenda-shell--task<)))
+
+(defun org-agenda-shell--clocked-in-record ()
+ "Return the currently clocked-in Org task as an alist, or nil."
+ (when (and (org-clocking-p)
+ (marker-buffer org-clock-marker))
+ (org-with-point-at org-clock-marker
+ (let* ((file (buffer-file-name (buffer-base-buffer)))
+ (begin (point))
+ (started-at org-clock-start-time)
+ (task-id (or (org-entry-get nil "ID")
+ (format "%s::%d" file begin))))
+ `((id . ,task-id)
+ (title . ,(or org-clock-current-task
+ (org-get-heading t t t t)))
+ (state . ,(org-get-todo-state))
+ (category . ,(org-get-category))
+ (source_file . ,file)
+ (started_at . ,(format-time-string "%FT%T%z" started-at))
+ (started_epoch . ,(org-agenda-shell--time-epoch started-at)))))))
+
+;;;###autoload
+(defun org-agenda-shell-export ()
+ "Write the JSON snapshot consumed by shell integrations."
+ (interactive)
+ (let* ((now (current-time))
+ (json-encoding-pretty-print nil)
+ (tasks (mapcar #'org-agenda-shell--public-task
+ (org-agenda-shell--collect-tasks)))
+ (clocked-in (org-agenda-shell--clocked-in-record))
+ (today-count (cl-count-if (lambda (task)
+ (eq t (alist-get 'is_today task)))
+ tasks))
+ (overdue-count (cl-count-if (lambda (task)
+ (eq t (alist-get 'is_overdue task)))
+ tasks))
+ (payload `((generated_at . ,(format-time-string "%FT%T%z" now))
+ (generated_epoch . ,(org-agenda-shell--time-epoch now))
+ (date . ,(format-time-string "%F" now))
+ (task_count . ,(length tasks))
+ (today_count . ,today-count)
+ (overdue_count . ,overdue-count)
+ (clocked_in . ,clocked-in)
+ (today_tasks . ,(vconcat tasks))))
+ (target (expand-file-name org-agenda-shell-snapshot-path))
+ (target-dir (file-name-directory target)))
+ (make-directory target-dir t)
+ (with-temp-file target
+ (insert (json-encode payload))
+ (insert "\n"))))
+
+(defun org-agenda-shell-safe-export ()
+ "Export the agenda snapshot and log any errors."
+ (setq org-agenda-shell--export-idle-timer nil)
+ (condition-case err
+ (org-agenda-shell-export)
+ (error
+ (message "org-agenda-shell export failed: %s"
+ (error-message-string err)))))
+
+(defun org-agenda-shell-schedule-export ()
+ "Schedule an idle export of the agenda snapshot."
+ (when org-agenda-shell--export-idle-timer
+ (cancel-timer org-agenda-shell--export-idle-timer))
+ (setq org-agenda-shell--export-idle-timer
+ (run-with-idle-timer
+ org-agenda-shell-export-idle-delay
+ nil
+ #'org-agenda-shell-safe-export)))
+
+(defun org-agenda-shell--after-save-hook ()
+ "Refresh the agenda snapshot when an agenda file is saved."
+ (when (and buffer-file-name
+ (member (file-truename buffer-file-name)
+ (mapcar #'file-truename (org-agenda-files))))
+ (org-agenda-shell-schedule-export)))
+
+;;;###autoload
+(define-minor-mode org-agenda-shell-mode
+ "Keep a JSON snapshot of the Org agenda up to date."
+ :global t
+ (if org-agenda-shell-mode
+ (progn
+ (add-hook 'after-save-hook #'org-agenda-shell--after-save-hook)
+ (add-hook 'org-clock-in-hook #'org-agenda-shell-schedule-export)
+ (add-hook 'org-clock-out-hook #'org-agenda-shell-schedule-export)
+ (add-hook 'org-clock-cancel-hook #'org-agenda-shell-schedule-export)
+ (org-agenda-shell-schedule-export))
+ (remove-hook 'after-save-hook #'org-agenda-shell--after-save-hook)
+ (remove-hook 'org-clock-in-hook #'org-agenda-shell-schedule-export)
+ (remove-hook 'org-clock-out-hook #'org-agenda-shell-schedule-export)
+ (remove-hook 'org-clock-cancel-hook #'org-agenda-shell-schedule-export)
+ (when org-agenda-shell--export-idle-timer
+ (cancel-timer org-agenda-shell--export-idle-timer)
+ (setq org-agenda-shell--export-idle-timer nil))))
+
+(provide 'org-agenda-shell)
+;;; org-agenda-shell.el ends here
diff --git a/.emacs.d/rul-lisp/packages/rul-org.el b/.emacs.d/rul-lisp/packages/rul-org.el
index 979fdab..f488ab0 100644
--- a/.emacs.d/rul-lisp/packages/rul-org.el
+++ b/.emacs.d/rul-lisp/packages/rul-org.el
@@ -5,6 +5,8 @@
(require 'org-habit)
(require 'rul-org-agenda)
+(require 'org-agenda-shell)
+(org-agenda-shell-mode 1)
(setq org-attach-use-inheritance t)
(setq org-cycle-separator-lines 0)
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<schemalist>
+ <schema id="org.gnome.shell.extensions.org-agenda-indicator"
+ path="/org/gnome/shell/extensions/org-agenda-indicator/">
+ <key name="show-clocked-in-task" type="b">
+ <default>true</default>
+ <summary>Show clocked-in task</summary>
+ <description>
+ When enabled, the indicator displays the active Org clock in the panel
+ and in a dedicated menu section.
+ </description>
+ </key>
+ </schema>
+</schemalist>
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;
+}
diff --git a/bin/gnome-set-config b/bin/gnome-set-config
index 608c111..a3cc753 100755
--- a/bin/gnome-set-config
+++ b/bin/gnome-set-config
@@ -5,14 +5,31 @@
NUM_WORKSPACES=9
WORKSPACE_ROUTER_UUID=workspace-router@rbenencia.name
+ORG_AGENDA_INDICATOR_UUID=org-agenda-indicator@rbenencia.name
+
+ensure_extension_enabled() {
+ uuid="$1"
+ extension_dir=""
+
+ for candidate in \
+ "$HOME/.local/share/gnome-shell/extensions/$uuid" \
+ "$HOME/.local/gnome-shell/extensions/$uuid"; do
+ if [ -d "$candidate" ]; then
+ extension_dir="$candidate"
+ break
+ fi
+ done
+
+ if [ -z "$extension_dir" ]; then
+ return 0
+ fi
-gsettings set org.gnome.mutter dynamic-workspaces false
-gsettings set org.gnome.desktop.wm.preferences num-workspaces $NUM_WORKSPACES
+ if [ -d "$extension_dir/schemas" ] && command -v glib-compile-schemas >/dev/null 2>&1; then
+ glib-compile-schemas "$extension_dir/schemas" >/dev/null 2>&1 || true
+ fi
-if [ -d "$HOME/.local/share/gnome-shell/extensions/$WORKSPACE_ROUTER_UUID" ] ||
- [ -d "$HOME/.local/gnome-shell/extensions/$WORKSPACE_ROUTER_UUID" ]; then
enabled_extensions=$(
- python3 - "$WORKSPACE_ROUTER_UUID" "$(gsettings get org.gnome.shell enabled-extensions)" <<'PY'
+ python3 - "$uuid" "$(gsettings get org.gnome.shell enabled-extensions)" <<'PY'
import ast
import sys
@@ -28,9 +45,16 @@ PY
gsettings set org.gnome.shell enabled-extensions "$enabled_extensions"
if command -v gnome-extensions >/dev/null 2>&1; then
- gnome-extensions enable "$WORKSPACE_ROUTER_UUID" >/dev/null 2>&1 || true
+ gnome-extensions enable "$uuid" >/dev/null 2>&1 || true
fi
-fi
+}
+
+gsettings set org.gnome.mutter dynamic-workspaces false
+gsettings set org.gnome.desktop.wm.preferences num-workspaces $NUM_WORKSPACES
+
+ensure_extension_enabled "$WORKSPACE_ROUTER_UUID"
+ensure_extension_enabled "$ORG_AGENDA_INDICATOR_UUID"
+
# Disable the default <Super>p. I don't use it, and it's disruptive when I accidentally trigger it.
gsettings set org.gnome.mutter.keybindings switch-monitor '[]'
nihil fit ex nihilo