@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,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events broker
|
|
3
|
+
* Manages SSE connections and event distribution
|
|
4
|
+
* @module realtime/broker
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { subscribeToChannel } from "../cache/redis.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SSE Client connection
|
|
11
|
+
* @typedef {object} SseClient
|
|
12
|
+
* @property {object} response - Express response object
|
|
13
|
+
* @property {string} userId - User ID
|
|
14
|
+
* @property {Set<string>} channels - Subscribed channel IDs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @type {Map<object, SseClient>} */
|
|
18
|
+
const clients = new Map();
|
|
19
|
+
|
|
20
|
+
/** @type {Map<string, object>} Map of userId to Redis subscriber */
|
|
21
|
+
const userSubscribers = new Map();
|
|
22
|
+
|
|
23
|
+
const PING_INTERVAL = 10_000; // 10 seconds
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Add a client to the broker
|
|
27
|
+
* @param {object} response - Express response object
|
|
28
|
+
* @param {string} userId - User ID
|
|
29
|
+
* @param {object} application - Indiekit application
|
|
30
|
+
* @returns {object} Client object
|
|
31
|
+
*/
|
|
32
|
+
export function addClient(response, userId, application) {
|
|
33
|
+
const client = {
|
|
34
|
+
response,
|
|
35
|
+
userId,
|
|
36
|
+
channels: new Set(),
|
|
37
|
+
pingInterval: setInterval(() => {
|
|
38
|
+
sendEvent(response, "ping", { timestamp: new Date().toISOString() });
|
|
39
|
+
}, PING_INTERVAL),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
clients.set(response, client);
|
|
43
|
+
|
|
44
|
+
// Set up Redis subscription for this user if not already done
|
|
45
|
+
setupUserSubscription(userId, application);
|
|
46
|
+
|
|
47
|
+
return client;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove a client from the broker
|
|
52
|
+
* @param {object} response - Express response object
|
|
53
|
+
*/
|
|
54
|
+
export function removeClient(response) {
|
|
55
|
+
const client = clients.get(response);
|
|
56
|
+
if (client) {
|
|
57
|
+
clearInterval(client.pingInterval);
|
|
58
|
+
clients.delete(response);
|
|
59
|
+
|
|
60
|
+
// Check if any other clients for this user
|
|
61
|
+
const hasOtherClients = [...clients.values()].some(
|
|
62
|
+
(c) => c.userId === client.userId,
|
|
63
|
+
);
|
|
64
|
+
if (!hasOtherClients) {
|
|
65
|
+
// Could clean up Redis subscription here if needed
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Subscribe a client to a channel
|
|
72
|
+
* @param {object} response - Express response object
|
|
73
|
+
* @param {string} channelId - Channel ID
|
|
74
|
+
*/
|
|
75
|
+
export function subscribeClient(response, channelId) {
|
|
76
|
+
const client = clients.get(response);
|
|
77
|
+
if (client) {
|
|
78
|
+
client.channels.add(channelId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Unsubscribe a client from a channel
|
|
84
|
+
* @param {object} response - Express response object
|
|
85
|
+
* @param {string} channelId - Channel ID
|
|
86
|
+
*/
|
|
87
|
+
export function unsubscribeClient(response, channelId) {
|
|
88
|
+
const client = clients.get(response);
|
|
89
|
+
if (client) {
|
|
90
|
+
client.channels.delete(channelId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Send an event to a specific client
|
|
96
|
+
* @param {object} response - Express response object
|
|
97
|
+
* @param {string} event - Event name
|
|
98
|
+
* @param {object} data - Event data
|
|
99
|
+
*/
|
|
100
|
+
export function sendEvent(response, event, data) {
|
|
101
|
+
try {
|
|
102
|
+
response.write(`event: ${event}\n`);
|
|
103
|
+
response.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
104
|
+
} catch {
|
|
105
|
+
// Client disconnected
|
|
106
|
+
removeClient(response);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Broadcast an event to all clients subscribed to a channel
|
|
112
|
+
* @param {string} channelId - Channel ID
|
|
113
|
+
* @param {string} event - Event name
|
|
114
|
+
* @param {object} data - Event data
|
|
115
|
+
*/
|
|
116
|
+
export function broadcastToChannel(channelId, event, data) {
|
|
117
|
+
for (const client of clients.values()) {
|
|
118
|
+
if (client.channels.has(channelId)) {
|
|
119
|
+
sendEvent(client.response, event, data);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Broadcast an event to all clients for a user
|
|
126
|
+
* @param {string} userId - User ID
|
|
127
|
+
* @param {string} event - Event name
|
|
128
|
+
* @param {object} data - Event data
|
|
129
|
+
*/
|
|
130
|
+
export function broadcastToUser(userId, event, data) {
|
|
131
|
+
for (const client of clients.values()) {
|
|
132
|
+
if (client.userId === userId) {
|
|
133
|
+
sendEvent(client.response, event, data);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Broadcast an event to all connected clients
|
|
140
|
+
* @param {string} event - Event name
|
|
141
|
+
* @param {object} data - Event data
|
|
142
|
+
*/
|
|
143
|
+
export function broadcastToAll(event, data) {
|
|
144
|
+
for (const client of clients.values()) {
|
|
145
|
+
sendEvent(client.response, event, data);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set up Redis subscription for a user
|
|
151
|
+
* @param {string} userId - User ID
|
|
152
|
+
* @param {object} application - Indiekit application
|
|
153
|
+
*/
|
|
154
|
+
async function setupUserSubscription(userId, application) {
|
|
155
|
+
if (userSubscribers.has(userId)) {
|
|
156
|
+
return; // Already subscribed
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const redis = application.redis;
|
|
160
|
+
if (!redis) {
|
|
161
|
+
return; // No Redis, skip real-time
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Create a duplicate connection for pub/sub
|
|
165
|
+
const subscriber = redis.duplicate();
|
|
166
|
+
userSubscribers.set(userId, subscriber);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await subscribeToChannel(subscriber, `microsub:user:${userId}`, (data) => {
|
|
170
|
+
handleRedisEvent(userId, data);
|
|
171
|
+
});
|
|
172
|
+
} catch {
|
|
173
|
+
// Subscription failed, remove from map
|
|
174
|
+
userSubscribers.delete(userId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Handle event received from Redis
|
|
180
|
+
* @param {string} userId - User ID
|
|
181
|
+
* @param {object} data - Event data
|
|
182
|
+
*/
|
|
183
|
+
function handleRedisEvent(userId, data) {
|
|
184
|
+
const { type, channelId, ...eventData } = data;
|
|
185
|
+
|
|
186
|
+
switch (type) {
|
|
187
|
+
case "new-item": {
|
|
188
|
+
broadcastToUser(userId, "new-item", { channelId, ...eventData });
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case "channel-update": {
|
|
192
|
+
broadcastToUser(userId, "channel-update", { channelId, ...eventData });
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "unread-count": {
|
|
196
|
+
broadcastToUser(userId, "unread-count", { channelId, ...eventData });
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
default: {
|
|
200
|
+
// Unknown event type, broadcast as generic event
|
|
201
|
+
broadcastToUser(userId, type, data);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get broker statistics
|
|
208
|
+
* @returns {object} Statistics
|
|
209
|
+
*/
|
|
210
|
+
export function getStats() {
|
|
211
|
+
const userCounts = new Map();
|
|
212
|
+
for (const client of clients.values()) {
|
|
213
|
+
const count = userCounts.get(client.userId) || 0;
|
|
214
|
+
userCounts.set(client.userId, count + 1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
totalClients: clients.size,
|
|
219
|
+
uniqueUsers: userCounts.size,
|
|
220
|
+
userSubscribers: userSubscribers.size,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Clean up all connections
|
|
226
|
+
*/
|
|
227
|
+
export function cleanup() {
|
|
228
|
+
for (const client of clients.values()) {
|
|
229
|
+
clearInterval(client.pingInterval);
|
|
230
|
+
}
|
|
231
|
+
clients.clear();
|
|
232
|
+
|
|
233
|
+
for (const subscriber of userSubscribers.values()) {
|
|
234
|
+
try {
|
|
235
|
+
subscriber.quit();
|
|
236
|
+
} catch {
|
|
237
|
+
// Ignore cleanup errors
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
userSubscribers.clear();
|
|
241
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search query module for full-text search
|
|
3
|
+
* @module search/query
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ObjectId } from "mongodb";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Search items using MongoDB text search
|
|
10
|
+
* @param {object} application - Indiekit application
|
|
11
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
12
|
+
* @param {string} query - Search query string
|
|
13
|
+
* @param {object} options - Search options
|
|
14
|
+
* @param {number} [options.limit] - Max results (default 20)
|
|
15
|
+
* @param {number} [options.skip] - Skip results for pagination
|
|
16
|
+
* @param {boolean} [options.sortByScore] - Sort by relevance (default true)
|
|
17
|
+
* @returns {Promise<Array>} Array of matching items
|
|
18
|
+
*/
|
|
19
|
+
export async function searchItemsFullText(
|
|
20
|
+
application,
|
|
21
|
+
channelId,
|
|
22
|
+
query,
|
|
23
|
+
options = {},
|
|
24
|
+
) {
|
|
25
|
+
const collection = application.collections.get("microsub_items");
|
|
26
|
+
const { limit = 20, skip = 0, sortByScore = true } = options;
|
|
27
|
+
|
|
28
|
+
const channelObjectId =
|
|
29
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
30
|
+
|
|
31
|
+
// Build the search query
|
|
32
|
+
const searchQuery = {
|
|
33
|
+
channelId: channelObjectId,
|
|
34
|
+
$text: { $search: query },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Build aggregation pipeline for scoring
|
|
38
|
+
const pipeline = [
|
|
39
|
+
{ $match: searchQuery },
|
|
40
|
+
{ $addFields: { score: { $meta: "textScore" } } },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
if (sortByScore) {
|
|
44
|
+
pipeline.push(
|
|
45
|
+
{ $sort: { score: -1, published: -1 } },
|
|
46
|
+
{ $skip: skip },
|
|
47
|
+
{ $limit: limit },
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
pipeline.push(
|
|
51
|
+
{ $sort: { published: -1 } },
|
|
52
|
+
{ $skip: skip },
|
|
53
|
+
{ $limit: limit },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const items = await collection.aggregate(pipeline).toArray();
|
|
58
|
+
|
|
59
|
+
return items.map((item) => transformToSearchResult(item));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Search items using regex fallback (for partial matching)
|
|
64
|
+
* @param {object} application - Indiekit application
|
|
65
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
66
|
+
* @param {string} query - Search query string
|
|
67
|
+
* @param {object} options - Search options
|
|
68
|
+
* @returns {Promise<Array>} Array of matching items
|
|
69
|
+
*/
|
|
70
|
+
export async function searchItemsRegex(
|
|
71
|
+
application,
|
|
72
|
+
channelId,
|
|
73
|
+
query,
|
|
74
|
+
options = {},
|
|
75
|
+
) {
|
|
76
|
+
const collection = application.collections.get("microsub_items");
|
|
77
|
+
const { limit = 20 } = options;
|
|
78
|
+
|
|
79
|
+
const channelObjectId =
|
|
80
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
81
|
+
|
|
82
|
+
// Escape regex special characters
|
|
83
|
+
const escapedQuery = query.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
|
|
84
|
+
const regex = new RegExp(escapedQuery, "i");
|
|
85
|
+
|
|
86
|
+
const items = await collection
|
|
87
|
+
.find({
|
|
88
|
+
channelId: channelObjectId,
|
|
89
|
+
$or: [
|
|
90
|
+
{ name: regex },
|
|
91
|
+
{ "content.text": regex },
|
|
92
|
+
{ "content.html": regex },
|
|
93
|
+
{ summary: regex },
|
|
94
|
+
{ "author.name": regex },
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
.toSorted({ published: -1 })
|
|
98
|
+
.limit(limit)
|
|
99
|
+
.toArray();
|
|
100
|
+
|
|
101
|
+
return items.map((item) => transformToSearchResult(item));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Search with automatic fallback
|
|
106
|
+
* Uses full-text search first, falls back to regex if no results
|
|
107
|
+
* @param {object} application - Indiekit application
|
|
108
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
109
|
+
* @param {string} query - Search query string
|
|
110
|
+
* @param {object} options - Search options
|
|
111
|
+
* @returns {Promise<Array>} Array of matching items
|
|
112
|
+
*/
|
|
113
|
+
export async function searchWithFallback(
|
|
114
|
+
application,
|
|
115
|
+
channelId,
|
|
116
|
+
query,
|
|
117
|
+
options = {},
|
|
118
|
+
) {
|
|
119
|
+
// Try full-text search first
|
|
120
|
+
try {
|
|
121
|
+
const results = await searchItemsFullText(
|
|
122
|
+
application,
|
|
123
|
+
channelId,
|
|
124
|
+
query,
|
|
125
|
+
options,
|
|
126
|
+
);
|
|
127
|
+
if (results.length > 0) {
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Text index might not exist, fall through to regex
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fall back to regex search
|
|
135
|
+
return searchItemsRegex(application, channelId, query, options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Transform database item to search result format
|
|
140
|
+
* @param {object} item - Database item
|
|
141
|
+
* @returns {object} Search result
|
|
142
|
+
*/
|
|
143
|
+
function transformToSearchResult(item) {
|
|
144
|
+
const result = {
|
|
145
|
+
type: item.type || "entry",
|
|
146
|
+
uid: item.uid,
|
|
147
|
+
url: item.url,
|
|
148
|
+
published: item.published?.toISOString(),
|
|
149
|
+
_id: item._id.toString(),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (item.name) result.name = item.name;
|
|
153
|
+
if (item.content) result.content = item.content;
|
|
154
|
+
if (item.summary) result.summary = item.summary;
|
|
155
|
+
if (item.author) result.author = item.author;
|
|
156
|
+
if (item.photo?.length > 0) result.photo = item.photo;
|
|
157
|
+
if (item.score) result._score = item.score;
|
|
158
|
+
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get search suggestions (autocomplete)
|
|
164
|
+
* @param {object} application - Indiekit application
|
|
165
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
166
|
+
* @param {string} prefix - Search prefix
|
|
167
|
+
* @param {number} limit - Max suggestions
|
|
168
|
+
* @returns {Promise<Array>} Array of suggestions
|
|
169
|
+
*/
|
|
170
|
+
export async function getSearchSuggestions(
|
|
171
|
+
application,
|
|
172
|
+
channelId,
|
|
173
|
+
prefix,
|
|
174
|
+
limit = 5,
|
|
175
|
+
) {
|
|
176
|
+
const collection = application.collections.get("microsub_items");
|
|
177
|
+
|
|
178
|
+
const channelObjectId =
|
|
179
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
180
|
+
|
|
181
|
+
const escapedPrefix = prefix.replaceAll(
|
|
182
|
+
/[$()*+.?[\\\]^{|}]/g,
|
|
183
|
+
String.raw`\$&`,
|
|
184
|
+
);
|
|
185
|
+
const regex = new RegExp(`^${escapedPrefix}`, "i");
|
|
186
|
+
|
|
187
|
+
// Get unique names/titles that match prefix
|
|
188
|
+
const results = await collection
|
|
189
|
+
.aggregate([
|
|
190
|
+
{ $match: { channelId: channelObjectId, name: regex } },
|
|
191
|
+
{ $group: { _id: "$name" } },
|
|
192
|
+
{ $limit: limit },
|
|
193
|
+
])
|
|
194
|
+
.toArray();
|
|
195
|
+
|
|
196
|
+
return results.map((r) => r._id).filter(Boolean);
|
|
197
|
+
}
|