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; } }