@rmdes/indiekit-endpoint-rss 1.1.2 → 1.1.3

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.
@@ -1,8 +1,8 @@
1
1
  import { ObjectId } from "mongodb";
2
2
  import { RssClient } from "../rss-client.js";
3
3
  import { defaultContent, defaultLinkProperty } from "../jf2-builder.js";
4
- import { watermarkFor } from "../publisher.js";
5
4
  import { formatFeed, isValidUrl, normalizeUrl } from "../utils.js";
5
+ import { pendingQuery, watermarkFor } from "../publisher.js";
6
6
 
7
7
  export const feedsController = {
8
8
  /**
@@ -263,7 +263,17 @@ export const feedsController = {
263
263
  { returnDocument: "after" },
264
264
  );
265
265
 
266
+ // Enabling publishing stamps a watermark, so a feed can be correctly
267
+ // configured and still have nothing to publish. Saying so beats silence.
268
+ let eligible;
269
+ if (result.publish?.enabled) {
270
+ eligible = await db
271
+ .collection("rssItems")
272
+ .countDocuments(pendingQuery(result));
273
+ }
274
+
266
275
  response.json({
276
+ eligible,
267
277
  message:
268
278
  enabled === undefined
269
279
  ? response.locals.__("rss.success.feedUpdated")
@@ -334,11 +344,10 @@ export const feedsController = {
334
344
  await feedsCollection.updateOne(
335
345
  { _id: feedId },
336
346
  {
337
- $set: {
338
- "publish.since": watermark,
339
- // Imported history is always drafts, whatever the feed is set to.
340
- "publish.status": "draft",
341
- },
347
+ // The feed's own post status is respected: silently forcing drafts
348
+ // would override a deliberate choice. The guard here is the count
349
+ // the user asked for, and maxPostsPerCycle pacing the catch-up.
350
+ $set: { "publish.since": watermark },
342
351
  },
343
352
  );
344
353
 
package/lib/publisher.js CHANGED
@@ -102,6 +102,35 @@ export function resolvePublishContext(application = {}, publication = {}) {
102
102
 
103
103
  const MAX_ATTEMPTS = 3;
104
104
 
105
+ /**
106
+ * Query selecting a feed's items that are still waiting to be published.
107
+ *
108
+ * Exported so the dashboard can count them with exactly the same rule the
109
+ * publish loop applies. Two copies of this would drift, and the symptom would
110
+ * be a count that disagrees with what actually publishes.
111
+ * @param {object} feed - Feed document
112
+ * @returns {object} MongoDB query
113
+ */
114
+ export function pendingQuery(feed) {
115
+ const query = {
116
+ feedId: feed._id,
117
+ postedAt: { $exists: false },
118
+ postSkipped: { $ne: true },
119
+ };
120
+
121
+ if (feed.publish?.since) {
122
+ // An item with no parsable date would be excluded forever by a bare
123
+ // `pubDate: { $gt: since }`, because null sorts below every Date in BSON.
124
+ // fetchedAt is always set on insert and stands in when pubDate is missing.
125
+ query.$or = [
126
+ { pubDate: { $gt: feed.publish.since } },
127
+ { pubDate: null, fetchedAt: { $gt: feed.publish.since } },
128
+ ];
129
+ }
130
+
131
+ return query;
132
+ }
133
+
105
134
  /**
106
135
  * Publish the pending items of one feed.
107
136
  *
@@ -131,22 +160,7 @@ export async function publishPending(feed, itemsCollection, options) {
131
160
  mintImpl = mintToken,
132
161
  } = options;
133
162
 
134
- const query = {
135
- feedId: feed._id,
136
- postedAt: { $exists: false },
137
- postSkipped: { $ne: true },
138
- };
139
-
140
- if (feed.publish.since) {
141
- // An item with no parsable date would be excluded forever by a bare
142
- // `pubDate: { $gt: since }`, because null sorts below every Date in BSON —
143
- // a silent permanent no-op. fetchedAt is always set on insert and is
144
- // monotonic with cache entry, so it stands in when pubDate is missing.
145
- query.$or = [
146
- { pubDate: { $gt: feed.publish.since } },
147
- { pubDate: null, fetchedAt: { $gt: feed.publish.since } },
148
- ];
149
- }
163
+ const query = pendingQuery(feed);
150
164
 
151
165
  const items = await itemsCollection
152
166
  .find(query)
package/locales/en.json CHANGED
@@ -68,6 +68,11 @@
68
68
  "title": "Feed settings",
69
69
  "url": "Feed URL",
70
70
  "urlHint": "Correcting this keeps the cached items; removing and re-adding the feed would lose them."
71
+ },
72
+ "backfill": {
73
+ "label": "Publish existing items",
74
+ "hint": "How many of the newest cached items to publish. They are created with this feed's post status, a few per sync cycle.",
75
+ "submit": "Publish existing items"
71
76
  }
72
77
  }
73
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
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
@@ -180,6 +180,24 @@
180
180
  text: __("rss.publish.save")
181
181
  }) }}
182
182
  </form>
183
+
184
+ {% if feed.publish.enabled %}
185
+ <form class="rss-backfill" data-backfill-feed="{{ feed.id }}">
186
+ {{ input({
187
+ id: "backfill-last-" + feed.id,
188
+ name: "last",
189
+ type: "number",
190
+ label: __("rss.backfill.label"),
191
+ hint: __("rss.backfill.hint"),
192
+ value: "10"
193
+ }) }}
194
+ {{ button({
195
+ classes: "button--secondary",
196
+ type: "submit",
197
+ text: __("rss.backfill.submit")
198
+ }) }}
199
+ </form>
200
+ {% endif %}
183
201
  {% endcall %}
184
202
  </div>
185
203
  <div class="rss-feed-actions">
@@ -338,6 +356,38 @@
338
356
  }
339
357
  });
340
358
 
359
+ // Handle backfill
360
+ document.querySelectorAll('[data-backfill-feed]').forEach(form => {
361
+ form.addEventListener('submit', async (e) => {
362
+ e.preventDefault();
363
+ const feedId = e.target.dataset.backfillFeed;
364
+ const last = Number(new FormData(e.target).get('last'));
365
+
366
+ if (!last || last < 1) {
367
+ alert('Enter how many recent items to publish.');
368
+ return;
369
+ }
370
+
371
+ try {
372
+ const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}/backfill`, {
373
+ method: 'POST',
374
+ headers: { 'Content-Type': 'application/json' },
375
+ body: JSON.stringify({ last })
376
+ });
377
+
378
+ const body = await response.json();
379
+ if (response.ok) {
380
+ alert(body.message || 'Backfill queued.');
381
+ location.reload();
382
+ } else {
383
+ alert(body.error || 'Failed to queue backfill');
384
+ }
385
+ } catch (err) {
386
+ alert('Failed to queue backfill: ' + err.message);
387
+ }
388
+ });
389
+ });
390
+
341
391
  // Handle delete feed
342
392
  document.querySelectorAll('[data-delete-feed]').forEach(btn => {
343
393
  btn.addEventListener('click', async (e) => {
@@ -386,10 +436,21 @@
386
436
  body: JSON.stringify({ url: data.get('url'), publish })
387
437
  });
388
438
 
439
+ const body = await response.json();
440
+
389
441
  if (response.ok) {
442
+ // A feed can be correctly configured and still have nothing to
443
+ // publish: the watermark only lets through items dated after the
444
+ // moment publishing was switched on.
445
+ if (body.eligible === 0) {
446
+ alert(
447
+ 'Saved. No items are waiting: only items published after you ' +
448
+ 'enabled this will post. Use "Publish existing items" to ' +
449
+ 'include the ones already cached.'
450
+ );
451
+ }
390
452
  location.reload();
391
453
  } else {
392
- const body = await response.json();
393
454
  alert(body.error || 'Failed to save publish settings');
394
455
  }
395
456
  } catch (err) {