@rmdes/indiekit-endpoint-rss 1.1.1 → 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.
package/assets/styles.css CHANGED
@@ -83,19 +83,28 @@
83
83
  }
84
84
 
85
85
  .rss-feed-item {
86
- align-items: center;
86
+ align-items: flex-start;
87
87
  border-block-end: 1px solid var(--color-border);
88
88
  display: flex;
89
- flex-wrap: wrap;
90
89
  gap: var(--space-s);
91
90
  padding: var(--space-s) 0;
92
91
  }
93
92
 
94
- /* The publish disclosure gets a row of its own. As a plain flex sibling of the
95
- icon, info and actions it was squeezed into the same line and overlapped the
96
- feed metadata. */
93
+ /* The publish disclosure lives inside the feed's own column. As a full-width
94
+ flex row it turned every feed into three rows and put a bar wider than the
95
+ feed between each entry, which made the list unscannable. Its summary stays
96
+ quiet so the feed title remains what the eye lands on. */
97
97
  .rss-publish {
98
- flex-basis: 100%;
98
+ margin-block-start: var(--space-2xs);
99
+ }
100
+
101
+ .rss-publish .details__summary {
102
+ color: var(--color-text-secondary);
103
+ font-size: var(--step--1);
104
+ }
105
+
106
+ .rss-publish .details__main {
107
+ padding-block-start: var(--space-2xs);
99
108
  }
100
109
 
