@rmdes/indiekit-endpoint-rss 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +3 -0
- package/lib/controllers/dashboard.js +53 -0
- package/lib/rss-client.js +61 -11
- package/lib/utils.js +17 -1
- package/locales/en.json +3 -1
- package/package.json +1 -1
- package/views/rss.njk +55 -7
package/index.js
CHANGED
|
@@ -61,6 +61,9 @@ export default class RssEndpoint {
|
|
|
61
61
|
// Manual sync trigger
|
|
62
62
|
protectedRouter.post("/sync", dashboardController.sync);
|
|
63
63
|
|
|
64
|
+
// Clear items and re-sync
|
|
65
|
+
protectedRouter.post("/clear-resync", dashboardController.clearResync);
|
|
66
|
+
|
|
64
67
|
// Feed management (protected - requires auth)
|
|
65
68
|
protectedRouter.post("/api/feeds", express.json(), feedsController.add);
|
|
66
69
|
protectedRouter.delete("/api/feeds/:id", feedsController.remove);
|
|
@@ -59,6 +59,59 @@ export const dashboardController = {
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Clear all items and re-sync
|
|
64
|
+
* POST /clear-resync
|
|
65
|
+
*/
|
|
66
|
+
async clearResync(request, response) {
|
|
67
|
+
try {
|
|
68
|
+
const { rssConfig, getRssDb } = request.app.locals.application;
|
|
69
|
+
|
|
70
|
+
if (!rssConfig) {
|
|
71
|
+
return response.status(500).json({
|
|
72
|
+
error: response.locals.__("rss.error.noConfig"),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const db = getRssDb?.();
|
|
77
|
+
if (!db) {
|
|
78
|
+
return response.status(500).json({
|
|
79
|
+
error: response.locals.__("rss.error.noDatabase"),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Drop all items
|
|
84
|
+
const itemsCollection = db.collection("rssItems");
|
|
85
|
+
const deleteResult = await itemsCollection.deleteMany({});
|
|
86
|
+
console.log(`[RSS] Cleared ${deleteResult.deletedCount} items`);
|
|
87
|
+
|
|
88
|
+
// Reset feed item counts
|
|
89
|
+
const feedsCollection = db.collection("rssFeeds");
|
|
90
|
+
await feedsCollection.updateMany({}, { $set: { itemCount: 0 } });
|
|
91
|
+
|
|
92
|
+
// Trigger sync
|
|
93
|
+
const result = await runSync(db, rssConfig);
|
|
94
|
+
|
|
95
|
+
if (result.error) {
|
|
96
|
+
return response.status(500).json({
|
|
97
|
+
success: false,
|
|
98
|
+
error: result.error,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
response.json({
|
|
103
|
+
success: true,
|
|
104
|
+
message: response.locals.__("rss.success.clearResync"),
|
|
105
|
+
itemsCleared: deleteResult.deletedCount,
|
|
106
|
+
feedsProcessed: result.feedsProcessed,
|
|
107
|
+
itemsAdded: result.itemsAdded,
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error("[RSS] Clear & re-sync error:", error.message);
|
|
111
|
+
response.status(500).json({ error: error.message });
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
62
115
|
/**
|
|
63
116
|
* Trigger manual sync
|
|
64
117
|
* POST /sync
|
package/lib/rss-client.js
CHANGED
|
@@ -158,26 +158,35 @@ export class RssClient {
|
|
|
158
158
|
!cat.startsWith("user/")
|
|
159
159
|
);
|
|
160
160
|
|
|
161
|
+
// For aggregators like FreshRSS, use origin info for the real source
|
|
162
|
+
const originTitle = item.origin?.title || null;
|
|
163
|
+
const originUrl = item.origin?.htmlUrl || null;
|
|
164
|
+
|
|
165
|
+
// Extract a meaningful description/summary
|
|
166
|
+
const description = this.extractDescription(content, item.summary);
|
|
167
|
+
|
|
161
168
|
return {
|
|
162
169
|
guid: item["frss:id"] || item.id || item.guid || link,
|
|
163
170
|
title: item.title || "Untitled",
|
|
164
171
|
link,
|
|
165
|
-
description
|
|
166
|
-
(typeof item.summary === "string" ? item.summary : "") ||
|
|
167
|
-
"",
|
|
172
|
+
description,
|
|
168
173
|
content,
|
|
169
|
-
author
|
|
174
|
+
// For author, prefer the actual author field, fall back to origin title only if no author
|
|
175
|
+
author: item.author || null,
|
|
170
176
|
pubDate,
|
|
171
|
-
imageUrl: this.extractJsonItemImage(item),
|
|
177
|
+
imageUrl: this.extractJsonItemImage(item, content),
|
|
172
178
|
categories,
|
|
173
179
|
enclosure: item.enclosure || null,
|
|
174
|
-
// Preserve FreshRSS-specific metadata
|
|
180
|
+
// Preserve FreshRSS-specific metadata - use origin for real source feed
|
|
175
181
|
origin: item.origin ? {
|
|
176
182
|
streamId: item.origin.streamId,
|
|
177
183
|
title: item.origin.title,
|
|
178
184
|
htmlUrl: item.origin.htmlUrl,
|
|
179
185
|
feedUrl: item.origin.feedUrl,
|
|
180
186
|
} : null,
|
|
187
|
+
// Denormalized fields for easier display
|
|
188
|
+
sourceTitle: originTitle,
|
|
189
|
+
sourceUrl: originUrl,
|
|
181
190
|
};
|
|
182
191
|
});
|
|
183
192
|
|
|
@@ -226,30 +235,71 @@ export class RssClient {
|
|
|
226
235
|
/**
|
|
227
236
|
* Extract image from JSON feed item
|
|
228
237
|
* @param {Object} item - JSON item
|
|
238
|
+
* @param {string} content - Pre-extracted content string
|
|
229
239
|
* @returns {string|null}
|
|
230
240
|
*/
|
|
231
|
-
extractJsonItemImage(item) {
|
|
241
|
+
extractJsonItemImage(item, content = "") {
|
|
232
242
|
// Direct image property
|
|
233
243
|
if (item.image) return item.image;
|
|
234
244
|
|
|
235
|
-
// Media content
|
|
245
|
+
// Media content (various formats)
|
|
236
246
|
if (item.media?.$?.url) return item.media.$.url;
|
|
247
|
+
if (item["media:content"]?.["$"]?.url) return item["media:content"]["$"].url;
|
|
248
|
+
if (item["media:thumbnail"]?.["$"]?.url) return item["media:thumbnail"]["$"].url;
|
|
237
249
|
|
|
238
250
|
// Enclosure
|
|
239
251
|
if (item.enclosure?.href && this.isImageUrl(item.enclosure.href)) {
|
|
240
252
|
return item.enclosure.href;
|
|
241
253
|
}
|
|
254
|
+
if (item.enclosure?.url && this.isImageUrl(item.enclosure.url)) {
|
|
255
|
+
return item.enclosure.url;
|
|
256
|
+
}
|
|
242
257
|
|
|
243
|
-
// Extract from content
|
|
244
|
-
|
|
245
|
-
if (typeof content === "string") {
|
|
258
|
+
// Extract from content HTML
|
|
259
|
+
if (typeof content === "string" && content.length > 0) {
|
|
246
260
|
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
247
261
|
if (imgMatch) return imgMatch[1];
|
|
248
262
|
}
|
|
249
263
|
|
|
264
|
+
// Try item's own content fields
|
|
265
|
+
const itemContent = item.content?.content || item.content || item.summary?.content || "";
|
|
266
|
+
if (typeof itemContent === "string" && itemContent.length > 0) {
|
|
267
|
+
const imgMatch = itemContent.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
268
|
+
if (imgMatch) return imgMatch[1];
|
|
269
|
+
}
|
|
270
|
+
|
|
250
271
|
return null;
|
|
251
272
|
}
|
|
252
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Extract a clean description/summary from content
|
|
276
|
+
* @param {string} content - HTML content
|
|
277
|
+
* @param {Object|string} summary - Summary object or string
|
|
278
|
+
* @returns {string}
|
|
279
|
+
*/
|
|
280
|
+
extractDescription(content, summary) {
|
|
281
|
+
// First try the summary
|
|
282
|
+
let desc = "";
|
|
283
|
+
if (typeof summary === "string") {
|
|
284
|
+
desc = summary;
|
|
285
|
+
} else if (summary?.content) {
|
|
286
|
+
desc = summary.content;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// If no summary, extract from content
|
|
290
|
+
if (!desc && typeof content === "string" && content.length > 0) {
|
|
291
|
+
// Strip HTML tags and get first 300 chars
|
|
292
|
+
desc = content
|
|
293
|
+
.replace(/<[^>]+>/g, " ") // Remove HTML tags
|
|
294
|
+
.replace(/\s+/g, " ") // Normalize whitespace
|
|
295
|
+
.trim()
|
|
296
|
+
.slice(0, 300);
|
|
297
|
+
if (content.length > 300) desc += "...";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return desc;
|
|
301
|
+
}
|
|
302
|
+
|
|
253
303
|
/**
|
|
254
304
|
* Extract feed metadata
|
|
255
305
|
* @param {Object} parsed - Parsed feed object
|
package/lib/utils.js
CHANGED
|
@@ -99,6 +99,14 @@ function isImageType(type) {
|
|
|
99
99
|
export function formatItem(item, options = {}) {
|
|
100
100
|
const { includeContent = false, descriptionLength = 200 } = options;
|
|
101
101
|
|
|
102
|
+
// Handle description - use item.description if available, otherwise generate from content
|
|
103
|
+
let description = item.description;
|
|
104
|
+
if (!description && item.content) {
|
|
105
|
+
description = truncateText(item.content, descriptionLength);
|
|
106
|
+
} else if (description) {
|
|
107
|
+
description = truncateText(description, descriptionLength);
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
const formatted = {
|
|
103
111
|
id: item._id?.toString(),
|
|
104
112
|
feedId: item.feedId?.toString(),
|
|
@@ -106,14 +114,22 @@ export function formatItem(item, options = {}) {
|
|
|
106
114
|
guid: item.guid,
|
|
107
115
|
title: item.title,
|
|
108
116
|
link: item.link,
|
|
109
|
-
description:
|
|
117
|
+
description: description || "",
|
|
110
118
|
author: item.author,
|
|
111
119
|
pubDate: item.pubDate?.toISOString(),
|
|
112
120
|
imageUrl: item.imageUrl,
|
|
113
121
|
categories: item.categories || [],
|
|
114
122
|
fetchedAt: item.fetchedAt?.toISOString(),
|
|
123
|
+
// Source info for aggregators (like FreshRSS) - represents the original feed
|
|
124
|
+
sourceTitle: item.sourceTitle || null,
|
|
125
|
+
sourceUrl: item.sourceUrl || null,
|
|
115
126
|
};
|
|
116
127
|
|
|
128
|
+
// Include origin object if present (for aggregator metadata)
|
|
129
|
+
if (item.origin) {
|
|
130
|
+
formatted.origin = item.origin;
|
|
131
|
+
}
|
|
132
|
+
|
|
117
133
|
if (includeContent) {
|
|
118
134
|
formatted.content = sanitizeHtml(item.content);
|
|
119
135
|
}
|
package/locales/en.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"enableFeed": "Enable",
|
|
9
9
|
"disableFeed": "Disable",
|
|
10
10
|
"syncNow": "Sync Now",
|
|
11
|
+
"clearResync": "Clear & Re-sync",
|
|
11
12
|
"syncing": "Syncing...",
|
|
12
13
|
"lastSynced": "Last synced",
|
|
13
14
|
"noFeeds": "No feeds configured. Add a feed URL to get started.",
|
|
@@ -40,7 +41,8 @@
|
|
|
40
41
|
"feedRemoved": "Feed removed",
|
|
41
42
|
"feedEnabled": "Feed enabled",
|
|
42
43
|
"feedDisabled": "Feed disabled",
|
|
43
|
-
"syncComplete": "Sync complete"
|
|
44
|
+
"syncComplete": "Sync complete",
|
|
45
|
+
"clearResync": "Items cleared and re-synced"
|
|
44
46
|
},
|
|
45
47
|
"widget": {
|
|
46
48
|
"description": "View aggregated RSS feeds on the public page",
|
package/package.json
CHANGED
package/views/rss.njk
CHANGED
|
@@ -284,13 +284,22 @@
|
|
|
284
284
|
<span class="rss-feed-error">{{ syncState.lastError }}</span>
|
|
285
285
|
{% endif %}
|
|
286
286
|
</div>
|
|
287
|
-
<
|
|
288
|
-
{{
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
<div style="display: flex; gap: 0.5rem;">
|
|
288
|
+
<form action="{{ publicUrl }}/sync" method="post" style="margin: 0;">
|
|
289
|
+
{{ button({
|
|
290
|
+
type: "submit",
|
|
291
|
+
text: __("rss.syncNow"),
|
|
292
|
+
disabled: syncState.syncing
|
|
293
|
+
}) }}
|
|
294
|
+
</form>
|
|
295
|
+
<form action="{{ publicUrl }}/clear-resync" method="post" style="margin: 0;" id="clear-resync-form">
|
|
296
|
+
{{ button({
|
|
297
|
+
type: "submit",
|
|
298
|
+
text: __("rss.clearResync"),
|
|
299
|
+
disabled: syncState.syncing
|
|
300
|
+
}) }}
|
|
301
|
+
</form>
|
|
302
|
+
</div>
|
|
294
303
|
</div>
|
|
295
304
|
|
|
296
305
|
{# Stats #}
|
|
@@ -471,6 +480,45 @@
|
|
|
471
480
|
});
|
|
472
481
|
});
|
|
473
482
|
|
|
483
|
+
// Handle clear & re-sync
|
|
484
|
+
document.getElementById('clear-resync-form')?.addEventListener('submit', async (e) => {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
if (!confirm('This will delete all cached items and re-fetch them from feeds. Continue?')) return;
|
|
487
|
+
|
|
488
|
+
const form = e.target;
|
|
489
|
+
const submitBtn = form.querySelector('button[type="submit"]');
|
|
490
|
+
const originalText = submitBtn?.textContent;
|
|
491
|
+
if (submitBtn) {
|
|
492
|
+
submitBtn.disabled = true;
|
|
493
|
+
submitBtn.textContent = 'Clearing...';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
const response = await fetch(form.action, {
|
|
498
|
+
method: 'POST',
|
|
499
|
+
headers: { 'Content-Type': 'application/json' }
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const data = await response.json();
|
|
503
|
+
if (response.ok) {
|
|
504
|
+
alert(`Cleared ${data.itemsCleared} items, re-fetched ${data.itemsAdded} items from ${data.feedsProcessed} feeds.`);
|
|
505
|
+
location.reload();
|
|
506
|
+
} else {
|
|
507
|
+
alert(data.error || 'Failed to clear & re-sync');
|
|
508
|
+
if (submitBtn) {
|
|
509
|
+
submitBtn.disabled = false;
|
|
510
|
+
submitBtn.textContent = originalText;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
} catch (err) {
|
|
514
|
+
alert('Failed to clear & re-sync: ' + err.message);
|
|
515
|
+
if (submitBtn) {
|
|
516
|
+
submitBtn.disabled = false;
|
|
517
|
+
submitBtn.textContent = originalText;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
474
522
|
// Handle delete feed
|
|
475
523
|
document.querySelectorAll('[data-delete-feed]').forEach(btn => {
|
|
476
524
|
btn.addEventListener('click', async (e) => {
|