@rmdes/indiekit-endpoint-microsub 1.0.0-beta.1
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/README.md +111 -0
- package/index.js +140 -0
- package/lib/cache/redis.js +133 -0
- package/lib/controllers/block.js +85 -0
- package/lib/controllers/channels.js +135 -0
- package/lib/controllers/events.js +56 -0
- package/lib/controllers/follow.js +108 -0
- package/lib/controllers/microsub.js +138 -0
- package/lib/controllers/mute.js +124 -0
- package/lib/controllers/preview.js +67 -0
- package/lib/controllers/reader.js +218 -0
- package/lib/controllers/search.js +142 -0
- package/lib/controllers/timeline.js +117 -0
- package/lib/feeds/atom.js +61 -0
- package/lib/feeds/fetcher.js +205 -0
- package/lib/feeds/hfeed.js +177 -0
- package/lib/feeds/jsonfeed.js +43 -0
- package/lib/feeds/normalizer.js +586 -0
- package/lib/feeds/parser.js +124 -0
- package/lib/feeds/rss.js +61 -0
- package/lib/polling/processor.js +201 -0
- package/lib/polling/scheduler.js +128 -0
- package/lib/polling/tier.js +139 -0
- package/lib/realtime/broker.js +241 -0
- package/lib/search/indexer.js +90 -0
- package/lib/search/query.js +197 -0
- package/lib/storage/channels.js +281 -0
- package/lib/storage/feeds.js +286 -0
- package/lib/storage/filters.js +265 -0
- package/lib/storage/items.js +419 -0
- package/lib/storage/read-state.js +109 -0
- package/lib/utils/jf2.js +170 -0
- package/lib/utils/pagination.js +157 -0
- package/lib/utils/validation.js +217 -0
- package/lib/webmention/processor.js +214 -0
- package/lib/webmention/receiver.js +54 -0
- package/lib/webmention/verifier.js +308 -0
- package/lib/websub/discovery.js +129 -0
- package/lib/websub/handler.js +163 -0
- package/lib/websub/subscriber.js +181 -0
- package/locales/en.json +80 -0
- package/package.json +54 -0
- package/views/channel-new.njk +33 -0
- package/views/channel.njk +41 -0
- package/views/compose.njk +61 -0
- package/views/item.njk +85 -0
- package/views/partials/actions.njk +15 -0
- package/views/partials/author.njk +17 -0
- package/views/partials/item-card.njk +65 -0
- package/views/partials/timeline.njk +10 -0
- package/views/reader.njk +37 -0
- package/views/settings.njk +81 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter storage operations (mute, block, channel filters)
|
|
3
|
+
* @module storage/filters
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ObjectId } from "mongodb";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get muted collection
|
|
10
|
+
* @param {object} application - Indiekit application
|
|
11
|
+
* @returns {object} MongoDB collection
|
|
12
|
+
*/
|
|
13
|
+
function getMutedCollection(application) {
|
|
14
|
+
return application.collections.get("microsub_muted");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get blocked collection
|
|
19
|
+
* @param {object} application - Indiekit application
|
|
20
|
+
* @returns {object} MongoDB collection
|
|
21
|
+
*/
|
|
22
|
+
function getBlockedCollection(application) {
|
|
23
|
+
return application.collections.get("microsub_blocked");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a URL is muted for a user/channel
|
|
28
|
+
* @param {object} application - Indiekit application
|
|
29
|
+
* @param {string} userId - User ID
|
|
30
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
31
|
+
* @param {string} url - URL to check
|
|
32
|
+
* @returns {Promise<boolean>} Whether the URL is muted
|
|
33
|
+
*/
|
|
34
|
+
export async function isMuted(application, userId, channelId, url) {
|
|
35
|
+
const collection = getMutedCollection(application);
|
|
36
|
+
const channelObjectId =
|
|
37
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
38
|
+
|
|
39
|
+
// Check for channel-specific mute
|
|
40
|
+
const channelMute = await collection.findOne({
|
|
41
|
+
userId,
|
|
42
|
+
channelId: channelObjectId,
|
|
43
|
+
url,
|
|
44
|
+
});
|
|
45
|
+
if (channelMute) return true;
|
|
46
|
+
|
|
47
|
+
// Check for global mute (no channelId)
|
|
48
|
+
const globalMute = await collection.findOne({
|
|
49
|
+
userId,
|
|
50
|
+
channelId: { $exists: false },
|
|
51
|
+
url,
|
|
52
|
+
});
|
|
53
|
+
return !!globalMute;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if a URL is blocked for a user
|
|
58
|
+
* @param {object} application - Indiekit application
|
|
59
|
+
* @param {string} userId - User ID
|
|
60
|
+
* @param {string} url - URL to check
|
|
61
|
+
* @returns {Promise<boolean>} Whether the URL is blocked
|
|
62
|
+
*/
|
|
63
|
+
export async function isBlocked(application, userId, url) {
|
|
64
|
+
const collection = getBlockedCollection(application);
|
|
65
|
+
const blocked = await collection.findOne({ userId, url });
|
|
66
|
+
return !!blocked;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if an item passes all filters
|
|
71
|
+
* @param {object} application - Indiekit application
|
|
72
|
+
* @param {string} userId - User ID
|
|
73
|
+
* @param {object} channel - Channel document with settings
|
|
74
|
+
* @param {object} item - Feed item to check
|
|
75
|
+
* @returns {Promise<boolean>} Whether the item passes all filters
|
|
76
|
+
*/
|
|
77
|
+
export async function passesAllFilters(application, userId, channel, item) {
|
|
78
|
+
// Check if author URL is blocked
|
|
79
|
+
if (
|
|
80
|
+
item.author?.url &&
|
|
81
|
+
(await isBlocked(application, userId, item.author.url))
|
|
82
|
+
) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check if source URL is muted
|
|
87
|
+
if (
|
|
88
|
+
item._source?.url &&
|
|
89
|
+
(await isMuted(application, userId, channel._id, item._source.url))
|
|
90
|
+
) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check channel settings filters
|
|
95
|
+
if (channel?.settings) {
|
|
96
|
+
// Check excludeTypes
|
|
97
|
+
if (!passesTypeFilter(item, channel.settings)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check excludeRegex
|
|
102
|
+
if (!passesRegexFilter(item, channel.settings)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if an item passes the excludeTypes filter
|
|
112
|
+
* @param {object} item - Feed item
|
|
113
|
+
* @param {object} settings - Channel settings
|
|
114
|
+
* @returns {boolean} Whether the item passes
|
|
115
|
+
*/
|
|
116
|
+
export function passesTypeFilter(item, settings) {
|
|
117
|
+
if (!settings.excludeTypes || settings.excludeTypes.length === 0) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const itemType = detectInteractionType(item);
|
|
122
|
+
return !settings.excludeTypes.includes(itemType);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if an item passes the excludeRegex filter
|
|
127
|
+
* @param {object} item - Feed item
|
|
128
|
+
* @param {object} settings - Channel settings
|
|
129
|
+
* @returns {boolean} Whether the item passes
|
|
130
|
+
*/
|
|
131
|
+
export function passesRegexFilter(item, settings) {
|
|
132
|
+
if (!settings.excludeRegex) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const regex = new RegExp(settings.excludeRegex, "i");
|
|
138
|
+
const searchText = [
|
|
139
|
+
item.name,
|
|
140
|
+
item.summary,
|
|
141
|
+
item.content?.text,
|
|
142
|
+
item.content?.html,
|
|
143
|
+
]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join(" ");
|
|
146
|
+
|
|
147
|
+
return !regex.test(searchText);
|
|
148
|
+
} catch {
|
|
149
|
+
// Invalid regex, skip filter
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Detect the interaction type of an item
|
|
156
|
+
* @param {object} item - Feed item
|
|
157
|
+
* @returns {string} Interaction type
|
|
158
|
+
*/
|
|
159
|
+
export function detectInteractionType(item) {
|
|
160
|
+
if (item["like-of"] && item["like-of"].length > 0) {
|
|
161
|
+
return "like";
|
|
162
|
+
}
|
|
163
|
+
if (item["repost-of"] && item["repost-of"].length > 0) {
|
|
164
|
+
return "repost";
|
|
165
|
+
}
|
|
166
|
+
if (item["bookmark-of"] && item["bookmark-of"].length > 0) {
|
|
167
|
+
return "bookmark";
|
|
168
|
+
}
|
|
169
|
+
if (item["in-reply-to"] && item["in-reply-to"].length > 0) {
|
|
170
|
+
return "reply";
|
|
171
|
+
}
|
|
172
|
+
if (item.rsvp) {
|
|
173
|
+
return "rsvp";
|
|
174
|
+
}
|
|
175
|
+
if (item.checkin) {
|
|
176
|
+
return "checkin";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return "post";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get all muted URLs for a user/channel
|
|
184
|
+
* @param {object} application - Indiekit application
|
|
185
|
+
* @param {string} userId - User ID
|
|
186
|
+
* @param {ObjectId|string} [channelId] - Channel ObjectId (optional, for channel-specific)
|
|
187
|
+
* @returns {Promise<Array>} Array of muted URLs
|
|
188
|
+
*/
|
|
189
|
+
export async function getMutedUrls(application, userId, channelId) {
|
|
190
|
+
const collection = getMutedCollection(application);
|
|
191
|
+
const filter = { userId };
|
|
192
|
+
|
|
193
|
+
if (channelId) {
|
|
194
|
+
const channelObjectId =
|
|
195
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
196
|
+
filter.channelId = channelObjectId;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
|
|
200
|
+
const muted = await collection.find(filter).toArray();
|
|
201
|
+
return muted.map((m) => m.url);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get all blocked URLs for a user
|
|
206
|
+
* @param {object} application - Indiekit application
|
|
207
|
+
* @param {string} userId - User ID
|
|
208
|
+
* @returns {Promise<Array>} Array of blocked URLs
|
|
209
|
+
*/
|
|
210
|
+
export async function getBlockedUrls(application, userId) {
|
|
211
|
+
const collection = getBlockedCollection(application);
|
|
212
|
+
const blocked = await collection.find({ userId }).toArray();
|
|
213
|
+
return blocked.map((b) => b.url);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Update channel filter settings
|
|
218
|
+
* @param {object} application - Indiekit application
|
|
219
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
220
|
+
* @param {object} filters - Filter settings to update
|
|
221
|
+
* @param {Array} [filters.excludeTypes] - Post types to exclude
|
|
222
|
+
* @param {string} [filters.excludeRegex] - Regex pattern to exclude
|
|
223
|
+
* @returns {Promise<object>} Updated channel
|
|
224
|
+
*/
|
|
225
|
+
export async function updateChannelFilters(application, channelId, filters) {
|
|
226
|
+
const collection = application.collections.get("microsub_channels");
|
|
227
|
+
const channelObjectId =
|
|
228
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
229
|
+
|
|
230
|
+
const updateFields = {};
|
|
231
|
+
|
|
232
|
+
if (filters.excludeTypes !== undefined) {
|
|
233
|
+
updateFields["settings.excludeTypes"] = filters.excludeTypes;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (filters.excludeRegex !== undefined) {
|
|
237
|
+
updateFields["settings.excludeRegex"] = filters.excludeRegex;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const result = await collection.findOneAndUpdate(
|
|
241
|
+
{ _id: channelObjectId },
|
|
242
|
+
{ $set: updateFields },
|
|
243
|
+
{ returnDocument: "after" },
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Create indexes for filter collections
|
|
251
|
+
* @param {object} application - Indiekit application
|
|
252
|
+
* @returns {Promise<void>}
|
|
253
|
+
*/
|
|
254
|
+
export async function createFilterIndexes(application) {
|
|
255
|
+
const mutedCollection = getMutedCollection(application);
|
|
256
|
+
const blockedCollection = getBlockedCollection(application);
|
|
257
|
+
|
|
258
|
+
// Muted collection indexes
|
|
259
|
+
await mutedCollection.createIndex({ userId: 1, channelId: 1, url: 1 });
|
|
260
|
+
await mutedCollection.createIndex({ userId: 1 });
|
|
261
|
+
|
|
262
|
+
// Blocked collection indexes
|
|
263
|
+
await blockedCollection.createIndex({ userId: 1, url: 1 }, { unique: true });
|
|
264
|
+
await blockedCollection.createIndex({ userId: 1 });
|
|
265
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline item storage operations
|
|
3
|
+
* @module storage/items
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ObjectId } from "mongodb";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
buildPaginationQuery,
|
|
10
|
+
buildPaginationSort,
|
|
11
|
+
generatePagingCursors,
|
|
12
|
+
parseLimit,
|
|
13
|
+
} from "../utils/pagination.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get items collection from application
|
|
17
|
+
* @param {object} application - Indiekit application
|
|
18
|
+
* @returns {object} MongoDB collection
|
|
19
|
+
*/
|
|
20
|
+
function getCollection(application) {
|
|
21
|
+
return application.collections.get("microsub_items");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Add an item to a channel
|
|
26
|
+
* @param {object} application - Indiekit application
|
|
27
|
+
* @param {object} data - Item data
|
|
28
|
+
* @param {ObjectId} data.channelId - Channel ObjectId
|
|
29
|
+
* @param {ObjectId} data.feedId - Feed ObjectId
|
|
30
|
+
* @param {string} data.uid - Unique item identifier
|
|
31
|
+
* @param {object} data.item - jf2 item data
|
|
32
|
+
* @returns {Promise<object|null>} Created item or null if duplicate
|
|
33
|
+
*/
|
|
34
|
+
export async function addItem(application, { channelId, feedId, uid, item }) {
|
|
35
|
+
const collection = getCollection(application);
|
|
36
|
+
|
|
37
|
+
// Check for duplicate
|
|
38
|
+
const existing = await collection.findOne({ channelId, uid });
|
|
39
|
+
if (existing) {
|
|
40
|
+
return; // Duplicate, don't add
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const document = {
|
|
44
|
+
channelId,
|
|
45
|
+
feedId,
|
|
46
|
+
uid,
|
|
47
|
+
type: item.type || "entry",
|
|
48
|
+
url: item.url,
|
|
49
|
+
name: item.name || undefined,
|
|
50
|
+
content: item.content || undefined,
|
|
51
|
+
summary: item.summary || undefined,
|
|
52
|
+
published: item.published ? new Date(item.published) : new Date(),
|
|
53
|
+
updated: item.updated ? new Date(item.updated) : undefined,
|
|
54
|
+
author: item.author || undefined,
|
|
55
|
+
category: item.category || [],
|
|
56
|
+
photo: item.photo || [],
|
|
57
|
+
video: item.video || [],
|
|
58
|
+
audio: item.audio || [],
|
|
59
|
+
likeOf: item["like-of"] || item.likeOf || [],
|
|
60
|
+
repostOf: item["repost-of"] || item.repostOf || [],
|
|
61
|
+
bookmarkOf: item["bookmark-of"] || item.bookmarkOf || [],
|
|
62
|
+
inReplyTo: item["in-reply-to"] || item.inReplyTo || [],
|
|
63
|
+
source: item._source || undefined,
|
|
64
|
+
readBy: [], // Array of user IDs who have read this item
|
|
65
|
+
createdAt: new Date(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
await collection.insertOne(document);
|
|
69
|
+
return document;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get timeline items for a channel
|
|
74
|
+
* @param {object} application - Indiekit application
|
|
75
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
76
|
+
* @param {object} options - Query options
|
|
77
|
+
* @param {string} [options.before] - Before cursor
|
|
78
|
+
* @param {string} [options.after] - After cursor
|
|
79
|
+
* @param {number} [options.limit] - Items per page
|
|
80
|
+
* @param {string} [options.userId] - User ID for read state
|
|
81
|
+
* @returns {Promise<object>} Timeline with items and paging
|
|
82
|
+
*/
|
|
83
|
+
export async function getTimelineItems(application, channelId, options = {}) {
|
|
84
|
+
const collection = getCollection(application);
|
|
85
|
+
const objectId =
|
|
86
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
87
|
+
const limit = parseLimit(options.limit);
|
|
88
|
+
|
|
89
|
+
const baseQuery = { channelId: objectId };
|
|
90
|
+
const query = buildPaginationQuery({
|
|
91
|
+
before: options.before,
|
|
92
|
+
after: options.after,
|
|
93
|
+
baseQuery,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const sort = buildPaginationSort(options.before);
|
|
97
|
+
|
|
98
|
+
// Fetch one extra to check if there are more
|
|
99
|
+
const items = await collection
|
|
100
|
+
// eslint-disable-next-line unicorn/no-array-callback-reference -- query is MongoDB query object
|
|
101
|
+
.find(query)
|
|
102
|
+
.toSorted(sort)
|
|
103
|
+
.limit(limit + 1)
|
|
104
|
+
.toArray();
|
|
105
|
+
|
|
106
|
+
const hasMore = items.length > limit;
|
|
107
|
+
if (hasMore) {
|
|
108
|
+
items.pop();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Transform to jf2 format
|
|
112
|
+
const jf2Items = items.map((item) => transformToJf2(item, options.userId));
|
|
113
|
+
|
|
114
|
+
// Generate paging cursors
|
|
115
|
+
const paging = generatePagingCursors(items, limit, hasMore, options.before);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
items: jf2Items,
|
|
119
|
+
paging,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Transform database item to jf2 format
|
|
125
|
+
* @param {object} item - Database item
|
|
126
|
+
* @param {string} [userId] - User ID for read state
|
|
127
|
+
* @returns {object} jf2 item
|
|
128
|
+
*/
|
|
129
|
+
function transformToJf2(item, userId) {
|
|
130
|
+
const jf2 = {
|
|
131
|
+
type: item.type,
|
|
132
|
+
uid: item.uid,
|
|
133
|
+
url: item.url,
|
|
134
|
+
published: item.published?.toISOString(),
|
|
135
|
+
_id: item._id.toString(),
|
|
136
|
+
_is_read: userId ? item.readBy?.includes(userId) : false,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Optional fields
|
|
140
|
+
if (item.name) jf2.name = item.name;
|
|
141
|
+
if (item.content) jf2.content = item.content;
|
|
142
|
+
if (item.summary) jf2.summary = item.summary;
|
|
143
|
+
if (item.updated) jf2.updated = item.updated.toISOString();
|
|
144
|
+
if (item.author) jf2.author = item.author;
|
|
145
|
+
if (item.category?.length > 0) jf2.category = item.category;
|
|
146
|
+
if (item.photo?.length > 0) jf2.photo = item.photo;
|
|
147
|
+
if (item.video?.length > 0) jf2.video = item.video;
|
|
148
|
+
if (item.audio?.length > 0) jf2.audio = item.audio;
|
|
149
|
+
|
|
150
|
+
// Interaction types
|
|
151
|
+
if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf;
|
|
152
|
+
if (item.repostOf?.length > 0) jf2["repost-of"] = item.repostOf;
|
|
153
|
+
if (item.bookmarkOf?.length > 0) jf2["bookmark-of"] = item.bookmarkOf;
|
|
154
|
+
if (item.inReplyTo?.length > 0) jf2["in-reply-to"] = item.inReplyTo;
|
|
155
|
+
|
|
156
|
+
// Source
|
|
157
|
+
if (item.source) jf2._source = item.source;
|
|
158
|
+
|
|
159
|
+
return jf2;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get an item by ID
|
|
164
|
+
* @param {object} application - Indiekit application
|
|
165
|
+
* @param {ObjectId|string} id - Item ObjectId
|
|
166
|
+
* @param {string} [userId] - User ID for read state
|
|
167
|
+
* @returns {Promise<object|null>} jf2 item or null
|
|
168
|
+
*/
|
|
169
|
+
export async function getItemById(application, id, userId) {
|
|
170
|
+
const collection = getCollection(application);
|
|
171
|
+
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
|
172
|
+
|
|
173
|
+
const item = await collection.findOne({ _id: objectId });
|
|
174
|
+
if (!item) return;
|
|
175
|
+
|
|
176
|
+
return transformToJf2(item, userId);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get items by UIDs
|
|
181
|
+
* @param {object} application - Indiekit application
|
|
182
|
+
* @param {Array} uids - Array of item UIDs
|
|
183
|
+
* @param {string} [userId] - User ID for read state
|
|
184
|
+
* @returns {Promise<Array>} Array of jf2 items
|
|
185
|
+
*/
|
|
186
|
+
export async function getItemsByUids(application, uids, userId) {
|
|
187
|
+
const collection = getCollection(application);
|
|
188
|
+
|
|
189
|
+
const items = await collection.find({ uid: { $in: uids } }).toArray();
|
|
190
|
+
return items.map((item) => transformToJf2(item, userId));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Mark items as read
|
|
195
|
+
* @param {object} application - Indiekit application
|
|
196
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
197
|
+
* @param {Array} entryIds - Array of entry IDs to mark as read
|
|
198
|
+
* @param {string} userId - User ID
|
|
199
|
+
* @returns {Promise<number>} Number of items updated
|
|
200
|
+
*/
|
|
201
|
+
export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
202
|
+
const collection = getCollection(application);
|
|
203
|
+
const channelObjectId =
|
|
204
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
205
|
+
|
|
206
|
+
// Handle "last-read-entry" special value
|
|
207
|
+
if (entryIds.includes("last-read-entry")) {
|
|
208
|
+
// Mark all items in channel as read
|
|
209
|
+
const result = await collection.updateMany(
|
|
210
|
+
{ channelId: channelObjectId },
|
|
211
|
+
{ $addToSet: { readBy: userId } },
|
|
212
|
+
);
|
|
213
|
+
return result.modifiedCount;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Convert string IDs to ObjectIds where possible
|
|
217
|
+
const objectIds = entryIds.map((id) => {
|
|
218
|
+
try {
|
|
219
|
+
return new ObjectId(id);
|
|
220
|
+
} catch {
|
|
221
|
+
return id;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = await collection.updateMany(
|
|
226
|
+
{
|
|
227
|
+
channelId: channelObjectId,
|
|
228
|
+
$or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
|
|
229
|
+
},
|
|
230
|
+
{ $addToSet: { readBy: userId } },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return result.modifiedCount;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Mark items as unread
|
|
238
|
+
* @param {object} application - Indiekit application
|
|
239
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
240
|
+
* @param {Array} entryIds - Array of entry IDs to mark as unread
|
|
241
|
+
* @param {string} userId - User ID
|
|
242
|
+
* @returns {Promise<number>} Number of items updated
|
|
243
|
+
*/
|
|
244
|
+
export async function markItemsUnread(
|
|
245
|
+
application,
|
|
246
|
+
channelId,
|
|
247
|
+
entryIds,
|
|
248
|
+
userId,
|
|
249
|
+
) {
|
|
250
|
+
const collection = getCollection(application);
|
|
251
|
+
const channelObjectId =
|
|
252
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
253
|
+
|
|
254
|
+
const objectIds = entryIds.map((id) => {
|
|
255
|
+
try {
|
|
256
|
+
return new ObjectId(id);
|
|
257
|
+
} catch {
|
|
258
|
+
return id;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const result = await collection.updateMany(
|
|
263
|
+
{
|
|
264
|
+
channelId: channelObjectId,
|
|
265
|
+
$or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
|
|
266
|
+
},
|
|
267
|
+
{ $pull: { readBy: userId } },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return result.modifiedCount;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Remove items from channel
|
|
275
|
+
* @param {object} application - Indiekit application
|
|
276
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
277
|
+
* @param {Array} entryIds - Array of entry IDs to remove
|
|
278
|
+
* @returns {Promise<number>} Number of items removed
|
|
279
|
+
*/
|
|
280
|
+
export async function removeItems(application, channelId, entryIds) {
|
|
281
|
+
const collection = getCollection(application);
|
|
282
|
+
const channelObjectId =
|
|
283
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
284
|
+
|
|
285
|
+
const objectIds = entryIds.map((id) => {
|
|
286
|
+
try {
|
|
287
|
+
return new ObjectId(id);
|
|
288
|
+
} catch {
|
|
289
|
+
return id;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const result = await collection.deleteMany({
|
|
294
|
+
channelId: channelObjectId,
|
|
295
|
+
$or: [{ _id: { $in: objectIds } }, { uid: { $in: entryIds } }],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return result.deletedCount;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Delete all items for a channel
|
|
303
|
+
* @param {object} application - Indiekit application
|
|
304
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
305
|
+
* @returns {Promise<number>} Number of deleted items
|
|
306
|
+
*/
|
|
307
|
+
export async function deleteItemsForChannel(application, channelId) {
|
|
308
|
+
const collection = getCollection(application);
|
|
309
|
+
const objectId =
|
|
310
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
311
|
+
|
|
312
|
+
const result = await collection.deleteMany({ channelId: objectId });
|
|
313
|
+
return result.deletedCount;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Delete items for a specific feed
|
|
318
|
+
* @param {object} application - Indiekit application
|
|
319
|
+
* @param {ObjectId|string} feedId - Feed ObjectId
|
|
320
|
+
* @returns {Promise<number>} Number of deleted items
|
|
321
|
+
*/
|
|
322
|
+
export async function deleteItemsForFeed(application, feedId) {
|
|
323
|
+
const collection = getCollection(application);
|
|
324
|
+
const objectId = typeof feedId === "string" ? new ObjectId(feedId) : feedId;
|
|
325
|
+
|
|
326
|
+
const result = await collection.deleteMany({ feedId: objectId });
|
|
327
|
+
return result.deletedCount;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get unread count for a channel
|
|
332
|
+
* @param {object} application - Indiekit application
|
|
333
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
334
|
+
* @param {string} userId - User ID
|
|
335
|
+
* @returns {Promise<number>} Unread count
|
|
336
|
+
*/
|
|
337
|
+
export async function getUnreadCount(application, channelId, userId) {
|
|
338
|
+
const collection = getCollection(application);
|
|
339
|
+
const objectId =
|
|
340
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
341
|
+
|
|
342
|
+
return collection.countDocuments({
|
|
343
|
+
channelId: objectId,
|
|
344
|
+
readBy: { $ne: userId },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Search items by text
|
|
350
|
+
* @param {object} application - Indiekit application
|
|
351
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
352
|
+
* @param {string} query - Search query
|
|
353
|
+
* @param {number} [limit] - Max results
|
|
354
|
+
* @returns {Promise<Array>} Array of matching items
|
|
355
|
+
*/
|
|
356
|
+
export async function searchItems(application, channelId, query, limit = 20) {
|
|
357
|
+
const collection = getCollection(application);
|
|
358
|
+
const objectId =
|
|
359
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
360
|
+
|
|
361
|
+
// Use regex search (consider adding text index for better performance)
|
|
362
|
+
const regex = new RegExp(query, "i");
|
|
363
|
+
const items = await collection
|
|
364
|
+
.find({
|
|
365
|
+
channelId: objectId,
|
|
366
|
+
$or: [
|
|
367
|
+
{ name: regex },
|
|
368
|
+
{ "content.text": regex },
|
|
369
|
+
{ "content.html": regex },
|
|
370
|
+
{ summary: regex },
|
|
371
|
+
],
|
|
372
|
+
})
|
|
373
|
+
.toSorted({ published: -1 })
|
|
374
|
+
.limit(limit)
|
|
375
|
+
.toArray();
|
|
376
|
+
|
|
377
|
+
return items.map((item) => transformToJf2(item));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Delete items by author URL (for blocking)
|
|
382
|
+
* @param {object} application - Indiekit application
|
|
383
|
+
* @param {string} userId - User ID (for filtering user's channels)
|
|
384
|
+
* @param {string} authorUrl - Author URL to delete items from
|
|
385
|
+
* @returns {Promise<number>} Number of deleted items
|
|
386
|
+
*/
|
|
387
|
+
export async function deleteItemsByAuthorUrl(application, userId, authorUrl) {
|
|
388
|
+
const collection = getCollection(application);
|
|
389
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
390
|
+
|
|
391
|
+
// Get all channel IDs for this user
|
|
392
|
+
const userChannels = await channelsCollection.find({ userId }).toArray();
|
|
393
|
+
const channelIds = userChannels.map((c) => c._id);
|
|
394
|
+
|
|
395
|
+
// Delete all items from blocked author in user's channels
|
|
396
|
+
const result = await collection.deleteMany({
|
|
397
|
+
channelId: { $in: channelIds },
|
|
398
|
+
"author.url": authorUrl,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return result.deletedCount;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Create indexes for efficient queries
|
|
406
|
+
* @param {object} application - Indiekit application
|
|
407
|
+
* @returns {Promise<void>}
|
|
408
|
+
*/
|
|
409
|
+
export async function createIndexes(application) {
|
|
410
|
+
const collection = getCollection(application);
|
|
411
|
+
|
|
412
|
+
await collection.createIndex({ channelId: 1, published: -1 });
|
|
413
|
+
await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true });
|
|
414
|
+
await collection.createIndex({ feedId: 1 });
|
|
415
|
+
await collection.createIndex(
|
|
416
|
+
{ name: "text", "content.text": "text", summary: "text" },
|
|
417
|
+
{ name: "text_search" },
|
|
418
|
+
);
|
|
419
|
+
}
|