diff --git a/newsfeed@Paul163-ai/README.md b/newsfeed@Paul163-ai/README.md new file mode 100644 index 000000000..fa4ea7eeb --- /dev/null +++ b/newsfeed@Paul163-ai/README.md @@ -0,0 +1,66 @@ +# News Feed — Multi-Source + +A lightweight Cinnamon desklet that displays live headlines from multiple RSS feeds on your desktop. Click any headline to open it in your default browser. + +![Screenshot](screenshot.png) + +--- + +## Features + +- Aggregates headlines from multiple RSS sources simultaneously +- Per-source error handling — one failing feed won't hide the others +- Clickable headlines open in your default browser +- Configurable number of stories per source (1–10) +- Adjustable refresh interval (1–60 minutes) +- Resizable width to fit your desktop layout + +## Supported sources + +| Source | Feed | +|---|---| +| Google News | `news.google.com/rss` | +| BBC World | `feeds.bbci.co.uk/news/world/rss.xml` | +| Linux Mint Blog | `blog.linuxmint.com/?feed=rss2` | + +## Requirements + +- Linux Mint with Cinnamon 5.4, 6.0, or 6.4 + +## Installation + +**From Cinnamon Spices (recommended)** + +1. Right-click the desktop → *Add Desklets* +2. Search for **News Feed** +3. Click *Install* then *Add to desktop* + +**Manual** + +```bash +cd ~/.local/share/cinnamon/desklets/ +git clone https://github.com/YOUR_USERNAME/cinnamon-spices-desklets newsfeed@paull --depth 1 --filter=blob:none --sparse +cd newsfeed@paull +git sparse-checkout set newsfeed@paull +``` + +Then right-click the desktop → *Add Desklets* → find News Feed in the list. + +## Settings + +| Setting | Default | Description | +|---|---|---| +| Google News | On | Enable/disable Google News feed | +| BBC World | Off | Enable/disable BBC World feed | +| Linux Mint Blog | On | Enable/disable Linux Mint Blog feed | +| Stories per source | 3 | How many headlines to show per feed | +| Desklet width | 350 px | Width of the desklet panel | +| Refresh interval | 15 min | How often to fetch new headlines | + +## Author + +Paul Lintott + +## License + +GPL-2.0 diff --git a/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/desklet.js b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/desklet.js new file mode 100644 index 000000000..2a784c0e7 --- /dev/null +++ b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/desklet.js @@ -0,0 +1,233 @@ +const Desklet = imports.ui.desklet; +const St = imports.gi.St; +const Soup = imports.gi.Soup; +const Settings = imports.ui.settings; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; + +class NewsFeedDesklet extends Desklet.Desklet { + constructor(metadata, deskletId) { + super(metadata, deskletId); + + this.settings = new Settings.DeskletSettings(this, "newsfeed@Paul163-ai", deskletId); + + // Bind settings — use .bind(this) to ensure correct context in callbacks + this.settings.bindProperty(Settings.BindingDirection.IN, "source-google", "useGoogle", this.onSettingsChanged.bind(this), null); + this.settings.bindProperty(Settings.BindingDirection.IN, "source-bbc", "useBBC", this.onSettingsChanged.bind(this), null); + this.settings.bindProperty(Settings.BindingDirection.IN, "source-mint", "useMint", this.onSettingsChanged.bind(this), null); + this.settings.bindProperty(Settings.BindingDirection.IN, "story-count", "storyCount", this.onSettingsChanged.bind(this), null); + this.settings.bindProperty(Settings.BindingDirection.IN, "desklet-width", "deskletWidth", this.onVisualsChanged.bind(this), null); + this.settings.bindProperty(Settings.BindingDirection.IN, "update-interval", "updateInterval", this.onSettingsChanged.bind(this), null); + + this.sourceMap = { + "useGoogle": { name: "Google News", url: "https://news.google.com/rss" }, + "useBBC": { name: "BBC World", url: "https://feeds.bbci.co.uk/news/world/rss.xml" }, + "useMint": { name: "Linux Mint Blog", url: "https://blog.linuxmint.com/?feed=rss2" } + }; + + // Reuse a single Soup session for all requests + this.session = new Soup.Session({ user_agent: 'Cinnamon-NewsDesklet/1.0' }); + + this.timeoutId = null; + this.setupUI(); + this.updateAllFeeds(); + } + + setupUI() { + this.container = new St.BoxLayout({ vertical: true, style_class: "news-container" }); + this.container.set_width(this.deskletWidth); + this.setContent(this.container); + } + + onVisualsChanged() { + this.container.set_width(this.deskletWidth); + } + + onSettingsChanged() { + if (this.timeoutId) { + GLib.source_remove(this.timeoutId); + this.timeoutId = null; + } + this.updateAllFeeds(); + } + + // Override destroy() to prevent the timer firing after desklet removal + destroy() { + if (this.timeoutId) { + GLib.source_remove(this.timeoutId); + this.timeoutId = null; + } + super.destroy(); + } + + updateAllFeeds() { + this.container.destroy_all_children(); + this.container.add_child(new St.Label({ text: "Updating feeds...", style_class: "status-label" })); + + let activeSources = []; + for (let key in this.sourceMap) { + if (this[key]) activeSources.push(this.sourceMap[key]); + } + + if (activeSources.length === 0) { + this.displayError("No sources selected."); + this._scheduleNextUpdate(); + return; + } + + // Track completion manually instead of Promise.all so one failed + // feed does not prevent the others from rendering. + let results = []; + let completed = 0; + let total = activeSources.length; + + const onDone = () => { + completed++; + if (completed < total) return; + + // All fetches finished (success or failure) — render what we have + this.container.destroy_all_children(); + let anySuccess = false; + results.forEach(r => { + if (r.xml) { + this.parseAndDisplay(r.name, r.xml); + anySuccess = true; + } else { + // Show a per-source error row instead of wiping everything + let errHeader = new St.Label({ + text: r.name.toUpperCase() + " — failed to load", + style_class: "error-label" + }); + this.container.add_child(errHeader); + } + }); + + if (!anySuccess) { + this.displayError("All feeds failed to load."); + } + + this._scheduleNextUpdate(); + }; + + activeSources.forEach(source => { + let entry = { name: source.name, xml: null }; + results.push(entry); + + this.fetchFeed(source.url, + (xml) => { + entry.xml = xml; + onDone(); + }, + (err) => { + // entry.xml stays null — error row will be shown + global.logError("NewsFeedDesklet: fetch failed for " + source.name + ": " + err); + onDone(); + } + ); + }); + } + + _scheduleNextUpdate() { + if (this.timeoutId) { + GLib.source_remove(this.timeoutId); + this.timeoutId = null; + } + this.timeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + this.updateInterval * 60, + () => { + this.timeoutId = null; + this.updateAllFeeds(); + return GLib.SOURCE_REMOVE; + } + ); + } + + fetchFeed(url, onSuccess, onError) { + let message = Soup.Message.new('GET', url); + + this.session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => { + try { + const bytes = session.send_and_read_finish(result); + // Decode Uint8Array properly — .toString() produces "1,2,3,..." + const decoder = new TextDecoder('utf-8'); + const xml = decoder.decode(bytes.get_data()); + onSuccess(xml); + } catch (e) { + onError(e.message || String(e)); + } + }); + } + + displayError(message) { + this.container.destroy_all_children(); + this.container.add_child(new St.Label({ text: message, style_class: "error-label" })); + } + + parseAndDisplay(sourceName, xml) { + const itemRegex = /([\s\S]*?)<\/item>/g; + let match; + let count = 0; + + let sourceHeader = new St.Label({ text: sourceName.toUpperCase(), style_class: "source-header" }); + this.container.add_child(sourceHeader); + + while ((match = itemRegex.exec(xml)) !== null && count < this.storyCount) { + let itemContent = match[1]; + + let title = itemContent.match(/([\s\S]*?)<\/title>/)?.[1] || "Untitled"; + let link = itemContent.match(/<link>([\s\S]*?)<\/link>/)?.[1] || ""; + + // Strip CDATA and decode HTML entities from both title and link + title = this._cleanText(title); + link = this._cleanText(link).trim(); + + // Skip items with empty or obviously invalid links + if (!link || link === "#") { + // Try <guid> as a fallback URL + let guid = itemContent.match(/<guid[^>]*>([\s\S]*?)<\/guid>/)?.[1] || ""; + guid = this._cleanText(guid).trim(); + if (guid.startsWith("http")) link = guid; + } + + let btn = new St.Button({ + label: " • " + title, + style_class: "news-button", + reactive: true, + x_align: St.Align.START + }); + + // Capture link in closure + const finalLink = link; + btn.connect('clicked', () => { + if (finalLink) { + try { + Gio.app_info_launch_default_for_uri(finalLink, null); + } catch (e) { + global.logError("NewsFeedDesklet: could not open link: " + e); + } + } + }); + + this.container.add_child(btn); + count++; + } + + this.container.add_child(new St.Label({ text: " ", style_class: "section-spacer" })); + } + + _cleanText(str) { + return str + .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .trim(); + } +} + +function main(metadata, deskletId) { + return new NewsFeedDesklet(metadata, deskletId); +} diff --git a/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/settings-schema.json b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/settings-schema.json new file mode 100644 index 000000000..c97493426 --- /dev/null +++ b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/settings-schema.json @@ -0,0 +1,39 @@ +{ + "header1": { + "type": "header", + "description": "Active News Sources" + }, + "source-google": { "type": "checkbox", "default": true, "description": "Google News" }, + "source-bbc": { "type": "checkbox", "default": true, "description": "BBC World" }, + "source-mint": { "type": "checkbox", "default": false, "description": "Linux Mint Blog" }, + "header2": { + "type": "header", + "description": "Display Settings" + }, + "desklet-width": { + "type": "scale", + "default": 350, + "min": 250, + "max": 800, + "step": 10, + "description": "Desklet Width (px)" + }, + "story-count": { + "type": "spinbutton", + "default": 3, + "units": "stories", + "step": 1, + "min": 1, + "max": 10, + "description": "Stories per source" + }, + "update-interval": { + "type": "spinbutton", + "default": 15, + "units": "minutes", + "step": 1, + "min": 1, + "max": 60, + "description": "Refresh Interval" + } +} diff --git a/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/stylesheet.css b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/stylesheet.css new file mode 100644 index 000000000..a59bfa7ea --- /dev/null +++ b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/stylesheet.css @@ -0,0 +1,42 @@ +.news-container { + background-color: rgba(25, 25, 25, 0.85); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 15px; + width: 380px; +} + +.source-header { + color: #4ed84e; /* Mint Green */ + font-size: 9pt; + font-weight: bold; + padding-bottom: 4px; + border-bottom: 1px solid rgba(78, 216, 78, 0.3); + margin-bottom: 5px; + margin-top: 5px; +} + +.news-button { + padding: 4px 8px; + color: #f0f0f0; + text-align: left; + font-size: 10pt; + border-radius: 4px; + transition-duration: 100ms; +} + +.news-button:hover { + background-color: rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.section-spacer { + font-size: 4pt; +} + +.status-label, .error-label { + font-size: 10pt; + color: #aaa; + padding: 10px; + font-style: italic; +} diff --git a/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/icon.png b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/icon.png new file mode 100644 index 000000000..4caf220ad Binary files /dev/null and b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/icon.png differ diff --git a/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/metadata.json b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/metadata.json new file mode 100644 index 000000000..8e3569dcb --- /dev/null +++ b/newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "newsfeed@Paul163-ai", + "name": "News Feed - Multi-Source", + "description": "Stay updated with your favorite RSS feeds.", + "prevent-resizing": false, + "cinnamon-version": ["5.4", "6.0", "6.4"], + "author": "Paul Lintott" +} diff --git a/newsfeed@Paul163-ai/info.json b/newsfeed@Paul163-ai/info.json new file mode 100644 index 000000000..c64270396 --- /dev/null +++ b/newsfeed@Paul163-ai/info.json @@ -0,0 +1,4 @@ +{ + "author": "Paul163-ai", + "uuid": "newsfeed@Paul163-ai" +} diff --git a/newsfeed@Paul163-ai/screenshot.png b/newsfeed@Paul163-ai/screenshot.png new file mode 100644 index 000000000..5906c684e Binary files /dev/null and b/newsfeed@Paul163-ai/screenshot.png differ