'use strict'; import St from 'gi://St'; import Clutter from 'gi://Clutter'; import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import Soup from 'gi://Soup'; import GObject from 'gi://GObject'; 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'; // Simple hash function for quote IDs function simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(16); } class QuotesExtension extends Extension { constructor(metadata) { super(metadata); this._quotes = []; this._currentQuoteIndex = 0; this._rotationTimer = null; this._syncTimer = null; this._indicator = null; this._session = null; this._dataDir = null; this._quotesFile = null; this._settings = null; this._isPaused = false; this._preChangeTimer = null; this._isShowingIndicator = false; } enable() { this._initializeSettings(); this._initializeDataDir(); this._processCustomQuotes(); this._loadQuotes(); this._createIndicator(); this._setupTimers(); this._syncQuotes(); } disable() { if (this._rotationTimer) { GLib.source_remove(this._rotationTimer); this._rotationTimer = null; } if (this._syncTimer) { GLib.source_remove(this._syncTimer); this._syncTimer = null; } if (this._preChangeTimer) { GLib.source_remove(this._preChangeTimer); this._preChangeTimer = null; } if (this._indicator) { this._indicator.destroy(); this._indicator = null; } if (this._session) { this._session = null; } } _initializeDataDir() { this._dataDir = GLib.build_filenamev([GLib.get_user_data_dir(), 'quotes-extension']); GLib.mkdir_with_parents(this._dataDir, 0o755); const fileName = this._settings ? this._settings.get_string('quotes-file') || 'quotes.json' : 'quotes.json'; this._quotesFile = GLib.build_filenamev([this._dataDir, fileName]); } _initializeSettings() { this._settings = this.getSettings(); this._rotationInterval = this._settings.get_int('rotation-interval') || 30; this._syncInterval = this._settings.get_int('sync-interval') || 3600; this._apiProviders = this._parseApiProviders(); this._fontSize = this._settings.get_int('font-size') || 14; this._textColor = this._settings.get_string('text-color') || '#ffffff'; this._maxWidth = this._settings.get_int('max-width') || 60; this._quotesFileName = this._settings.get_string('quotes-file') || 'quotes.json'; this._useCustomQuotes = this._settings.get_boolean('use-custom-quotes'); this._customQuotes = this._settings.get_string('custom-quotes') || '[]'; this._showChangeIndicator = this._settings.get_boolean('show-change-indicator'); this._indicatorDuration = this._settings.get_int('indicator-duration') || 5; this._quoteSources = { synced: [], custom: [] }; // Listen for settings changes this._settings.connect('changed', (settings, key) => { if (key === 'font-size' || key === 'text-color') { this._updateLabelStyle(); } else if (key === 'max-width') { this._maxWidth = this._settings.get_int('max-width') || 60; this._label.set_width(this._maxWidth * 8); this._label.set_text(this._getCurrentQuote()); } else if (key === 'quotes-file') { this._quotesFileName = this._settings.get_string('quotes-file') || 'quotes.json'; this._initializeDataDir(); this._loadQuotes(); } else if (key === 'use-custom-quotes' || key === 'custom-quotes') { this._useCustomQuotes = this._settings.get_boolean('use-custom-quotes'); this._customQuotes = this._settings.get_string('custom-quotes') || '[]'; this._processCustomQuotes(); this._mergeAndSaveQuotes(); this._loadQuotes(); if (this._label) { this._label.set_text(this._getCurrentQuote()); } } else if (key === 'api-providers') { this._apiProviders = this._parseApiProviders(); } else if (key === 'show-change-indicator') { this._showChangeIndicator = this._settings.get_boolean('show-change-indicator'); } else if (key === 'indicator-duration') { this._indicatorDuration = this._settings.get_int('indicator-duration') || 5; } }); } _loadQuotes() { this._quotes = []; // Always load from file (contains both synced and custom quotes) try { if (GLib.file_test(this._quotesFile, GLib.FileTest.EXISTS)) { const [success, contents] = GLib.file_get_contents(this._quotesFile); if (success) { const decoder = new TextDecoder(); const fileData = JSON.parse(decoder.decode(contents)); // Handle both old format (array) and new format (object with sources) if (Array.isArray(fileData)) { this._quotes = fileData; } else if (fileData.synced && fileData.custom) { this._quoteSources = fileData; // Combine quotes based on use-custom-quotes setting if (this._useCustomQuotes) { this._quotes = [...this._quoteSources.custom, ...this._quoteSources.synced]; } else { this._quotes = [...this._quoteSources.synced, ...this._quoteSources.custom]; } } } } } catch (e) { console.error('Failed to load quotes:', e); } if (this._quotes.length === 0) { const defaultQuotes = [ { text: "The only way to do great work is to love what you do.", author: "Steve Jobs" }, { text: "Life is what happens to you while you're busy making other plans.", author: "John Lennon" }, { text: "The future belongs to those who believe in the beauty of their dreams.", author: "Eleanor Roosevelt" }, { text: "It is during our darkest moments that we must focus to see the light.", author: "Aristotle" }, { text: "Success is not final, failure is not fatal: it is the courage to continue that counts.", author: "Winston Churchill" }, { text: "The way to get started is to quit talking and begin doing.", author: "Walt Disney" }, { text: "Don't be afraid to give up the good to go for the great.", author: "John D. Rockefeller" }, { text: "Innovation distinguishes between a leader and a follower.", author: "Steve Jobs" }, { text: "Your time is limited, don't waste it living someone else's life.", author: "Steve Jobs" }, { text: "If you look at what you have in life, you'll always have more.", author: "Oprah Winfrey" }, { text: "The only impossible journey is the one you never begin.", author: "Tony Robbins" }, { text: "In the midst of winter, I found there was, within me, an invincible summer.", author: "Albert Camus" }, { text: "Be yourself; everyone else is already taken.", author: "Oscar Wilde" }, { text: "Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.", author: "Albert Einstein" }, { text: "You miss 100% of the shots you don't take.", author: "Wayne Gretzky" }, { text: "Whether you think you can or you think you can't, you're right.", author: "Henry Ford" }, { text: "I have learned throughout my life as a composer chiefly through my mistakes and pursuits of false assumptions.", author: "Igor Stravinsky" }, { text: "The greatest glory in living lies not in never falling, but in rising every time we fall.", author: "Nelson Mandela" }, { text: "The only person you are destined to become is the person you decide to be.", author: "Ralph Waldo Emerson" }, { text: "What lies behind us and what lies before us are tiny matters compared to what lies within us.", author: "Ralph Waldo Emerson" }, { text: "Believe you can and you're halfway there.", author: "Theodore Roosevelt" }, { text: "The best time to plant a tree was 20 years ago. The second best time is now.", author: "Chinese Proverb" }, { text: "Don't judge each day by the harvest you reap but by the seeds that you plant.", author: "Robert Louis Stevenson" }, { text: "The only way to make sense out of change is to plunge into it, move with it, and join the dance.", author: "Alan Watts" }, { text: "Yesterday is history, tomorrow is a mystery, today is a gift of God, which is why we call it the present.", author: "Bill Keane" } ]; // Add hashes to default quotes and mark as synced this._quoteSources.synced = defaultQuotes.map(quote => ({ ...quote, hash: simpleHash(quote.text + quote.author), source: 'default' })); this._quotes = this._quoteSources.synced; this._saveQuotes(); } } _saveQuotes() { try { const contents = JSON.stringify(this._quoteSources, null, 2); GLib.file_set_contents(this._quotesFile, contents); } catch (e) { console.error('Failed to save quotes:', e); } } _processCustomQuotes() { try { const customQuotes = JSON.parse(this._customQuotes); if (Array.isArray(customQuotes)) { this._quoteSources.custom = customQuotes.map(quote => ({ ...quote, hash: simpleHash(quote.text + quote.author), source: 'custom' })); } } catch (e) { console.error('Failed to process custom quotes:', e); this._quoteSources.custom = []; } } _mergeAndSaveQuotes() { // Remove duplicates based on hash const existingHashes = new Set(); this._quoteSources.synced = this._quoteSources.synced.filter(quote => { if (existingHashes.has(quote.hash)) { return false; } existingHashes.add(quote.hash); return true; }); this._quoteSources.custom = this._quoteSources.custom.filter(quote => { if (existingHashes.has(quote.hash)) { return false; } existingHashes.add(quote.hash); return true; }); this._saveQuotes(); } _createIndicator() { this._indicator = new PanelMenu.Button(0.0, 'Quotes', false); this._label = new St.Label({ text: this._getCurrentQuote(), y_align: Clutter.ActorAlign.CENTER, style_class: 'quote-label' }); this._updateLabelStyle(); this._label.set_width(this._maxWidth * 8); // Set viewport width based on maxWidth this._label.clutter_text.set_ellipsize(3); // PANGO_ELLIPSIZE_END this._indicator.add_child(this._label); Main.panel.addToStatusArea('quotes', this._indicator, 1, 'left'); // Create popup menu this._createPopupMenu(); // Override the reactive property to handle clicks properly this._indicator.reactive = true; this._indicator.can_focus = true; this._indicator.track_hover = true; // Connect to event signals using a different approach this._indicator.connect('event', (actor, event) => { if (event.type() === Clutter.EventType.BUTTON_PRESS) { if (event.get_button() === 1) { // Left click this._nextQuote(); return Clutter.EVENT_STOP; } else if (event.get_button() === 3) { // Right click this._indicator.menu.toggle(); return Clutter.EVENT_STOP; } } return Clutter.EVENT_PROPAGATE; }); } _createPopupMenu() { // Toggle pause/resume this._pauseMenuItem = new PopupMenu.PopupMenuItem('Pause Rotation'); this._pauseMenuItem.connect('activate', () => { this._togglePause(); }); this._indicator.menu.addMenuItem(this._pauseMenuItem); // Next quote const nextMenuItem = new PopupMenu.PopupMenuItem('Next Quote'); nextMenuItem.connect('activate', () => { this._nextQuote(); }); this._indicator.menu.addMenuItem(nextMenuItem); // Separator this._indicator.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); // Delay options const delaySubmenu = new PopupMenu.PopupSubMenuMenuItem('Delay Next Quote'); const delay30s = new PopupMenu.PopupMenuItem('30 seconds'); delay30s.connect('activate', () => this._delayRotation(30)); delaySubmenu.menu.addMenuItem(delay30s); const delay1m = new PopupMenu.PopupMenuItem('1 minute'); delay1m.connect('activate', () => this._delayRotation(60)); delaySubmenu.menu.addMenuItem(delay1m); const delay5m = new PopupMenu.PopupMenuItem('5 minutes'); delay5m.connect('activate', () => this._delayRotation(300)); delaySubmenu.menu.addMenuItem(delay5m); const delay15m = new PopupMenu.PopupMenuItem('15 minutes'); delay15m.connect('activate', () => this._delayRotation(900)); delaySubmenu.menu.addMenuItem(delay15m); this._indicator.menu.addMenuItem(delaySubmenu); } _togglePause() { this._isPaused = !this._isPaused; if (this._isPaused) { this._pauseMenuItem.label.text = 'Resume Rotation'; if (this._rotationTimer) { GLib.source_remove(this._rotationTimer); this._rotationTimer = null; } } else { this._pauseMenuItem.label.text = 'Pause Rotation'; this._setupRotationTimer(); } } _delayRotation(seconds) { if (this._rotationTimer) { GLib.source_remove(this._rotationTimer); this._rotationTimer = null; } if (this._preChangeTimer) { GLib.source_remove(this._preChangeTimer); this._preChangeTimer = null; } this._hideIndicator(); this._rotationTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, () => { if (!this._isPaused) { this._scheduleQuoteChange(); this._setupRotationTimer(); } return GLib.SOURCE_REMOVE; }); } _getCurrentQuote() { if (this._quotes.length === 0) return ' No quotes available'; const quote = this._quotes[this._currentQuoteIndex]; let text = ` "${quote.text}" - ${quote.author}`; return text; } _updateLabelStyle() { if (!this._label) return; this._fontSize = this._settings.get_int('font-size') || 14; this._textColor = this._settings.get_string('text-color') || '#ffffff'; const style = `font-size: ${this._fontSize}px; color: ${this._textColor};`; this._label.set_style(style); } _nextQuote() { if (this._quotes.length === 0) return; this._currentQuoteIndex = (this._currentQuoteIndex + 1) % this._quotes.length; this._label.set_text(this._getCurrentQuote()); } _setupTimers() { this._setupRotationTimer(); this._setupSyncTimer(); } _setupRotationTimer() { if (this._isPaused) return; this._rotationTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this._rotationInterval, () => { if (!this._isPaused) { this._scheduleQuoteChange(); } return GLib.SOURCE_CONTINUE; }); } _scheduleQuoteChange() { if (this._preChangeTimer) { GLib.source_remove(this._preChangeTimer); this._preChangeTimer = null; } if (this._showChangeIndicator) { this._showIndicator(); this._preChangeTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this._indicatorDuration, () => { this._hideIndicator(); this._nextQuote(); this._preChangeTimer = null; return GLib.SOURCE_REMOVE; }); } else { this._nextQuote(); } } _showIndicator() { if (!this._label || this._isShowingIndicator) return; this._isShowingIndicator = true; const currentQuote = this._getCurrentQuote(); let countdown = this._indicatorDuration; this._label.set_text(`⏳${countdown}${currentQuote}`); const countdownTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { countdown--; if (this._isShowingIndicator && countdown > 0) { this._label.set_text(`⏳${countdown}${currentQuote}`); return GLib.SOURCE_CONTINUE; } return GLib.SOURCE_REMOVE; }); } _hideIndicator() { this._isShowingIndicator = false; } _setupSyncTimer() { this._syncTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this._syncInterval, () => { this._syncQuotes(); return GLib.SOURCE_CONTINUE; }); } _parseApiProviders() { try { const providersString = this._settings.get_string('api-providers') || '[]'; return JSON.parse(providersString); } catch (e) { console.error('Failed to parse API providers:', e); return [{ name: 'quotable', url: 'https://api.quotable.io/random', method: 'GET', mapping: { text: 'content', author: 'author' }, enabled: true }]; } } _syncQuotes() { const enabledProviders = this._apiProviders.filter(p => p.enabled); if (enabledProviders.length === 0) { console.log('No enabled API providers'); return; } enabledProviders.forEach(provider => this._syncFromProvider(provider)); } _syncFromProvider(provider) { try { this._session = new Soup.Session(); const message = Soup.Message.new(provider.method || 'GET', provider.url); if (provider.headers) { Object.entries(provider.headers).forEach(([key, value]) => { message.request_headers.append(key, value); }); } if (provider.body && (provider.method === 'POST' || provider.method === 'PUT')) { message.set_request_body_from_bytes('application/json', new GLib.Bytes(JSON.stringify(provider.body))); } this._session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => { try { const bytes = session.send_and_read_finish(result); const decoder = new TextDecoder(); const response = decoder.decode(bytes.get_data()); const data = JSON.parse(response); this._processProviderResponse(data, provider); } catch (e) { console.error(`Failed to sync from ${provider.name}:`, e); } }); } catch (e) { console.error(`Failed to create sync session for ${provider.name}:`, e); } } _processProviderResponse(data, provider) { const mapping = provider.mapping || { text: 'text', author: 'author' }; let quotes = []; if (Array.isArray(data)) { quotes = data; } else if (provider.responseArray) { quotes = this._getNestedProperty(data, provider.responseArray) || []; } else { quotes = [data]; } quotes.forEach(item => { const text = this._getNestedProperty(item, mapping.text); const author = this._getNestedProperty(item, mapping.author); if (text && author) { const newQuote = { text: text, author: author, hash: simpleHash(text + author), source: provider.name }; const exists = this._quoteSources.synced.some(q => q.hash === newQuote.hash); if (!exists) { this._quoteSources.synced.push(newQuote); } } }); this._mergeAndSaveQuotes(); this._loadQuotes(); } _getNestedProperty(obj, path) { return path.split('.').reduce((current, key) => current?.[key], obj); } } export default class QuotesGnomeExtension extends Extension { enable() { this._extension = new QuotesExtension(this.metadata); this._extension.enable(); } disable() { if (this._extension) { this._extension.disable(); this._extension = null; } } }