commit 6a728ecd8ebc0e409b37b5ebff05d939e8bdc621 Author: Thinh Ha Date: Wed Jul 16 15:12:58 2025 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..c90c0fa --- /dev/null +++ b/extension.js @@ -0,0 +1,276 @@ +'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'; + +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; + } + + enable() { + this._initializeSettings(); + this._initializeDataDir(); + 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._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._apiUrl = this._settings.get_string('api-url') || 'https://api.quotable.io/random'; + 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') || '[]'; + + // 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_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._loadQuotes(); + if (this._label) { + this._label.set_text(this._getCurrentQuote()); + } + } + }); + } + + _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)); + } + } + } catch (e) { + console.error('Failed to load quotes:', e); + } + } + + if (this._quotes.length === 0) { + this._quotes = [ + { 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" } + ]; + this._saveQuotes(); + } + } + + _saveQuotes() { + try { + const contents = JSON.stringify(this._quotes, null, 2); + GLib.file_set_contents(this._quotesFile, contents); + } catch (e) { + console.error('Failed to save quotes:', e); + } + } + + _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._indicator.add_child(this._label); + Main.panel.addToStatusArea('quotes', this._indicator, 1, 'left'); + + this._indicator.connect('button-press-event', () => { + this._nextQuote(); + }); + } + + _getCurrentQuote() { + if (this._quotes.length === 0) return 'No quotes available'; + + 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; + } + + _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._rotationTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, + this._rotationInterval, () => { + this._nextQuote(); + return GLib.SOURCE_CONTINUE; + }); + + this._syncTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, + this._syncInterval, () => { + this._syncQuotes(); + return GLib.SOURCE_CONTINUE; + }); + } + + _syncQuotes() { + try { + this._session = new Soup.Session(); + const message = Soup.Message.new('GET', this._apiUrl); + + 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); + + if (data.content && data.author) { + const newQuote = { + text: data.content, + author: data.author + }; + + const exists = this._quotes.some(q => + q.text === newQuote.text && q.author === newQuote.author); + + if (!exists) { + this._quotes.push(newQuote); + this._saveQuotes(); + } + } + } catch (e) { + console.error('Failed to sync quotes:', e); + } + }); + } catch (e) { + console.error('Failed to create sync session:', e); + } + } +} + +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; + } + } +} \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..033161f --- /dev/null +++ b/metadata.json @@ -0,0 +1,14 @@ +{ + "name": "Quotes", + "description": "Display quotes in the top bar with periodic rotation and remote synchronization", + "uuid": "quotes@thinh.dev", + "shell-version": [ + "45", + "46", + "47", + "48" + ], + "version": 1, + "url": "https://git.thinhha.pro/public/quotes-extension", + "settings-schema": "org.gnome.shell.extensions.quotes" +} diff --git a/prefs.js b/prefs.js new file mode 100644 index 0000000..57c2b91 --- /dev/null +++ b/prefs.js @@ -0,0 +1,272 @@ +'use strict'; + +import Adw from 'gi://Adw'; +import Gio from 'gi://Gio'; +import Gtk from 'gi://Gtk'; + +import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +export default class QuotesPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings(); + + // Behavior Settings Page + const behaviorPage = new Adw.PreferencesPage({ + title: 'Behavior', + icon_name: 'preferences-system-symbolic', + }); + window.add(behaviorPage); + + const timingGroup = new Adw.PreferencesGroup({ + title: 'Timing Settings', + description: 'Configure how quotes are rotated and synced', + }); + behaviorPage.add(timingGroup); + + // Rotation interval + const rotationRow = new Adw.SpinRow({ + title: 'Rotation Interval', + subtitle: 'Time in seconds between quote changes', + adjustment: new Gtk.Adjustment({ + lower: 10, + upper: 3600, + step_increment: 10, + page_increment: 60, + value: settings.get_int('rotation-interval'), + }), + }); + timingGroup.add(rotationRow); + + // Sync interval + const syncRow = new Adw.SpinRow({ + title: 'Sync Interval', + subtitle: 'Time in seconds between API syncs', + adjustment: new Gtk.Adjustment({ + lower: 300, + upper: 86400, + step_increment: 300, + page_increment: 3600, + value: settings.get_int('sync-interval'), + }), + }); + timingGroup.add(syncRow); + + // API Group + const apiGroup = new Adw.PreferencesGroup({ + title: 'API Settings', + description: 'Configure external quote sources', + }); + behaviorPage.add(apiGroup); + + // 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); + + // Appearance Settings Page + const appearancePage = new Adw.PreferencesPage({ + title: 'Appearance', + icon_name: 'preferences-desktop-theme-symbolic', + }); + window.add(appearancePage); + + const displayGroup = new Adw.PreferencesGroup({ + title: 'Display Settings', + description: 'Configure how quotes are displayed', + }); + appearancePage.add(displayGroup); + + // Font size + const fontSizeRow = new Adw.SpinRow({ + title: 'Font Size', + subtitle: 'Size of quote text in pixels', + adjustment: new Gtk.Adjustment({ + lower: 8, + upper: 32, + step_increment: 1, + page_increment: 2, + value: settings.get_int('font-size'), + }), + }); + displayGroup.add(fontSizeRow); + + // 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); + + // Maximum width + const widthRow = new Adw.SpinRow({ + title: 'Maximum Width', + subtitle: 'Maximum characters before truncating', + adjustment: new Gtk.Adjustment({ + lower: 20, + upper: 200, + step_increment: 5, + page_increment: 10, + value: settings.get_int('max-width'), + }), + }); + displayGroup.add(widthRow); + + // Storage Settings Page + const storagePage = new Adw.PreferencesPage({ + title: 'Storage', + icon_name: 'folder-symbolic', + }); + window.add(storagePage); + + const storageGroup = new Adw.PreferencesGroup({ + title: 'Storage Settings', + description: 'Configure data storage options', + }); + storagePage.add(storageGroup); + + // 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); + + // Quotes Settings Page + const quotesPage = new Adw.PreferencesPage({ + title: 'Quotes', + icon_name: 'text-editor-symbolic', + }); + window.add(quotesPage); + + const quotesSourceGroup = new Adw.PreferencesGroup({ + title: 'Quote Source', + description: 'Choose between built-in/synced quotes or custom quotes', + }); + 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', + 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', + }); + 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'); + + try { + const quotesArray = JSON.parse(customQuotesText); + quotesBuffer.set_text(JSON.stringify(quotesArray, null, 2), -1); + } catch (e) { + quotesBuffer.set_text('[\n {\n "text": "Your custom quote here",\n "author": "Author Name"\n }\n]', -1); + } + + const scrolledWindow = new Gtk.ScrolledWindow({ + child: quotesTextView, + hexpand: true, + vexpand: true, + min_content_height: 200, + }); + + const quotesExpander = new Adw.ExpanderRow({ + title: 'Custom Quotes Editor', + subtitle: 'JSON format: [{"text": "quote", "author": "author"}]', + }); + + quotesExpander.add_row(new Adw.PreferencesRow({ + child: scrolledWindow, + })); + + customQuotesGroup.add(quotesExpander); + + // Bind settings + settings.bind( + 'rotation-interval', + rotationRow, + 'value', + Gio.SettingsBindFlags.DEFAULT + ); + + settings.bind( + 'sync-interval', + syncRow, + 'value', + Gio.SettingsBindFlags.DEFAULT + ); + + settings.bind( + 'api-url', + apiRow, + 'text', + Gio.SettingsBindFlags.DEFAULT + ); + + settings.bind( + 'font-size', + fontSizeRow, + 'value', + Gio.SettingsBindFlags.DEFAULT + ); + + settings.bind( + 'text-color', + colorRow, + 'text', + Gio.SettingsBindFlags.DEFAULT + ); + + settings.bind( + 'max-width', + widthRow, + 'value', + Gio.SettingsBindFlags.DEFAULT + ); + + settings.bind( + 'quotes-file', + quotesFileRow, + 'text', + Gio.SettingsBindFlags.DEFAULT + ); + + settings.bind( + 'use-custom-quotes', + useCustomQuotesRow, + 'active', + 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 diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled new file mode 100644 index 0000000..f5637f5 Binary files /dev/null and b/schemas/gschemas.compiled differ diff --git a/schemas/org.gnome.shell.extensions.quotes.gschema.xml b/schemas/org.gnome.shell.extensions.quotes.gschema.xml new file mode 100644 index 0000000..7918a00 --- /dev/null +++ b/schemas/org.gnome.shell.extensions.quotes.gschema.xml @@ -0,0 +1,50 @@ + + + + + 30 + Quote rotation interval + Time in seconds between quote rotations + + + 3600 + Sync interval + Time in seconds between API synchronizations + + + 'https://api.quotable.io/random' + API URL + URL for fetching quotes from remote server + + + 14 + Font size + Font size for quote text in pixels + + + '#ffffff' + Text color + Color for quote text in hex format + + + 60 + Maximum character width + Maximum number of characters to display before truncating + + + 'quotes.json' + Quotes file name + Name of the JSON file to store quotes + + + '[]' + Custom quotes + JSON array of custom quotes objects with text and author properties + + + false + Use custom quotes + Whether to use custom quotes instead of built-in/synced quotes + + + \ No newline at end of file diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..d82f949 --- /dev/null +++ b/stylesheet.css @@ -0,0 +1,13 @@ +.quote-label { + font-size: 11px; + color: #fff; + padding: 0 8px; + max-width: 400px; + text-align: left; +} + +.quote-label:hover { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px; + cursor: pointer; +} \ No newline at end of file