Skip to content

Commit 2bbdc50

Browse files
Paul163-aiclaudiux
andauthored
newsfeed@Paul163-ai: Add multi-source news feed desklet (#1832)
* newsfeed@Paul163-ai: Add multi-source news feed desklet * newsfeed@Paul163-ai: Fix structure per reviewer feedback * Fix formatting in metadata.json --------- Co-authored-by: claudiux <33965039+claudiux@users.noreply.github.com>
1 parent c8cc888 commit 2bbdc50

8 files changed

Lines changed: 392 additions & 0 deletions

File tree

newsfeed@Paul163-ai/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# News Feed — Multi-Source
2+
3+
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.
4+
5+
![Screenshot](screenshot.png)
6+
7+
---
8+
9+
## Features
10+
11+
- Aggregates headlines from multiple RSS sources simultaneously
12+
- Per-source error handling — one failing feed won't hide the others
13+
- Clickable headlines open in your default browser
14+
- Configurable number of stories per source (1–10)
15+
- Adjustable refresh interval (1–60 minutes)
16+
- Resizable width to fit your desktop layout
17+
18+
## Supported sources
19+
20+
| Source | Feed |
21+
|---|---|
22+
| Google News | `news.google.com/rss` |
23+
| BBC World | `feeds.bbci.co.uk/news/world/rss.xml` |
24+
| Linux Mint Blog | `blog.linuxmint.com/?feed=rss2` |
25+
26+
## Requirements
27+
28+
- Linux Mint with Cinnamon 5.4, 6.0, or 6.4
29+
30+
## Installation
31+
32+
**From Cinnamon Spices (recommended)**
33+
34+
1. Right-click the desktop → *Add Desklets*
35+
2. Search for **News Feed**
36+
3. Click *Install* then *Add to desktop*
37+
38+
**Manual**
39+
40+
```bash
41+
cd ~/.local/share/cinnamon/desklets/
42+
git clone https://github.com/YOUR_USERNAME/cinnamon-spices-desklets newsfeed@paull --depth 1 --filter=blob:none --sparse
43+
cd newsfeed@paull
44+
git sparse-checkout set newsfeed@paull
45+
```
46+
47+
Then right-click the desktop → *Add Desklets* → find News Feed in the list.
48+
49+
## Settings
50+
51+
| Setting | Default | Description |
52+
|---|---|---|
53+
| Google News | On | Enable/disable Google News feed |
54+
| BBC World | Off | Enable/disable BBC World feed |
55+
| Linux Mint Blog | On | Enable/disable Linux Mint Blog feed |
56+
| Stories per source | 3 | How many headlines to show per feed |
57+
| Desklet width | 350 px | Width of the desklet panel |
58+
| Refresh interval | 15 min | How often to fetch new headlines |
59+
60+
## Author
61+
62+
Paul Lintott
63+
64+
## License
65+
66+
GPL-2.0
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
const Desklet = imports.ui.desklet;
2+
const St = imports.gi.St;
3+
const Soup = imports.gi.Soup;
4+
const Settings = imports.ui.settings;
5+
const GLib = imports.gi.GLib;
6+
const Gio = imports.gi.Gio;
7+
8+
class NewsFeedDesklet extends Desklet.Desklet {
9+
constructor(metadata, deskletId) {
10+
super(metadata, deskletId);
11+
12+
this.settings = new Settings.DeskletSettings(this, "newsfeed@Paul163-ai", deskletId);
13+
14+
// Bind settings — use .bind(this) to ensure correct context in callbacks
15+
this.settings.bindProperty(Settings.BindingDirection.IN, "source-google", "useGoogle", this.onSettingsChanged.bind(this), null);
16+
this.settings.bindProperty(Settings.BindingDirection.IN, "source-bbc", "useBBC", this.onSettingsChanged.bind(this), null);
17+
this.settings.bindProperty(Settings.BindingDirection.IN, "source-mint", "useMint", this.onSettingsChanged.bind(this), null);
18+
this.settings.bindProperty(Settings.BindingDirection.IN, "story-count", "storyCount", this.onSettingsChanged.bind(this), null);
19+
this.settings.bindProperty(Settings.BindingDirection.IN, "desklet-width", "deskletWidth", this.onVisualsChanged.bind(this), null);
20+
this.settings.bindProperty(Settings.BindingDirection.IN, "update-interval", "updateInterval", this.onSettingsChanged.bind(this), null);
21+
22+
this.sourceMap = {
23+
"useGoogle": { name: "Google News", url: "https://news.google.com/rss" },
24+
"useBBC": { name: "BBC World", url: "https://feeds.bbci.co.uk/news/world/rss.xml" },
25+
"useMint": { name: "Linux Mint Blog", url: "https://blog.linuxmint.com/?feed=rss2" }
26+
};
27+
28+
// Reuse a single Soup session for all requests
29+
this.session = new Soup.Session({ user_agent: 'Cinnamon-NewsDesklet/1.0' });
30+
31+
this.timeoutId = null;
32+
this.setupUI();
33+
this.updateAllFeeds();
34+
}
35+
36+
setupUI() {
37+
this.container = new St.BoxLayout({ vertical: true, style_class: "news-container" });
38+
this.container.set_width(this.deskletWidth);
39+
this.setContent(this.container);
40+
}
41+
42+
onVisualsChanged() {
43+
this.container.set_width(this.deskletWidth);
44+
}
45+
46+
onSettingsChanged() {
47+
if (this.timeoutId) {
48+
GLib.source_remove(this.timeoutId);
49+
this.timeoutId = null;
50+
}
51+
this.updateAllFeeds();
52+
}
53+
54+
// Override destroy() to prevent the timer firing after desklet removal
55+
destroy() {
56+
if (this.timeoutId) {
57+
GLib.source_remove(this.timeoutId);
58+
this.timeoutId = null;
59+
}
60+
super.destroy();
61+
}
62+
63+
updateAllFeeds() {
64+
this.container.destroy_all_children();
65+
this.container.add_child(new St.Label({ text: "Updating feeds...", style_class: "status-label" }));
66+
67+
let activeSources = [];
68+
for (let key in this.sourceMap) {
69+
if (this[key]) activeSources.push(this.sourceMap[key]);
70+
}
71+
72+
if (activeSources.length === 0) {
73+
this.displayError("No sources selected.");
74+
this._scheduleNextUpdate();
75+
return;
76+
}
77+
78+
// Track completion manually instead of Promise.all so one failed
79+
// feed does not prevent the others from rendering.
80+
let results = [];
81+
let completed = 0;
82+
let total = activeSources.length;
83+
84+
const onDone = () => {
85+
completed++;
86+
if (completed < total) return;
87+
88+
// All fetches finished (success or failure) — render what we have
89+
this.container.destroy_all_children();
90+
let anySuccess = false;
91+
results.forEach(r => {
92+
if (r.xml) {
93+
this.parseAndDisplay(r.name, r.xml);
94+
anySuccess = true;
95+
} else {
96+
// Show a per-source error row instead of wiping everything
97+
let errHeader = new St.Label({
98+
text: r.name.toUpperCase() + " — failed to load",
99+
style_class: "error-label"
100+
});
101+
this.container.add_child(errHeader);
102+
}
103+
});
104+
105+
if (!anySuccess) {
106+
this.displayError("All feeds failed to load.");
107+
}
108+
109+
this._scheduleNextUpdate();
110+
};
111+
112+
activeSources.forEach(source => {
113+
let entry = { name: source.name, xml: null };
114+
results.push(entry);
115+
116+
this.fetchFeed(source.url,
117+
(xml) => {
118+
entry.xml = xml;
119+
onDone();
120+
},
121+
(err) => {
122+
// entry.xml stays null — error row will be shown
123+
global.logError("NewsFeedDesklet: fetch failed for " + source.name + ": " + err);
124+
onDone();
125+
}
126+
);
127+
});
128+
}
129+
130+
_scheduleNextUpdate() {
131+
if (this.timeoutId) {
132+
GLib.source_remove(this.timeoutId);
133+
this.timeoutId = null;
134+
}
135+
this.timeoutId = GLib.timeout_add_seconds(
136+
GLib.PRIORITY_DEFAULT,
137+
this.updateInterval * 60,
138+
() => {
139+
this.timeoutId = null;
140+
this.updateAllFeeds();
141+
return GLib.SOURCE_REMOVE;
142+
}
143+
);
144+
}
145+
146+
fetchFeed(url, onSuccess, onError) {
147+
let message = Soup.Message.new('GET', url);
148+
149+
this.session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => {
150+
try {
151+
const bytes = session.send_and_read_finish(result);
152+
// Decode Uint8Array properly — .toString() produces "1,2,3,..."
153+
const decoder = new TextDecoder('utf-8');
154+
const xml = decoder.decode(bytes.get_data());
155+
onSuccess(xml);
156+
} catch (e) {
157+
onError(e.message || String(e));
158+
}
159+
});
160+
}
161+
162+
displayError(message) {
163+
this.container.destroy_all_children();
164+
this.container.add_child(new St.Label({ text: message, style_class: "error-label" }));
165+
}
166+
167+
parseAndDisplay(sourceName, xml) {
168+
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
169+
let match;
170+
let count = 0;
171+
172+
let sourceHeader = new St.Label({ text: sourceName.toUpperCase(), style_class: "source-header" });
173+
this.container.add_child(sourceHeader);
174+
175+
while ((match = itemRegex.exec(xml)) !== null && count < this.storyCount) {
176+
let itemContent = match[1];
177+
178+
let title = itemContent.match(/<title>([\s\S]*?)<\/title>/)?.[1] || "Untitled";
179+
let link = itemContent.match(/<link>([\s\S]*?)<\/link>/)?.[1] || "";
180+
181+
// Strip CDATA and decode HTML entities from both title and link
182+
title = this._cleanText(title);
183+
link = this._cleanText(link).trim();
184+
185+
// Skip items with empty or obviously invalid links
186+
if (!link || link === "#") {
187+
// Try <guid> as a fallback URL
188+
let guid = itemContent.match(/<guid[^>]*>([\s\S]*?)<\/guid>/)?.[1] || "";
189+
guid = this._cleanText(guid).trim();
190+
if (guid.startsWith("http")) link = guid;
191+
}
192+
193+
let btn = new St.Button({
194+
label: " • " + title,
195+
style_class: "news-button",
196+
reactive: true,
197+
x_align: St.Align.START
198+
});
199+
200+
// Capture link in closure
201+
const finalLink = link;
202+
btn.connect('clicked', () => {
203+
if (finalLink) {
204+
try {
205+
Gio.app_info_launch_default_for_uri(finalLink, null);
206+
} catch (e) {
207+
global.logError("NewsFeedDesklet: could not open link: " + e);
208+
}
209+
}
210+
});
211+
212+
this.container.add_child(btn);
213+
count++;
214+
}
215+
216+
this.container.add_child(new St.Label({ text: " ", style_class: "section-spacer" }));
217+
}
218+
219+
_cleanText(str) {
220+
return str
221+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
222+
.replace(/&amp;/g, "&")
223+
.replace(/&quot;/g, '"')
224+
.replace(/&lt;/g, "<")
225+
.replace(/&gt;/g, ">")
226+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
227+
.trim();
228+
}
229+
}
230+
231+
function main(metadata, deskletId) {
232+
return new NewsFeedDesklet(metadata, deskletId);
233+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"header1": {
3+
"type": "header",
4+
"description": "Active News Sources"
5+
},
6+
"source-google": { "type": "checkbox", "default": true, "description": "Google News" },
7+
"source-bbc": { "type": "checkbox", "default": true, "description": "BBC World" },
8+
"source-mint": { "type": "checkbox", "default": false, "description": "Linux Mint Blog" },
9+
"header2": {
10+
"type": "header",
11+
"description": "Display Settings"
12+
},
13+
"desklet-width": {
14+
"type": "scale",
15+
"default": 350,
16+
"min": 250,
17+
"max": 800,
18+
"step": 10,
19+
"description": "Desklet Width (px)"
20+
},
21+
"story-count": {
22+
"type": "spinbutton",
23+
"default": 3,
24+
"units": "stories",
25+
"step": 1,
26+
"min": 1,
27+
"max": 10,
28+
"description": "Stories per source"
29+
},
30+
"update-interval": {
31+
"type": "spinbutton",
32+
"default": 15,
33+
"units": "minutes",
34+
"step": 1,
35+
"min": 1,
36+
"max": 60,
37+
"description": "Refresh Interval"
38+
}
39+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
.news-container {
2+
background-color: rgba(25, 25, 25, 0.85);
3+
border: 1px solid rgba(255, 255, 255, 0.1);
4+
border-radius: 12px;
5+
padding: 15px;
6+
width: 380px;
7+
}
8+
9+
.source-header {
10+
color: #4ed84e; /* Mint Green */
11+
font-size: 9pt;
12+
font-weight: bold;
13+
padding-bottom: 4px;
14+
border-bottom: 1px solid rgba(78, 216, 78, 0.3);
15+
margin-bottom: 5px;
16+
margin-top: 5px;
17+
}
18+
19+
.news-button {
20+
padding: 4px 8px;
21+
color: #f0f0f0;
22+
text-align: left;
23+
font-size: 10pt;
24+
border-radius: 4px;
25+
transition-duration: 100ms;
26+
}
27+
28+
.news-button:hover {
29+
background-color: rgba(255, 255, 255, 0.1);
30+
color: #ffffff;
31+
}
32+
33+
.section-spacer {
34+
font-size: 4pt;
35+
}
36+
37+
.status-label, .error-label {
38+
font-size: 10pt;
39+
color: #aaa;
40+
padding: 10px;
41+
font-style: italic;
42+
}
10.4 KB
Loading
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"uuid": "newsfeed@Paul163-ai",
3+
"name": "News Feed - Multi-Source",
4+
"description": "Stay updated with your favorite RSS feeds.",
5+
"prevent-resizing": false,
6+
"cinnamon-version": ["5.4", "6.0", "6.4"],
7+
"author": "Paul Lintott"
8+
}

newsfeed@Paul163-ai/info.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"author": "Paul163-ai",
3+
"uuid": "newsfeed@Paul163-ai"
4+
}

newsfeed@Paul163-ai/screenshot.png

176 KB
Loading

0 commit comments

Comments
 (0)