@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.57
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/reader.js +408 -0
- package/index.js +37 -36
- package/lib/cache/redis.js +12 -3
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +250 -0
- package/lib/controllers/timeline.js +4 -2
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/fetcher.js +1 -30
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +20 -560
- package/lib/feeds/rss.js +1 -1
- package/lib/polling/processor.js +3 -17
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +99 -590
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/webmention/processor.js +1 -1
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +1 -1
- package/views/channel.njk +1 -348
- package/views/timeline.njk +3 -274
- package/lib/controllers/reader.js +0 -1562
package/lib/storage/items.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Timeline item storage operations
|
|
2
|
+
* Timeline item storage operations — core CRUD
|
|
3
3
|
* @module storage/items
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { ObjectId } from "mongodb";
|
|
7
7
|
|
|
8
|
+
import { extractImagesFromHtml } from "../utils/html.js";
|
|
8
9
|
import {
|
|
9
10
|
buildPaginationQuery,
|
|
10
11
|
buildPaginationSort,
|
|
@@ -13,33 +14,111 @@ import {
|
|
|
13
14
|
} from "../utils/pagination.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
* @param {
|
|
18
|
-
* @returns {
|
|
17
|
+
* Get items collection from application
|
|
18
|
+
* @param {object} application - Indiekit application
|
|
19
|
+
* @returns {object} MongoDB collection
|
|
20
|
+
*/
|
|
21
|
+
export function getCollection(application) {
|
|
22
|
+
return application.collections.get("microsub_items");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract URL string from a media value
|
|
27
|
+
* @param {object|string} media - Media value (can be string URL or object)
|
|
28
|
+
* @returns {string|undefined} URL string
|
|
19
29
|
*/
|
|
20
|
-
function
|
|
21
|
-
if (!
|
|
30
|
+
function extractMediaUrl(media) {
|
|
31
|
+
if (!media) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (typeof media === "string") {
|
|
35
|
+
return media;
|
|
36
|
+
}
|
|
37
|
+
if (typeof media === "object") {
|
|
38
|
+
return media.value || media.url || media.src;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize media array to URL strings
|
|
44
|
+
* @param {Array} mediaArray - Array of media items
|
|
45
|
+
* @returns {Array} Array of URL strings
|
|
46
|
+
*/
|
|
47
|
+
function normalizeMediaArray(mediaArray) {
|
|
48
|
+
if (!mediaArray || !Array.isArray(mediaArray)) {
|
|
22
49
|
return [];
|
|
23
50
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
return mediaArray.map((media) => extractMediaUrl(media)).filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize author object to ensure photo is a URL string
|
|
56
|
+
* @param {object} author - Author object
|
|
57
|
+
* @returns {object} Normalized author
|
|
58
|
+
*/
|
|
59
|
+
function normalizeAuthor(author) {
|
|
60
|
+
if (!author) {
|
|
61
|
+
return;
|
|
32
62
|
}
|
|
33
|
-
return
|
|
63
|
+
return {
|
|
64
|
+
...author,
|
|
65
|
+
photo: extractMediaUrl(author.photo),
|
|
66
|
+
};
|
|
34
67
|
}
|
|
35
68
|
|
|
36
69
|
/**
|
|
37
|
-
*
|
|
38
|
-
* @param {object}
|
|
39
|
-
* @
|
|
70
|
+
* Transform database item to jf2 format
|
|
71
|
+
* @param {object} item - Database item
|
|
72
|
+
* @param {string} [userId] - User ID for read state
|
|
73
|
+
* @returns {object} jf2 item
|
|
40
74
|
*/
|
|
41
|
-
function
|
|
42
|
-
|
|
75
|
+
export function transformToJf2(item, userId) {
|
|
76
|
+
const jf2 = {
|
|
77
|
+
type: item.type,
|
|
78
|
+
uid: item.uid,
|
|
79
|
+
url: item.url,
|
|
80
|
+
published: item.published?.toISOString(), // Convert Date to ISO string
|
|
81
|
+
_id: item._id.toString(),
|
|
82
|
+
_is_read: userId ? item.readBy?.includes(userId) : false,
|
|
83
|
+
_channelId: item.channelId?.toString(),
|
|
84
|
+
_feedId: item.feedId?.toString(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Optional fields
|
|
88
|
+
if (item.name) jf2.name = item.name;
|
|
89
|
+
if (item.content) jf2.content = item.content;
|
|
90
|
+
if (item.summary) jf2.summary = item.summary;
|
|
91
|
+
if (item.updated) jf2.updated = item.updated.toISOString(); // Convert Date to ISO string
|
|
92
|
+
if (item.author) jf2.author = normalizeAuthor(item.author);
|
|
93
|
+
if (item.category?.length > 0) jf2.category = item.category;
|
|
94
|
+
|
|
95
|
+
// Normalize media arrays to ensure they contain URL strings
|
|
96
|
+
const photos = normalizeMediaArray(item.photo);
|
|
97
|
+
const videos = normalizeMediaArray(item.video);
|
|
98
|
+
const audios = normalizeMediaArray(item.audio);
|
|
99
|
+
|
|
100
|
+
// Fallback: extract images from HTML content if no explicit photos
|
|
101
|
+
if (photos.length === 0 && item.content?.html) {
|
|
102
|
+
const extracted = extractImagesFromHtml(item.content.html);
|
|
103
|
+
if (extracted.length > 0) {
|
|
104
|
+
photos.push(...extracted);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (photos.length > 0) jf2.photo = photos;
|
|
109
|
+
if (videos.length > 0) jf2.video = videos;
|
|
110
|
+
if (audios.length > 0) jf2.audio = audios;
|
|
111
|
+
|
|
112
|
+
// Interaction types
|
|
113
|
+
if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf;
|
|
114
|
+
if (item.repostOf?.length > 0) jf2["repost-of"] = item.repostOf;
|
|
115
|
+
if (item.bookmarkOf?.length > 0) jf2["bookmark-of"] = item.bookmarkOf;
|
|
116
|
+
if (item.inReplyTo?.length > 0) jf2["in-reply-to"] = item.inReplyTo;
|
|
117
|
+
|
|
118
|
+
// Source
|
|
119
|
+
if (item.source) jf2._source = item.source;
|
|
120
|
+
|
|
121
|
+
return jf2;
|
|
43
122
|
}
|
|
44
123
|
|
|
45
124
|
/**
|
|
@@ -212,105 +291,6 @@ export async function getAllTimelineItems(application, options = {}) {
|
|
|
212
291
|
};
|
|
213
292
|
}
|
|
214
293
|
|
|
215
|
-
/**
|
|
216
|
-
* Extract URL string from a media value
|
|
217
|
-
* @param {object|string} media - Media value (can be string URL or object)
|
|
218
|
-
* @returns {string|undefined} URL string
|
|
219
|
-
*/
|
|
220
|
-
function extractMediaUrl(media) {
|
|
221
|
-
if (!media) {
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
if (typeof media === "string") {
|
|
225
|
-
return media;
|
|
226
|
-
}
|
|
227
|
-
if (typeof media === "object") {
|
|
228
|
-
return media.value || media.url || media.src;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Normalize media array to URL strings
|
|
234
|
-
* @param {Array} mediaArray - Array of media items
|
|
235
|
-
* @returns {Array} Array of URL strings
|
|
236
|
-
*/
|
|
237
|
-
function normalizeMediaArray(mediaArray) {
|
|
238
|
-
if (!mediaArray || !Array.isArray(mediaArray)) {
|
|
239
|
-
return [];
|
|
240
|
-
}
|
|
241
|
-
return mediaArray.map((media) => extractMediaUrl(media)).filter(Boolean);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Normalize author object to ensure photo is a URL string
|
|
246
|
-
* @param {object} author - Author object
|
|
247
|
-
* @returns {object} Normalized author
|
|
248
|
-
*/
|
|
249
|
-
function normalizeAuthor(author) {
|
|
250
|
-
if (!author) {
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
return {
|
|
254
|
-
...author,
|
|
255
|
-
photo: extractMediaUrl(author.photo),
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Transform database item to jf2 format
|
|
261
|
-
* @param {object} item - Database item
|
|
262
|
-
* @param {string} [userId] - User ID for read state
|
|
263
|
-
* @returns {object} jf2 item
|
|
264
|
-
*/
|
|
265
|
-
function transformToJf2(item, userId) {
|
|
266
|
-
const jf2 = {
|
|
267
|
-
type: item.type,
|
|
268
|
-
uid: item.uid,
|
|
269
|
-
url: item.url,
|
|
270
|
-
published: item.published?.toISOString(), // Convert Date to ISO string
|
|
271
|
-
_id: item._id.toString(),
|
|
272
|
-
_is_read: userId ? item.readBy?.includes(userId) : false,
|
|
273
|
-
_channelId: item.channelId?.toString(),
|
|
274
|
-
_feedId: item.feedId?.toString(),
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
// Optional fields
|
|
278
|
-
if (item.name) jf2.name = item.name;
|
|
279
|
-
if (item.content) jf2.content = item.content;
|
|
280
|
-
if (item.summary) jf2.summary = item.summary;
|
|
281
|
-
if (item.updated) jf2.updated = item.updated.toISOString(); // Convert Date to ISO string
|
|
282
|
-
if (item.author) jf2.author = normalizeAuthor(item.author);
|
|
283
|
-
if (item.category?.length > 0) jf2.category = item.category;
|
|
284
|
-
|
|
285
|
-
// Normalize media arrays to ensure they contain URL strings
|
|
286
|
-
const photos = normalizeMediaArray(item.photo);
|
|
287
|
-
const videos = normalizeMediaArray(item.video);
|
|
288
|
-
const audios = normalizeMediaArray(item.audio);
|
|
289
|
-
|
|
290
|
-
// Fallback: extract images from HTML content if no explicit photos
|
|
291
|
-
if (photos.length === 0 && item.content?.html) {
|
|
292
|
-
const extracted = extractImagesFromHtml(item.content.html);
|
|
293
|
-
if (extracted.length > 0) {
|
|
294
|
-
photos.push(...extracted);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (photos.length > 0) jf2.photo = photos;
|
|
299
|
-
if (videos.length > 0) jf2.video = videos;
|
|
300
|
-
if (audios.length > 0) jf2.audio = audios;
|
|
301
|
-
|
|
302
|
-
// Interaction types
|
|
303
|
-
if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf;
|
|
304
|
-
if (item.repostOf?.length > 0) jf2["repost-of"] = item.repostOf;
|
|
305
|
-
if (item.bookmarkOf?.length > 0) jf2["bookmark-of"] = item.bookmarkOf;
|
|
306
|
-
if (item.inReplyTo?.length > 0) jf2["in-reply-to"] = item.inReplyTo;
|
|
307
|
-
|
|
308
|
-
// Source
|
|
309
|
-
if (item.source) jf2._source = item.source;
|
|
310
|
-
|
|
311
|
-
return jf2;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
294
|
/**
|
|
315
295
|
* Get an item by ID (MongoDB _id or uid)
|
|
316
296
|
* @param {object} application - Indiekit application
|
|
@@ -357,425 +337,6 @@ export async function getItemsByUids(application, uids, userId) {
|
|
|
357
337
|
return items.map((item) => transformToJf2(item, userId));
|
|
358
338
|
}
|
|
359
339
|
|
|
360
|
-
/**
|
|
361
|
-
* Count read items in a channel
|
|
362
|
-
* @param {object} application - Indiekit application
|
|
363
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
364
|
-
* @param {string} userId - User ID
|
|
365
|
-
* @returns {Promise<number>} Number of read items
|
|
366
|
-
*/
|
|
367
|
-
export async function countReadItems(application, channelId, userId) {
|
|
368
|
-
const collection = getCollection(application);
|
|
369
|
-
const objectId =
|
|
370
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
371
|
-
|
|
372
|
-
return collection.countDocuments({
|
|
373
|
-
channelId: objectId,
|
|
374
|
-
readBy: userId,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Mark items as read
|
|
380
|
-
* @param {object} application - Indiekit application
|
|
381
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
382
|
-
* @param {Array} entryIds - Array of entry IDs to mark as read (can be ObjectId, uid, or URL)
|
|
383
|
-
* @param {string} userId - User ID
|
|
384
|
-
* @returns {Promise<number>} Number of items updated
|
|
385
|
-
*/
|
|
386
|
-
// Maximum number of full read items to keep per channel before stripping content.
|
|
387
|
-
// Items beyond this limit are converted to lightweight dedup skeletons (channelId,
|
|
388
|
-
// uid, readBy) so the poller doesn't re-ingest them as new unread entries.
|
|
389
|
-
const MAX_FULL_READ_ITEMS = 200;
|
|
390
|
-
|
|
391
|
-
// Maximum age (in days) for stripped skeletons and unread items.
|
|
392
|
-
// After this period, both are hard-deleted to prevent unbounded growth.
|
|
393
|
-
const MAX_ITEM_AGE_DAYS = 30;
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Cleanup old read items by stripping content but preserving dedup skeletons.
|
|
397
|
-
* This prevents the vicious cycle where deleted read items get re-ingested as
|
|
398
|
-
* unread by the poller because the dedup record (channelId + uid) was destroyed.
|
|
399
|
-
*
|
|
400
|
-
* AP items (feedId: null) are hard-deleted instead of stripped, since no poller
|
|
401
|
-
* re-ingests them — they arrive via inbox push and don't need dedup skeletons.
|
|
402
|
-
*
|
|
403
|
-
* @param {object} collection - MongoDB collection
|
|
404
|
-
* @param {ObjectId} channelObjectId - Channel ObjectId
|
|
405
|
-
* @param {string} userId - User ID
|
|
406
|
-
*/
|
|
407
|
-
async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
408
|
-
const readCount = await collection.countDocuments({
|
|
409
|
-
channelId: channelObjectId,
|
|
410
|
-
readBy: userId,
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
if (readCount > MAX_FULL_READ_ITEMS) {
|
|
414
|
-
// Find old read items beyond the retention limit
|
|
415
|
-
const itemsToCleanup = await collection
|
|
416
|
-
.find({
|
|
417
|
-
channelId: channelObjectId,
|
|
418
|
-
readBy: userId,
|
|
419
|
-
_stripped: { $ne: true },
|
|
420
|
-
})
|
|
421
|
-
.sort({ published: -1, _id: -1 })
|
|
422
|
-
.skip(MAX_FULL_READ_ITEMS)
|
|
423
|
-
.project({ _id: 1, feedId: 1 })
|
|
424
|
-
.toArray();
|
|
425
|
-
|
|
426
|
-
if (itemsToCleanup.length === 0) return;
|
|
427
|
-
|
|
428
|
-
// Separate AP items (feedId: null) from RSS items (feedId: ObjectId)
|
|
429
|
-
const apItemIds = [];
|
|
430
|
-
const rssItemIds = [];
|
|
431
|
-
for (const item of itemsToCleanup) {
|
|
432
|
-
if (item.feedId) {
|
|
433
|
-
rssItemIds.push(item._id);
|
|
434
|
-
} else {
|
|
435
|
-
apItemIds.push(item._id);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// Hard-delete AP items — no poller to re-ingest, skeletons are useless
|
|
440
|
-
if (apItemIds.length > 0) {
|
|
441
|
-
const deleted = await collection.deleteMany({
|
|
442
|
-
_id: { $in: apItemIds },
|
|
443
|
-
});
|
|
444
|
-
console.info(
|
|
445
|
-
`[Microsub] Deleted ${deleted.deletedCount} old AP read items`,
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Strip RSS items to dedup skeletons — poller would re-ingest if deleted
|
|
450
|
-
if (rssItemIds.length > 0) {
|
|
451
|
-
const stripped = await collection.updateMany(
|
|
452
|
-
{ _id: { $in: rssItemIds } },
|
|
453
|
-
{
|
|
454
|
-
$set: { _stripped: true },
|
|
455
|
-
$unset: {
|
|
456
|
-
name: "",
|
|
457
|
-
content: "",
|
|
458
|
-
summary: "",
|
|
459
|
-
author: "",
|
|
460
|
-
category: "",
|
|
461
|
-
photo: "",
|
|
462
|
-
video: "",
|
|
463
|
-
audio: "",
|
|
464
|
-
likeOf: "",
|
|
465
|
-
repostOf: "",
|
|
466
|
-
bookmarkOf: "",
|
|
467
|
-
inReplyTo: "",
|
|
468
|
-
source: "",
|
|
469
|
-
},
|
|
470
|
-
},
|
|
471
|
-
);
|
|
472
|
-
console.info(
|
|
473
|
-
`[Microsub] Stripped ${stripped.modifiedCount} old RSS read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
|
|
474
|
-
);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Cleanup all read items across all channels (startup cleanup).
|
|
481
|
-
* RSS items are stripped to dedup skeletons; AP items are hard-deleted.
|
|
482
|
-
* @param {object} application - Indiekit application
|
|
483
|
-
* @returns {Promise<number>} Total number of items cleaned up
|
|
484
|
-
*/
|
|
485
|
-
export async function cleanupAllReadItems(application) {
|
|
486
|
-
const collection = getCollection(application);
|
|
487
|
-
const channelsCollection = application.collections.get("microsub_channels");
|
|
488
|
-
|
|
489
|
-
const channels = await channelsCollection.find({}).toArray();
|
|
490
|
-
let totalCleaned = 0;
|
|
491
|
-
|
|
492
|
-
for (const channel of channels) {
|
|
493
|
-
const readByUsers = await collection.distinct("readBy", {
|
|
494
|
-
channelId: channel._id,
|
|
495
|
-
readBy: { $exists: true, $ne: [] },
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
for (const userId of readByUsers) {
|
|
499
|
-
if (!userId) continue;
|
|
500
|
-
|
|
501
|
-
const readCount = await collection.countDocuments({
|
|
502
|
-
channelId: channel._id,
|
|
503
|
-
readBy: userId,
|
|
504
|
-
_stripped: { $ne: true },
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
if (readCount > MAX_FULL_READ_ITEMS) {
|
|
508
|
-
const itemsToCleanup = await collection
|
|
509
|
-
.find({
|
|
510
|
-
channelId: channel._id,
|
|
511
|
-
readBy: userId,
|
|
512
|
-
_stripped: { $ne: true },
|
|
513
|
-
})
|
|
514
|
-
.sort({ published: -1, _id: -1 })
|
|
515
|
-
.skip(MAX_FULL_READ_ITEMS)
|
|
516
|
-
.project({ _id: 1, feedId: 1 })
|
|
517
|
-
.toArray();
|
|
518
|
-
|
|
519
|
-
if (itemsToCleanup.length > 0) {
|
|
520
|
-
const apItemIds = [];
|
|
521
|
-
const rssItemIds = [];
|
|
522
|
-
for (const item of itemsToCleanup) {
|
|
523
|
-
if (item.feedId) {
|
|
524
|
-
rssItemIds.push(item._id);
|
|
525
|
-
} else {
|
|
526
|
-
apItemIds.push(item._id);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Hard-delete AP items
|
|
531
|
-
if (apItemIds.length > 0) {
|
|
532
|
-
const deleted = await collection.deleteMany({
|
|
533
|
-
_id: { $in: apItemIds },
|
|
534
|
-
});
|
|
535
|
-
totalCleaned += deleted.deletedCount;
|
|
536
|
-
console.info(
|
|
537
|
-
`[Microsub] Startup cleanup: deleted ${deleted.deletedCount} AP items from channel "${channel.name}"`,
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Strip RSS items to skeletons
|
|
542
|
-
if (rssItemIds.length > 0) {
|
|
543
|
-
const stripped = await collection.updateMany(
|
|
544
|
-
{ _id: { $in: rssItemIds } },
|
|
545
|
-
{
|
|
546
|
-
$set: { _stripped: true },
|
|
547
|
-
$unset: {
|
|
548
|
-
name: "",
|
|
549
|
-
content: "",
|
|
550
|
-
summary: "",
|
|
551
|
-
author: "",
|
|
552
|
-
category: "",
|
|
553
|
-
photo: "",
|
|
554
|
-
video: "",
|
|
555
|
-
audio: "",
|
|
556
|
-
likeOf: "",
|
|
557
|
-
repostOf: "",
|
|
558
|
-
bookmarkOf: "",
|
|
559
|
-
inReplyTo: "",
|
|
560
|
-
source: "",
|
|
561
|
-
},
|
|
562
|
-
},
|
|
563
|
-
);
|
|
564
|
-
totalCleaned += stripped.modifiedCount;
|
|
565
|
-
console.info(
|
|
566
|
-
`[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} RSS items from channel "${channel.name}"`,
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
if (totalCleaned > 0) {
|
|
575
|
-
console.info(
|
|
576
|
-
`[Microsub] Startup cleanup complete: ${totalCleaned} total items cleaned`,
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
return totalCleaned;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Delete stale items: stripped skeletons and unread items older than MAX_ITEM_AGE_DAYS.
|
|
585
|
-
* Stripped skeletons have served their dedup purpose; stale unread items are unlikely
|
|
586
|
-
* to be read. Both are hard-deleted to prevent unbounded collection growth.
|
|
587
|
-
* @param {object} application - Indiekit application
|
|
588
|
-
* @returns {Promise<number>} Total number of items deleted
|
|
589
|
-
*/
|
|
590
|
-
export async function cleanupStaleItems(application) {
|
|
591
|
-
const collection = getCollection(application);
|
|
592
|
-
const cutoff = new Date();
|
|
593
|
-
cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
|
|
594
|
-
|
|
595
|
-
// Delete stripped skeletons older than cutoff
|
|
596
|
-
const strippedResult = await collection.deleteMany({
|
|
597
|
-
_stripped: true,
|
|
598
|
-
$or: [
|
|
599
|
-
{ published: { $lt: cutoff } },
|
|
600
|
-
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
601
|
-
],
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
// Delete unread items older than cutoff
|
|
605
|
-
const unreadResult = await collection.deleteMany({
|
|
606
|
-
readBy: { $in: [null, []] },
|
|
607
|
-
_stripped: { $ne: true },
|
|
608
|
-
$or: [
|
|
609
|
-
{ published: { $lt: cutoff } },
|
|
610
|
-
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
611
|
-
],
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
// Also catch items with no readBy field at all
|
|
615
|
-
const noReadByResult = await collection.deleteMany({
|
|
616
|
-
readBy: { $exists: false },
|
|
617
|
-
_stripped: { $ne: true },
|
|
618
|
-
$or: [
|
|
619
|
-
{ published: { $lt: cutoff } },
|
|
620
|
-
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
621
|
-
],
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
const total =
|
|
625
|
-
strippedResult.deletedCount +
|
|
626
|
-
unreadResult.deletedCount +
|
|
627
|
-
noReadByResult.deletedCount;
|
|
628
|
-
|
|
629
|
-
if (total > 0) {
|
|
630
|
-
console.info(
|
|
631
|
-
`[Microsub] Stale cleanup: deleted ${strippedResult.deletedCount} stripped skeletons, ` +
|
|
632
|
-
`${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
|
|
633
|
-
`(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
return total;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
641
|
-
const collection = getCollection(application);
|
|
642
|
-
const channelObjectId =
|
|
643
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
644
|
-
|
|
645
|
-
console.info(
|
|
646
|
-
`[Microsub] markItemsRead called for channel ${channelId}, entries:`,
|
|
647
|
-
entryIds,
|
|
648
|
-
`userId: ${userId}`,
|
|
649
|
-
);
|
|
650
|
-
|
|
651
|
-
// Handle "last-read-entry" special value
|
|
652
|
-
if (entryIds.includes("last-read-entry")) {
|
|
653
|
-
// Mark all items in channel as read
|
|
654
|
-
const result = await collection.updateMany(
|
|
655
|
-
{ channelId: channelObjectId },
|
|
656
|
-
{ $addToSet: { readBy: userId } },
|
|
657
|
-
);
|
|
658
|
-
console.info(
|
|
659
|
-
`[Microsub] Marked all items as read: ${result.modifiedCount} updated`,
|
|
660
|
-
);
|
|
661
|
-
|
|
662
|
-
// Cleanup old read items, keeping only the most recent
|
|
663
|
-
await cleanupOldReadItems(collection, channelObjectId, userId);
|
|
664
|
-
|
|
665
|
-
return result.modifiedCount;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Convert string IDs to ObjectIds where possible
|
|
669
|
-
const objectIds = entryIds
|
|
670
|
-
.map((id) => {
|
|
671
|
-
try {
|
|
672
|
-
return new ObjectId(id);
|
|
673
|
-
} catch {
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
})
|
|
677
|
-
.filter(Boolean);
|
|
678
|
-
|
|
679
|
-
// Build query to match by _id, uid, or url (Microsub spec uses URLs as entry identifiers)
|
|
680
|
-
const result = await collection.updateMany(
|
|
681
|
-
{
|
|
682
|
-
channelId: channelObjectId,
|
|
683
|
-
$or: [
|
|
684
|
-
...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
|
|
685
|
-
{ uid: { $in: entryIds } },
|
|
686
|
-
{ url: { $in: entryIds } },
|
|
687
|
-
],
|
|
688
|
-
},
|
|
689
|
-
{ $addToSet: { readBy: userId } },
|
|
690
|
-
);
|
|
691
|
-
|
|
692
|
-
console.info(
|
|
693
|
-
`[Microsub] markItemsRead result: ${result.modifiedCount} items updated`,
|
|
694
|
-
);
|
|
695
|
-
|
|
696
|
-
return result.modifiedCount;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Mark all items from a specific feed as read in a channel
|
|
701
|
-
* @param {object} application - Indiekit application
|
|
702
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
703
|
-
* @param {ObjectId|string} feedId - Feed ObjectId
|
|
704
|
-
* @param {string} userId - User ID
|
|
705
|
-
* @returns {Promise<number>} Number of items updated
|
|
706
|
-
*/
|
|
707
|
-
export async function markFeedItemsRead(
|
|
708
|
-
application,
|
|
709
|
-
channelId,
|
|
710
|
-
feedId,
|
|
711
|
-
userId,
|
|
712
|
-
) {
|
|
713
|
-
const collection = getCollection(application);
|
|
714
|
-
const channelObjectId =
|
|
715
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
716
|
-
const feedObjectId =
|
|
717
|
-
typeof feedId === "string" ? new ObjectId(feedId) : feedId;
|
|
718
|
-
|
|
719
|
-
const result = await collection.updateMany(
|
|
720
|
-
{ channelId: channelObjectId, feedId: feedObjectId },
|
|
721
|
-
{ $addToSet: { readBy: userId } },
|
|
722
|
-
);
|
|
723
|
-
|
|
724
|
-
console.info(
|
|
725
|
-
`[Microsub] markFeedItemsRead: marked ${result.modifiedCount} items from feed ${feedId} as read`,
|
|
726
|
-
);
|
|
727
|
-
|
|
728
|
-
// Cleanup old read items
|
|
729
|
-
await cleanupOldReadItems(collection, channelObjectId, userId);
|
|
730
|
-
|
|
731
|
-
return result.modifiedCount;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* Mark items as unread
|
|
736
|
-
* @param {object} application - Indiekit application
|
|
737
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
738
|
-
* @param {Array} entryIds - Array of entry IDs to mark as unread (can be ObjectId, uid, or URL)
|
|
739
|
-
* @param {string} userId - User ID
|
|
740
|
-
* @returns {Promise<number>} Number of items updated
|
|
741
|
-
*/
|
|
742
|
-
export async function markItemsUnread(
|
|
743
|
-
application,
|
|
744
|
-
channelId,
|
|
745
|
-
entryIds,
|
|
746
|
-
userId,
|
|
747
|
-
) {
|
|
748
|
-
const collection = getCollection(application);
|
|
749
|
-
const channelObjectId =
|
|
750
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
751
|
-
|
|
752
|
-
// Convert string IDs to ObjectIds where possible
|
|
753
|
-
const objectIds = entryIds
|
|
754
|
-
.map((id) => {
|
|
755
|
-
try {
|
|
756
|
-
return new ObjectId(id);
|
|
757
|
-
} catch {
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
})
|
|
761
|
-
.filter(Boolean);
|
|
762
|
-
|
|
763
|
-
// Match by _id, uid, or url
|
|
764
|
-
const result = await collection.updateMany(
|
|
765
|
-
{
|
|
766
|
-
channelId: channelObjectId,
|
|
767
|
-
$or: [
|
|
768
|
-
...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
|
|
769
|
-
{ uid: { $in: entryIds } },
|
|
770
|
-
{ url: { $in: entryIds } },
|
|
771
|
-
],
|
|
772
|
-
},
|
|
773
|
-
{ $pull: { readBy: userId } },
|
|
774
|
-
);
|
|
775
|
-
|
|
776
|
-
return result.modifiedCount;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
340
|
/**
|
|
780
341
|
* Remove items from channel
|
|
781
342
|
* @param {object} application - Indiekit application
|
|
@@ -841,58 +402,6 @@ export async function deleteItemsForFeed(application, feedId) {
|
|
|
841
402
|
return result.deletedCount;
|
|
842
403
|
}
|
|
843
404
|
|
|
844
|
-
import { UNREAD_RETENTION_DAYS } from "../utils/constants.js";
|
|
845
|
-
|
|
846
|
-
/**
|
|
847
|
-
* Get unread count for a channel
|
|
848
|
-
* @param {object} application - Indiekit application
|
|
849
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
850
|
-
* @param {string} userId - User ID
|
|
851
|
-
* @returns {Promise<number>} Unread count
|
|
852
|
-
*/
|
|
853
|
-
export async function getUnreadCount(application, channelId, userId) {
|
|
854
|
-
const collection = getCollection(application);
|
|
855
|
-
const objectId =
|
|
856
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
857
|
-
|
|
858
|
-
// Only count items from the last UNREAD_RETENTION_DAYS, exclude stripped skeletons
|
|
859
|
-
const cutoffDate = new Date();
|
|
860
|
-
cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
|
|
861
|
-
|
|
862
|
-
return collection.countDocuments({
|
|
863
|
-
channelId: objectId,
|
|
864
|
-
readBy: { $ne: userId },
|
|
865
|
-
published: { $gte: cutoffDate },
|
|
866
|
-
_stripped: { $ne: true },
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
/**
|
|
871
|
-
* Search items by text
|
|
872
|
-
* @param {object} application - Indiekit application
|
|
873
|
-
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
874
|
-
* @param {string} query - Search query
|
|
875
|
-
* @param {number} [limit] - Max results
|
|
876
|
-
* @returns {Promise<Array>} Array of matching items
|
|
877
|
-
*/
|
|
878
|
-
export async function searchItems(application, channelId, query, limit = 20) {
|
|
879
|
-
const collection = getCollection(application);
|
|
880
|
-
const objectId =
|
|
881
|
-
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
882
|
-
|
|
883
|
-
// Use MongoDB text index for efficient full-text search
|
|
884
|
-
const items = await collection
|
|
885
|
-
.find({
|
|
886
|
-
channelId: objectId,
|
|
887
|
-
$text: { $search: query },
|
|
888
|
-
})
|
|
889
|
-
.sort({ score: { $meta: "textScore" } })
|
|
890
|
-
.limit(limit)
|
|
891
|
-
.toArray();
|
|
892
|
-
|
|
893
|
-
return items.map((item) => transformToJf2(item));
|
|
894
|
-
}
|
|
895
|
-
|
|
896
405
|
/**
|
|
897
406
|
* Delete items by author URL (for blocking)
|
|
898
407
|
* @param {object} application - Indiekit application
|