Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions newsfeed@Paul163-ai/README.md
Original file line number Diff line number Diff line change
@@ -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
233 changes: 233 additions & 0 deletions newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/desklet.js
Original file line number Diff line number Diff line change
@@ -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 = /<item>([\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(/<title>([\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(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
.trim();
}
}

function main(metadata, deskletId) {
return new NewsFeedDesklet(metadata, deskletId);
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
42 changes: 42 additions & 0 deletions newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/6.0/stylesheet.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions newsfeed@Paul163-ai/files/newsfeed@Paul163-ai/metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 4 additions & 0 deletions newsfeed@Paul163-ai/info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"author": "Paul163-ai",
"uuid": "newsfeed@Paul163-ai"
}
Binary file added newsfeed@Paul163-ai/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading