@rmdes/indiekit-endpoint-rss 1.1.3 → 1.2.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.
- package/index.js +18 -0
- package/lib/controllers/dashboard.js +176 -0
- package/lib/controllers/feeds.js +17 -170
- package/lib/feed-settings.js +214 -0
- package/lib/jf2-builder.js +53 -7
- package/lib/publisher.js +14 -5
- package/lib/utils.js +73 -4
- package/locales/en.json +10 -2
- package/package.json +1 -1
- package/views/rss.njk +30 -80
package/index.js
CHANGED
|
@@ -74,6 +74,24 @@ export default class RssEndpoint {
|
|
|
74
74
|
// Clear items and re-sync
|
|
75
75
|
router.post("/clear-resync", dashboardController.clearResync);
|
|
76
76
|
|
|
77
|
+
// Dashboard forms: plain POST and redirect, so results land in
|
|
78
|
+
// Indiekit's own notification banner rather than a browser alert.
|
|
79
|
+
router.post(
|
|
80
|
+
"/feeds/:id/settings",
|
|
81
|
+
express.urlencoded({ extended: false }),
|
|
82
|
+
dashboardController.saveFeedSettings,
|
|
83
|
+
);
|
|
84
|
+
router.post(
|
|
85
|
+
"/feeds/:id/backfill",
|
|
86
|
+
express.urlencoded({ extended: false }),
|
|
87
|
+
dashboardController.backfillFeed,
|
|
88
|
+
);
|
|
89
|
+
router.post(
|
|
90
|
+
"/feeds/:id/republish",
|
|
91
|
+
express.urlencoded({ extended: false }),
|
|
92
|
+
dashboardController.republishFeed,
|
|
93
|
+
);
|
|
94
|
+
|
|
77
95
|
// Feed management (protected - requires auth)
|
|
78
96
|
router.post("/api/feeds", express.json(), feedsController.add);
|
|
79
97
|
router.delete("/api/feeds/:id", feedsController.remove);
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyFeedSettings,
|
|
3
|
+
resetPublishState,
|
|
4
|
+
rewindWatermark,
|
|
5
|
+
} from "../feed-settings.js";
|
|
1
6
|
import { getSyncState, runSync } from "../sync.js";
|
|
2
7
|
import { formatFeed, formatItem } from "../utils.js";
|
|
3
8
|
|
|
@@ -17,6 +22,32 @@ function consumeFlashMessage(request) {
|
|
|
17
22
|
return result;
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Publish context for a request, or null when the site is not configured.
|
|
28
|
+
* @param {object} request - Request
|
|
29
|
+
* @returns {object|null} Micropub endpoint and publication URL
|
|
30
|
+
*/
|
|
31
|
+
function publishContextFrom(request) {
|
|
32
|
+
const { application, publication } = request.app.locals;
|
|
33
|
+
|
|
34
|
+
return application?.micropubEndpoint && publication?.me
|
|
35
|
+
? { micropubEndpoint: application.micropubEndpoint, me: publication.me }
|
|
36
|
+
: null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Record a flash message and return to the dashboard.
|
|
41
|
+
* @param {object} request - Request
|
|
42
|
+
* @param {object} response - Response
|
|
43
|
+
* @param {string} type - success, error or warning
|
|
44
|
+
* @param {string} content - Message
|
|
45
|
+
*/
|
|
46
|
+
function flashBack(request, response, type, content) {
|
|
47
|
+
request.session.messages = [{ type, content }];
|
|
48
|
+
response.redirect(request.baseUrl);
|
|
49
|
+
}
|
|
50
|
+
|
|
20
51
|
export const dashboardController = {
|
|
21
52
|
/**
|
|
22
53
|
* Render admin dashboard
|
|
@@ -81,6 +112,151 @@ export const dashboardController = {
|
|
|
81
112
|
}
|
|
82
113
|
},
|
|
83
114
|
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Save a feed's settings from the dashboard form
|
|
118
|
+
* POST /feeds/:id/settings
|
|
119
|
+
*/
|
|
120
|
+
async saveFeedSettings(request, response) {
|
|
121
|
+
try {
|
|
122
|
+
const db = request.app.locals.application.getRssDb?.();
|
|
123
|
+
if (!db) {
|
|
124
|
+
return flashBack(request, response, "error", "Database not available");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const body = {
|
|
128
|
+
url: request.body.url,
|
|
129
|
+
publish: {
|
|
130
|
+
// An unchecked checkbox is omitted from a form post entirely, so
|
|
131
|
+
// presence is the signal.
|
|
132
|
+
enabled: request.body.enabled !== undefined,
|
|
133
|
+
postType: request.body.postType,
|
|
134
|
+
content: request.body.content,
|
|
135
|
+
linkProperty: request.body.linkProperty || null,
|
|
136
|
+
status: request.body.status,
|
|
137
|
+
dateSource: request.body.dateSource,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const result = await applyFeedSettings(
|
|
142
|
+
db,
|
|
143
|
+
request.params.id,
|
|
144
|
+
body,
|
|
145
|
+
request.app.locals.publication,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (result.error) {
|
|
149
|
+
return flashBack(request, response, "error", result.error);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Saying "nothing is waiting" beats silence: a feed can be correctly
|
|
153
|
+
// configured and still publish nothing, because the watermark only
|
|
154
|
+
// admits items dated after publishing was switched on.
|
|
155
|
+
const message =
|
|
156
|
+
result.eligible === 0
|
|
157
|
+
? "Settings saved. No items are waiting — only items published after you enabled this will post. Use Publish existing items to include the ones already cached."
|
|
158
|
+
: result.eligible > 0
|
|
159
|
+
? `Settings saved. ${result.eligible} item(s) waiting to publish.`
|
|
160
|
+
: "Settings saved.";
|
|
161
|
+
|
|
162
|
+
flashBack(request, response, "success", message);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error("[RSS] Error saving feed settings:", error.message);
|
|
165
|
+
flashBack(request, response, "error", error.message);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Rewind a feed's watermark and publish straight away
|
|
171
|
+
* POST /feeds/:id/backfill
|
|
172
|
+
*/
|
|
173
|
+
async backfillFeed(request, response) {
|
|
174
|
+
try {
|
|
175
|
+
const { rssConfig, getRssDb } = request.app.locals.application;
|
|
176
|
+
const db = getRssDb?.();
|
|
177
|
+
if (!db) {
|
|
178
|
+
return flashBack(request, response, "error", "Database not available");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = await rewindWatermark(db, request.params.id, {
|
|
182
|
+
last: Number(request.body.last),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (result.error) {
|
|
186
|
+
return flashBack(request, response, "error", result.error);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (result.eligible === 0) {
|
|
190
|
+
return flashBack(
|
|
191
|
+
request,
|
|
192
|
+
response,
|
|
193
|
+
"warning",
|
|
194
|
+
"Nothing to publish: those items are already published or were skipped after repeated failures.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Run the sync now rather than leaving the user to wait a cycle and
|
|
199
|
+
// guess. This is the only way the answer can be "3 published" instead
|
|
200
|
+
// of "queued, come back later".
|
|
201
|
+
const sync = await runSync(db, rssConfig, publishContextFrom(request));
|
|
202
|
+
|
|
203
|
+
if (sync.error) {
|
|
204
|
+
return flashBack(request, response, "error", sync.error);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const published = sync.itemsPublished || 0;
|
|
208
|
+
flashBack(
|
|
209
|
+
request,
|
|
210
|
+
response,
|
|
211
|
+
published > 0 ? "success" : "warning",
|
|
212
|
+
published > 0
|
|
213
|
+
? `Published ${published} item(s) from the cache.`
|
|
214
|
+
: `${result.eligible} item(s) became eligible but none published — check the logs for the reason.`,
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error("[RSS] Error running backfill:", error.message);
|
|
218
|
+
flashBack(request, response, "error", error.message);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Forget what a feed has published, then publish it again
|
|
225
|
+
* POST /feeds/:id/republish
|
|
226
|
+
*/
|
|
227
|
+
async republishFeed(request, response) {
|
|
228
|
+
try {
|
|
229
|
+
const { rssConfig, getRssDb } = request.app.locals.application;
|
|
230
|
+
const db = getRssDb?.();
|
|
231
|
+
if (!db) {
|
|
232
|
+
return flashBack(request, response, "error", "Database not available");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const reset = await resetPublishState(db, request.params.id);
|
|
236
|
+
if (reset.error) {
|
|
237
|
+
return flashBack(request, response, "error", reset.error);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sync = await runSync(db, rssConfig, publishContextFrom(request));
|
|
241
|
+
if (sync.error) {
|
|
242
|
+
return flashBack(request, response, "error", sync.error);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const published = sync.itemsPublished || 0;
|
|
246
|
+
flashBack(
|
|
247
|
+
request,
|
|
248
|
+
response,
|
|
249
|
+
published > 0 ? "success" : "warning",
|
|
250
|
+
published > 0
|
|
251
|
+
? `Cleared ${reset.reset} item(s) and republished ${published}.`
|
|
252
|
+
: `Cleared ${reset.reset} item(s), but none published — check the watermark and the logs.`,
|
|
253
|
+
);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.error("[RSS] Error republishing feed:", error.message);
|
|
256
|
+
flashBack(request, response, "error", error.message);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
|
|
84
260
|
/**
|
|
85
261
|
* Clear all items and re-sync
|
|
86
262
|
* POST /clear-resync
|
package/lib/controllers/feeds.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { ObjectId } from "mongodb";
|
|
2
2
|
import { RssClient } from "../rss-client.js";
|
|
3
|
-
import {
|
|
3
|
+
import { applyFeedSettings, rewindWatermark } from "../feed-settings.js";
|
|
4
4
|
import { formatFeed, isValidUrl, normalizeUrl } from "../utils.js";
|
|
5
|
-
import { pendingQuery, watermarkFor } from "../publisher.js";
|
|
6
5
|
|
|
7
6
|
export const feedsController = {
|
|
8
7
|
/**
|
|
@@ -149,138 +148,31 @@ export const feedsController = {
|
|
|
149
148
|
},
|
|
150
149
|
|
|
151
150
|
/**
|
|
152
|
-
* Update a feed: enable/disable,
|
|
151
|
+
* Update a feed: enable/disable, change its url, or its publish config
|
|
153
152
|
* PATCH /api/feeds/:id
|
|
154
|
-
* Body: { enabled?: boolean, publish?: object }
|
|
155
153
|
*/
|
|
156
154
|
async toggle(request, response) {
|
|
157
155
|
try {
|
|
158
|
-
const { id } = request.params;
|
|
159
|
-
const { enabled, publish, url } = request.body;
|
|
160
|
-
|
|
161
|
-
if (!ObjectId.isValid(id)) {
|
|
162
|
-
return response.status(400).json({ error: "Invalid feed ID" });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (enabled === undefined && publish === undefined && url === undefined) {
|
|
166
|
-
return response.status(400).json({ error: "Nothing to update" });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
170
|
-
return response.status(400).json({ error: "enabled must be boolean" });
|
|
171
|
-
}
|
|
172
|
-
|
|
173
156
|
const db = request.app.locals.application.getRssDb?.();
|
|
174
157
|
if (!db) {
|
|
175
158
|
return response.status(500).json({ error: "Database not available" });
|
|
176
159
|
}
|
|
177
160
|
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return response.status(404).json({
|
|
184
|
-
error: response.locals.__("rss.error.feedNotFound"),
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const update = {};
|
|
189
|
-
|
|
190
|
-
if (enabled !== undefined) {
|
|
191
|
-
update.enabled = enabled;
|
|
192
|
-
}
|
|
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
|
-
|
|
219
|
-
if (publish !== undefined) {
|
|
220
|
-
const { postTypes } = request.app.locals.publication || {};
|
|
221
|
-
|
|
222
|
-
if (publish.enabled) {
|
|
223
|
-
if (!postTypes) {
|
|
224
|
-
return response.status(500).json({
|
|
225
|
-
error: "Post type configuration unavailable",
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// postData.create() throws notImplemented for an unconfigured type,
|
|
230
|
-
// and the failure would be buried deep in the sync loop. Sites
|
|
231
|
-
// differ: chardonsbleus has audio, event, jam and rsvp disabled.
|
|
232
|
-
if (!Object.hasOwn(postTypes, publish.postType)) {
|
|
233
|
-
return response.status(400).json({
|
|
234
|
-
error: `Post type "${publish.postType}" is not enabled on this site`,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const merged = { ...feed.publish, ...publish };
|
|
240
|
-
|
|
241
|
-
update.publish = {
|
|
242
|
-
...merged,
|
|
243
|
-
// Pre-fill from the post type when the caller left them out, so a
|
|
244
|
-
// minimal PATCH of { enabled, postType } yields a working config.
|
|
245
|
-
// An explicit null is respected: it means "no link property".
|
|
246
|
-
content: merged.content || defaultContent(merged.postType),
|
|
247
|
-
linkProperty:
|
|
248
|
-
publish.linkProperty === undefined
|
|
249
|
-
? (merged.linkProperty ?? defaultLinkProperty(merged.postType))
|
|
250
|
-
: publish.linkProperty,
|
|
251
|
-
// Stamp the watermark the first time publishing is switched on, so
|
|
252
|
-
// enabling a feed does not publish its entire cached backlog.
|
|
253
|
-
since:
|
|
254
|
-
publish.enabled && !feed.publish?.since
|
|
255
|
-
? new Date().toISOString()
|
|
256
|
-
: (feed.publish?.since ?? null),
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const result = await feedsCollection.findOneAndUpdate(
|
|
261
|
-
{ _id: feedId },
|
|
262
|
-
{ $set: update },
|
|
263
|
-
{ returnDocument: "after" },
|
|
161
|
+
const result = await applyFeedSettings(
|
|
162
|
+
db,
|
|
163
|
+
request.params.id,
|
|
164
|
+
request.body,
|
|
165
|
+
request.app.locals.publication,
|
|
264
166
|
);
|
|
265
167
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
let eligible;
|
|
269
|
-
if (result.publish?.enabled) {
|
|
270
|
-
eligible = await db
|
|
271
|
-
.collection("rssItems")
|
|
272
|
-
.countDocuments(pendingQuery(result));
|
|
168
|
+
if (result.error) {
|
|
169
|
+
return response.status(result.status).json({ error: result.error });
|
|
273
170
|
}
|
|
274
171
|
|
|
275
172
|
response.json({
|
|
276
|
-
eligible,
|
|
277
|
-
message:
|
|
278
|
-
|
|
279
|
-
? response.locals.__("rss.success.feedUpdated")
|
|
280
|
-
: enabled
|
|
281
|
-
? response.locals.__("rss.success.feedEnabled")
|
|
282
|
-
: response.locals.__("rss.success.feedDisabled"),
|
|
283
|
-
feed: formatFeed(result),
|
|
173
|
+
eligible: result.eligible,
|
|
174
|
+
message: response.locals.__("rss.success.feedUpdated"),
|
|
175
|
+
feed: formatFeed(result.feed),
|
|
284
176
|
});
|
|
285
177
|
} catch (error) {
|
|
286
178
|
console.error("[RSS] Error updating feed:", error.message);
|
|
@@ -291,69 +183,24 @@ export const feedsController = {
|
|
|
291
183
|
/**
|
|
292
184
|
* Rewind a feed's watermark so past items get published
|
|
293
185
|
* POST /api/feeds/:id/backfill
|
|
294
|
-
* Body: { since?: string } or { last?: number }
|
|
295
186
|
*/
|
|
296
187
|
async backfill(request, response) {
|
|
297
188
|
try {
|
|
298
|
-
const { id } = request.params;
|
|
299
|
-
const { since, last } = request.body;
|
|
300
|
-
|
|
301
|
-
if (!ObjectId.isValid(id)) {
|
|
302
|
-
return response.status(400).json({ error: "Invalid feed ID" });
|
|
303
|
-
}
|
|
304
|
-
|
|
305
189
|
const db = request.app.locals.application.getRssDb?.();
|
|
306
190
|
if (!db) {
|
|
307
191
|
return response.status(500).json({ error: "Database not available" });
|
|
308
192
|
}
|
|
309
193
|
|
|
310
|
-
const
|
|
311
|
-
const itemsCollection = db.collection("rssItems");
|
|
312
|
-
const feedId = new ObjectId(id);
|
|
313
|
-
const feed = await feedsCollection.findOne({ _id: feedId });
|
|
314
|
-
|
|
315
|
-
if (!feed) {
|
|
316
|
-
return response.status(404).json({
|
|
317
|
-
error: response.locals.__("rss.error.feedNotFound"),
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (!feed.publish?.enabled) {
|
|
322
|
-
return response.status(400).json({
|
|
323
|
-
error: "Enable publishing on this feed first",
|
|
324
|
-
});
|
|
325
|
-
}
|
|
194
|
+
const result = await rewindWatermark(db, request.params.id, request.body);
|
|
326
195
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const items = await itemsCollection
|
|
330
|
-
.find({ feedId })
|
|
331
|
-
.sort({ pubDate: -1 })
|
|
332
|
-
.limit(Number(last))
|
|
333
|
-
.toArray();
|
|
334
|
-
oldestPubDate = items.at(-1)?.pubDate || null;
|
|
196
|
+
if (result.error) {
|
|
197
|
+
return response.status(result.status).json({ error: result.error });
|
|
335
198
|
}
|
|
336
199
|
|
|
337
|
-
let watermark;
|
|
338
|
-
try {
|
|
339
|
-
watermark = watermarkFor({ since, last }, oldestPubDate);
|
|
340
|
-
} catch (error) {
|
|
341
|
-
return response.status(400).json({ error: error.message });
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
await feedsCollection.updateOne(
|
|
345
|
-
{ _id: feedId },
|
|
346
|
-
{
|
|
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 },
|
|
351
|
-
},
|
|
352
|
-
);
|
|
353
|
-
|
|
354
200
|
response.json({
|
|
201
|
+
eligible: result.eligible,
|
|
355
202
|
message: response.locals.__("rss.success.backfillQueued"),
|
|
356
|
-
since:
|
|
203
|
+
since: result.since,
|
|
357
204
|
});
|
|
358
205
|
} catch (error) {
|
|
359
206
|
console.error("[RSS] Error queueing backfill:", error.message);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { ObjectId } from "mongodb";
|
|
2
|
+
|
|
3
|
+
import { defaultContent, defaultLinkProperty } from "./jf2-builder.js";
|
|
4
|
+
import { pendingQuery, watermarkFor } from "./publisher.js";
|
|
5
|
+
import { isValidUrl, normalizeUrl } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Apply a settings change to one feed.
|
|
9
|
+
*
|
|
10
|
+
* Shared by the JSON API and the dashboard form so the two cannot drift: two
|
|
11
|
+
* copies of this validation would eventually disagree, and the symptom would
|
|
12
|
+
* be a form that accepts what the API rejects.
|
|
13
|
+
* @param {object} db - Database
|
|
14
|
+
* @param {string} id - Feed id
|
|
15
|
+
* @param {object} body - Fields to change: enabled, url, publish
|
|
16
|
+
* @param {object} [publication] - Publication configuration
|
|
17
|
+
* @returns {Promise<object>} Result carrying status, error, feed, eligible
|
|
18
|
+
*/
|
|
19
|
+
export async function applyFeedSettings(db, id, body, publication = {}) {
|
|
20
|
+
const { enabled, publish, url } = body;
|
|
21
|
+
|
|
22
|
+
if (!ObjectId.isValid(id)) {
|
|
23
|
+
return { status: 400, error: "Invalid feed ID" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (enabled === undefined && publish === undefined && url === undefined) {
|
|
27
|
+
return { status: 400, error: "Nothing to update" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
31
|
+
return { status: 400, error: "enabled must be boolean" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const feedsCollection = db.collection("rssFeeds");
|
|
35
|
+
const feedId = new ObjectId(id);
|
|
36
|
+
const feed = await feedsCollection.findOne({ _id: feedId });
|
|
37
|
+
|
|
38
|
+
if (!feed) {
|
|
39
|
+
return { status: 404, error: "Feed not found" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const update = {};
|
|
43
|
+
|
|
44
|
+
if (enabled !== undefined) {
|
|
45
|
+
update.enabled = enabled;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (url !== undefined) {
|
|
49
|
+
if (!isValidUrl(url)) {
|
|
50
|
+
return { status: 400, error: "Invalid feed URL" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const normalizedUrl = normalizeUrl(url);
|
|
54
|
+
const clash = await feedsCollection.findOne({
|
|
55
|
+
url: normalizedUrl,
|
|
56
|
+
_id: { $ne: feedId },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (clash) {
|
|
60
|
+
return { status: 409, error: "Another feed already uses that URL" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
update.url = normalizedUrl;
|
|
64
|
+
// Items key on feedId, so they survive the change. Any error from the old
|
|
65
|
+
// URL is stale the moment it is corrected.
|
|
66
|
+
update.lastError = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (publish !== undefined) {
|
|
70
|
+
const { postTypes } = publication;
|
|
71
|
+
|
|
72
|
+
if (publish.enabled) {
|
|
73
|
+
if (!postTypes) {
|
|
74
|
+
return { status: 500, error: "Post type configuration unavailable" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// postData.create() throws notImplemented for an unconfigured type, and
|
|
78
|
+
// the failure would be buried deep in the sync loop. Sites differ:
|
|
79
|
+
// chardonsbleus has audio, event, jam and rsvp disabled.
|
|
80
|
+
if (!Object.hasOwn(postTypes, publish.postType)) {
|
|
81
|
+
return {
|
|
82
|
+
status: 400,
|
|
83
|
+
error: `Post type "${publish.postType}" is not enabled on this site`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const merged = { ...feed.publish, ...publish };
|
|
89
|
+
|
|
90
|
+
update.publish = {
|
|
91
|
+
...merged,
|
|
92
|
+
// Pre-fill from the post type when the caller left them out, so a
|
|
93
|
+
// minimal update of enabled plus postType yields a working config. An
|
|
94
|
+
// explicit null is respected: it means "no link property".
|
|
95
|
+
content: merged.content || defaultContent(merged.postType),
|
|
96
|
+
linkProperty:
|
|
97
|
+
publish.linkProperty === undefined
|
|
98
|
+
? (merged.linkProperty ?? defaultLinkProperty(merged.postType))
|
|
99
|
+
: publish.linkProperty,
|
|
100
|
+
// Stamp the watermark the first time publishing is switched on, so
|
|
101
|
+
// enabling a feed does not publish its entire cached backlog.
|
|
102
|
+
since:
|
|
103
|
+
publish.enabled && !feed.publish?.since
|
|
104
|
+
? new Date().toISOString()
|
|
105
|
+
: (feed.publish?.since ?? null),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await feedsCollection.findOneAndUpdate(
|
|
110
|
+
{ _id: feedId },
|
|
111
|
+
{ $set: update },
|
|
112
|
+
{ returnDocument: "after" },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// A feed can be correctly configured and still have nothing to publish, so
|
|
116
|
+
// report the count rather than leaving the user to guess.
|
|
117
|
+
let eligible;
|
|
118
|
+
if (result.publish?.enabled) {
|
|
119
|
+
eligible = await db
|
|
120
|
+
.collection("rssItems")
|
|
121
|
+
.countDocuments(pendingQuery(result));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { status: 200, feed: result, eligible };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Move a feed's watermark back so already-cached items become eligible.
|
|
129
|
+
* @param {object} db - Database
|
|
130
|
+
* @param {string} id - Feed id
|
|
131
|
+
* @param {object} body - Either a since date or a last item count
|
|
132
|
+
* @returns {Promise<object>} Result carrying status, error, since, eligible
|
|
133
|
+
*/
|
|
134
|
+
export async function rewindWatermark(db, id, body) {
|
|
135
|
+
const { since, last } = body;
|
|
136
|
+
|
|
137
|
+
if (!ObjectId.isValid(id)) {
|
|
138
|
+
return { status: 400, error: "Invalid feed ID" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const feedsCollection = db.collection("rssFeeds");
|
|
142
|
+
const itemsCollection = db.collection("rssItems");
|
|
143
|
+
const feedId = new ObjectId(id);
|
|
144
|
+
const feed = await feedsCollection.findOne({ _id: feedId });
|
|
145
|
+
|
|
146
|
+
if (!feed) {
|
|
147
|
+
return { status: 404, error: "Feed not found" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!feed.publish?.enabled) {
|
|
151
|
+
return { status: 400, error: "Enable publishing on this feed first" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let oldestPubDate = null;
|
|
155
|
+
if (last) {
|
|
156
|
+
const items = await itemsCollection
|
|
157
|
+
.find({ feedId })
|
|
158
|
+
.sort({ pubDate: -1 })
|
|
159
|
+
.limit(Number(last))
|
|
160
|
+
.toArray();
|
|
161
|
+
oldestPubDate = items.at(-1)?.pubDate || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let watermark;
|
|
165
|
+
try {
|
|
166
|
+
watermark = watermarkFor({ since, last }, oldestPubDate);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { status: 400, error: error.message };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The feed's own post status is respected: silently forcing drafts would
|
|
172
|
+
// override a deliberate choice. The guard is the count the user asked for,
|
|
173
|
+
// and maxPostsPerCycle pacing the catch-up.
|
|
174
|
+
const updated = await feedsCollection.findOneAndUpdate(
|
|
175
|
+
{ _id: feedId },
|
|
176
|
+
{ $set: { "publish.since": watermark } },
|
|
177
|
+
{ returnDocument: "after" },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const eligible = await itemsCollection.countDocuments(pendingQuery(updated));
|
|
181
|
+
|
|
182
|
+
return { status: 200, since: watermark, eligible };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Clear a feed's publish state so its items can be published again.
|
|
187
|
+
*
|
|
188
|
+
* Without this a feed is stuck the moment its items carry postedAt: changing
|
|
189
|
+
* the template, the post type or the date source has no way to reach items
|
|
190
|
+
* already sent. Existing posts are not deleted — republishing writes over the
|
|
191
|
+
* same URL, because the slug is derived from the item's guid.
|
|
192
|
+
* @param {object} db - Database
|
|
193
|
+
* @param {string} id - Feed id
|
|
194
|
+
* @returns {Promise<object>} Result carrying status, error, reset
|
|
195
|
+
*/
|
|
196
|
+
export async function resetPublishState(db, id) {
|
|
197
|
+
if (!ObjectId.isValid(id)) {
|
|
198
|
+
return { status: 400, error: "Invalid feed ID" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const feedId = new ObjectId(id);
|
|
202
|
+
const feed = await db.collection("rssFeeds").findOne({ _id: feedId });
|
|
203
|
+
|
|
204
|
+
if (!feed) {
|
|
205
|
+
return { status: 404, error: "Feed not found" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = await db.collection("rssItems").updateMany(
|
|
209
|
+
{ feedId },
|
|
210
|
+
{ $unset: { postedAt: "", postUrl: "", postSkipped: "", postError: "", postErrorAt: "", postAttempts: "" } },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return { status: 200, reset: result.modifiedCount };
|
|
214
|
+
}
|
package/lib/jf2-builder.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { sanitizeHtml, stripHtml } from "./utils.js";
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -14,14 +16,44 @@ const LINK_PROPERTY_DEFAULTS = {
|
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
const PLACEHOLDERS = {
|
|
17
|
-
title: (item) => stripHtml(item.title),
|
|
19
|
+
title: (item) => oneLine(stripHtml(item.title)),
|
|
18
20
|
link: (item) => item.link,
|
|
19
|
-
description: (item) => stripHtml(item.description),
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
description: (item) => oneLine(stripHtml(item.description)),
|
|
22
|
+
// The item's own link is the right base: a feed's markup is relative to
|
|
23
|
+
// the site that published it, not to ours.
|
|
24
|
+
content: (item) => sanitizeHtml(item.content, { baseUrl: item.link }),
|
|
25
|
+
author: (item) => oneLine(stripHtml(item.author)),
|
|
26
|
+
sourceTitle: (item) => oneLine(stripHtml(item.sourceTitle)),
|
|
23
27
|
};
|
|
24
28
|
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collapse whitespace runs so an excerpt stays one line.
|
|
32
|
+
*
|
|
33
|
+
* Feed descriptions often arrive indented. Markdown reads an indented line as
|
|
34
|
+
* a code block, which is how a GitHub timeline entry rendered as <pre><code>.
|
|
35
|
+
* @param {string} value - Text
|
|
36
|
+
* @returns {string} Single-line text
|
|
37
|
+
*/
|
|
38
|
+
const oneLine = (value) => (value ? String(value).replace(/\s+/g, " ").trim() : "");
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Short, stable discriminator for an item.
|
|
42
|
+
*
|
|
43
|
+
* Indiekit derives a slug from the first five words of the name, so feed items
|
|
44
|
+
* sharing a title prefix produce the same URL — and postData.create replaces
|
|
45
|
+
* on that URL, so the second item silently overwrites the first. Three GitHub
|
|
46
|
+
* timeline entries collapsed into one post that way. The guid is already the
|
|
47
|
+
* deduplication key, so it is the right thing to discriminate on.
|
|
48
|
+
* @param {object} item - RSS item document
|
|
49
|
+
* @returns {string} Six hex characters
|
|
50
|
+
*/
|
|
51
|
+
const discriminator = (item) =>
|
|
52
|
+
createHash("sha1")
|
|
53
|
+
.update(String(item.guid ?? item.link ?? item.title ?? ""))
|
|
54
|
+
.digest("hex")
|
|
55
|
+
.slice(0, 6);
|
|
56
|
+
|
|
25
57
|
/**
|
|
26
58
|
* Link property offered when a post type is chosen
|
|
27
59
|
* @param {string} postType - Post type key
|
|
@@ -73,7 +105,7 @@ export function buildJf2(item, publish) {
|
|
|
73
105
|
// A `name` next to content makes discovery return "article" — see
|
|
74
106
|
// post-type-discovery.js: `if (content && properties.name) return "article"`.
|
|
75
107
|
// A note must therefore carry none.
|
|
76
|
-
const name = stripHtml(item.title);
|
|
108
|
+
const name = oneLine(stripHtml(item.title));
|
|
77
109
|
if (publish.postType !== "note" && name) {
|
|
78
110
|
properties.name = name;
|
|
79
111
|
}
|
|
@@ -91,7 +123,14 @@ export function buildJf2(item, publish) {
|
|
|
91
123
|
properties[publish.linkProperty] = value;
|
|
92
124
|
}
|
|
93
125
|
|
|
94
|
-
|
|
126
|
+
// dateSource decides whether the post carries the item's own date or the
|
|
127
|
+
// moment it was published here. The item's date is truthful — the article
|
|
128
|
+
// really was written then — but it buries the post at its chronological
|
|
129
|
+
// place, so a feed of older items produces posts nobody sees. "created"
|
|
130
|
+
// trades that accuracy for visibility.
|
|
131
|
+
if (publish.dateSource === "created") {
|
|
132
|
+
properties.published = new Date().toISOString();
|
|
133
|
+
} else if (item.pubDate) {
|
|
95
134
|
properties.published =
|
|
96
135
|
item.pubDate instanceof Date ? item.pubDate.toISOString() : item.pubDate;
|
|
97
136
|
}
|
|
@@ -104,6 +143,13 @@ export function buildJf2(item, publish) {
|
|
|
104
143
|
properties.syndication = [item.link];
|
|
105
144
|
}
|
|
106
145
|
|
|
146
|
+
// Five title words plus a guid-derived suffix: readable, and impossible for
|
|
147
|
+
// two distinct items to collide on.
|
|
148
|
+
properties["mp-slug"] = [
|
|
149
|
+
...name.split(/\s+/).filter(Boolean).slice(0, 5),
|
|
150
|
+
discriminator(item),
|
|
151
|
+
].join(" ");
|
|
152
|
+
|
|
107
153
|
properties["post-status"] =
|
|
108
154
|
publish.status === "published" ? "published" : "draft";
|
|
109
155
|
|
package/lib/publisher.js
CHANGED
|
@@ -119,12 +119,21 @@ export function pendingQuery(feed) {
|
|
|
119
119
|
};
|
|
120
120
|
|
|
121
121
|
if (feed.publish?.since) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
122
|
+
const since = feed.publish.since;
|
|
123
|
+
|
|
124
|
+
// One watermark, two operand types, because the two fields are stored
|
|
125
|
+
// differently: pubDate is a BSON Date (rss-client builds real Dates and
|
|
126
|
+
// pruneOldItems compares it against one), while fetchedAt — like every
|
|
127
|
+
// other date here — is an ISO string. MongoDB orders by BSON type before
|
|
128
|
+
// value, so a Date is never $gt a String: passing the ISO string to
|
|
129
|
+
// pubDate matches nothing at all, with no error to show for it.
|
|
130
|
+
//
|
|
131
|
+
// An item with no parsable date would also be excluded forever by a bare
|
|
132
|
+
// comparison, because null sorts below every Date. fetchedAt is always set
|
|
133
|
+
// on insert and stands in when pubDate is missing.
|
|
125
134
|
query.$or = [
|
|
126
|
-
{ pubDate: { $gt:
|
|
127
|
-
{ pubDate: null, fetchedAt: { $gt:
|
|
135
|
+
{ pubDate: { $gt: new Date(since) } },
|
|
136
|
+
{ pubDate: null, fetchedAt: { $gt: since } },
|
|
128
137
|
];
|
|
129
138
|
}
|
|
130
139
|
|
package/lib/utils.js
CHANGED
|
@@ -14,13 +14,34 @@ function toISO(value) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Sanitize HTML content
|
|
17
|
+
* Sanitize HTML content, keeping as much of it as is safe to keep.
|
|
18
|
+
*
|
|
19
|
+
* Feeds carry real formatting — headings, images, links, code — and dropping
|
|
20
|
+
* it produces a flat paraphrase of the source. The job here is to remove what
|
|
21
|
+
* is dangerous, not what is rich.
|
|
18
22
|
* @param {string} html - Raw HTML
|
|
19
|
-
* @
|
|
23
|
+
* @param {object} [options] - Options
|
|
24
|
+
* @param {string} [options.baseUrl] - Resolves relative hrefs and image srcs
|
|
25
|
+
* @returns {string} Sanitized HTML
|
|
20
26
|
*/
|
|
21
|
-
export function sanitizeHtml(html) {
|
|
27
|
+
export function sanitizeHtml(html, { baseUrl } = {}) {
|
|
22
28
|
if (!html) return "";
|
|
23
|
-
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Make a feed-relative URL absolute against its source.
|
|
32
|
+
* @param {string} value - href or src
|
|
33
|
+
* @returns {string} Absolute URL where possible
|
|
34
|
+
*/
|
|
35
|
+
const absolute = (value) => {
|
|
36
|
+
if (!value || !baseUrl) return value;
|
|
37
|
+
try {
|
|
38
|
+
return new URL(value, baseUrl).href;
|
|
39
|
+
} catch {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const clean = sanitizeHtmlLib(html, {
|
|
24
45
|
allowedTags: [
|
|
25
46
|
"p",
|
|
26
47
|
"br",
|
|
@@ -35,9 +56,17 @@ export function sanitizeHtml(html) {
|
|
|
35
56
|
"blockquote",
|
|
36
57
|
"code",
|
|
37
58
|
"pre",
|
|
59
|
+
"img",
|
|
60
|
+
"figure",
|
|
61
|
+
"figcaption",
|
|
62
|
+
"h2",
|
|
63
|
+
"h3",
|
|
64
|
+
"h4",
|
|
65
|
+
"hr",
|
|
38
66
|
],
|
|
39
67
|
allowedAttributes: {
|
|
40
68
|
a: ["href", "title", "rel"],
|
|
69
|
+
img: ["src", "alt", "title", "width", "height", "loading"],
|
|
41
70
|
code: ["class"],
|
|
42
71
|
pre: ["class"],
|
|
43
72
|
},
|
|
@@ -46,7 +75,47 @@ export function sanitizeHtml(html) {
|
|
|
46
75
|
pre: ["language-*"],
|
|
47
76
|
},
|
|
48
77
|
allowedSchemes: ["http", "https", "mailto"],
|
|
78
|
+
transformTags: {
|
|
79
|
+
// A feed's markup is relative to the feed's own site. Left as-is these
|
|
80
|
+
// resolve against the publishing site instead, so every link and image
|
|
81
|
+
// silently points at the wrong host.
|
|
82
|
+
a: (tagName, attribs) => ({
|
|
83
|
+
tagName,
|
|
84
|
+
attribs: { ...attribs, ...(attribs.href && { href: absolute(attribs.href) }) },
|
|
85
|
+
}),
|
|
86
|
+
img: (tagName, attribs) => ({
|
|
87
|
+
tagName,
|
|
88
|
+
attribs: {
|
|
89
|
+
...attribs,
|
|
90
|
+
...(attribs.src && { src: absolute(attribs.src) }),
|
|
91
|
+
loading: "lazy",
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
// An anchor whose only child was a stripped element leaves an empty link.
|
|
96
|
+
exclusiveFilter: (frame) =>
|
|
97
|
+
frame.tag === "a" && !frame.text.trim() && !frame.mediaChildren?.length,
|
|
49
98
|
});
|
|
99
|
+
|
|
100
|
+
return collapseIndentation(clean).trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collapse whitespace runs outside <pre> blocks.
|
|
105
|
+
*
|
|
106
|
+
* Micropub turns HTML into the markdown that gets stored, normalising through
|
|
107
|
+
* markdown-it first — where a line starting with four spaces is an indented
|
|
108
|
+
* code block. Feed markup is usually pretty-printed, so whole paragraphs came
|
|
109
|
+
* back wrapped in a fence with their tags escaped. Inside <pre> the
|
|
110
|
+
* indentation is the content, so it is left alone.
|
|
111
|
+
* @param {string} html - Sanitized HTML
|
|
112
|
+
* @returns {string} HTML without cosmetic indentation
|
|
113
|
+
*/
|
|
114
|
+
function collapseIndentation(html) {
|
|
115
|
+
return html
|
|
116
|
+
.split(/(<pre[\s\S]*?<\/pre>)/i)
|
|
117
|
+
.map((part, index) => (index % 2 ? part : part.replace(/\s+/g, " ")))
|
|
118
|
+
.join("");
|
|
50
119
|
}
|
|
51
120
|
|
|
52
121
|
/**
|
package/locales/en.json
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"feedUpdated": "Feed updated",
|
|
43
43
|
"feedEnabled": "Feed enabled",
|
|
44
44
|
"feedDisabled": "Feed disabled",
|
|
45
|
-
"backfillQueued": "Backfill queued
|
|
45
|
+
"backfillQueued": "Backfill queued: the newest cached items will be published over the next sync cycles, using this feed's post status.",
|
|
46
46
|
"syncComplete": "Sync complete",
|
|
47
47
|
"clearResync": "Items cleared and re-synced"
|
|
48
48
|
},
|
|
@@ -62,7 +62,11 @@
|
|
|
62
62
|
"status": "Post status",
|
|
63
63
|
"draft": "Draft",
|
|
64
64
|
"published": "Published",
|
|
65
|
-
"save": "Save settings"
|
|
65
|
+
"save": "Save settings",
|
|
66
|
+
"dateSource": "Post date",
|
|
67
|
+
"dateFromItem": "The item's own publication date",
|
|
68
|
+
"dateFromCreation": "When it was published here",
|
|
69
|
+
"dateSourceHint": "The item's own date is accurate but sorts the post into the past, where a feed of older items produces posts nobody sees."
|
|
66
70
|
},
|
|
67
71
|
"settings": {
|
|
68
72
|
"title": "Feed settings",
|
|
@@ -73,6 +77,10 @@
|
|
|
73
77
|
"label": "Publish existing items",
|
|
74
78
|
"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
79
|
"submit": "Publish existing items"
|
|
80
|
+
},
|
|
81
|
+
"republish": {
|
|
82
|
+
"submit": "Forget and republish",
|
|
83
|
+
"hint": "Clears what this feed has published so the current settings apply to every item again. Existing posts are overwritten, not duplicated."
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
}
|
package/package.json
CHANGED
package/views/rss.njk
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
classes: "rss-publish",
|
|
114
114
|
summary: publishSummary
|
|
115
115
|
}) %}
|
|
116
|
-
<form
|
|
116
|
+
<form action="{{ mountPath }}/feeds/{{ feed.id }}/settings" method="post">
|
|
117
117
|
{{ input({
|
|
118
118
|
id: "feed-url-" + feed.id,
|
|
119
119
|
name: "url",
|
|
@@ -175,6 +175,25 @@
|
|
|
175
175
|
]
|
|
176
176
|
}) }}
|
|
177
177
|
|
|
178
|
+
{{ select({
|
|
179
|
+
id: "publish-date-source-" + feed.id,
|
|
180
|
+
name: "dateSource",
|
|
181
|
+
label: __("rss.publish.dateSource"),
|
|
182
|
+
hint: __("rss.publish.dateSourceHint"),
|
|
183
|
+
items: [
|
|
184
|
+
{
|
|
185
|
+
text: __("rss.publish.dateFromItem"),
|
|
186
|
+
value: "item",
|
|
187
|
+
selected: feed.publish.dateSource != "created"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
text: __("rss.publish.dateFromCreation"),
|
|
191
|
+
value: "created",
|
|
192
|
+
selected: feed.publish.dateSource == "created"
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}) }}
|
|
196
|
+
|
|
178
197
|
{{ button({
|
|
179
198
|
type: "submit",
|
|
180
199
|
text: __("rss.publish.save")
|
|
@@ -182,7 +201,7 @@
|
|
|
182
201
|
</form>
|
|
183
202
|
|
|
184
203
|
{% if feed.publish.enabled %}
|
|
185
|
-
<form class="rss-backfill"
|
|
204
|
+
<form class="rss-backfill" action="{{ mountPath }}/feeds/{{ feed.id }}/backfill" method="post">
|
|
186
205
|
{{ input({
|
|
187
206
|
id: "backfill-last-" + feed.id,
|
|
188
207
|
name: "last",
|
|
@@ -197,6 +216,15 @@
|
|
|
197
216
|
text: __("rss.backfill.submit")
|
|
198
217
|
}) }}
|
|
199
218
|
</form>
|
|
219
|
+
|
|
220
|
+
<form class="rss-republish" action="{{ mountPath }}/feeds/{{ feed.id }}/republish" method="post">
|
|
221
|
+
<p class="hint">{{ __("rss.republish.hint") }}</p>
|
|
222
|
+
{{ button({
|
|
223
|
+
classes: "button--secondary",
|
|
224
|
+
type: "submit",
|
|
225
|
+
text: __("rss.republish.submit")
|
|
226
|
+
}) }}
|
|
227
|
+
</form>
|
|
200
228
|
{% endif %}
|
|
201
229
|
{% endcall %}
|
|
202
230
|
</div>
|
|
@@ -356,38 +384,6 @@
|
|
|
356
384
|
}
|
|
357
385
|
});
|
|
358
386
|
|
|
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
|
-
|
|
391
387
|
// Handle delete feed
|
|
392
388
|
document.querySelectorAll('[data-delete-feed]').forEach(btn => {
|
|
393
389
|
btn.addEventListener('click', async (e) => {
|
|
@@ -412,51 +408,5 @@
|
|
|
412
408
|
});
|
|
413
409
|
});
|
|
414
410
|
|
|
415
|
-
// Handle publish configuration
|
|
416
|
-
document.querySelectorAll('[data-publish-feed]').forEach(form => {
|
|
417
|
-
form.addEventListener('submit', async (e) => {
|
|
418
|
-
e.preventDefault();
|
|
419
|
-
const feedId = e.target.dataset.publishFeed;
|
|
420
|
-
const data = new FormData(e.target);
|
|
421
|
-
|
|
422
|
-
const publish = {
|
|
423
|
-
// The checkboxes component sets value="true"; an unchecked box is
|
|
424
|
-
// omitted from FormData entirely, so presence is the signal.
|
|
425
|
-
enabled: data.get('enabled') !== null,
|
|
426
|
-
postType: data.get('postType'),
|
|
427
|
-
content: data.get('content'),
|
|
428
|
-
linkProperty: data.get('linkProperty') || null,
|
|
429
|
-
status: data.get('status'),
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
try {
|
|
433
|
-
const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}`, {
|
|
434
|
-
method: 'PATCH',
|
|
435
|
-
headers: { 'Content-Type': 'application/json' },
|
|
436
|
-
body: JSON.stringify({ url: data.get('url'), publish })
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
const body = await response.json();
|
|
440
|
-
|
|
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
|
-
}
|
|
452
|
-
location.reload();
|
|
453
|
-
} else {
|
|
454
|
-
alert(body.error || 'Failed to save publish settings');
|
|
455
|
-
}
|
|
456
|
-
} catch (err) {
|
|
457
|
-
alert('Failed to save publish settings: ' + err.message);
|
|
458
|
-
}
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
411
|
</script>
|
|
462
412
|
{% endblock %}
|