@rmdes/indiekit-endpoint-rss 1.0.0

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.
@@ -0,0 +1,408 @@
1
+ import Parser from "rss-parser";
2
+ import { IndiekitError } from "@indiekit/error";
3
+
4
+ const DEFAULT_TIMEOUT = 10_000;
5
+ const DEFAULT_MAX_REDIRECTS = 5;
6
+
7
+ export class RssClient {
8
+ constructor(options = {}) {
9
+ this.timeout = options.timeout || DEFAULT_TIMEOUT;
10
+ this.maxRedirects = options.maxRedirects || DEFAULT_MAX_REDIRECTS;
11
+ this.parser = new Parser({
12
+ timeout: this.timeout,
13
+ maxRedirects: this.maxRedirects,
14
+ headers: {
15
+ "User-Agent": "Indiekit-RSS-Reader/1.0 (+https://getindiekit.com)",
16
+ Accept:
17
+ "application/feed+json, application/json, application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
18
+ },
19
+ customFields: {
20
+ feed: ["image", "icon", "logo"],
21
+ item: [
22
+ ["media:content", "media"],
23
+ ["media:thumbnail", "mediaThumbnail"],
24
+ ["enclosure", "enclosure"],
25
+ ["dc:creator", "creator"],
26
+ ["content:encoded", "contentEncoded"],
27
+ ],
28
+ },
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Fetch and parse an RSS/Atom/JSON feed
34
+ * @param {string} url - Feed URL
35
+ * @returns {Promise<{feed: Object, items: Array}>}
36
+ */
37
+ async fetchFeed(url) {
38
+ try {
39
+ // First, fetch the content to detect format
40
+ const response = await fetch(url, {
41
+ headers: {
42
+ "User-Agent": "Indiekit-RSS-Reader/1.0 (+https://getindiekit.com)",
43
+ Accept:
44
+ "application/feed+json, application/json, application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
45
+ },
46
+ signal: AbortSignal.timeout(this.timeout),
47
+ });
48
+
49
+ if (!response.ok) {
50
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
51
+ }
52
+
53
+ const contentType = response.headers.get("content-type") || "";
54
+ const text = await response.text();
55
+
56
+ // Detect JSON feed (JSON Feed format or Google Reader API JSON)
57
+ if (
58
+ contentType.includes("application/json") ||
59
+ contentType.includes("application/feed+json") ||
60
+ url.includes("f=greader") ||
61
+ url.includes("f=json")
62
+ ) {
63
+ try {
64
+ const json = JSON.parse(text);
65
+ return this.parseJsonFeed(json, url);
66
+ } catch {
67
+ // Fall through to RSS parser if JSON parse fails
68
+ }
69
+ }
70
+
71
+ // Use rss-parser for RSS/Atom
72
+ const parsed = await this.parser.parseString(text);
73
+ return {
74
+ feed: this.extractFeedMeta(parsed, url),
75
+ items: this.transformItems(parsed.items || [], url),
76
+ };
77
+ } catch (error) {
78
+ throw new IndiekitError(`Failed to fetch feed: ${error.message}`, {
79
+ status: 502,
80
+ cause: error,
81
+ });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Parse JSON feed (JSON Feed spec or Google Reader API format)
87
+ * @param {Object} json - Parsed JSON
88
+ * @param {string} feedUrl - Original feed URL
89
+ * @returns {{feed: Object, items: Array}}
90
+ */
91
+ parseJsonFeed(json, feedUrl) {
92
+ // Google Reader API format (items array at root level)
93
+ if (json.items && !json.version) {
94
+ return this.parseGoogleReaderJson(json, feedUrl);
95
+ }
96
+
97
+ // JSON Feed spec (https://jsonfeed.org/version/1.1)
98
+ if (json.version?.startsWith("https://jsonfeed.org/")) {
99
+ return this.parseJsonFeedSpec(json, feedUrl);
100
+ }
101
+
102
+ // Generic JSON with items array
103
+ if (Array.isArray(json.items)) {
104
+ return this.parseGoogleReaderJson(json, feedUrl);
105
+ }
106
+
107
+ throw new Error("Unknown JSON feed format");
108
+ }
109
+
110
+ /**
111
+ * Parse Google Reader API / FreshRSS JSON format
112
+ * @param {Object} json - Google Reader JSON
113
+ * @param {string} feedUrl - Feed URL
114
+ * @returns {{feed: Object, items: Array}}
115
+ */
116
+ parseGoogleReaderJson(json, feedUrl) {
117
+ const feed = {
118
+ title: json.title || "RSS Feed",
119
+ description: json.description || "",
120
+ siteUrl: json.link || json.alternate?.[0]?.href || this.extractBaseUrl(feedUrl),
121
+ feedUrl: feedUrl,
122
+ imageUrl: null,
123
+ language: null,
124
+ lastBuildDate: null,
125
+ };
126
+
127
+ const items = (json.items || []).map((item) => {
128
+ // Parse timestamp - FreshRSS uses `published` (Unix seconds) or `timestampUsec` (microseconds)
129
+ let pubDate = null;
130
+ if (item.published) {
131
+ // Unix timestamp in seconds
132
+ pubDate = new Date(item.published * 1000);
133
+ } else if (item.timestampUsec) {
134
+ // Microseconds timestamp
135
+ pubDate = new Date(parseInt(item.timestampUsec) / 1000);
136
+ } else if (item.crawlTimeMsec) {
137
+ // Milliseconds timestamp
138
+ pubDate = new Date(parseInt(item.crawlTimeMsec));
139
+ }
140
+
141
+ // Extract content from various FreshRSS structures
142
+ const content = item.content?.content ||
143
+ (typeof item.content === "string" ? item.content : "") ||
144
+ item.summary?.content ||
145
+ (typeof item.summary === "string" ? item.summary : "") ||
146
+ "";
147
+
148
+ // Extract link from Google Reader format
149
+ const link = item.canonical?.[0]?.href ||
150
+ item.alternate?.[0]?.href ||
151
+ item.link ||
152
+ null;
153
+
154
+ // Filter out internal FreshRSS categories, keep only user tags
155
+ const categories = (item.categories || []).filter(cat =>
156
+ !cat.includes("state/com.google/") &&
157
+ !cat.includes("state/org.freshrss/") &&
158
+ !cat.startsWith("user/")
159
+ );
160
+
161
+ return {
162
+ guid: item["frss:id"] || item.id || item.guid || link,
163
+ title: item.title || "Untitled",
164
+ link,
165
+ description: item.summary?.content ||
166
+ (typeof item.summary === "string" ? item.summary : "") ||
167
+ "",
168
+ content,
169
+ author: item.author || item.origin?.title || null,
170
+ pubDate,
171
+ imageUrl: this.extractJsonItemImage(item),
172
+ categories,
173
+ enclosure: item.enclosure || null,
174
+ // Preserve FreshRSS-specific metadata
175
+ origin: item.origin ? {
176
+ streamId: item.origin.streamId,
177
+ title: item.origin.title,
178
+ htmlUrl: item.origin.htmlUrl,
179
+ feedUrl: item.origin.feedUrl,
180
+ } : null,
181
+ };
182
+ });
183
+
184
+ return { feed, items };
185
+ }
186
+
187
+ /**
188
+ * Parse JSON Feed spec format
189
+ * @param {Object} json - JSON Feed object
190
+ * @param {string} feedUrl - Feed URL
191
+ * @returns {{feed: Object, items: Array}}
192
+ */
193
+ parseJsonFeedSpec(json, feedUrl) {
194
+ const feed = {
195
+ title: json.title || "Untitled Feed",
196
+ description: json.description || "",
197
+ siteUrl: json.home_page_url || this.extractBaseUrl(feedUrl),
198
+ feedUrl: json.feed_url || feedUrl,
199
+ imageUrl: json.icon || json.favicon || null,
200
+ language: json.language || null,
201
+ lastBuildDate: null,
202
+ };
203
+
204
+ const items = (json.items || []).map((item) => ({
205
+ guid: item.id || item.url,
206
+ title: item.title || "Untitled",
207
+ link: item.url || item.external_url || null,
208
+ description: item.summary || "",
209
+ content: item.content_html || item.content_text || item.summary || "",
210
+ author: item.authors?.[0]?.name || item.author?.name || null,
211
+ pubDate: this.parseDate(item.date_published || item.date_modified),
212
+ imageUrl: item.image || item.banner_image || null,
213
+ categories: item.tags || [],
214
+ enclosure: item.attachments?.[0]
215
+ ? {
216
+ url: item.attachments[0].url,
217
+ type: item.attachments[0].mime_type,
218
+ length: item.attachments[0].size_in_bytes,
219
+ }
220
+ : null,
221
+ }));
222
+
223
+ return { feed, items };
224
+ }
225
+
226
+ /**
227
+ * Extract image from JSON feed item
228
+ * @param {Object} item - JSON item
229
+ * @returns {string|null}
230
+ */
231
+ extractJsonItemImage(item) {
232
+ // Direct image property
233
+ if (item.image) return item.image;
234
+
235
+ // Media content
236
+ if (item.media?.$?.url) return item.media.$.url;
237
+
238
+ // Enclosure
239
+ if (item.enclosure?.href && this.isImageUrl(item.enclosure.href)) {
240
+ return item.enclosure.href;
241
+ }
242
+
243
+ // Extract from content
244
+ const content = item.content?.content || item.content || item.summary?.content || "";
245
+ if (typeof content === "string") {
246
+ const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
247
+ if (imgMatch) return imgMatch[1];
248
+ }
249
+
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Extract feed metadata
255
+ * @param {Object} parsed - Parsed feed object
256
+ * @param {string} feedUrl - Original feed URL
257
+ * @returns {Object}
258
+ */
259
+ extractFeedMeta(parsed, feedUrl) {
260
+ return {
261
+ title: parsed.title || "Untitled Feed",
262
+ description: parsed.description || "",
263
+ siteUrl: parsed.link || this.extractBaseUrl(feedUrl),
264
+ feedUrl: feedUrl,
265
+ imageUrl: this.extractFeedImage(parsed),
266
+ language: parsed.language || null,
267
+ lastBuildDate: parsed.lastBuildDate
268
+ ? new Date(parsed.lastBuildDate)
269
+ : null,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Extract feed image from various formats
275
+ * @param {Object} parsed - Parsed feed
276
+ * @returns {string|null}
277
+ */
278
+ extractFeedImage(parsed) {
279
+ // RSS 2.0 image
280
+ if (parsed.image?.url) return parsed.image.url;
281
+ // Atom icon
282
+ if (parsed.icon) return parsed.icon;
283
+ // Atom logo
284
+ if (parsed.logo) return parsed.logo;
285
+ // itunes:image
286
+ if (parsed.itunes?.image) return parsed.itunes.image;
287
+ return null;
288
+ }
289
+
290
+ /**
291
+ * Transform feed items to normalized format
292
+ * @param {Array} items - Raw feed items
293
+ * @param {string} feedUrl - Feed URL for context
294
+ * @returns {Array}
295
+ */
296
+ transformItems(items, feedUrl) {
297
+ return items.map((item) => this.transformItem(item, feedUrl));
298
+ }
299
+
300
+ /**
301
+ * Transform a single feed item
302
+ * @param {Object} item - Raw feed item
303
+ * @param {string} feedUrl - Feed URL
304
+ * @returns {Object}
305
+ */
306
+ transformItem(item, feedUrl) {
307
+ const pubDate = this.parseDate(item.pubDate || item.isoDate);
308
+ return {
309
+ guid: item.guid || item.id || item.link || `${feedUrl}#${pubDate?.getTime()}`,
310
+ title: item.title || "Untitled",
311
+ link: item.link || null,
312
+ description: item.contentSnippet || item.summary || "",
313
+ content: item.contentEncoded || item.content || item.summary || "",
314
+ author: item.creator || item.author || item["dc:creator"] || null,
315
+ pubDate: pubDate,
316
+ imageUrl: this.extractItemImage(item),
317
+ categories: this.extractCategories(item),
318
+ enclosure: this.extractEnclosure(item),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Extract image URL from item
324
+ * @param {Object} item - Feed item
325
+ * @returns {string|null}
326
+ */
327
+ extractItemImage(item) {
328
+ // Media RSS
329
+ if (item.media?.["$"]?.url) return item.media["$"].url;
330
+ if (item.mediaThumbnail?.["$"]?.url) return item.mediaThumbnail["$"].url;
331
+
332
+ // Enclosure image
333
+ if (item.enclosure?.url && this.isImageUrl(item.enclosure.url)) {
334
+ return item.enclosure.url;
335
+ }
336
+
337
+ // Extract from content
338
+ const content = item.contentEncoded || item.content || "";
339
+ const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
340
+ if (imgMatch) return imgMatch[1];
341
+
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Check if URL is an image
347
+ * @param {string} url - URL to check
348
+ * @returns {boolean}
349
+ */
350
+ isImageUrl(url) {
351
+ if (!url) return false;
352
+ const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
353
+ const lowerUrl = url.toLowerCase();
354
+ return imageExtensions.some((ext) => lowerUrl.includes(ext));
355
+ }
356
+
357
+ /**
358
+ * Extract categories/tags from item
359
+ * @param {Object} item - Feed item
360
+ * @returns {Array<string>}
361
+ */
362
+ extractCategories(item) {
363
+ if (!item.categories) return [];
364
+ return item.categories
365
+ .map((cat) => (typeof cat === "string" ? cat : cat.name || cat.term))
366
+ .filter(Boolean);
367
+ }
368
+
369
+ /**
370
+ * Extract enclosure (podcast/media attachment)
371
+ * @param {Object} item - Feed item
372
+ * @returns {Object|null}
373
+ */
374
+ extractEnclosure(item) {
375
+ if (!item.enclosure) return null;
376
+ return {
377
+ url: item.enclosure.url,
378
+ type: item.enclosure.type || null,
379
+ length: item.enclosure.length ? parseInt(item.enclosure.length, 10) : null,
380
+ };
381
+ }
382
+
383
+ /**
384
+ * Parse date from various formats
385
+ * @param {string|Date} dateInput - Date input
386
+ * @returns {Date|null}
387
+ */
388
+ parseDate(dateInput) {
389
+ if (!dateInput) return null;
390
+ if (dateInput instanceof Date) return dateInput;
391
+ const parsed = new Date(dateInput);
392
+ return isNaN(parsed.getTime()) ? null : parsed;
393
+ }
394
+
395
+ /**
396
+ * Extract base URL from feed URL
397
+ * @param {string} feedUrl - Feed URL
398
+ * @returns {string}
399
+ */
400
+ extractBaseUrl(feedUrl) {
401
+ try {
402
+ const url = new URL(feedUrl);
403
+ return `${url.protocol}//${url.host}`;
404
+ } catch {
405
+ return feedUrl;
406
+ }
407
+ }
408
+ }
package/lib/sync.js ADDED
@@ -0,0 +1,299 @@
1
+ import { RssClient } from "./rss-client.js";
2
+
3
+ let syncInterval = null;
4
+ let syncState = {
5
+ lastSync: null,
6
+ syncing: false,
7
+ lastError: null,
8
+ feedsProcessed: 0,
9
+ itemsAdded: 0,
10
+ };
11
+
12
+ /**
13
+ * Get current sync state
14
+ * @returns {Object}
15
+ */
16
+ export function getSyncState() {
17
+ return { ...syncState };
18
+ }
19
+
20
+ /**
21
+ * Start background sync
22
+ * @param {Object} Indiekit - Indiekit instance
23
+ * @param {Object} options - Plugin options
24
+ */
25
+ export function startSync(Indiekit, options) {
26
+ const intervalMs = options.syncInterval || 900_000; // 15 minutes default
27
+
28
+ console.log(
29
+ `[RSS] Starting background sync with ${intervalMs / 60_000}min interval`
30
+ );
31
+
32
+ // Initial sync after delay
33
+ setTimeout(() => {
34
+ runSync(Indiekit, options).catch((err) => {
35
+ console.error("[RSS] Initial sync error:", err.message);
36
+ });
37
+ }, 10_000); // 10 second delay
38
+
39
+ // Schedule recurring sync
40
+ syncInterval = setInterval(() => {
41
+ runSync(Indiekit, options).catch((err) => {
42
+ console.error("[RSS] Sync error:", err.message);
43
+ });
44
+ }, intervalMs);
45
+ }
46
+
47
+ /**
48
+ * Stop background sync
49
+ */
50
+ export function stopSync() {
51
+ if (syncInterval) {
52
+ clearInterval(syncInterval);
53
+ syncInterval = null;
54
+ console.log("[RSS] Background sync stopped");
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Run a single sync cycle
60
+ * @param {Object} Indiekit - Indiekit instance
61
+ * @param {Object} options - Plugin options
62
+ * @returns {Promise<Object>}
63
+ */
64
+ export async function runSync(Indiekit, options) {
65
+ const db = Indiekit.database;
66
+ if (!db) {
67
+ syncState.lastError = "No database available";
68
+ return { error: syncState.lastError };
69
+ }
70
+
71
+ if (syncState.syncing) {
72
+ return { error: "Sync already in progress" };
73
+ }
74
+
75
+ syncState.syncing = true;
76
+ syncState.lastError = null;
77
+ syncState.feedsProcessed = 0;
78
+ syncState.itemsAdded = 0;
79
+
80
+ const client = new RssClient({
81
+ timeout: options.fetchTimeout || 10_000,
82
+ });
83
+
84
+ try {
85
+ const feedsCollection = db.collection("rssFeeds");
86
+ const itemsCollection = db.collection("rssItems");
87
+
88
+ // Create indexes if they don't exist
89
+ await createIndexes(feedsCollection, itemsCollection);
90
+
91
+ // Get all enabled feeds
92
+ const feeds = await feedsCollection.find({ enabled: true }).toArray();
93
+
94
+ if (feeds.length === 0) {
95
+ syncState.lastSync = new Date();
96
+ syncState.syncing = false;
97
+ return { feedsProcessed: 0, itemsAdded: 0 };
98
+ }
99
+
100
+ // Process feeds with concurrency limit
101
+ const maxConcurrent = options.maxConcurrentFetches || 3;
102
+ const results = await processFeedsWithLimit(
103
+ feeds,
104
+ maxConcurrent,
105
+ async (feed) => {
106
+ return syncFeed(feed, feedsCollection, itemsCollection, client, options);
107
+ }
108
+ );
109
+
110
+ // Aggregate results
111
+ for (const result of results) {
112
+ if (result.itemsAdded) {
113
+ syncState.itemsAdded += result.itemsAdded;
114
+ }
115
+ syncState.feedsProcessed++;
116
+ }
117
+
118
+ syncState.lastSync = new Date();
119
+ syncState.syncing = false;
120
+
121
+ console.log(
122
+ `[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items`
123
+ );
124
+
125
+ return {
126
+ feedsProcessed: syncState.feedsProcessed,
127
+ itemsAdded: syncState.itemsAdded,
128
+ };
129
+ } catch (error) {
130
+ syncState.lastError = error.message;
131
+ syncState.syncing = false;
132
+ console.error("[RSS] Sync failed:", error.message);
133
+ return { error: error.message };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Sync a single feed
139
+ * @param {Object} feed - Feed document
140
+ * @param {Collection} feedsCollection - Feeds collection
141
+ * @param {Collection} itemsCollection - Items collection
142
+ * @param {RssClient} client - RSS client
143
+ * @param {Object} options - Plugin options
144
+ * @returns {Promise<Object>}
145
+ */
146
+ async function syncFeed(
147
+ feed,
148
+ feedsCollection,
149
+ itemsCollection,
150
+ client,
151
+ options
152
+ ) {
153
+ const maxItemsPerFeed = options.maxItemsPerFeed || 50;
154
+ let itemsAdded = 0;
155
+ let lastError = null;
156
+
157
+ try {
158
+ const { feed: feedMeta, items } = await client.fetchFeed(feed.url);
159
+
160
+ // Update feed metadata
161
+ await feedsCollection.updateOne(
162
+ { _id: feed._id },
163
+ {
164
+ $set: {
165
+ title: feedMeta.title,
166
+ siteUrl: feedMeta.siteUrl,
167
+ description: feedMeta.description,
168
+ imageUrl: feedMeta.imageUrl,
169
+ lastFetchedAt: new Date(),
170
+ lastError: null,
171
+ },
172
+ }
173
+ );
174
+
175
+ // Insert new items
176
+ const recentItems = items.slice(0, maxItemsPerFeed);
177
+ for (const item of recentItems) {
178
+ try {
179
+ const result = await itemsCollection.updateOne(
180
+ {
181
+ feedId: feed._id,
182
+ guid: item.guid,
183
+ },
184
+ {
185
+ $setOnInsert: {
186
+ feedId: feed._id,
187
+ feedTitle: feedMeta.title,
188
+ ...item,
189
+ fetchedAt: new Date(),
190
+ },
191
+ },
192
+ { upsert: true }
193
+ );
194
+
195
+ if (result.upsertedCount > 0) {
196
+ itemsAdded++;
197
+ }
198
+ } catch (err) {
199
+ // Ignore duplicate key errors
200
+ if (err.code !== 11000) {
201
+ console.error(`[RSS] Error inserting item: ${err.message}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ // Update item count
207
+ const itemCount = await itemsCollection.countDocuments({ feedId: feed._id });
208
+ await feedsCollection.updateOne(
209
+ { _id: feed._id },
210
+ { $set: { itemCount } }
211
+ );
212
+
213
+ return { feedId: feed._id, itemsAdded };
214
+ } catch (error) {
215
+ lastError = error.message;
216
+ console.error(`[RSS] Error syncing ${feed.url}: ${lastError}`);
217
+
218
+ // Update feed with error
219
+ await feedsCollection.updateOne(
220
+ { _id: feed._id },
221
+ {
222
+ $set: {
223
+ lastFetchedAt: new Date(),
224
+ lastError: lastError,
225
+ },
226
+ }
227
+ );
228
+
229
+ return { feedId: feed._id, itemsAdded: 0, error: lastError };
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Create indexes for collections
235
+ * @param {Collection} feedsCollection
236
+ * @param {Collection} itemsCollection
237
+ */
238
+ async function createIndexes(feedsCollection, itemsCollection) {
239
+ // Feeds indexes
240
+ await feedsCollection.createIndex({ url: 1 }, { unique: true });
241
+ await feedsCollection.createIndex({ enabled: 1 });
242
+
243
+ // Items indexes
244
+ await itemsCollection.createIndex({ feedId: 1, guid: 1 }, { unique: true });
245
+ await itemsCollection.createIndex({ feedId: 1 });
246
+ await itemsCollection.createIndex({ pubDate: -1 });
247
+ await itemsCollection.createIndex({ fetchedAt: -1 });
248
+ }
249
+
250
+ /**
251
+ * Process feeds with concurrency limit
252
+ * @param {Array} feeds - Array of feeds
253
+ * @param {number} limit - Concurrency limit
254
+ * @param {Function} processor - Async function to process each feed
255
+ * @returns {Promise<Array>}
256
+ */
257
+ async function processFeedsWithLimit(feeds, limit, processor) {
258
+ const results = [];
259
+ const executing = [];
260
+
261
+ for (const feed of feeds) {
262
+ const promise = processor(feed).then((result) => {
263
+ executing.splice(executing.indexOf(promise), 1);
264
+ return result;
265
+ });
266
+ results.push(promise);
267
+ executing.push(promise);
268
+
269
+ if (executing.length >= limit) {
270
+ await Promise.race(executing);
271
+ }
272
+ }
273
+
274
+ return Promise.all(results);
275
+ }
276
+
277
+ /**
278
+ * Sync a single feed by ID (for manual refresh)
279
+ * @param {Object} db - Database instance
280
+ * @param {string} feedId - Feed ID
281
+ * @param {Object} options - Plugin options
282
+ * @returns {Promise<Object>}
283
+ */
284
+ export async function syncSingleFeed(db, feedId, options) {
285
+ const { ObjectId } = await import("mongodb");
286
+ const feedsCollection = db.collection("rssFeeds");
287
+ const itemsCollection = db.collection("rssItems");
288
+
289
+ const feed = await feedsCollection.findOne({ _id: new ObjectId(feedId) });
290
+ if (!feed) {
291
+ return { error: "Feed not found" };
292
+ }
293
+
294
+ const client = new RssClient({
295
+ timeout: options.fetchTimeout || 10_000,
296
+ });
297
+
298
+ return syncFeed(feed, feedsCollection, itemsCollection, client, options);
299
+ }