@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 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: item.summary?.content ||
166
- (typeof item.summary === "string" ? item.summary : "") ||
167
- "",
172
+ description,
168
173
  content,
169
- author: item.author || item.origin?.title || null,
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
- const content = item.content?.content || item.content || item.summary?.content || "";
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: truncateText(item.description, descriptionLength),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
5
5
  "keywords": [
6
6
  "indiekit",
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
- <form action="{{ publicUrl }}/sync" method="post">
288
- {{ button({
289
- type: "submit",
290
- text: __("rss.syncNow"),
291
- disabled: syncState.syncing
292
- }) }}
293
- </form>
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) => {