@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.
Files changed (51) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +37 -36
  3. package/lib/cache/redis.js +12 -3
  4. package/lib/controllers/reader/actor.js +142 -0
  5. package/lib/controllers/reader/channel.js +301 -0
  6. package/lib/controllers/reader/compose.js +242 -0
  7. package/lib/controllers/reader/deck.js +129 -0
  8. package/lib/controllers/reader/feed-repair.js +117 -0
  9. package/lib/controllers/reader/feed.js +246 -0
  10. package/lib/controllers/reader/index.js +126 -0
  11. package/lib/controllers/reader/search.js +157 -0
  12. package/lib/controllers/reader/timeline.js +250 -0
  13. package/lib/controllers/timeline.js +4 -2
  14. package/lib/feeds/atom.js +1 -1
  15. package/lib/feeds/fetcher.js +1 -30
  16. package/lib/feeds/hfeed.js +1 -1
  17. package/lib/feeds/jsonfeed.js +1 -1
  18. package/lib/feeds/normalizer-hfeed.js +209 -0
  19. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  20. package/lib/feeds/normalizer-rss.js +178 -0
  21. package/lib/feeds/normalizer.js +20 -560
  22. package/lib/feeds/rss.js +1 -1
  23. package/lib/polling/processor.js +3 -17
  24. package/lib/storage/items-read-state.js +287 -0
  25. package/lib/storage/items-retention.js +174 -0
  26. package/lib/storage/items-search.js +34 -0
  27. package/lib/storage/items.js +99 -590
  28. package/lib/storage/read-state.js +1 -1
  29. package/lib/utils/async-handler.js +7 -0
  30. package/lib/utils/html.js +25 -0
  31. package/lib/utils/source-type.js +28 -0
  32. package/lib/webmention/processor.js +1 -1
  33. package/locales/de.json +3 -0
  34. package/locales/en.json +2 -0
  35. package/locales/es-419.json +3 -0
  36. package/locales/es.json +3 -0
  37. package/locales/fr.json +3 -0
  38. package/locales/hi.json +3 -0
  39. package/locales/id.json +3 -0
  40. package/locales/it.json +3 -0
  41. package/locales/nl.json +3 -0
  42. package/locales/pl.json +3 -0
  43. package/locales/pt-BR.json +3 -0
  44. package/locales/pt.json +3 -0
  45. package/locales/sr.json +3 -0
  46. package/locales/sv.json +3 -0
  47. package/locales/zh-Hans-CN.json +3 -0
  48. package/package.json +1 -1
  49. package/views/channel.njk +1 -348
  50. package/views/timeline.njk +3 -274
  51. package/lib/controllers/reader.js +0 -1562
@@ -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
- * Extract image URLs from HTML content (fallback for items without explicit photos)
17
- * @param {string} html - HTML content
18
- * @returns {string[]} Array of image URLs
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 extractImagesFromHtml(html) {
21
- if (!html) {
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
- const urls = [];
25
- const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
26
- let match;
27
- while ((match = imgRegex.exec(html)) !== null) {
28
- const src = match[1];
29
- if (src && !urls.includes(src)) {
30
- urls.push(src);
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 urls;
63
+ return {
64
+ ...author,
65
+ photo: extractMediaUrl(author.photo),
66
+ };
34
67
  }
35
68
 
36
69
  /**
37
- * Get items collection from application
38
- * @param {object} application - Indiekit application
39
- * @returns {object} MongoDB collection
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 getCollection(application) {
42
- return application.collections.get("microsub_items");
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