101
110
  .rss-feed-item:last-child {
@@ -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
  /**
@@ -156,13 +156,13 @@ export const feedsController = {
156
156
  async toggle(request, response) {
157
157
  try {
158
158
  const { id } = request.params;
159
- const { enabled, publish } = request.body;
159
+ const { enabled, publish, url } = request.body;
160
160
 
161
161
  if (!ObjectId.isValid(id)) {
162
162
  return response.status(400).json({ error: "Invalid feed ID" });
163
163
  }
164
164
 
165
- if (enabled === undefined && publish === undefined) {
165
+ if (enabled === undefined && publish === undefined && url === undefined) {
166
166
  return response.status(400).json({ error: "Nothing to update" });
167
167
  }
168
168
 
@@ -191,6 +191,31 @@ export const feedsController = {
191
191
  update.enabled = enabled;
192
192
  }
193
193
 
194
+ if (url !== undefined) {
195
+ if (!isValidUrl(url)) {
196
+ return response.status(400).json({
197
+ error: response.locals.__("rss.error.invalidUrl"),
198
+ });
199
+ }
200
+
201
+ const normalizedUrl = normalizeUrl(url);
202
+ const clash = await feedsCollection.findOne({
203
+ url: normalizedUrl,
204
+ _id: { $ne: feedId },
205
+ });
206
+
207
+ if (clash) {
208
+ return response.status(409).json({
209
+ error: response.locals.__("rss.error.feedExists"),
210
+ });
211
+ }
212
+
213
+ update.url = normalizedUrl;
214
+ // Items key on feedId, so they survive the change. Any error from the
215
+ // old URL is stale the moment it is corrected.
216
+ update.lastError = null;
217
+ }
218
+
194
219
  if (publish !== undefined) {
195
220
  const { postTypes } = request.app.locals.publication || {};
196
221
 
@@ -238,7 +263,17 @@ export const feedsController = {
238
263
  { returnDocument: "after" },
239
264
  );
240
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
+
241
275
  response.json({
276
+ eligible,
242
277
  message:
243
278
  enabled === undefined
244
279
  ? response.locals.__("rss.success.feedUpdated")
@@ -309,11 +344,10 @@ export const feedsController = {
309
344
  await feedsCollection.updateOne(
310
345
  { _id: feedId },
311
346
  {
312
- $set: {
313
- "publish.since": watermark,
314
- // Imported history is always drafts, whatever the feed is set to.
315
- "publish.status": "draft",
316
- },
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 },
317
351
  },
318
352
  );
319
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/lib/sync.js CHANGED
@@ -173,7 +173,7 @@ export async function runSync(dbOrIndiekit, options, publishContext) {
173
173
  itemsCollection,
174
174
  feedsCollection,
175
175
  retentionDays,
176
- options.minItemsPerFeed ?? 10,
176
+ retentionFloor(options),
177
177
  );
178
178
 
179
179
  syncState.lastSync = new Date().toISOString();
@@ -310,6 +310,21 @@ async function createIndexes(feedsCollection, itemsCollection) {
310
310
  await itemsCollection.createIndex({ fetchedAt: -1 });
311
311
  }
312
312
 
313
+ /**
314
+ * Number of newest items per feed that pruning must never touch.
315
+ *
316
+ * Pruning an item the feed still serves is a no-op by construction — the next
317
+ * sync re-inserts it. A sync stores at most `maxItemsPerFeed` items per feed,
318
+ * so age-based retention below that threshold can only churn: insert, delete,
319
+ * refetch, forever. The floor is therefore derived from what a sync can store,
320
+ * not set as an independent constant.
321
+ * @param {object} options - Plugin options
322
+ * @returns {number} Items to keep per feed regardless of age
323
+ */
324
+ export function retentionFloor(options = {}) {
325
+ return Math.max(options.minItemsPerFeed ?? 10, options.maxItemsPerFeed ?? 50);
326
+ }
327
+
313
328
  /**
314
329
  * Rewrite legacy BSON Date values of `addedAt` as ISO 8601 strings.
315
330
  *
package/locales/en.json CHANGED
@@ -56,13 +56,23 @@
56
56
  "enabled": "Create posts from this feed",
57
57
  "postType": "Post type",
58
58
  "content": "Content template",
59
- "contentHint": "Placeholders: {{title}} {{link}} {{description}} {{content}} {{author}} {{sourceTitle}}",
59
+ "contentHint": "Available: title, link, description, content, author, sourceTitle. Write each one in double braces, as shown below.",
60
60
  "linkProperty": "Link property",
61
61
  "linkPropertyHint": "Discovery property carrying the item URL, e.g. bookmark-of. Leave empty for a plain note or article.",
62
62
  "status": "Post status",
63
63
  "draft": "Draft",
64
64
  "published": "Published",
65
- "save": "Save publish settings"
65
+ "save": "Save settings"
66
+ },
67
+ "settings": {
68
+ "title": "Feed settings",
69
+ "url": "Feed URL",
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"
66
76
  }
67
77
  }
68
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.1.1",
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
@@ -100,79 +100,106 @@
100
100
  {% if feed.lastError %}
101
101
  <div class="rss-feed-error">{{ feed.lastError }}</div>
102
102
  {% endif %}
103
+ {% set typeItems = [] %}
104
+ {% for type in postTypes %}
105
+ {% set typeItems = (typeItems.push({
106
+ text: type,
107
+ value: type,
108
+ selected: feed.publish.postType == type
109
+ }), typeItems) %}
110
+ {% endfor %}
111
+ {% set publishSummary %}{{ __("rss.settings.title") }}{% if feed.publish.enabled %} &bull; {{ feed.publish.postType }}{% endif %}{% endset %}
112
+ {% call details({
113
+ classes: "rss-publish",
114
+ summary: publishSummary
115
+ }) %}
116
+ <form data-publish-feed="{{ feed.id }}">
117
+ {{ input({
118
+ id: "feed-url-" + feed.id,
119
+ name: "url",
120
+ type: "url",
121
+ label: __("rss.settings.url"),
122
+ hint: __("rss.settings.urlHint"),
123
+ value: feed.url
124
+ }) }}
125
+
126
+ {{ checkboxes({
127
+ idPrefix: "publish-enabled-" + feed.id,
128
+ name: "enabled",
129
+ items: [{
130
+ value: "true",
131
+ label: __("rss.publish.enabled"),
132
+ checked: feed.publish.enabled
133
+ }]
134
+ }) }}
135
+
136
+ {{ select({
137
+ id: "publish-post-type-" + feed.id,
138
+ name: "postType",
139
+ label: __("rss.publish.postType"),
140
+ items: typeItems
141
+ }) }}
142
+
143
+ {{ input({
144
+ id: "publish-content-" + feed.id,
145
+ name: "content",
146
+ label: __("rss.publish.content"),
147
+ hint: __("rss.publish.contentHint"),
148
+ value: feed.publish.content or "{{description}}"
149
+ }) }}
150
+
151
+ {{ input({
152
+ id: "publish-link-property-" + feed.id,
153
+ name: "linkProperty",
154
+ label: __("rss.publish.linkProperty"),
155
+ hint: __("rss.publish.linkPropertyHint"),
156
+ optional: true,
157
+ value: feed.publish.linkProperty
158
+ }) }}
159
+
160
+ {{ select({
161
+ id: "publish-status-" + feed.id,
162
+ name: "status",
163
+ label: __("rss.publish.status"),
164
+ items: [
165
+ {
166
+ text: __("rss.publish.draft"),
167
+ value: "draft",
168
+ selected: feed.publish.status != "published"
169
+ },
170
+ {
171
+ text: __("rss.publish.published"),
172
+ value: "published",
173
+ selected: feed.publish.status == "published"
174
+ }
175
+ ]
176
+ }) }}
177
+
178
+ {{ button({
179
+ type: "submit",
180
+ text: __("rss.publish.save")
181
+ }) }}
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 %}
201
+ {% endcall %}
103
202
  </div>
104
- {% set typeItems = [] %}
105
- {% for type in postTypes %}
106
- {% set typeItems = (typeItems.push({
107
- text: type,
108
- value: type,
109
- selected: feed.publish.postType == type
110
- }), typeItems) %}
111
- {% endfor %}
112
- {% set publishSummary %}{{ __("rss.publish.title") }}{% if feed.publish.enabled %} &bull; {{ feed.publish.postType }}{% endif %}{% endset %}
113
- {% call details({
114
- classes: "rss-publish",
115
- summary: publishSummary
116
- }) %}
117
- <form data-publish-feed="{{ feed.id }}">
118
- {{ checkboxes({
119
- idPrefix: "publish-enabled-" + feed.id,
120
- name: "enabled",
121
- items: [{
122
- value: "true",
123
- text: __("rss.publish.enabled"),
124
- checked: feed.publish.enabled
125
- }]
126
- }) }}
127
-
128
- {{ select({
129
- id: "publish-post-type-" + feed.id,
130
- name: "postType",
131
- label: __("rss.publish.postType"),
132
- items: typeItems
133
- }) }}
134
-
135
- {{ input({
136
- id: "publish-content-" + feed.id,
137
- name: "content",
138
- label: __("rss.publish.content"),
139
- hint: __("rss.publish.contentHint"),
140
- value: feed.publish.content or "{{description}}"
141
- }) }}
142
-
143
- {{ input({
144
- id: "publish-link-property-" + feed.id,
145
- name: "linkProperty",
146
- label: __("rss.publish.linkProperty"),
147
- hint: __("rss.publish.linkPropertyHint"),
148
- optional: true,
149
- value: feed.publish.linkProperty
150
- }) }}
151
-
152
- {{ select({
153
- id: "publish-status-" + feed.id,
154
- name: "status",
155
- label: __("rss.publish.status"),
156
- items: [
157
- {
158
- text: __("rss.publish.draft"),
159
- value: "draft",
160
- selected: feed.publish.status != "published"
161
- },
162
- {
163
- text: __("rss.publish.published"),
164
- value: "published",
165
- selected: feed.publish.status == "published"
166
- }
167
- ]
168
- }) }}
169
-
170
- {{ button({
171
- type: "submit",
172
- text: __("rss.publish.save")
173
- }) }}
174
- </form>
175
- {% endcall %}
176
203
  <div class="rss-feed-actions">
177
204
  <label class="rss-toggle">
178
205
  <input
@@ -329,6 +356,38 @@
329
356
  }
330
357
  });
331
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
+
332
391
  // Handle delete feed
333
392
  document.querySelectorAll('[data-delete-feed]').forEach(btn => {
334
393
  btn.addEventListener('click', async (e) => {
@@ -374,13 +433,24 @@
374
433
  const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}`, {
375
434
  method: 'PATCH',
376
435
  headers: { 'Content-Type': 'application/json' },
377
- body: JSON.stringify({ publish })
436
+ body: JSON.stringify({ url: data.get('url'), publish })
378
437
  });
379
438
 
439
+ const body = await response.json();
440
+
380
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
+ }
381
452
  location.reload();
382
453
  } else {
383
- const body = await response.json();
384
454
  alert(body.error || 'Failed to save publish settings');
385
455
  }
386
456
  } catch (err) {