@rmdes/indiekit-endpoint-microsub 1.0.61 → 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.
@@ -3,18 +3,21 @@
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
- // Maximum age (in days) for stripped skeletons and unread items.
12
- // After this period, both are hard-deleted to prevent unbounded growth.
13
- const MAX_ITEM_AGE_DAYS = 30;
9
+ // Global retention defaults. Each can be overridden per channel via
10
+ // channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}. The "notifications"
11
+ // channel is exempt from these caps entirely — webmentions are high-signal and
12
+ // users may want long history there.
13
+ export const DEFAULT_MAX_ITEMS = 1000;
14
+ export const DEFAULT_MAX_ITEMS_PER_FEED = 50;
15
+ export const DEFAULT_MAX_UNREAD_AGE_DAYS = 30;
14
16
 
15
17
  /**
16
18
  * Cleanup all read items across all channels (startup cleanup).
17
- * RSS items are stripped to dedup skeletons; AP items are hard-deleted.
19
+ * Read items beyond MAX_FULL_READ_ITEMS are stripped to skeletons (kept for
20
+ * dedup, content removed).
18
21
  * @param {object} application - Indiekit application
19
22
  * @returns {Promise<number>} Total number of items cleaned up
20
23
  */
@@ -49,59 +52,36 @@ export async function cleanupAllReadItems(application) {
49
52
  })
50
53
  .sort({ published: -1, _id: -1 })
51
54
  .skip(MAX_FULL_READ_ITEMS)
52
- .project({ _id: 1, feedId: 1 })
55
+ .project({ _id: 1 })
53
56
  .toArray();
54
57
 
55
58
  if (itemsToCleanup.length > 0) {
56
- const apItemIds = [];
57
- const rssItemIds = [];
58
- for (const item of itemsToCleanup) {
59
- if (item.feedId) {
60
- rssItemIds.push(item._id);
61
- } else {
62
- apItemIds.push(item._id);
63
- }
64
- }
65
-
66
- // Hard-delete AP items
67
- if (apItemIds.length > 0) {
68
- const deleted = await collection.deleteMany({
69
- _id: { $in: apItemIds },
70
- });
71
- totalCleaned += deleted.deletedCount;
72
- console.info(
73
- `[Microsub] Startup cleanup: deleted ${deleted.deletedCount} AP items from channel "${channel.name}"`,
74
- );
75
- }
76
-
77
- // Strip RSS items to skeletons
78
- if (rssItemIds.length > 0) {
79
- const stripped = await collection.updateMany(
80
- { _id: { $in: rssItemIds } },
81
- {
82
- $set: { _stripped: true },
83
- $unset: {
84
- name: "",
85
- content: "",
86
- summary: "",
87
- author: "",
88
- category: "",
89
- photo: "",
90
- video: "",
91
- audio: "",
92
- likeOf: "",
93
- repostOf: "",
94
- bookmarkOf: "",
95
- inReplyTo: "",
96
- source: "",
97
- },
59
+ const ids = itemsToCleanup.map((item) => item._id);
60
+ const stripped = await collection.updateMany(
61
+ { _id: { $in: ids } },
62
+ {
63
+ $set: { _stripped: true },
64
+ $unset: {
65
+ name: "",
66
+ content: "",
67
+ summary: "",
68
+ author: "",
69
+ category: "",
70
+ photo: "",
71
+ video: "",
72
+ audio: "",
73
+ likeOf: "",
74
+ repostOf: "",
75
+ bookmarkOf: "",
76
+ inReplyTo: "",
77
+ source: "",
98
78
  },
99
- );
100
- totalCleaned += stripped.modifiedCount;
101
- console.info(
102
- `[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} RSS items from channel "${channel.name}"`,
103
- );
104
- }
79
+ },
80
+ );
81
+ totalCleaned += stripped.modifiedCount;
82
+ console.info(
83
+ `[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} items from channel "${channel.name}"`,
84
+ );
105
85
  }
106
86
  }
107
87
  }
@@ -117,58 +97,187 @@ export async function cleanupAllReadItems(application) {
117
97
  }
118
98
 
