@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.
@@ -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,
@@ -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 html
210
- .replace(/<[^>]*>/g, " ")
211
- .replace(/\s+/g, " ")
212
- .trim();
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(/&amp;/g, "&")
226
+ .replace(/&lt;/g, "<")
227
+ .replace(/&gt;/g, ">")
228
+ .replace(/&quot;/g, '"')
229
+ .replace(/&apos;/g, "'")
230
+ .replace(/&#39;/g, "'")
231
+ .replace(/&#x27;/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
  /**
@@ -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, {
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-blogroll",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.",
5
5
  "keywords": [
6
6
  "indiekit",