@rmdes/indiekit-endpoint-blogroll 1.0.11 → 1.0.13
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/lib/controllers/api.js +2 -0
- package/lib/storage/blogs.js +29 -11
- package/lib/sync/feed.js +29 -8
- package/lib/sync/microsub.js +30 -2
- package/lib/sync/scheduler.js +2 -2
- package/package.json +1 -1
package/lib/controllers/api.js
CHANGED
|
@@ -249,12 +249,14 @@ function sanitizeBlog(blog) {
|
|
|
249
249
|
* @returns {object} Sanitized item
|
|
250
250
|
*/
|
|
251
251
|
function sanitizeItem(item) {
|
|
252
|
+
const published = item.published ? new Date(item.published) : null;
|
|
252
253
|
return {
|
|
253
254
|
id: item._id.toString(),
|
|
254
255
|
url: item.url,
|
|
255
256
|
title: item.title,
|
|
256
257
|
summary: item.summary,
|
|
257
258
|
published: item.published,
|
|
259
|
+
isFuture: published ? published > new Date() : false,
|
|
258
260
|
author: item.author,
|
|
259
261
|
photo: item.photo,
|
|
260
262
|
categories: item.categories,
|
package/lib/storage/blogs.js
CHANGED
|
@@ -262,29 +262,47 @@ export async function upsertBlog(application, data) {
|
|
|
262
262
|
filter.sourceId = new ObjectId(data.sourceId);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Build $set with base fields
|
|
266
|
+
const setFields = {
|
|
267
|
+
title: data.title,
|
|
268
|
+
siteUrl: data.siteUrl,
|
|
269
|
+
feedType: data.feedType,
|
|
270
|
+
category: data.category,
|
|
271
|
+
sourceId: data.sourceId ? new ObjectId(data.sourceId) : null,
|
|
272
|
+
updatedAt: now,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Conditionally add microsub fields when present
|
|
276
|
+
if (data.source) setFields.source = data.source;
|
|
277
|
+
if (data.microsubFeedId) setFields.microsubFeedId = data.microsubFeedId;
|
|
278
|
+
if (data.microsubChannelId) setFields.microsubChannelId = data.microsubChannelId;
|
|
279
|
+
if (data.microsubChannelName) setFields.microsubChannelName = data.microsubChannelName;
|
|
280
|
+
if (data.skipItemFetch !== undefined) setFields.skipItemFetch = data.skipItemFetch;
|
|
281
|
+
if (data.photo) setFields.photo = data.photo;
|
|
282
|
+
if (data.lastFetchAt !== undefined) setFields.lastFetchAt = data.lastFetchAt;
|
|
283
|
+
if (data.status) setFields.status = data.status;
|
|
284
|
+
|
|
265
285
|
const result = await collection.updateOne(
|
|
266
286
|
filter,
|
|
267
287
|
{
|
|
268
|
-
$set:
|
|
269
|
-
title: data.title,
|
|
270
|
-
siteUrl: data.siteUrl,
|
|
271
|
-
feedType: data.feedType,
|
|
272
|
-
category: data.category,
|
|
273
|
-
sourceId: data.sourceId ? new ObjectId(data.sourceId) : null,
|
|
274
|
-
updatedAt: now,
|
|
275
|
-
},
|
|
288
|
+
$set: setFields,
|
|
276
289
|
$setOnInsert: {
|
|
277
290
|
description: null,
|
|
278
291
|
tags: [],
|
|
279
|
-
photo: null,
|
|
292
|
+
photo: data.photo || null,
|
|
280
293
|
author: null,
|
|
281
|
-
status: "active",
|
|
282
|
-
lastFetchAt: null,
|
|
294
|
+
status: data.status || "active",
|
|
295
|
+
lastFetchAt: data.lastFetchAt || null,
|
|
283
296
|
lastError: null,
|
|
284
297
|
itemCount: 0,
|
|
285
298
|
pinned: false,
|
|
286
299
|
hidden: false,
|
|
287
300
|
notes: null,
|
|
301
|
+
source: data.source || null,
|
|
302
|
+
microsubFeedId: data.microsubFeedId || null,
|
|
303
|
+
microsubChannelId: data.microsubChannelId || null,
|
|
304
|
+
microsubChannelName: data.microsubChannelName || null,
|
|
305
|
+
skipItemFetch: data.skipItemFetch || false,
|
|
288
306
|
createdAt: now,
|
|
289
307
|
},
|
|
290
308
|
},
|
package/lib/sync/feed.js
CHANGED
|
@@ -134,14 +134,14 @@ function parseJsonFeed(content, feedUrl, maxItems) {
|
|
|
134
134
|
const items = (feed.items || []).slice(0, maxItems).map((item) => ({
|
|
135
135
|
uid: generateUid(feedUrl, item.id || item.url),
|
|
136
136
|
url: item.url || item.external_url,
|
|
137
|
-
title: item.title || "Untitled",
|
|
137
|
+
title: decodeEntities(item.title) || "Untitled",
|
|
138
138
|
content: {
|
|
139
139
|
html: item.content_html
|
|
140
140
|
? sanitizeHtml(item.content_html, SANITIZE_OPTIONS)
|
|
141
141
|
: undefined,
|
|
142
142
|
text: item.content_text,
|
|
143
143
|
},
|
|
144
|
-
summary: item.summary || truncateText(item.content_text, 300),
|
|
144
|
+
summary: decodeEntities(item.summary) || truncateText(item.content_text, 300),
|
|
145
145
|
published: item.date_published ? new Date(item.date_published) : new Date(),
|
|
146
146
|
updated: item.date_modified ? new Date(item.date_modified) : undefined,
|
|
147
147
|
author: item.author || (item.authors?.[0]),
|
|
@@ -171,7 +171,7 @@ function normalizeItem(item, feedUrl) {
|
|
|
171
171
|
return {
|
|
172
172
|
uid: generateUid(feedUrl, item.guid || item.link),
|
|
173
173
|
url: item.link || item.origlink,
|
|
174
|
-
title: item.title || "Untitled",
|
|
174
|
+
title: decodeEntities(item.title) || "Untitled",
|
|
175
175
|
content: {
|
|
176
176
|
html: description ? sanitizeHtml(description, SANITIZE_OPTIONS) : undefined,
|
|
177
177
|
text: stripHtml(description),
|
|
@@ -200,16 +200,37 @@ function generateUid(feedUrl, itemId) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
/**
|
|
203
|
-
* Strip HTML tags from string
|
|
203
|
+
* Strip HTML tags and decode HTML entities from string
|
|
204
204
|
* @param {string} html - HTML string
|
|
205
205
|
* @returns {string} Plain text
|
|
206
206
|
*/
|
|
207
207
|
function stripHtml(html) {
|
|
208
208
|
if (!html) return "";
|
|
209
|
-
return
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
return decodeEntities(
|
|
210
|
+
html
|
|
211
|
+
.replace(/<[^>]*>/g, " ")
|
|
212
|
+
.replace(/\s+/g, " ")
|
|
213
|
+
.trim()
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Decode HTML entities to their character equivalents
|
|
219
|
+
* @param {string} str - String with HTML entities
|
|
220
|
+
* @returns {string} Decoded string
|
|
221
|
+
*/
|
|
222
|
+
function decodeEntities(str) {
|
|
223
|
+
if (!str) return "";
|
|
224
|
+
return str
|
|
225
|
+
.replace(/&/g, "&")
|
|
226
|
+
.replace(/</g, "<")
|
|
227
|
+
.replace(/>/g, ">")
|
|
228
|
+
.replace(/"/g, '"')
|
|
229
|
+
.replace(/'/g, "'")
|
|
230
|
+
.replace(/'/g, "'")
|
|
231
|
+
.replace(/'/g, "'")
|
|
232
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
233
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
213
234
|
}
|
|
214
235
|
|
|
215
236
|
/**
|
package/lib/sync/microsub.js
CHANGED
|
@@ -43,6 +43,7 @@ export async function syncMicrosubSource(application, source) {
|
|
|
43
43
|
let added = 0;
|
|
44
44
|
let updated = 0;
|
|
45
45
|
let total = 0;
|
|
46
|
+
const currentMicrosubFeedIds = [];
|
|
46
47
|
|
|
47
48
|
for (const channel of channels) {
|
|
48
49
|
// Get all feeds subscribed in this channel
|
|
@@ -50,6 +51,7 @@ export async function syncMicrosubSource(application, source) {
|
|
|
50
51
|
|
|
51
52
|
for (const feed of feeds) {
|
|
52
53
|
total++;
|
|
54
|
+
currentMicrosubFeedIds.push(feed._id.toString());
|
|
53
55
|
|
|
54
56
|
// Store REFERENCE to Microsub feed, not a copy
|
|
55
57
|
// Items will be queried from microsub_items directly
|
|
@@ -83,14 +85,40 @@ export async function syncMicrosubSource(application, source) {
|
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
// Orphan detection: soft-delete blogs whose microsub feed no longer exists
|
|
89
|
+
let orphaned = 0;
|
|
90
|
+
if (currentMicrosubFeedIds.length > 0) {
|
|
91
|
+
const db = application.getBlogrollDb();
|
|
92
|
+
const orphanResult = await db.collection("blogrollBlogs").updateMany(
|
|
93
|
+
{
|
|
94
|
+
source: "microsub",
|
|
95
|
+
sourceId: source._id,
|
|
96
|
+
microsubFeedId: { $nin: currentMicrosubFeedIds },
|
|
97
|
+
status: { $ne: "deleted" },
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
$set: {
|
|
101
|
+
status: "deleted",
|
|
102
|
+
hidden: true,
|
|
103
|
+
deletedAt: new Date(),
|
|
104
|
+
updatedAt: new Date(),
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
orphaned = orphanResult.modifiedCount;
|
|
109
|
+
if (orphaned > 0) {
|
|
110
|
+
console.log(`[Blogroll] Cleaned up ${orphaned} orphaned Microsub blog(s) no longer subscribed`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
// Update source sync status
|
|
87
115
|
await updateSourceSyncStatus(application, source._id, { success: true });
|
|
88
116
|
|
|
89
117
|
console.log(
|
|
90
|
-
`[Blogroll] Synced Microsub source "${source.name}": ${added} added, ${updated} updated, ${total} total from ${channels.length} channels (items served from Microsub)`
|
|
118
|
+
`[Blogroll] Synced Microsub source "${source.name}": ${added} added, ${updated} updated, ${orphaned} orphaned, ${total} total from ${channels.length} channels (items served from Microsub)`
|
|
91
119
|
);
|
|
92
120
|
|
|
93
|
-
return { success: true, added, updated, total };
|
|
121
|
+
return { success: true, added, updated, orphaned, total };
|
|
94
122
|
} catch (error) {
|
|
95
123
|
// Update source with error status
|
|
96
124
|
await updateSourceSyncStatus(application, source._id, {
|
package/lib/sync/scheduler.js
CHANGED
|
@@ -198,9 +198,9 @@ export async function clearAndResync(application, options = {}) {
|
|
|
198
198
|
// Clear all items (but keep blogs and sources)
|
|
199
199
|
await db.collection("blogrollItems").deleteMany({});
|
|
200
200
|
|
|
201
|
-
// Reset blog item counts and status
|
|
201
|
+
// Reset blog item counts and status (skip soft-deleted blogs)
|
|
202
202
|
await db.collection("blogrollBlogs").updateMany(
|
|
203
|
-
{},
|
|
203
|
+
{ status: { $ne: "deleted" } },
|
|
204
204
|
{
|
|
205
205
|
$set: {
|
|
206
206
|
itemCount: 0,
|