563 lines
22 KiB
JavaScript
563 lines
22 KiB
JavaScript
'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;
|
|
}
|
|
}
|
|
} |