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 }