119
99
  /**
120
- * Delete stale items: stripped skeletons and unread items older than MAX_ITEM_AGE_DAYS.
121
- * Stripped skeletons have served their dedup purpose; stale unread items are unlikely
122
- * to be read. Both are hard-deleted to prevent unbounded collection growth.
100
+ * Per-channel retention cleanup. For each channel (excluding `notifications`):
101
+ * 1. Drop unread items + stripped skeletons older than `maxUnreadAgeDays`.
102
+ * 2. Per-feed cap: keep most recent `maxItemsPerFeed` items per feed, drop the rest.
103
+ * 3. Channel-wide cap: keep most recent `maxItems` items total, drop the rest.
104
+ *
105
+ * Each channel uses its own `channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}`
106
+ * when present; otherwise the module-level defaults apply. This makes the policy
107
+ * configurable per channel — a noisy aggregator channel can set tight caps while
108
+ * a low-volume curated channel keeps a long tail.
109
+ *
110
+ * The order matters: per-feed cap runs before channel cap so a single prolific
111
+ * feed cannot starve other feeds in the channel of representation after the
112
+ * channel-wide trim.
113
+ *
123
114
  * @param {object} application - Indiekit application
124
- * @returns {Promise<number>} Total number of items deleted
115
+ * @returns {Promise<number>} Total number of items deleted across all channels
125
116
  */
