diff --git a/extension.js b/extension.js index c90c0fa..7e5b6fb 100644 --- a/extension.js +++ b/extension.js @@ -10,6 +10,18 @@ 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) { @@ -23,11 +35,13 @@ class QuotesExtension extends Extension { this._dataDir = null; this._quotesFile = null; this._settings = null; + this._isPaused = false; } enable() { this._initializeSettings(); this._initializeDataDir(); + this._processCustomQuotes(); this._loadQuotes(); this._createIndicator(); this._setupTimers(); @@ -70,6 +84,7 @@ class QuotesExtension extends Extension { 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._quoteSources = { synced: [], custom: [] }; // Listen for settings changes this._settings.connect('changed', (settings, key) => { @@ -77,6 +92,7 @@ class QuotesExtension extends Extension { 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'; @@ -85,6 +101,8 @@ class QuotesExtension extends Extension { } 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()); @@ -96,34 +114,34 @@ class QuotesExtension extends Extension { _loadQuotes() { this._quotes = []; - // Use custom quotes if enabled - if (this._useCustomQuotes) { - try { - this._quotes = JSON.parse(this._customQuotes); - if (!Array.isArray(this._quotes)) { - this._quotes = []; - } - } catch (e) { - console.error('Failed to parse custom quotes:', e); - this._quotes = []; - } - } else { - // Load from file - 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(); - this._quotes = JSON.parse(decoder.decode(contents)); + // 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); } + } catch (e) { + console.error('Failed to load quotes:', e); } if (this._quotes.length === 0) { - this._quotes = [ + 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" }, @@ -150,18 +168,64 @@ class QuotesExtension extends Extension { { 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._quotes, null, 2); + 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); @@ -173,12 +237,92 @@ class QuotesExtension extends Extension { }); 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'); - this._indicator.connect('button-press-event', () => { + // Create popup menu + this._createPopupMenu(); + + this._indicator.connect('button-press-event', (actor, event) => { + if (event.get_button() === 1) { // Left click + this._nextQuote(); + return Clutter.EVENT_STOP; // Prevent menu from opening + } + 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; + } + + this._rotationTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, + seconds, () => { + if (!this._isPaused) { + this._nextQuote(); + this._setupRotationTimer(); + } + return GLib.SOURCE_REMOVE; + }); } _getCurrentQuote() { @@ -187,10 +331,6 @@ class QuotesExtension extends Extension { const quote = this._quotes[this._currentQuoteIndex]; let text = `"${quote.text}" - ${quote.author}`; - if (text.length > this._maxWidth) { - text = text.substring(0, this._maxWidth - 3) + '...'; - } - return text; } @@ -212,12 +352,23 @@ class QuotesExtension extends Extension { } _setupTimers() { + this._setupRotationTimer(); + this._setupSyncTimer(); + } + + _setupRotationTimer() { + if (this._isPaused) return; + this._rotationTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this._rotationInterval, () => { - this._nextQuote(); + if (!this._isPaused) { + this._nextQuote(); + } return GLib.SOURCE_CONTINUE; }); + } + _setupSyncTimer() { this._syncTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this._syncInterval, () => { this._syncQuotes(); @@ -240,15 +391,17 @@ class QuotesExtension extends Extension { if (data.content && data.author) { const newQuote = { text: data.content, - author: data.author + author: data.author, + hash: simpleHash(data.content + data.author), + source: 'api' }; - const exists = this._quotes.some(q => - q.text === newQuote.text && q.author === newQuote.author); + const exists = this._quoteSources.synced.some(q => q.hash === newQuote.hash); if (!exists) { - this._quotes.push(newQuote); - this._saveQuotes(); + this._quoteSources.synced.push(newQuote); + this._mergeAndSaveQuotes(); + this._loadQuotes(); } } } catch (e) { diff --git a/prefs.js b/prefs.js index 57c2b91..6200eaa 100644 --- a/prefs.js +++ b/prefs.js @@ -61,7 +61,6 @@ export default class QuotesPreferences extends ExtensionPreferences { // API URL const apiRow = new Adw.EntryRow({ title: 'API URL', - subtitle: 'URL for fetching quotes from remote server', text: settings.get_string('api-url'), }); apiGroup.add(apiRow); @@ -96,7 +95,6 @@ export default class QuotesPreferences extends ExtensionPreferences { // Text color const colorRow = new Adw.EntryRow({ title: 'Text Color', - subtitle: 'Color for quote text in hex format', text: settings.get_string('text-color'), }); displayGroup.add(colorRow); @@ -131,7 +129,6 @@ export default class QuotesPreferences extends ExtensionPreferences { // Quotes file const quotesFileRow = new Adw.EntryRow({ title: 'Quotes File Name', - subtitle: 'JSON file name for storing quotes', text: settings.get_string('quotes-file'), }); storageGroup.add(quotesFileRow); @@ -144,61 +141,250 @@ export default class QuotesPreferences extends ExtensionPreferences { window.add(quotesPage); const quotesSourceGroup = new Adw.PreferencesGroup({ - title: 'Quote Source', - description: 'Choose between built-in/synced quotes or custom quotes', + title: 'Quote Priority', + description: 'Custom quotes and synced quotes are both saved to the JSON file', }); quotesPage.add(quotesSourceGroup); // Use custom quotes switch const useCustomQuotesRow = new Adw.SwitchRow({ - title: 'Use Custom Quotes', - subtitle: 'Use custom quotes instead of built-in/synced quotes', + title: 'Prioritize Custom Quotes', active: settings.get_boolean('use-custom-quotes'), }); quotesSourceGroup.add(useCustomQuotesRow); const customQuotesGroup = new Adw.PreferencesGroup({ title: 'Custom Quotes', - description: 'Edit your custom quotes in JSON format', + description: 'Manage your custom quotes with an easy-to-use interface.', }); quotesPage.add(customQuotesGroup); - // Custom quotes text area - const quotesTextView = new Gtk.TextView({ - editable: true, - wrap_mode: Gtk.WrapMode.WORD, - hexpand: true, - vexpand: true, - }); - - const quotesBuffer = quotesTextView.get_buffer(); - const customQuotesText = settings.get_string('custom-quotes'); - + // Parse existing custom quotes + let customQuotesArray = []; try { - const quotesArray = JSON.parse(customQuotesText); - quotesBuffer.set_text(JSON.stringify(quotesArray, null, 2), -1); + customQuotesArray = JSON.parse(settings.get_string('custom-quotes')); } catch (e) { - quotesBuffer.set_text('[\n {\n "text": "Your custom quote here",\n "author": "Author Name"\n }\n]', -1); + customQuotesArray = []; } + // Create list box for quotes + const quotesListBox = new Gtk.ListBox({ + selection_mode: Gtk.SelectionMode.SINGLE, + hexpand: true, + margin_top: 12, + margin_bottom: 12, + margin_start: 12, + margin_end: 12, + css_classes: ['boxed-list'], + }); + + // Function to create quote row + const createQuoteRow = (quote, index) => { + const row = new Gtk.ListBoxRow(); + const box = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + spacing: 6, + margin_top: 12, + margin_bottom: 12, + margin_start: 12, + margin_end: 12, + }); + + const quoteLabel = new Gtk.Label({ + label: `"${quote.text}"`, + wrap: true, + xalign: 0, + css_classes: ['title-4'], + }); + + const authorLabel = new Gtk.Label({ + label: `— ${quote.author}`, + xalign: 0, + css_classes: ['dim-label'], + }); + + box.append(quoteLabel); + box.append(authorLabel); + row.set_child(box); + row._quoteIndex = index; + return row; + }; + + // Populate list with existing quotes + const refreshQuotesList = () => { + const child = quotesListBox.get_first_child(); + while (child) { + const next = child.get_next_sibling(); + quotesListBox.remove(child); + child = next; + } + + customQuotesArray.forEach((quote, index) => { + quotesListBox.append(createQuoteRow(quote, index)); + }); + }; + + refreshQuotesList(); + + // Buttons container + const buttonsBox = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 12, + margin_top: 12, + halign: Gtk.Align.END, + }); + + // Add button + const addButton = new Gtk.Button({ + label: 'Add Quote', + css_classes: ['suggested-action'], + }); + + // Edit button + const editButton = new Gtk.Button({ + label: 'Edit', + sensitive: false, + }); + + // Remove button + const removeButton = new Gtk.Button({ + label: 'Remove', + css_classes: ['destructive-action'], + sensitive: false, + }); + + buttonsBox.append(addButton); + buttonsBox.append(editButton); + buttonsBox.append(removeButton); + + // Main container + const quotesContainer = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + spacing: 12, + }); + const scrolledWindow = new Gtk.ScrolledWindow({ - child: quotesTextView, + child: quotesListBox, hexpand: true, vexpand: true, min_content_height: 200, + max_content_height: 300, }); + quotesContainer.append(scrolledWindow); + quotesContainer.append(buttonsBox); + const quotesExpander = new Adw.ExpanderRow({ - title: 'Custom Quotes Editor', - subtitle: 'JSON format: [{"text": "quote", "author": "author"}]', + title: 'Custom Quotes Manager', }); quotesExpander.add_row(new Adw.PreferencesRow({ - child: scrolledWindow, + child: quotesContainer, })); customQuotesGroup.add(quotesExpander); + // Handle list selection + quotesListBox.connect('row-selected', (listbox, row) => { + const hasSelection = row !== null; + editButton.sensitive = hasSelection; + removeButton.sensitive = hasSelection; + }); + + // Quote dialog function + const showQuoteDialog = (quote = null, index = -1) => { + const dialog = new Gtk.Dialog({ + title: quote ? 'Edit Quote' : 'Add Quote', + modal: true, + transient_for: window, + }); + + dialog.add_button('Cancel', Gtk.ResponseType.CANCEL); + dialog.add_button(quote ? 'Save' : 'Add', Gtk.ResponseType.OK); + + const contentArea = dialog.get_content_area(); + contentArea.set_spacing(12); + contentArea.set_margin_top(12); + contentArea.set_margin_bottom(12); + contentArea.set_margin_start(12); + contentArea.set_margin_end(12); + + const textEntry = new Gtk.Entry({ + placeholder_text: 'Enter quote text...', + text: quote ? quote.text : '', + hexpand: true, + }); + + const authorEntry = new Gtk.Entry({ + placeholder_text: 'Enter author name...', + text: quote ? quote.author : '', + hexpand: true, + }); + + const textLabel = new Gtk.Label({ + label: 'Quote Text:', + xalign: 0, + }); + + const authorLabel = new Gtk.Label({ + label: 'Author:', + xalign: 0, + }); + + contentArea.append(textLabel); + contentArea.append(textEntry); + contentArea.append(authorLabel); + contentArea.append(authorEntry); + + dialog.connect('response', (dialog, response) => { + if (response === Gtk.ResponseType.OK) { + const text = textEntry.get_text().trim(); + const author = authorEntry.get_text().trim(); + + if (text && author) { + const newQuote = { text, author }; + + if (index >= 0) { + customQuotesArray[index] = newQuote; + } else { + customQuotesArray.push(newQuote); + } + + settings.set_string('custom-quotes', JSON.stringify(customQuotesArray)); + refreshQuotesList(); + } + } + dialog.destroy(); + }); + + dialog.present(); + }; + + // Button handlers + addButton.connect('clicked', () => { + showQuoteDialog(); + }); + + editButton.connect('clicked', () => { + const selectedRow = quotesListBox.get_selected_row(); + if (selectedRow) { + const index = selectedRow._quoteIndex; + showQuoteDialog(customQuotesArray[index], index); + } + }); + + removeButton.connect('clicked', () => { + const selectedRow = quotesListBox.get_selected_row(); + if (selectedRow) { + const index = selectedRow._quoteIndex; + customQuotesArray.splice(index, 1); + settings.set_string('custom-quotes', JSON.stringify(customQuotesArray)); + refreshQuotesList(); + editButton.sensitive = false; + removeButton.sensitive = false; + } + }); + // Bind settings settings.bind( 'rotation-interval', @@ -256,17 +442,5 @@ export default class QuotesPreferences extends ExtensionPreferences { Gio.SettingsBindFlags.DEFAULT ); - // Save custom quotes when text changes - quotesBuffer.connect('changed', () => { - const [start, end] = quotesBuffer.get_bounds(); - const text = quotesBuffer.get_text(start, end, false); - try { - // Validate JSON - JSON.parse(text); - settings.set_string('custom-quotes', text); - } catch (e) { - // Invalid JSON, don't save - } - }); } } \ No newline at end of file