doomsday.git

doomsday

~
git clone https://github.com/debuman/doomsday.git

extension.js (17850B)

      1 import GObject from 'gi://GObject';
      2 import St from 'gi://St';
      3 import GLib from 'gi://GLib';
      4 import Clutter from 'gi://Clutter';
      5 import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
      6 import * as Main from 'resource:///org/gnome/shell/ui/main.js';
      7 import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
      8 import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
      9 import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js';
     10 
     11 // Custom menu item with edit and delete buttons
     12 const DoomsdayMenuItem = GObject.registerClass(
     13 class DoomsdayMenuItem extends PopupMenu.PopupBaseMenuItem {
     14     _init(indicator, event, displayText, isSelected) {
     15         super._init();
     16 
     17         this._indicator = indicator;
     18         this._event = event;
     19 
     20         // Create horizontal box layout
     21         const box = new St.BoxLayout({
     22             vertical: false,
     23             x_expand: true,
     24             style_class: 'doomsday-menu-item-box',
     25         });
     26 
     27         // Event label
     28         this._label = new St.Label({
     29             text: displayText,
     30             y_align: Clutter.ActorAlign.CENTER,
     31             x_expand: true,
     32         });
     33         box.add_child(this._label);
     34 
     35         // Mark selected item with a dot
     36         if (isSelected) {
     37             this.setOrnament(PopupMenu.Ornament.DOT);
     38         }
     39 
     40         // Edit button
     41         const editButton = new St.Button({
     42             style_class: 'doomsday-edit-button',
     43             child: new St.Icon({
     44                 icon_name: 'document-edit-symbolic',
     45                 icon_size: 16,
     46             }),
     47             x_align: Clutter.ActorAlign.END,
     48         });
     49         editButton.connect('clicked', () => {
     50             this._indicator._showEditDialog(this._event);
     51             return Clutter.EVENT_STOP;
     52         });
     53         box.add_child(editButton);
     54 
     55         // Delete button
     56         const deleteButton = new St.Button({
     57             style_class: 'doomsday-delete-button',
     58             child: new St.Icon({
     59                 icon_name: 'user-trash-symbolic',
     60                 icon_size: 16,
     61             }),
     62             x_align: Clutter.ActorAlign.END,
     63         });
     64         deleteButton.connect('clicked', () => {
     65             this._indicator._showDeleteDialog(this._event);
     66             return Clutter.EVENT_STOP;
     67         });
     68         box.add_child(deleteButton);
     69 
     70         this.add_child(box);
     71 
     72         // Click on the item itself to select it
     73         this.connect('activate', () => {
     74             this._indicator._settings.set_string('selected-event-id', this._event.id);
     75         });
     76     }
     77 });
     78 
     79 const DoomsdayIndicator = GObject.registerClass(
     80 class DoomsdayIndicator extends PanelMenu.Button {
     81     _init(extension) {
     82         super._init(0.0, 'Doomsday Indicator', false);
     83 
     84         this._extension = extension;
     85         this._settings = extension.getSettings();
     86 
     87         // Create label for panel
     88         this._label = new St.Label({
     89             text: 'D-Day',
     90             y_align: Clutter.ActorAlign.CENTER,
     91             style_class: 'doomsday-label'
     92         });
     93         this.add_child(this._label);
     94 
     95         // Build popup menu
     96         this._buildMenu();
     97 
     98         // Connect to settings changes
     99         this._settingsChangedId = this._settings.connect('changed',
    100             this._onSettingsChanged.bind(this));
    101 
    102         // Initial update
    103         this._updateDisplay();
    104 
    105         // Setup daily timer
    106         this._setupTimer();
    107     }
    108 
    109     _buildMenu() {
    110         // Add "Open Preferences" menu item at the bottom
    111         // Menu items for D-Days will be added dynamically in _updateMenuItems
    112     }
    113 
    114     _updateDisplay() {
    115         // Get selected event from settings
    116         const selectedId = this._settings.get_string('selected-event-id');
    117         const eventsJson = this._settings.get_string('dday-events');
    118 
    119         try {
    120             const events = JSON.parse(eventsJson);
    121             const selectedEvent = events.find(e => e.id === selectedId);
    122 
    123             if (selectedEvent) {
    124                 const displayText = this._calculateDDay(
    125                     selectedEvent.name,
    126                     selectedEvent.date
    127                 );
    128                 this._label.set_text(displayText);
    129             } else {
    130                 this._label.set_text('No D-Day');
    131             }
    132 
    133             // Update menu items
    134             this._updateMenuItems(events, selectedId);
    135 
    136         } catch (e) {
    137             console.error('Doomsday: Failed to parse D-Day events:', e);
    138             this._label.set_text('Error');
    139         }
    140     }
    141 
    142     _calculateDDay(name, targetDateStr) {
    143         // Parse target date (YYYY-MM-DD format)
    144         const [year, month, day] = targetDateStr.split('-').map(Number);
    145         const targetDate = new Date(year, month - 1, day);
    146 
    147         // Get today at midnight (ignore time component)
    148         const today = new Date();
    149         today.setHours(0, 0, 0, 0);
    150 
    151         // Calculate difference in days
    152         const diffTime = targetDate - today;
    153         const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
    154 
    155         // Format according to requirements
    156         if (diffDays === 0) {
    157             return `${name} D-Day`;
    158         } else if (diffDays > 0) {
    159             return `${name} D-${diffDays}`;
    160         } else {
    161             return `${name} D+${Math.abs(diffDays)}`;
    162         }
    163     }
    164 
    165     _setupTimer() {
    166         // Calculate seconds until next midnight
    167         const now = new Date();
    168         const tomorrow = new Date(now);
    169         tomorrow.setDate(tomorrow.getDate() + 1);
    170         tomorrow.setHours(0, 0, 0, 0);
    171         const secondsUntilMidnight = Math.floor((tomorrow - now) / 1000);
    172 
    173         // Set up initial timeout to midnight
    174         this._midnightTimeoutId = GLib.timeout_add_seconds(
    175             GLib.PRIORITY_DEFAULT,
    176             secondsUntilMidnight,
    177             () => {
    178                 this._updateDisplay();
    179                 // After first midnight, update every 24 hours
    180                 this._dailyTimeoutId = GLib.timeout_add_seconds(
    181                     GLib.PRIORITY_DEFAULT,
    182                     86400, // 24 hours
    183                     () => {
    184                         this._updateDisplay();
    185                         return GLib.SOURCE_CONTINUE;
    186                     }
    187                 );
    188                 return GLib.SOURCE_REMOVE;
    189             }
    190         );
    191     }
    192 
    193     _onSettingsChanged() {
    194         this._updateDisplay();
    195     }
    196 
    197     _updateMenuItems(events, selectedId) {
    198         // Clear existing menu items
    199         this.menu.removeAll();
    200 
    201         // Add custom menu items for each D-Day event with edit/delete buttons
    202         events.forEach(event => {
    203             const displayText = this._calculateDDay(event.name, event.date);
    204             const item = new DoomsdayMenuItem(
    205                 this,
    206                 event,
    207                 displayText,
    208                 event.id === selectedId
    209             );
    210             this.menu.addMenuItem(item);
    211         });
    212 
    213         // Add separator
    214         if (events.length > 0) {
    215             this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
    216         }
    217 
    218         // Add "Add D-Day" menu item
    219         const addItem = new PopupMenu.PopupMenuItem('➕ Add D-Day');
    220         addItem.connect('activate', () => {
    221             this._showAddDialog();
    222         });
    223         this.menu.addMenuItem(addItem);
    224 
    225         // Add "Manage Events" menu item that opens preferences
    226         const manageItem = new PopupMenu.PopupMenuItem('⚙️ Manage Events');
    227         manageItem.connect('activate', () => {
    228             this._extension.openPreferences();
    229         });
    230         this.menu.addMenuItem(manageItem);
    231     }
    232 
    233     _showAddDialog() {
    234         const dialog = new ModalDialog.ModalDialog();
    235 
    236         // Title
    237         const title = new St.Label({
    238             text: 'Add D-Day Event',
    239             style_class: 'doomsday-dialog-title',
    240             x_align: Clutter.ActorAlign.CENTER,
    241         });
    242         dialog.contentLayout.add_child(title);
    243 
    244         // Name entry
    245         const nameBox = new St.BoxLayout({
    246             vertical: false,
    247             style_class: 'doomsday-dialog-row',
    248         });
    249         const nameLabel = new St.Label({
    250             text: 'Name: ',
    251             style_class: 'doomsday-dialog-label',
    252             y_align: Clutter.ActorAlign.CENTER,
    253         });
    254         const nameEntry = new St.Entry({
    255             hint_text: 'Event name',
    256             style_class: 'doomsday-dialog-entry',
    257             can_focus: true,
    258             x_expand: true,
    259         });
    260         nameBox.add_child(nameLabel);
    261         nameBox.add_child(nameEntry);
    262         dialog.contentLayout.add_child(nameBox);
    263 
    264         // Date entry
    265         const dateBox = new St.BoxLayout({
    266             vertical: false,
    267             style_class: 'doomsday-dialog-row',
    268         });
    269         const dateLabel = new St.Label({
    270             text: 'Date: ',
    271             style_class: 'doomsday-dialog-label',
    272             y_align: Clutter.ActorAlign.CENTER,
    273         });
    274         const today = new Date();
    275         const dateStr = today.toISOString().split('T')[0];
    276         const dateEntry = new St.Entry({
    277             text: dateStr,
    278             hint_text: 'YYYY-MM-DD',
    279             style_class: 'doomsday-dialog-entry',
    280             can_focus: true,
    281             x_expand: true,
    282         });
    283         dateBox.add_child(dateLabel);
    284         dateBox.add_child(dateEntry);
    285         dialog.contentLayout.add_child(dateBox);
    286 
    287         // Buttons
    288         dialog.addButton({
    289             label: 'Cancel',
    290             action: () => dialog.close(),
    291             key: Clutter.KEY_Escape,
    292         });
    293 
    294         dialog.addButton({
    295             label: 'Add',
    296             action: () => {
    297                 const newName = nameEntry.get_text().trim();
    298                 const newDate = dateEntry.get_text().trim();
    299 
    300                 // Validate inputs
    301                 if (!newName || !newDate) {
    302                     return;
    303                 }
    304 
    305                 if (!/^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
    306                     return;
    307                 }
    308 
    309                 // Add new event
    310                 this._addEvent(newName, newDate);
    311                 dialog.close();
    312             },
    313             default: true,
    314         });
    315 
    316         dialog.open();
    317     }
    318 
    319     _showEditDialog(event) {
    320         const dialog = new ModalDialog.ModalDialog();
    321 
    322         // Title
    323         const title = new St.Label({
    324             text: 'Edit D-Day Event',
    325             style_class: 'doomsday-dialog-title',
    326             x_align: Clutter.ActorAlign.CENTER,
    327         });
    328         dialog.contentLayout.add_child(title);
    329 
    330         // Name entry
    331         const nameBox = new St.BoxLayout({
    332             vertical: false,
    333             style_class: 'doomsday-dialog-row',
    334         });
    335         const nameLabel = new St.Label({
    336             text: 'Name: ',
    337             style_class: 'doomsday-dialog-label',
    338             y_align: Clutter.ActorAlign.CENTER,
    339         });
    340         const nameEntry = new St.Entry({
    341             text: event.name,
    342             style_class: 'doomsday-dialog-entry',
    343             can_focus: true,
    344             x_expand: true,
    345         });
    346         nameBox.add_child(nameLabel);
    347         nameBox.add_child(nameEntry);
    348         dialog.contentLayout.add_child(nameBox);
    349 
    350         // Date entry
    351         const dateBox = new St.BoxLayout({
    352             vertical: false,
    353             style_class: 'doomsday-dialog-row',
    354         });
    355         const dateLabel = new St.Label({
    356             text: 'Date: ',
    357             style_class: 'doomsday-dialog-label',
    358             y_align: Clutter.ActorAlign.CENTER,
    359         });
    360         const dateEntry = new St.Entry({
    361             text: event.date,
    362             hint_text: 'YYYY-MM-DD',
    363             style_class: 'doomsday-dialog-entry',
    364             can_focus: true,
    365             x_expand: true,
    366         });
    367         dateBox.add_child(dateLabel);
    368         dateBox.add_child(dateEntry);
    369         dialog.contentLayout.add_child(dateBox);
    370 
    371         // Buttons
    372         dialog.addButton({
    373             label: 'Cancel',
    374             action: () => dialog.close(),
    375             key: Clutter.KEY_Escape,
    376         });
    377 
    378         dialog.addButton({
    379             label: 'Save',
    380             action: () => {
    381                 const newName = nameEntry.get_text().trim();
    382                 const newDate = dateEntry.get_text().trim();
    383 
    384                 // Validate inputs
    385                 if (!newName || !newDate) {
    386                     return;
    387                 }
    388 
    389                 if (!/^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
    390                     return;
    391                 }
    392 
    393                 // Update event
    394                 this._updateEvent(event.id, newName, newDate);
    395                 dialog.close();
    396             },
    397             default: true,
    398         });
    399 
    400         dialog.open();
    401     }
    402 
    403     _showDeleteDialog(event) {
    404         const dialog = new ModalDialog.ModalDialog();
    405 
    406         // Message
    407         const message = new St.Label({
    408             text: `Delete "${event.name}"?\nThis action cannot be undone.`,
    409             style_class: 'doomsday-dialog-message',
    410             x_align: Clutter.ActorAlign.CENTER,
    411         });
    412         dialog.contentLayout.add_child(message);
    413 
    414         // Buttons
    415         dialog.addButton({
    416             label: 'Cancel',
    417             action: () => dialog.close(),
    418             key: Clutter.KEY_Escape,
    419         });
    420 
    421         dialog.addButton({
    422             label: 'Delete',
    423             action: () => {
    424                 this._deleteEvent(event.id);
    425                 dialog.close();
    426             },
    427         });
    428 
    429         dialog.open();
    430     }
    431 
    432     _addEvent(name, date) {
    433         const eventsJson = this._settings.get_string('dday-events');
    434         let events = [];
    435 
    436         try {
    437             events = JSON.parse(eventsJson);
    438         } catch (e) {
    439             console.error('Doomsday: Failed to parse events:', e);
    440             events = [];
    441         }
    442 
    443         // Create new event with UUID
    444         const newId = GLib.uuid_string_random();
    445         events.push({ id: newId, name, date });
    446 
    447         // Auto-select if it's the first event
    448         if (events.length === 1) {
    449             this._settings.set_string('selected-event-id', newId);
    450         }
    451 
    452         // Save
    453         this._settings.set_string('dday-events', JSON.stringify(events));
    454     }
    455 
    456     _updateEvent(eventId, newName, newDate) {
    457         const eventsJson = this._settings.get_string('dday-events');
    458         let events = [];
    459 
    460         try {
    461             events = JSON.parse(eventsJson);
    462         } catch (e) {
    463             console.error('Doomsday: Failed to parse events:', e);
    464             return;
    465         }
    466 
    467         // Update event
    468         const index = events.findIndex(e => e.id === eventId);
    469         if (index !== -1) {
    470             events[index] = { id: eventId, name: newName, date: newDate };
    471             this._settings.set_string('dday-events', JSON.stringify(events));
    472         }
    473     }
    474 
    475     _deleteEvent(eventId) {
    476         const eventsJson = this._settings.get_string('dday-events');
    477         let events = [];
    478 
    479         try {
    480             events = JSON.parse(eventsJson);
    481         } catch (e) {
    482             console.error('Doomsday: Failed to parse events:', e);
    483             return;
    484         }
    485 
    486         // Remove event
    487         events = events.filter(e => e.id !== eventId);
    488 
    489         // If deleted event was selected, select first or clear
    490         const selectedId = this._settings.get_string('selected-event-id');
    491         if (selectedId === eventId) {
    492             const newSelectedId = events.length > 0 ? events[0].id : '';
    493             this._settings.set_string('selected-event-id', newSelectedId);
    494         }
    495 
    496         // Save
    497         this._settings.set_string('dday-events', JSON.stringify(events));
    498     }
    499 
    500     destroy() {
    501         // Clean up timers
    502         if (this._midnightTimeoutId) {
    503             GLib.Source.remove(this._midnightTimeoutId);
    504             this._midnightTimeoutId = null;
    505         }
    506         if (this._dailyTimeoutId) {
    507             GLib.Source.remove(this._dailyTimeoutId);
    508             this._dailyTimeoutId = null;
    509         }
    510 
    511         // Disconnect settings
    512         if (this._settingsChangedId) {
    513             this._settings.disconnect(this._settingsChangedId);
    514             this._settingsChangedId = null;
    515         }
    516 
    517         super.destroy();
    518     }
    519 });
    520 
    521 export default class DoomsdayExtension extends Extension {
    522     enable() {
    523         console.debug(`enabling ${this.metadata.name}`);
    524 
    525         this._settings = this.getSettings();
    526         this._indicator = new DoomsdayIndicator(this);
    527         this._addIndicatorToPanel();
    528 
    529         // Watch for panel position changes
    530         this._positionChangedId = this._settings.connect('changed::panel-position', () => {
    531             this._repositionIndicator();
    532         });
    533 
    534         // Watch for panel index changes
    535         this._indexChangedId = this._settings.connect('changed::panel-index', () => {
    536             this._repositionIndicator();
    537         });
    538     }
    539 
    540     disable() {
    541         console.debug(`disabling ${this.metadata.name}`);
    542 
    543         if (this._positionChangedId) {
    544             this._settings.disconnect(this._positionChangedId);
    545             this._positionChangedId = null;
    546         }
    547 
    548         if (this._indexChangedId) {
    549             this._settings.disconnect(this._indexChangedId);
    550             this._indexChangedId = null;
    551         }
    552 
    553         if (this._indicator) {
    554             this._indicator.destroy();
    555             this._indicator = null;
    556         }
    557 
    558         this._settings = null;
    559     }
    560 
    561     _addIndicatorToPanel() {
    562         const position = this._settings.get_string('panel-position');
    563         const index = this._settings.get_int('panel-index');
    564         const box = position === 'left' ? 'left' : position === 'center' ? 'center' : 'right';
    565 
    566         Main.panel.addToStatusArea(this.uuid, this._indicator, index, box);
    567     }
    568 
    569     _repositionIndicator() {
    570         if (this._indicator) {
    571             // Destroy and recreate the indicator
    572             this._indicator.destroy();
    573             this._indicator = new DoomsdayIndicator(this);
    574             this._addIndicatorToPanel();
    575         }
    576     }
    577 }