@rmdes/indiekit-endpoint-microsub 1.0.25 → 1.0.27

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.
@@ -14,6 +14,7 @@ import {
14
14
  getFeedsForChannel,
15
15
  } from "../storage/feeds.js";
16
16
  import { getUserId } from "../utils/auth.js";
17
+ import { notifyBlogroll } from "../utils/blogroll-notify.js";
17
18
  import { createFeedResponse } from "../utils/jf2.js";
18
19
  import { validateChannel, validateUrl } from "../utils/validation.js";
19
20
  import {
@@ -78,6 +79,17 @@ export async function follow(request, response) {
78
79
  console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
79
80
  });
80
81
 
82
+ // Notify blogroll plugin (fire-and-forget)
83
+ notifyBlogroll(application, "follow", {
84
+ url,
85
+ title: feed.title,
86
+ channelName: channelDocument.name,
87
+ feedId: feed._id.toString(),
88
+ channelId: channelDocument._id.toString(),
89
+ }).catch((error) => {
90
+ console.error(`[Microsub] Blogroll notify error:`, error.message);
91
+ });
92
+
81
93
  response.status(201).json(createFeedResponse(feed));
82
94
  }
83
95
 
@@ -122,6 +134,11 @@ export async function unfollow(request, response) {
122
134
  throw new IndiekitError("Feed not found", { status: 404 });
123
135
  }
124
136
 
137
+ // Notify blogroll plugin (fire-and-forget)
138
+ notifyBlogroll(application, "unfollow", { url }).catch((error) => {
139
+ console.error(`[Microsub] Blogroll notify error:`, error.message);
140
+ });
141
+
125
142
  response.json({ result: "ok" });
126
143
  }
127
144
 
@@ -83,6 +83,9 @@ export async function createChannel(application, { name, userId }) {
83
83
  return channel;
84
84
  }
85
85
 
