@rmdes/indiekit-endpoint-microsub 1.0.63 → 1.0.64

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.
@@ -10,7 +10,6 @@ import {
10
10
  updateChannelSettings,
11
11
  deleteChannel,
12
12
  } from "../../storage/channels.js";
13
- import { getFeedsForChannel } from "../../storage/feeds.js";
14
13
  import { getTimelineItems } from "../../storage/items.js";
15
14
  import { countReadItems } from "../../storage/items-read-state.js";
16
15
  import { getUserId } from "../../utils/auth.js";
@@ -35,7 +35,7 @@ export function startScheduler(indiekit) {
35
35
  // Run immediately on start
36
36
  runSchedulerCycle();
37
37
 
38
- console.log("[Microsub] Feed polling scheduler started");
38
+ console.info("[Microsub] Feed polling scheduler started");
39
39
  }
40
40
 
41
41
  /**
@@ -47,7 +47,7 @@ export function stopScheduler() {
47
47
  schedulerInterval = undefined;
48
48
  }
49
49
  indiekitInstance = undefined;
50
- console.log("[Microsub] Feed polling scheduler stopped");
50
+ console.info("[Microsub] Feed polling scheduler stopped");
51
51
  }
52
52
 
53
53
  /**
@@ -74,13 +74,13 @@ async function runSchedulerCycle() {
74
74
  return;
75
75
  }
76
76
 
77
- console.log(`[Microsub] Processing ${feeds.length} feeds due for refresh`);
77
+ console.info(`[Microsub] Processing ${feeds.length} feeds due for refresh`);
78
78
 
79
79
  const result = await processFeedBatch(application, feeds, {
80
80
  concurrency: BATCH_CONCURRENCY,
81
81
  });
82
82
 
83
- console.log(
83
+ console.info(
84
84
  `[Microsub] Processed ${result.total} feeds: ${result.successful} successful, ` +
85
85
  `${result.failed} failed, ${result.itemsAdded} new items`,
86
86
  );
@@ -5,22 +5,17 @@
5
5
 
6
6
  import { ObjectId } from "mongodb";
7
7
 
8
- import { UNREAD_RETENTION_DAYS } from "../utils/constants.js";
8
+ import {
9
+ MAX_FULL_READ_ITEMS,
10
+ UNREAD_RETENTION_DAYS,
11
+ } from "../utils/constants.js";
9
12
  import { getCollection } from "./items.js";
10
13
 
11
- // Maximum number of full read items to keep per channel before stripping content.
12
- // Items beyond this limit are converted to lightweight dedup skeletons (channelId,
13
- // uid, readBy) so the poller doesn't re-ingest them as new unread entries.
14
- const MAX_FULL_READ_ITEMS = 200;
15
-
16
14
  /**
17
15
  * Cleanup old read items by stripping content but preserving dedup skeletons.
18
- * This prevents the vicious cycle where deleted read items get re-ingested as
16
+ * Prevents the vicious cycle where deleted read items get re-ingested as
19
17
  * unread by the poller because the dedup record (channelId + uid) was destroyed.
20
18
  *
21
- * AP items (feedId: null) are hard-deleted instead of stripped, since no poller
22
- * re-ingests them — they arrive via inbox push and don't need dedup skeletons.
23
- *
24
19
  * @param {object} collection - MongoDB collection
25
20
  * @param {ObjectId} channelObjectId - Channel ObjectId
26
21
  * @param {string} userId - User ID
@@ -32,7 +27,6 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
32
27
  });
33
28
 
34
29
  if (readCount > MAX_FULL_READ_ITEMS) {
35
- // Find old read items beyond the retention limit
36
30
  const itemsToCleanup = await collection
37
31
  .find({
38
32
  channelId: channelObjectId,
@@ -41,59 +35,36 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
41
35
  })
42
36
  .sort({ published: -1, _id: -1 })
43
37
  .skip(MAX_FULL_READ_ITEMS)
44
- .project({ _id: 1, feedId: 1 })
38
+ .project({ _id: 1 })
45
39
  .toArray();
46
40
 
47
41
  if (itemsToCleanup.length === 0) return;
48
42
 
49
- // Separate AP items (feedId: null) from RSS items (feedId: ObjectId)
50
- const apItemIds = [];
51
- const rssItemIds = [];
52
- for (const item of itemsToCleanup) {
53
- if (item.feedId) {
54
- rssItemIds.push(item._id);
55
- } else {
56
- apItemIds.push(item._id);
57
- }
58
- }
59
-
60
- // Hard-delete AP items — no poller to re-ingest, skeletons are useless
61
- if (apItemIds.length > 0) {
62
- const deleted = await collection.deleteMany({
63
- _id: { $in: apItemIds },
64
- });
65
- console.info(
66
- `[Microsub] Deleted ${deleted.deletedCount} old AP read items`,
67
- );
68
- }
69
-
70
- // Strip RSS items to dedup skeletons — poller would re-ingest if deleted
71
- if (rssItemIds.length > 0) {
72
- const stripped = await collection.updateMany(
73
- { _id: { $in: rssItemIds } },
74
- {
75
- $set: { _stripped: true },
76
- $unset: {
77
- name: "",
78
- content: "",
79
- summary: "",
80
- author: "",
81
- category: "",
82
- photo: "",
83
- video: "",
84
- audio: "",
85
- likeOf: "",
86
- repostOf: "",
87
- bookmarkOf: "",
88
- inReplyTo: "",
89
- source: "",
90
- },
43
+ const ids = itemsToCleanup.map((item) => item._id);
44
+ const stripped = await collection.updateMany(
45
+ { _id: { $in: ids } },
46
+ {
47
+ $set: { _stripped: true },
48
+ $unset: {
49
+ name: "",
50
+ content: "",
51
+ summary: "",
52
+ author: "",
53
+ category: "",
54
+ photo: "",
55
+ video: "",
56
+ audio: "",
57
+ likeOf: "",
58
+ repostOf: "",
59
+ bookmarkOf: "",
60
+ inReplyTo: "",
61
+ source: "",
91
62
  },
92
- );
93
- console.info(
94
- `[Microsub] Stripped ${stripped.modifiedCount} old RSS read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
95
- );
96
- }
63
+ },
64
+ );
65
+ console.info(
66
+ `[Microsub] Stripped ${stripped.modifiedCount} old read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
67
+ );
97
68
  }
98
69
  }
99
70
 
@@ -3,11 +3,9 @@
3
3
  * @module storage/items-retention
4
4
  */