126
117
  export async function cleanupStaleItems(application) {
127
- const collection = getCollection(application);
128
- const cutoff = new Date();
129
- cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
130
-
131
- // Delete stripped skeletons older than cutoff
132
- const strippedResult = await collection.deleteMany({
133
- _stripped: true,
134
- $or: [
135
- { published: { $lt: cutoff } },
136
- { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
137
- ],
138
- });
118
+ const itemsCollection = getCollection(application);
119
+ const channelsCollection = application.collections.get("microsub_channels");
139
120
 
140
- // Delete unread items older than cutoff
141
- const unreadResult = await collection.deleteMany({
142
- readBy: { $in: [null, []] },
143
- _stripped: { $ne: true },
144
- $or: [
145
- { published: { $lt: cutoff } },
146
- { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
147
- ],
148
- });
121
+ const channels = await channelsCollection.find({}).toArray();
122
+ let totalDeleted = 0;
149
123
 
150
- // Also catch items with no readBy field at all
151
- const noReadByResult = await collection.deleteMany({
152
- readBy: { $exists: false },
153
- _stripped: { $ne: true },
154
- $or: [
155
- { published: { $lt: cutoff } },
156
- { published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
157
- ],
158
- });
124
+ for (const channel of channels) {
125
+ // Notifications channel (webmentions) is exempt — high-signal, kept indefinitely.
126
+ if (channel.uid === "notifications") continue;
127
+
128
+ const settings = channel.settings || {};
129
+ const maxItems = settings.maxItems ?? DEFAULT_MAX_ITEMS;
130
+ const maxItemsPerFeed =
131
+ settings.maxItemsPerFeed ?? DEFAULT_MAX_ITEMS_PER_FEED;
132
+ const maxUnreadAgeDays =
133
+ settings.maxUnreadAgeDays ?? DEFAULT_MAX_UNREAD_AGE_DAYS;
134
+
135
+ const cutoff = new Date();
136
+ cutoff.setDate(cutoff.getDate() - maxUnreadAgeDays);
137
+ const cutoffIso = cutoff.toISOString();
138
+ let channelDeleted = 0;
139
+ let staleDeleted = 0;
140
+ let perFeedDeleted = 0;
141
+ let channelCapDeleted = 0;
142
+
143
+ // 1. Drop stripped skeletons older than cutoff (served their dedup purpose).
144
+ const strippedResult = await itemsCollection.deleteMany({
145
+ channelId: channel._id,
146
+ _stripped: true,
147
+ $or: [
148
+ { published: { $lt: cutoff } },
149
+ {
150
+ published: { $exists: false },
151
+ createdAt: { $lt: cutoffIso },
152
+ },
153
+ ],
154
+ });
155
+ staleDeleted += strippedResult.deletedCount;
156
+
157
+ // 1b. Drop unread (or never-read) items older than cutoff.
158
+ const unreadAgeResult = await itemsCollection.deleteMany({
159
+ channelId: channel._id,
160
+ _stripped: { $ne: true },
161
+ $and: [
162
+ {
163
+ $or: [
164
+ { readBy: { $exists: false } },
165
+ { readBy: { $size: 0 } },
166
+ { readBy: null },
167
+ ],
168
+ },
169
+ {
170
+ $or: [
171
+ { published: { $lt: cutoff } },
172
+ {
173
+ published: { $exists: false },
174
+ createdAt: { $lt: cutoffIso },
175
+ },
176
+ ],
177
+ },
178
+ ],
179
+ });
180
+ staleDeleted += unreadAgeResult.deletedCount;
181
+ channelDeleted += staleDeleted;
182
+
183
+ // 2. Per-feed cap. Iterate feeds in the channel; for each, delete oldest
184
+ // items beyond maxItemsPerFeed regardless of read state.
185
+ const feedIds = await itemsCollection.distinct("feedId", {
186
+ channelId: channel._id,
187
+ feedId: { $exists: true, $ne: null },
188
+ });
189
+
190
+ for (const feedId of feedIds) {
191
+ const excess = await itemsCollection
192
+ .find({ channelId: channel._id, feedId })
193
+ .sort({ published: -1, _id: -1 })
194
+ .skip(maxItemsPerFeed)
195
+ .project({ _id: 1 })
196
+ .toArray();
197
+
198
+ if (excess.length > 0) {
199
+ const ids = excess.map((item) => item._id);
200
+ const result = await itemsCollection.deleteMany({
201
+ _id: { $in: ids },
202
+ });
203
+ perFeedDeleted += result.deletedCount;
204
+ channelDeleted += result.deletedCount;
205
+ }
206
+ }
207
+
208
+ // 3. Channel-wide cap. Catches items without feedId plus anything still over
209
+ // the per-channel ceiling after the per-feed pass.
210
+ const excessChannel = await itemsCollection
211
+ .find({ channelId: channel._id })
212
+ .sort({ published: -1, _id: -1 })
213
+ .skip(maxItems)
214
+ .project({ _id: 1 })
215
+ .toArray();
216
+
217
+ if (excessChannel.length > 0) {
218
+ const ids = excessChannel.map((item) => item._id);
219
+ const result = await itemsCollection.deleteMany({ _id: { $in: ids } });
220
+ channelCapDeleted += result.deletedCount;
221
+ channelDeleted += result.deletedCount;
222
+ }
159
223
 
160
- const total =
161
- strippedResult.deletedCount +
162
- unreadResult.deletedCount +
163
- noReadByResult.deletedCount;
224
+ if (channelDeleted > 0) {
225
+ console.info(
226
+ `[Microsub] Retention cleanup "${channel.name}": deleted ${channelDeleted} items ` +
227
+ `(stale: ${staleDeleted}, per-feed: ${perFeedDeleted}, channel-cap: ${channelCapDeleted}; ` +
228
+ `maxItems=${maxItems}, maxItemsPerFeed=${maxItemsPerFeed}, maxUnreadAgeDays=${maxUnreadAgeDays})`,
229
+ );
230
+ }
231
+ totalDeleted += channelDeleted;
232
+ }
164
233
 
165
- if (total > 0) {
234
+ if (totalDeleted > 0) {
166
235
  console.info(
167
- `[Microsub] Stale cleanup: deleted ${strippedResult.deletedCount} stripped skeletons, ` +
168
- `${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
169
- `(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
236
+ `[Microsub] Retention cleanup complete: ${totalDeleted} total items deleted across ${channels.length} channels`,
170
237
  );
171
238
  }
172
239
 
173
- return total;
240
+ return totalDeleted;
241
+ }
242
+
243
+ /**
244
+ * One-time migration: remove the abandoned "Fediverse" channel and its items.
245
+ * The microsub reader briefly tried to ingest ActivityPub outboxes into a
246
+ * dedicated channel (uid: "activitypub"). That feature was abandoned — fediverse
247
+ * federation lives entirely in the separate `indiekit-endpoint-activitypub`
248
+ * plugin now. This migration cleans up the leftover channel and items.
249
+ * Idempotent — safe to run on every startup.
250
+ * @param {object} application - Indiekit application
251
+ * @returns {Promise<{ channelsRemoved: number, itemsRemoved: number }>}
252
+ */
253
+ export async function removeActivityPubData(application) {
254
+ const itemsCollection = getCollection(application);
255
+ const channelsCollection = application.collections.get("microsub_channels");
256
+
257
+ const apChannels = await channelsCollection
258
+ .find({ uid: "activitypub" })
259
+ .toArray();
260
+
261
+ if (apChannels.length === 0) {
262
+ return { channelsRemoved: 0, itemsRemoved: 0 };
263
+ }
264
+
265
+ const channelIds = apChannels.map((c) => c._id);
266
+
267
+ const itemsResult = await itemsCollection.deleteMany({
268
+ channelId: { $in: channelIds },
269
+ });
270
+
271
+ const channelsResult = await channelsCollection.deleteMany({
272
+ _id: { $in: channelIds },
273
+ });
274
+
275
+ console.info(
276
+ `[Microsub] Removed abandoned Fediverse channel: ${channelsResult.deletedCount} channel(s), ${itemsResult.deletedCount} item(s)`,
277
+ );
278
+
279
+ return {
280
+ channelsRemoved: channelsResult.deletedCount,
281
+ itemsRemoved: itemsResult.deletedCount,
282
+ };
174
283
  }
@@ -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
 
@@ -163,6 +163,31 @@ export function validateExcludeTypes(types) {
163
163
  return types.filter((type) => VALID_EXCLUDE_TYPES.includes(type));
164
164
  }
165
165
 
166
+ /**
167
+ * Validate a per-channel retention numeric setting.
168
+ * Accepts a positive integer; returns undefined for empty/invalid input so the
169
+ * caller can fall back to the global default.
170
+ * @param {string|number|undefined} value - Raw form value
171
+ * @param {object} [options]
172
+ * @param {number} [options.min] - Minimum allowed value (inclusive). Default 1.
173
+ * @param {number} [options.max] - Maximum allowed value (inclusive). Default 1_000_000.
174
+ * @returns {number|undefined} Validated integer, or undefined if empty/invalid
175
+ */
176
+ export function validateRetentionSetting(value, options = {}) {
177
+ const { min = 1, max = 1_000_000 } = options;
178
+
179
+ if (value === undefined || value === null || value === "") {
180
+ return undefined;
181
+ }
182
+
183
+ const parsed = Number.parseInt(String(value), 10);
184
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
185
+ return undefined;
186
+ }
187
+
188
+ return parsed;
189
+ }
190
+
166
191
  /**
167
192
  * Validate regex pattern
168
193
  * @param {string} pattern - Regex pattern to validate
@@ -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",
@@ -75,6 +61,14 @@
75
61
  "excludeRegex": "Exclude pattern",
76
62
  "excludeRegexHelp": "Regular expression to filter out matching content",
77
63
  "save": "Save settings",
64
+ "retentionTitle": "Retention",
65
+ "retentionHelp": "Control how many items this channel keeps in MongoDB. Useful for high-volume aggregator channels that would otherwise grow without bound. Leave any field blank to use the global default.",
66
+ "maxItems": "Maximum items in channel",
67
+ "maxItemsHelp": "Keep at most this many items total. Oldest items are deleted regardless of read state. Default: {{default}}.",
68
+ "maxItemsPerFeed": "Maximum items per feed",
69
+ "maxItemsPerFeedHelp": "Keep at most this many items per feed inside this channel. Prevents one prolific feed from monopolising the channel cap. Default: {{default}}.",
70
+ "maxUnreadAgeDays": "Drop unread items older than (days)",
71
+ "maxUnreadAgeDaysHelp": "Unread items older than this are deleted, even if the channel is below its item cap. Default: {{default}}.",
78
72
  "dangerZone": "Danger zone",
79
73
  "deleteWarning": "Deleting this channel will permanently remove all feeds and items. This action cannot be undone.",
80
74
  "deleteConfirm": "Are you sure you want to delete this channel and all its content?",
@@ -93,20 +87,10 @@
93
87
  "submit": "Search",
94
88
  "noResults": "No results found"
95
89
  },
96
- "preview": {
97
- "title": "Preview",
98
- "subscribe": "Subscribe to this feed"
99
- },
100
90
  "views": {
101
91
  "channels": "Channels",
102
92
  "deck": "Deck",
103
93
  "timeline": "Timeline"
104
- },
105
- "error": {
106
- "channelNotFound": "Channel not found",
107
- "feedNotFound": "Feed not found",
108
- "invalidUrl": "Invalid URL",
109
- "invalidAction": "Invalid action"
110
94
  }
111
95
  }
112
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.61",
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",