86
+ // Retention period for unread count (only count recent items)
87
+ const UNREAD_RETENTION_DAYS = 30;
88
+
86
89
  /**
87
90
  * Get all channels for a user
88
91
  * @param {object} application - Indiekit application
@@ -101,12 +104,17 @@ export async function getChannels(application, userId) {
101
104
  .sort({ order: 1 })
102
105
  .toArray();
103
106
 
104
- // Get unread counts for each channel
107
+ // Calculate cutoff date for unread counts (only count recent items)
108
+ const cutoffDate = new Date();
109
+ cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
110
+
111
+ // Get unread counts for each channel (only recent items)
105
112
  const channelsWithCounts = await Promise.all(
106
113
  channels.map(async (channel) => {
107
114
  const unreadCount = await itemsCollection.countDocuments({
108
115
  channelId: channel._id,
109
116
  readBy: { $ne: userId },
117
+ published: { $gte: cutoffDate },
110
118
  });
111
119
 
112
120
  return {
@@ -562,6 +562,9 @@ export async function deleteItemsForFeed(application, feedId) {
562
562
  return result.deletedCount;
563
563
  }
564
564
 
565
+ // Retention period for unread count (only count recent items)
566
+ const UNREAD_RETENTION_DAYS = 30;
567
+
565
568
  /**
566
569
  * Get unread count for a channel
567
570
  * @param {object} application - Indiekit application
@@ -574,9 +577,14 @@ export async function getUnreadCount(application, channelId, userId) {
574
577
  const objectId =
575
578
  typeof channelId === "string" ? new ObjectId(channelId) : channelId;
576
579
 
580
+ // Only count items from the last UNREAD_RETENTION_DAYS
581
+ const cutoffDate = new Date();
582
+ cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
583
+
577
584
  return collection.countDocuments({
578
585
  channelId: objectId,
579
586
  readBy: { $ne: userId },
587
+ published: { $gte: cutoffDate },
580
588
  });
581
589
  }
582
590
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Notify blogroll plugin of Microsub follow/unfollow events
3
+ * @module utils/blogroll-notify
4
+ */
5
+
6
+ /**
7
+ * Notify blogroll of a feed subscription change
8
+ * Fire-and-forget — errors are logged but don't block the response
9
+ * @param {object} application - Application instance
10
+ * @param {string} action - "follow" or "unfollow"
11
+ * @param {object} data - Feed data
12
+ * @param {string} data.url - Feed URL
13
+ * @param {string} [data.title] - Feed title
14
+ * @param {string} [data.channelName] - Channel name
15
+ * @param {string} [data.feedId] - Microsub feed ID
16
+ * @param {string} [data.channelId] - Microsub channel ID
17
+ */
18
+ export async function notifyBlogroll(application, action, data) {
19
+ // Check if blogroll plugin is installed
20
+ if (typeof application.getBlogrollDb !== "function") {
21
+ return;
22
+ }
23
+
24
+ const db = application.getBlogrollDb();
25
+ if (!db) {
26
+ return;
27
+ }
28
+
29
+ const collection = db.collection("blogrollBlogs");
30
+ const now = new Date();
31
+
32
+ if (action === "follow") {
33
+ // Skip if this feed was explicitly deleted by the user
34
+ const deleted = await collection.findOne({
35
+ feedUrl: data.url,
36
+ status: "deleted",
37
+ });
38
+ if (deleted) {
39
+ console.log(
40
+ `[Microsub→Blogroll] Skipping follow for ${data.url} — previously deleted by user`,
41
+ );
42
+ return;
43
+ }
44
+
45
+ // Upsert the blog entry
46
+ await collection.updateOne(
47
+ { feedUrl: data.url },
48
+ {
49
+ $set: {
50
+ title: data.title || extractDomain(data.url),
51
+ siteUrl: extractSiteUrl(data.url),
52
+ feedType: "rss",
53
+ category: data.channelName || "Microsub",
54
+ source: "microsub",
55
+ microsubFeedId: data.feedId || null,
56
+ microsubChannelId: data.channelId || null,
57
+ microsubChannelName: data.channelName || null,
58
+ skipItemFetch: true,
59
+ status: "active",
60
+ updatedAt: now,
61
+ },
62
+ $setOnInsert: {
63
+ description: null,
64
+ tags: [],
65
+ photo: null,
66
+ author: null,
67
+ lastFetchAt: null,
68
+ lastError: null,
69
+ itemCount: 0,
70
+ pinned: false,
71
+ hidden: false,
72
+ notes: null,
73
+ createdAt: now,
74
+ },
75
+ },
76
+ { upsert: true },
77
+ );
78
+
79
+ console.log(`[Microsub→Blogroll] Added/updated feed ${data.url}`);
80
+ } else if (action === "unfollow") {
81
+ // Soft-delete the blog entry if it came from microsub
82
+ const result = await collection.updateOne(
83
+ {
84
+ feedUrl: data.url,
85
+ source: "microsub",
86
+ status: { $ne: "deleted" },
87
+ },
88
+ {
89
+ $set: {
90
+ status: "deleted",
91
+ hidden: true,
92
+ deletedAt: now,
93
+ updatedAt: now,
94
+ },
95
+ },
96
+ );
97
+
98
+ if (result.modifiedCount > 0) {
99
+ console.log(`[Microsub→Blogroll] Soft-deleted feed ${data.url}`);
100
+ }
101
+ }
102
+ }
103
+
104
+ function extractDomain(url) {
105
+ try {
106
+ return new URL(url).hostname.replace(/^www\./, "");
107
+ } catch {
108
+ return url;
109
+ }
110
+ }
111
+
112
+ function extractSiteUrl(feedUrl) {
113
+ try {
114
+ const parsed = new URL(feedUrl);
115
+ return `${parsed.protocol}//${parsed.host}`;
116
+ } catch {
117
+ return "";
118
+ }
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",