5
5
 
6
+ import { MAX_FULL_READ_ITEMS } from "../utils/constants.js";
6
7
  import { getCollection } from "./items.js";
7
8
 
8
- // Maximum number of full read items to keep per channel before stripping content.
9
- const MAX_FULL_READ_ITEMS = 200;
10
-
11
9
  // Global retention defaults. Each can be overridden per channel via
12
10
  // channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}. The "notifications"
13
11
  // channel is exempt from these caps entirely — webmentions are high-signal and
@@ -36,7 +36,7 @@ export async function notifyBlogroll(application, action, data) {
36
36
  status: "deleted",
37
37
  });
38
38
  if (deleted) {
39
- console.log(
39
+ console.info(
40
40
  `[Microsub→Blogroll] Skipping follow for ${data.url} — previously deleted by user`,
41
41
  );
42
42
  return;
@@ -76,7 +76,7 @@ export async function notifyBlogroll(application, action, data) {
76
76
  { upsert: true },
77
77
  );
78
78
 
79
- console.log(`[Microsub→Blogroll] Added/updated feed ${data.url}`);
79
+ console.info(`[Microsub→Blogroll] Added/updated feed ${data.url}`);
80
80
  } else if (action === "unfollow") {
81
81
  // Soft-delete the blog entry if it came from microsub
82
82
  const result = await collection.updateOne(
@@ -96,7 +96,7 @@ export async function notifyBlogroll(application, action, data) {
96
96
  );
97
97
 
98
98
  if (result.modifiedCount > 0) {
99
- console.log(`[Microsub→Blogroll] Soft-deleted feed ${data.url}`);
99
+ console.info(`[Microsub→Blogroll] Soft-deleted feed ${data.url}`);
100
100
  }
101
101
  }
102
102
  }
@@ -5,3 +5,10 @@
5
5
 
6
6
  /** Retention period for unread count queries (only count recent items) */
7
7
  export const UNREAD_RETENTION_DAYS = 30;
8
+
9
+ /**
10
+ * Maximum number of full read items to keep per channel/user before stripping
11
+ * content. Items beyond this limit are converted to lightweight dedup skeletons
12
+ * (channelId, uid, readBy) so the poller doesn't re-ingest them as new unread.
13
+ */
14
+ export const MAX_FULL_READ_ITEMS = 200;
package/lib/utils/jf2.js CHANGED
@@ -29,115 +29,6 @@ export function generateChannelUid() {
29
29
  return result;
30
30
  }
31
31
 
32
- /**
33
- * Create a jf2 Item from normalized feed data
34
- * @param {object} data - Normalized item data
35
- * @param {object} source - Feed source metadata
36
- * @returns {object} jf2 Item object
37
- */
38
- export function createJf2Item(data, source) {
39
- return {
40
- type: "entry",
41
- uid: data.uid,
42
- url: data.url,
43
- name: data.name || undefined,
44
- content: data.content || undefined,
45
- summary: data.summary || undefined,
46
- published: data.published,
47
- updated: data.updated || undefined,
48
- author: data.author || undefined,
49
- category: data.category || [],
50
- photo: data.photo || [],
51
- video: data.video || [],
52
- audio: data.audio || [],
53
- // Interaction types
54
- "like-of": data.likeOf || [],
55
- "repost-of": data.repostOf || [],
56
- "bookmark-of": data.bookmarkOf || [],
57
- "in-reply-to": data.inReplyTo || [],
58
- // Internal properties (prefixed with _)
59
- _id: data._id,
60
- _is_read: data._is_read || false,
61
- _source: source,
62
- };
63
- }
64
-
65
- /**
66
- * Create a jf2 Card (author/person)
67
- * @param {object} data - Author data
68
- * @returns {object} jf2 Card object
69
- */
70
- export function createJf2Card(data) {
71
- if (!data) return;
72
-
73
- return {
74
- type: "card",
75
- name: data.name || undefined,
76
- url: data.url || undefined,
77
- photo: data.photo || undefined,
78
- };
79
- }
80
-
81
- /**
82
- * Create a jf2 Content object
83
- * @param {string} text - Plain text content
84
- * @param {string} html - HTML content
85
- * @returns {object|undefined} jf2 Content object
86
- */
87
- export function createJf2Content(text, html) {
88
- if (!text && !html) return;
89
-
90
- return {
91
- text: text || stripHtml(html),
92
- html: html || undefined,
93
- };
94
- }
95
-
96
- /**
97
- * Strip HTML tags from string
98
- * @param {string} html - HTML string
99
- * @returns {string} Plain text
100
- */
101
- export function stripHtml(html) {
102
- if (!html) return "";
103
- return html.replaceAll(/<[^>]*>/g, "").trim();
104
- }
105
-
106
- /**
107
- * Create a jf2 Feed response
108
- * @param {object} options - Feed options
109
- * @param {Array} options.items - Array of jf2 items
110
- * @param {object} options.paging - Pagination cursors
111
- * @returns {object} jf2 Feed object
112
- */
113
- export function createJf2Feed({ items, paging }) {
114
- const feed = {
115
- items: items || [],
116
- };
117
-
118
- if (paging) {
119
- feed.paging = {};
120
- if (paging.before) feed.paging.before = paging.before;
121
- if (paging.after) feed.paging.after = paging.after;
122
- }
123
-
124
- return feed;
125
- }
126
-
127
- /**
128
- * Create a Channel response object
129
- * @param {object} channel - Channel data
130
- * @param {number} unreadCount - Number of unread items
131
- * @returns {object} Channel object for API response
132
- */
133
- export function createChannelResponse(channel, unreadCount = 0) {
134
- return {
135
- uid: channel.uid,
136
- name: channel.name,
137
- unread: unreadCount > 0 ? unreadCount : false,
138
- };
139
- }
140
-
141
32
  /**
142
33
  * Create a Feed response object
143
34
  * @param {object} feed - Feed data
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Shared HTML sanitization configuration
3
- * Used by both RSS/Atom normalizer and ActivityPub outbox fetcher
2
+ * Shared HTML sanitization configuration used by the feed normalizers.
4
3
  * @module utils/sanitize
5
4
  */
6
5
 
@@ -30,7 +30,7 @@ export async function processWebmention(application, source, target, userId) {
30
30
  const verification = await verifyWebmention(source, target);
31
31
 
32
32
  if (!verification.verified) {
33
- console.log(
33
+ console.info(
34
34
  `[Microsub] Webmention verification failed: ${verification.error}`,
35
35
  );
36
36
  return {
@@ -87,7 +87,7 @@ export async function processWebmention(application, source, target, userId) {
87
87
  });
88
88
  }
89
89
 
90
- console.log(
90
+ console.info(
91
91
  `[Microsub] Webmention processed: ${verification.type} from ${source}`,
92
92
  );
93
93
 
@@ -64,7 +64,7 @@ export async function verify(request, response) {
64
64
  });
65
65
  }
66
66
 
67
- console.log(`[Microsub] WebSub subscription verified for ${feed.url}`);
67
+ console.info(`[Microsub] WebSub subscription verified for ${feed.url}`);
68
68
 
69
69
  // Return challenge to verify subscription
70
70
  response.type("text/plain").send(challenge);
@@ -143,7 +143,7 @@ async function processWebsubContent(application, feed, contentType, body) {
143
143
  // Parse the pushed content
144
144
  const parsed = await parseFeed(content, feed.url, { contentType });
145
145
 
146
- console.log(
146
+ console.info(
147
147
  `[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
148
148
  );
149
149
 
package/locales/en.json CHANGED
@@ -10,7 +10,6 @@
10
10
  "showRead": "Show read ({{count}})",
11
11
  "hideRead": "Hide read items",
12
12
  "allRead": "All caught up!",
13
- "newer": "Newer",
14
13
  "older": "Older"
15
14
  },
16
15
  "channels": {
@@ -18,34 +17,21 @@
18
17
  "name": "Channel name",
19
18
  "new": "New channel",
20
19
  "create": "Create channel",
21
- "delete": "Delete channel",
22
20
  "settings": "Channel settings",
23
- "empty": "No channels yet. Create one to get started.",
24
- "notifications": "Notifications"
21
+ "empty": "No channels yet. Create one to get started."
25
22
  },
26
23
  "timeline": {
27
- "title": "Timeline",
28
24
  "empty": "No items in this channel",
29
- "markRead": "Mark as read",
30
- "markUnread": "Mark as unread",
31
- "remove": "Remove"
25
+ "markRead": "Mark as read"
32
26
  },
33
27
  "feeds": {
34
28
  "title": "Feeds",
35
29
  "follow": "Follow",
36
30
  "subscribe": "Subscribe to a feed",
37
- "unfollow": "Unfollow",
38
31
  "empty": "No feeds followed in this channel",
39
32
  "url": "Feed URL",
40
33
  "urlPlaceholder": "https://example.com/feed.xml",
41
- "edit": "Edit feed",
42
- "rediscover": "Rediscover feed",
43
- "refresh": "Refresh now",
44
- "status": {
45
- "active": "Active",
46
- "error": "Error",
47
- "stale": "Stale"
48
- }
34
+ "edit": "Edit feed"
49
35
  },
50
36
  "item": {
51
37
  "reply": "Reply",
@@ -101,20 +87,10 @@
101
87
  "submit": "Search",
102
88
  "noResults": "No results found"
103
89
  },
104
- "preview": {
105
- "title": "Preview",
106
- "subscribe": "Subscribe to this feed"
107
- },
108
90
  "views": {
109
91
  "channels": "Channels",
110
92
  "deck": "Deck",
111
93
  "timeline": "Timeline"
112
- },
113
- "error": {
114
- "channelNotFound": "Channel not found",
115
- "feedNotFound": "Feed not found",
116
- "invalidUrl": "Invalid URL",
117
- "invalidAction": "Invalid action"
118
94
  }
119
95
  }
120
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.63",
3
+ "version": "1.0.64",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -1,90 +0,0 @@
1
- /**
2
- * Search indexer for MongoDB text search
3
- * @module search/indexer
4
- */
5
-
6
- /**
7
- * Create text indexes for microsub items
8
- * @param {object} application - Indiekit application
9
- * @returns {Promise<void>}
10
- */
11
- export async function createSearchIndexes(application) {
12
- const itemsCollection = application.collections.get("microsub_items");
13
-
14
- // Create compound text index for full-text search
15
- await itemsCollection.createIndex(
16
- {
17
- name: "text",
18
- "content.text": "text",
19
- "content.html": "text",
20
- summary: "text",
21
- "author.name": "text",
22
- },
23
- {
24
- name: "text_search",
25
- weights: {
26
- name: 10,
27
- "content.text": 5,
28
- summary: 3,
29
- "author.name": 2,
30
- },
31
- default_language: "english",
32
- background: true,
33
- },
34
- );
35
-
36
- // Create index for channel + published for efficient timeline queries
37
- await itemsCollection.createIndex(
38
- { channelId: 1, published: -1 },
39
- { name: "channel_timeline" },
40
- );
41
-
42
- // Create index for deduplication
43
- await itemsCollection.createIndex(
44
- { channelId: 1, uid: 1 },
45
- { name: "channel_uid", unique: true },
46
- );
47
-
48
- // Create index for feed-based queries
49
- await itemsCollection.createIndex({ feedId: 1 }, { name: "feed_items" });
50
- }
51
-
52
- /**
53
- * Rebuild search indexes (drops and recreates)
54
- * @param {object} application - Indiekit application
55
- * @returns {Promise<void>}
56
- */
57
- export async function rebuildSearchIndexes(application) {
58
- const itemsCollection = application.collections.get("microsub_items");
59
-
60
- // Drop existing text index
61
- try {
62
- await itemsCollection.dropIndex("text_search");
63
- } catch {
64
- // Index may not exist
65
- }
66
-
67
- // Recreate indexes
68
- await createSearchIndexes(application);
69
- }
70
-
71
- /**
72
- * Get search index stats
73
- * @param {object} application - Indiekit application
74
- * @returns {Promise<object>} Index statistics
75
- */
76
- export async function getSearchIndexStats(application) {
77
- const itemsCollection = application.collections.get("microsub_items");
78
-
79
- const indexes = await itemsCollection.indexes();
80
- const stats = await itemsCollection.stats();
81
-
82
- return {
83
- indexes: indexes.map((index) => ({
84
- name: index.name,
85
- key: index.key,
86
- })),
87
- totalDocuments: stats.count,
88
- size: stats.size,
89
- };
90
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Timeline item search
3
- * @module storage/items-search
4
- */
5
-
6
- import { ObjectId } from "mongodb";
7
-
8
- import { getCollection, transformToJf2 } from "./items.js";
9
-
10
- /**
11
- * Search items by text
12
- * @param {object} application - Indiekit application
13
- * @param {ObjectId|string} channelId - Channel ObjectId
14
- * @param {string} query - Search query
15
- * @param {number} [limit] - Max results
16
- * @returns {Promise<Array>} Array of matching items
17
- */
18
- export async function searchItems(application, channelId, query, limit = 20) {
19
- const collection = getCollection(application);
20
- const objectId =
21
- typeof channelId === "string" ? new ObjectId(channelId) : channelId;
22
-
23
- // Use MongoDB text index for efficient full-text search
24
- const items = await collection
25
- .find({
26
- channelId: objectId,
27
- $text: { $search: query },
28
- })
29
- .sort({ score: { $meta: "textScore" } })
30
- .limit(limit)
31
- .toArray();
32
-
33
- return items.map((item) => transformToJf2(item));
34
- }