@jasonwch/nodebb-plugin-meilisearch-r 1.1.2 → 1.2.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 +29 -28
- package/lib/alerts.js +24 -0
- package/lib/chat-search-global.js +221 -0
- package/lib/client.js +497 -0
- package/lib/constants.js +42 -0
- package/lib/defaults.js +73 -0
- package/lib/embedder.js +297 -0
- package/lib/hooks/messages.js +49 -0
- package/lib/hooks/posts.js +186 -0
- package/lib/hooks/topics.js +218 -0
- package/lib/pending-queue.js +69 -0
- package/lib/redact.js +131 -0
- package/lib/reindex.js +311 -0
- package/lib/routes.js +129 -0
- package/lib/search.js +249 -0
- package/lib/settings-helpers.js +15 -0
- package/lib/settings.js +136 -0
- package/lib/state.js +69 -0
- package/library.js +17 -1555
- package/package.json +1 -1
- package/static/languages/en-GB/meilisearch.json +112 -79
- package/static/languages/he/meilisearch.json +113 -0
- package/static/languages/pl/meilisearch.json +34 -1
- package/static/languages/zh-CN/meilisearch.json +34 -1
- package/static/lib/admin.js +317 -2
- package/static/templates/admin/plugins/meilisearch.tpl +289 -222
package/library.js
CHANGED
|
@@ -1,1555 +1,17 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const plugin = {};
|
|
20
|
-
|
|
21
|
-
plugin.id = 'meilisearch';
|
|
22
|
-
|
|
23
|
-
plugin.healthy = false;
|
|
24
|
-
|
|
25
|
-
const REINDEX_BATCH_SIZE = 500;
|
|
26
|
-
const PENDING_KEY = 'plugin:meilisearch:pending';
|
|
27
|
-
const PENDING_MAX = 100000;
|
|
28
|
-
const DRAIN_MAX = 500;
|
|
29
|
-
const REINDEX_LOCK_KEY = 'plugin:meilisearch:reindex:lock';
|
|
30
|
-
const REINDEX_LOCK_TTL = 3600;
|
|
31
|
-
const LOCK_REFRESH_INTERVAL = 5 * 60 * 1000;
|
|
32
|
-
const REPLAY_OPS = ['indexPost', 'deindexPost', 'indexTopic', 'deindexTopic', 'deindexPostsPurge', 'deindexTopicsPurge', 'reindexTopicPosts', 'deindexTopicPosts', 'restoreTopic', 'changePostOwner', 'changeTopicOwner', 'onTopicMerge', 'onScheduledPublish', 'indexMessage', 'deindexMessage'];
|
|
33
|
-
const GLOBAL_CHAT_SEARCH_LIMIT = 200;
|
|
34
|
-
const GLOBAL_CHAT_SEARCH_FETCH = 300;
|
|
35
|
-
|
|
36
|
-
const emitProgress = _.throttle(() => {
|
|
37
|
-
pubsub.publish('meilisearch:reindex', plugin.indexing);
|
|
38
|
-
}, 500, { leading: true, trailing: true });
|
|
39
|
-
|
|
40
|
-
plugin.indexing = {
|
|
41
|
-
running: false,
|
|
42
|
-
topic_progress: {
|
|
43
|
-
total: null,
|
|
44
|
-
current: null,
|
|
45
|
-
},
|
|
46
|
-
post_progress: {
|
|
47
|
-
total: null,
|
|
48
|
-
current: null,
|
|
49
|
-
},
|
|
50
|
-
message_progress: {
|
|
51
|
-
total: null,
|
|
52
|
-
current: null,
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
plugin.reindexingForced = false;
|
|
57
|
-
plugin.pendingDuringReindex = [];
|
|
58
|
-
plugin.reindexLockRefresh = null;
|
|
59
|
-
|
|
60
|
-
/** @type {import('meilisearch').Meilisearch} */
|
|
61
|
-
plugin.client = undefined;
|
|
62
|
-
plugin.defaults = {
|
|
63
|
-
host: 'http://localhost:7700',
|
|
64
|
-
apiKey: undefined,
|
|
65
|
-
maxDocuments: undefined,
|
|
66
|
-
indexed: false,
|
|
67
|
-
rankingRules: [
|
|
68
|
-
{ rule: 'words' },
|
|
69
|
-
{ rule: 'typo' },
|
|
70
|
-
{ rule: 'proximity' },
|
|
71
|
-
{ rule: 'attribute' },
|
|
72
|
-
{ rule: 'sort' },
|
|
73
|
-
{ rule: 'exactness' },
|
|
74
|
-
],
|
|
75
|
-
stopWords: [],
|
|
76
|
-
typoTolerance: 'on',
|
|
77
|
-
typoToleranceMinWordSizeOneTypo: 5,
|
|
78
|
-
typoToleranceMinWordSizeTwoTypos: 9,
|
|
79
|
-
typoToleranceDisableOnWords: [],
|
|
80
|
-
synonyms: [],
|
|
81
|
-
healthCheckInterval: 60,
|
|
82
|
-
searchMinTermLength: 2,
|
|
83
|
-
globalChatSearchEnabled: 'on',
|
|
84
|
-
lastReindexResult: {
|
|
85
|
-
success: false,
|
|
86
|
-
finishedAt: null,
|
|
87
|
-
topic_progress: { current: null, total: null },
|
|
88
|
-
post_progress: { current: null, total: null },
|
|
89
|
-
message_progress: { current: null, total: null },
|
|
90
|
-
skippedDeletedTopics: 0,
|
|
91
|
-
skippedDeletedPosts: 0,
|
|
92
|
-
skippedDeletedMessages: 0,
|
|
93
|
-
skippedSystemMessages: 0,
|
|
94
|
-
skippedOrphanMessages: 0,
|
|
95
|
-
error: null,
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
plugin.breakingSettings = [
|
|
100
|
-
'maxDocuments',
|
|
101
|
-
'rankingRules',
|
|
102
|
-
'stopWords',
|
|
103
|
-
'typoTolerance',
|
|
104
|
-
'typoToleranceMinWordSizeOneTypo',
|
|
105
|
-
'typoToleranceMinWordSizeTwoTypos',
|
|
106
|
-
'typoToleranceDisableOnWords',
|
|
107
|
-
'typoToleranceDisableOnAttributes',
|
|
108
|
-
'synonyms',
|
|
109
|
-
];
|
|
110
|
-
plugin.initialized = false;
|
|
111
|
-
|
|
112
|
-
plugin.initializingOnAnotherInstance = false;
|
|
113
|
-
|
|
114
|
-
plugin.healthCheckTask = null;
|
|
115
|
-
|
|
116
|
-
pubsub.on('meilisearch:reindex', (indexing) => {
|
|
117
|
-
plugin.indexing = indexing;
|
|
118
|
-
Sockets.server.to('admin/plugins/meilisearch').emit('plugins.meilisearch.reindex', plugin.indexing);
|
|
119
|
-
});
|
|
120
|
-
pubsub.on('meilisearch:init', (initializing) => {
|
|
121
|
-
plugin.initializingOnAnotherInstance = initializing;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
plugin.init = async function (params) {
|
|
125
|
-
const { router } = params;
|
|
126
|
-
winston.debug('[plugin/meilisearch] Initializing MeiliSearch plugin');
|
|
127
|
-
routeHelpers.setupAdminPageRoute(router, '/admin/plugins/meilisearch', [], async (req, res) => {
|
|
128
|
-
const topicTotal = Number(plugin.indexing.topic_progress.total) || 0;
|
|
129
|
-
const topicCurrent = Number(plugin.indexing.topic_progress.current) || 0;
|
|
130
|
-
const postTotal = Number(plugin.indexing.post_progress.total) || 0;
|
|
131
|
-
const postCurrent = Number(plugin.indexing.post_progress.current) || 0;
|
|
132
|
-
const messageTotal = Number(plugin.indexing.message_progress.total) || 0;
|
|
133
|
-
const messageCurrent = Number(plugin.indexing.message_progress.current) || 0;
|
|
134
|
-
const topicPercent = topicTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * topicCurrent / topicTotal))) : 0;
|
|
135
|
-
const postPercent = postTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * postCurrent / postTotal))) : 0;
|
|
136
|
-
const messagePercent = messageTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * messageCurrent / messageTotal))) : 0;
|
|
137
|
-
const lastReindexResult = await settings.getOne(plugin.id, 'lastReindexResult') || {};
|
|
138
|
-
res.render('admin/plugins/meilisearch', {
|
|
139
|
-
title: '[[meilisearch:admin.settings]]',
|
|
140
|
-
indexing: plugin.indexing,
|
|
141
|
-
topicPercent,
|
|
142
|
-
postPercent,
|
|
143
|
-
messagePercent,
|
|
144
|
-
lastReindexResult,
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
await settings.setOnEmpty(plugin.id, plugin.defaults);
|
|
148
|
-
await plugin.prepareSearch();
|
|
149
|
-
await plugin.ensureChatMessageIndex();
|
|
150
|
-
plugin.patchTopicsSearch();
|
|
151
|
-
plugin.initialized = true;
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
// Ensure the chat_message index + filterable attributes exist, independent of reindex state.
|
|
155
|
-
// prepareSearch() returns early when `indexed=true`, so updateIndexSettings (which configures
|
|
156
|
-
// chat_message) is skipped on normal restarts — but indexMessage auto-creates the index via
|
|
157
|
-
// document add, leaving filterableAttributes empty. Without roomId/uid filterable, searchMessages
|
|
158
|
-
// would error on its filter clause. Idempotent + cheap (2 calls, settings deduped by Meilisearch).
|
|
159
|
-
plugin.ensureChatMessageIndex = async function () {
|
|
160
|
-
if (!plugin.client) return;
|
|
161
|
-
try {
|
|
162
|
-
await ensureIndex('chat_message', 'mid');
|
|
163
|
-
await plugin.client.index('chat_message').updateFilterableAttributes(['roomId', 'uid', 'timestamp']);
|
|
164
|
-
} catch (err) {
|
|
165
|
-
winston.error(`[plugin/meilisearch] ensureChatMessageIndex failed: ${err.message}`);
|
|
166
|
-
plugin.healthy = false;
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
// Global chat search: queries the chat_message Meilisearch index across ALL rooms
|
|
171
|
-
// the user belongs to (unlike the per-room searchMessages which is scoped to one roomId).
|
|
172
|
-
// Uses 1.5× over-fetch (300) to compensate for join-timestamp/deleted-message filtering,
|
|
173
|
-
// then renders only surviving hits through NodeBB's getMessagesData pipeline.
|
|
174
|
-
// Results are sorted by message timestamp (newest first).
|
|
175
|
-
plugin.chatSearchGlobal = async function (socket, data) {
|
|
176
|
-
data = data || {};
|
|
177
|
-
if (!socket.uid) throw new Error('Not logged in');
|
|
178
|
-
const chatSearchEnabled = await settings.getOne(plugin.id, 'globalChatSearchEnabled');
|
|
179
|
-
if (chatSearchEnabled === false || chatSearchEnabled === 'off' || chatSearchEnabled === 'false') {
|
|
180
|
-
return [];
|
|
181
|
-
}
|
|
182
|
-
if (!plugin.healthy && !(await plugin.checkHealth())) {
|
|
183
|
-
throw new Error('MeiliSearch is not healthy');
|
|
184
|
-
}
|
|
185
|
-
const targetUid = socket.uid;
|
|
186
|
-
const userSettings = await user.getSettings(socket.uid);
|
|
187
|
-
const userLang = userSettings.userLang || 'en-GB';
|
|
188
|
-
|
|
189
|
-
const query = String(data.query || '').trim();
|
|
190
|
-
if (!query) {
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
let roomIds = await db.getSortedSetRevRange('uid:' + targetUid + ':chat:rooms', 0, -1);
|
|
195
|
-
// Chunked membership check with event loop yields to prevent blocking on large room lists.
|
|
196
|
-
// Each chunk fires hooks synchronously inside Promise.all (~0.6ms per 100 rooms);
|
|
197
|
-
// setImmediate between chunks lets other users' I/O callbacks run.
|
|
198
|
-
const MEMBERSHIP_CHUNK_SIZE = 100;
|
|
199
|
-
let inRoom = [];
|
|
200
|
-
for (let i = 0; i < roomIds.length; i += MEMBERSHIP_CHUNK_SIZE) {
|
|
201
|
-
const chunk = roomIds.slice(i, i + MEMBERSHIP_CHUNK_SIZE);
|
|
202
|
-
inRoom = inRoom.concat(await Messaging.isUserInRoom(targetUid, chunk));
|
|
203
|
-
await new Promise(resolve => setImmediate(resolve));
|
|
204
|
-
}
|
|
205
|
-
roomIds = roomIds.filter((roomId, idx) => inRoom[idx]);
|
|
206
|
-
if (!roomIds.length) {
|
|
207
|
-
return [];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const minTermLength = await resolveMinTermLength();
|
|
211
|
-
if (!query.split(' ').some(word => word.length >= minTermLength)) {
|
|
212
|
-
return [];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const filter = [roomIds.map(rid => `roomId = ${parseInt(rid, 10)}`)];
|
|
216
|
-
winston.debug(`[plugin/meilisearch] Global chat search for "${query}" in ${roomIds.length} rooms`);
|
|
217
|
-
const result = await plugin.client.index('chat_message').search(query, {
|
|
218
|
-
attributesToRetrieve: ['mid', 'roomId', 'uid', 'timestamp'],
|
|
219
|
-
limit: GLOBAL_CHAT_SEARCH_FETCH,
|
|
220
|
-
filter: filter,
|
|
221
|
-
matchingStrategy: 'last',
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
if (!result.hits || !result.hits.length) {
|
|
225
|
-
return [];
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Batch-fetch room public flags (1 query) + join timestamps for private rooms (N queries).
|
|
229
|
-
const hitRoomIds = [...new Set(result.hits.map(h => parseInt(h.roomId, 10)))];
|
|
230
|
-
const roomData = await db.getObjectsFields(
|
|
231
|
-
hitRoomIds.map(rid => `chat:room:${rid}`),
|
|
232
|
-
['public'],
|
|
233
|
-
);
|
|
234
|
-
const roomIsPublic = {};
|
|
235
|
-
hitRoomIds.forEach((rid, i) => {
|
|
236
|
-
roomIsPublic[rid] = parseInt(roomData[i] && roomData[i].public, 10) === 1;
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
const privateRoomIds = hitRoomIds.filter(rid => !roomIsPublic[rid]);
|
|
240
|
-
const joinTimestamps = {};
|
|
241
|
-
if (privateRoomIds.length) {
|
|
242
|
-
const scores = await Promise.all(
|
|
243
|
-
privateRoomIds.map(rid => db.sortedSetScore(`chat:room:${rid}:uids`, targetUid)),
|
|
244
|
-
);
|
|
245
|
-
privateRoomIds.forEach((rid, i) => {
|
|
246
|
-
joinTimestamps[rid] = scores[i] || 0;
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Filter + sort by timestamp (newest first) + slice to limit.
|
|
251
|
-
const visibleHits = result.hits
|
|
252
|
-
.filter((hit) => {
|
|
253
|
-
const rid = parseInt(hit.roomId, 10);
|
|
254
|
-
if (roomIsPublic[rid]) return true;
|
|
255
|
-
const ts = parseInt(hit.timestamp, 10);
|
|
256
|
-
return ts >= (joinTimestamps[rid] || 0);
|
|
257
|
-
})
|
|
258
|
-
.sort((a, b) => parseInt(b.timestamp, 10) - parseInt(a.timestamp, 10))
|
|
259
|
-
.slice(0, GLOBAL_CHAT_SEARCH_LIMIT);
|
|
260
|
-
|
|
261
|
-
if (!visibleHits.length) {
|
|
262
|
-
return [];
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Group by room for decoration, then reassemble in timestamp order via mid→message map.
|
|
266
|
-
const hitsByRoom = {};
|
|
267
|
-
visibleHits.forEach((hit) => {
|
|
268
|
-
const rid = parseInt(hit.roomId, 10);
|
|
269
|
-
if (!hitsByRoom[rid]) hitsByRoom[rid] = [];
|
|
270
|
-
hitsByRoom[rid].push(hit.mid);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// Process rooms in bounded-concurrency batches (10 at a time) to avoid
|
|
274
|
-
// saturating the MongoDB connection pool (maxPoolSize: 20).
|
|
275
|
-
const DECORATION_BATCH_SIZE = 10;
|
|
276
|
-
const roomIdsWithHits = Object.keys(hitsByRoom);
|
|
277
|
-
const decoratedMap = {};
|
|
278
|
-
for (let i = 0; i < roomIdsWithHits.length; i += DECORATION_BATCH_SIZE) {
|
|
279
|
-
const batch = roomIdsWithHits.slice(i, i + DECORATION_BATCH_SIZE);
|
|
280
|
-
const batchResults = await Promise.all(batch.map(async (ridStr) => {
|
|
281
|
-
const rid = parseInt(ridStr, 10);
|
|
282
|
-
const mids = hitsByRoom[rid];
|
|
283
|
-
const messages = await Messaging.getMessagesData(mids, targetUid, rid, false);
|
|
284
|
-
if (!messages || !messages.length) return [];
|
|
285
|
-
return decorateRoomMatches({ matches: messages, roomId: rid, targetUid, userLang });
|
|
286
|
-
}));
|
|
287
|
-
batchResults.forEach((decorated) => {
|
|
288
|
-
decorated.forEach((msg) => {
|
|
289
|
-
const mid = msg.mid || msg.messageId;
|
|
290
|
-
if (mid) decoratedMap[mid] = msg;
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Reassemble in pre-sorted timestamp order.
|
|
296
|
-
const allResults = [];
|
|
297
|
-
for (const hit of visibleHits) {
|
|
298
|
-
const msg = decoratedMap[hit.mid];
|
|
299
|
-
if (msg) allResults.push(msg);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return allResults;
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
// Register socket handler at module level per NodeBB 4.14.x convention
|
|
306
|
-
// (src/socket.io/plugins.js docs: require and add listeners at load time).
|
|
307
|
-
// The health check inside chatSearchGlobal guards against calls before init completes.
|
|
308
|
-
const socketPlugins = nodebb.require('./src/socket.io/plugins');
|
|
309
|
-
socketPlugins.meilisearch = socketPlugins.meilisearch || {};
|
|
310
|
-
socketPlugins.meilisearch.chatSearchGlobal = plugin.chatSearchGlobal;
|
|
311
|
-
|
|
312
|
-
// Expose globalChatSearchEnabled to client config (standard filter:config.get pattern,
|
|
313
|
-
// same as nodebb-plugin-emoji's emojiCustomFirst).
|
|
314
|
-
plugin.addConfig = async function (config) {
|
|
315
|
-
const enabled = await settings.getOne(plugin.id, 'globalChatSearchEnabled');
|
|
316
|
-
config.globalChatSearchEnabled = enabled !== false && enabled !== 'off' && enabled !== 'false';
|
|
317
|
-
return config;
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
// Attaches room name, participants, and sender metadata to each message for the client UI.
|
|
321
|
-
async function decorateRoomMatches({ matches, roomId, targetUid, userLang }) {
|
|
322
|
-
const uids = await Messaging.getUidsInRoom(roomId, 0, -1);
|
|
323
|
-
const usersData = await user.getUsersFields(uids, ['uid', 'username', 'picture', 'icon:text', 'icon:bgColor']);
|
|
324
|
-
const otherUsers = usersData.filter(u => parseInt(u.uid, 10) !== parseInt(targetUid, 10));
|
|
325
|
-
|
|
326
|
-
let displayName = '';
|
|
327
|
-
if (otherUsers.length === 0) {
|
|
328
|
-
displayName = await translateMeili(userLang, 'chatSearch.room.self-chat');
|
|
329
|
-
} else if (otherUsers.length <= 2) {
|
|
330
|
-
displayName = otherUsers.map(u => u.username).join(', ');
|
|
331
|
-
} else {
|
|
332
|
-
const firstTwo = otherUsers.slice(0, 2).map(u => u.username).join(', ');
|
|
333
|
-
const remaining = otherUsers.length - 2;
|
|
334
|
-
displayName = await translateMeili(userLang, 'chatSearch.room.and-more-users', firstTwo, remaining);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const roomData = await Messaging.getRoomData(roomId);
|
|
338
|
-
const roomName = (roomData && roomData.roomName) || displayName;
|
|
339
|
-
|
|
340
|
-
// Batch-fetch sorted-set ranks for all matched mids.
|
|
341
|
-
// Core's /message/:mid redirect drops the index segment when rank is 0 (falsy),
|
|
342
|
-
// so we attach the rank here and construct the chat URL client-side with rank+1
|
|
343
|
-
// to always include a truthy 1-based index segment.
|
|
344
|
-
const midsForRank = matches.map(m => m.mid || m.messageId);
|
|
345
|
-
const ranks = await db.sortedSetRanks(`chat:room:${roomId}:mids`, midsForRank);
|
|
346
|
-
|
|
347
|
-
matches.forEach((m, i) => {
|
|
348
|
-
if (!m.mid && m.messageId) m.mid = m.messageId;
|
|
349
|
-
if (!m.roomId) m.roomId = roomId;
|
|
350
|
-
if (!m.user || !m.user.username) {
|
|
351
|
-
m.user = m.fromUser || { username: 'Unknown', 'icon:text': '?', 'icon:bgColor': 'var(--bs-tertiary-bg)' };
|
|
352
|
-
}
|
|
353
|
-
m.roomName = roomName;
|
|
354
|
-
m.participants = otherUsers.length ? [otherUsers[0]] : [];
|
|
355
|
-
m.rank = ranks[i];
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
return matches;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
async function translateMeili(language, key, ...args) {
|
|
362
|
-
return await translator.translate(
|
|
363
|
-
translator.compile(`meilisearch:${key}`, ...args),
|
|
364
|
-
language,
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
plugin.patchTopicsSearch = function () {
|
|
368
|
-
if (Topics.__meiliOriginalSearch) { return; }
|
|
369
|
-
Topics.__meiliOriginalSearch = Topics.search;
|
|
370
|
-
Topics.search = async function (tid, term) {
|
|
371
|
-
if (!tid || !term || !String(term).trim()) { return []; }
|
|
372
|
-
return Topics.__meiliOriginalSearch.call(Topics, tid, term);
|
|
373
|
-
};
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
plugin.prepareSearch = async function (data, connectionChanged = false) {
|
|
377
|
-
winston.debug(`[plugin/meilisearch] Connecting to MeiliSearch host: ${data?.host || await settings.getOne(plugin.id, 'host')}`);
|
|
378
|
-
const { Meilisearch } = await import('meilisearch');
|
|
379
|
-
plugin.client = new Meilisearch({
|
|
380
|
-
host: data?.host || await settings.getOne(plugin.id, 'host'),
|
|
381
|
-
apiKey: data?.apiKey || await settings.getOne(plugin.id, 'apiKey') || undefined,
|
|
382
|
-
});
|
|
383
|
-
if (plugin.healthCheckTask) clearInterval(plugin.healthCheckTask);
|
|
384
|
-
const intervalRaw = parseInt(data?.healthCheckInterval || await settings.getOne(plugin.id, 'healthCheckInterval'), 10);
|
|
385
|
-
const intervalSeconds = Number.isFinite(intervalRaw) && intervalRaw > 0
|
|
386
|
-
? Math.min(Math.max(intervalRaw, 10), 3600)
|
|
387
|
-
: 60;
|
|
388
|
-
plugin.healthCheckTask = setInterval(plugin.checkHealth, intervalSeconds * 1000);
|
|
389
|
-
// #15: Always sync plugin.healthy on (re)connect — otherwise it stays false from
|
|
390
|
-
// initialization until the first setInterval tick, causing stale "MeiliSearch
|
|
391
|
-
// Unreachable" warnings on save during the startup window (MS online, flag stale).
|
|
392
|
-
await plugin.checkHealth();
|
|
393
|
-
// #14: Don't auto-reindex after a failed reindex unless connection settings changed.
|
|
394
|
-
const indexed = await settings.getOne(plugin.id, 'indexed');
|
|
395
|
-
if (indexed) return;
|
|
396
|
-
if (!plugin.healthy || plugin.initializingOnAnotherInstance) return;
|
|
397
|
-
const lastResult = await settings.getOne(plugin.id, 'lastReindexResult') || {};
|
|
398
|
-
const allowAutoReindex = connectionChanged || lastResult.success !== false;
|
|
399
|
-
if (!allowAutoReindex) {
|
|
400
|
-
winston.warn('[plugin/meilisearch] Skipping auto-reindex: last reindex failed. Trigger manually from ACP.');
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
await plugin.updateIndexSettings();
|
|
404
|
-
await plugin.reindex(false);
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
plugin.addRoutes = async function ({ router, middleware, helpers }) {
|
|
408
|
-
const middlewares = [
|
|
409
|
-
middleware.ensureLoggedIn,
|
|
410
|
-
middleware.admin.checkPrivileges,
|
|
411
|
-
];
|
|
412
|
-
routeHelpers.setupApiRoute(router, 'get', '/meilisearch/reindex', middlewares, async (req, res) => {
|
|
413
|
-
helpers.formatApiResponse(200, res, {
|
|
414
|
-
indexing: plugin.indexing,
|
|
415
|
-
healthy: plugin.healthy,
|
|
416
|
-
lastReindexResult: await settings.getOne(plugin.id, 'lastReindexResult') || {},
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
routeHelpers.setupApiRoute(router, 'post', '/meilisearch/reindex', middlewares, async (req, res) => {
|
|
421
|
-
await settings.set(plugin.id, { indexed: false }, true);
|
|
422
|
-
plugin.reindex(false);
|
|
423
|
-
helpers.formatApiResponse(202, res, plugin.indexing);
|
|
424
|
-
});
|
|
425
|
-
routeHelpers.setupApiRoute(router, 'delete', '/meilisearch/reindex', middlewares, async (req, res) => {
|
|
426
|
-
await settings.set(plugin.id, { indexed: false }, true);
|
|
427
|
-
plugin.reindex(true);
|
|
428
|
-
helpers.formatApiResponse(202, res, plugin.indexing);
|
|
429
|
-
});
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
plugin.addAdminNavigation = function (header) {
|
|
433
|
-
header.plugins.push({
|
|
434
|
-
route: '/plugins/meilisearch',
|
|
435
|
-
icon: 'fa-tint',
|
|
436
|
-
name: 'Meilisearch',
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
return header;
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
plugin.checkHealth = async function () {
|
|
443
|
-
try {
|
|
444
|
-
plugin.healthy = await plugin.client.isHealthy();
|
|
445
|
-
if (!plugin.healthy) {
|
|
446
|
-
winston.warn('[plugin/meilisearch] MeiliSearch host is unhealthy');
|
|
447
|
-
} else {
|
|
448
|
-
drainPending().catch(err => winston.error(`[plugin/meilisearch] drain failed: ${err.message}`));
|
|
449
|
-
}
|
|
450
|
-
return plugin.healthy;
|
|
451
|
-
} catch (err) {
|
|
452
|
-
plugin.healthy = false;
|
|
453
|
-
winston.warn(`[plugin/meilisearch] ${err.message}`);
|
|
454
|
-
return false;
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
async function maybeDefer(op, payload) {
|
|
459
|
-
if (plugin.reindexingForced) {
|
|
460
|
-
if (plugin.pendingDuringReindex.length >= PENDING_MAX) {
|
|
461
|
-
plugin.pendingDuringReindex.shift();
|
|
462
|
-
}
|
|
463
|
-
plugin.pendingDuringReindex.push({ op, payload });
|
|
464
|
-
return true;
|
|
465
|
-
}
|
|
466
|
-
if (!plugin.healthy && !(await plugin.checkHealth())) {
|
|
467
|
-
await enqueuePending(op, payload);
|
|
468
|
-
return true;
|
|
469
|
-
}
|
|
470
|
-
return false;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
async function enqueuePending(op, payload) {
|
|
474
|
-
const count = await db.sortedSetCard(PENDING_KEY);
|
|
475
|
-
if (count >= PENDING_MAX) {
|
|
476
|
-
const oldest = await db.getSortedSetRange(PENDING_KEY, 0, 0);
|
|
477
|
-
if (oldest.length) await db.sortedSetRemove(PENDING_KEY, ...oldest);
|
|
478
|
-
}
|
|
479
|
-
await db.sortedSetAdd(PENDING_KEY, Date.now(), JSON.stringify({ op, payload }));
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
async function meiliWrite(op, payload, fn) {
|
|
483
|
-
try {
|
|
484
|
-
await fn();
|
|
485
|
-
} catch (err) {
|
|
486
|
-
winston.error(`[plugin/meilisearch] ${op} write failed: ${err.message}`);
|
|
487
|
-
plugin.healthy = false;
|
|
488
|
-
await enqueuePending(op, payload);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
async function drainPending() {
|
|
493
|
-
if (!plugin.healthy || plugin.reindexingForced) return;
|
|
494
|
-
let drained = 0;
|
|
495
|
-
for (;;) {
|
|
496
|
-
if (!plugin.healthy) break;
|
|
497
|
-
const members = await db.getSortedSetRange(PENDING_KEY, 0, 49);
|
|
498
|
-
if (!members.length) break;
|
|
499
|
-
await db.sortedSetRemove(PENDING_KEY, ...members);
|
|
500
|
-
for (const member of members) {
|
|
501
|
-
try {
|
|
502
|
-
const { op, payload } = JSON.parse(member);
|
|
503
|
-
if (op && REPLAY_OPS.includes(op)) {
|
|
504
|
-
await plugin[op](payload);
|
|
505
|
-
}
|
|
506
|
-
} catch (err) {
|
|
507
|
-
winston.error(`[plugin/meilisearch] replay failed: ${err.message}`);
|
|
508
|
-
}
|
|
509
|
-
drained += 1;
|
|
510
|
-
}
|
|
511
|
-
if (drained >= DRAIN_MAX) break;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
plugin.getNotices = async function (notices) {
|
|
516
|
-
const checkHealth = await plugin.checkHealth();
|
|
517
|
-
notices.push({
|
|
518
|
-
done: checkHealth,
|
|
519
|
-
doneText: 'MeiliSearch connection OK',
|
|
520
|
-
notDoneText: 'Could not connect to MeiliSearch',
|
|
521
|
-
});
|
|
522
|
-
return notices;
|
|
523
|
-
};
|
|
524
|
-
|
|
525
|
-
plugin.updateIndexSettings = async (data) => {
|
|
526
|
-
await ensureIndex('post', 'pid');
|
|
527
|
-
await ensureIndex('topic', 'tid');
|
|
528
|
-
await ensureIndex('chat_message', 'mid');
|
|
529
|
-
data = {
|
|
530
|
-
maxDocuments: parseInt(data?.maxDocuments || await settings.getOne(plugin.id, 'maxDocuments') || 500, 10),
|
|
531
|
-
rankingRules: (data?.rankingRules || await settings.getOne(plugin.id, 'rankingRules'))?.map(value => value.rule),
|
|
532
|
-
stopWords: (data?.stopWords || await settings.getOne(plugin.id, 'stopWords'))?.map(value => value.word),
|
|
533
|
-
typoTolerance: ['on', true].includes(
|
|
534
|
-
data?.typoTolerance || await settings.getOne(plugin.id, 'typoTolerance') || undefined,
|
|
535
|
-
),
|
|
536
|
-
typoToleranceMinWordSizeOneTypo: parseInt(
|
|
537
|
-
data?.typoToleranceMinWordSizeOneTypo ||
|
|
538
|
-
await settings.getOne(plugin.id, 'typoToleranceMinWordSizeOneTypo') || 5,
|
|
539
|
-
10,
|
|
540
|
-
),
|
|
541
|
-
typoToleranceMinWordSizeTwoTypos: parseInt(
|
|
542
|
-
data?.typoToleranceMinWordSizeTwoTypos ||
|
|
543
|
-
await settings.getOne(plugin.id, 'typoToleranceMinWordSizeTwoTypos') || 9,
|
|
544
|
-
10,
|
|
545
|
-
),
|
|
546
|
-
typoToleranceDisableOnWords:
|
|
547
|
-
(data?.typoToleranceDisableOnWords || await settings.getOne(plugin.id, 'typoToleranceDisableOnWords') ||
|
|
548
|
-
undefined)?.map(value => value.word),
|
|
549
|
-
synonyms: Object.fromEntries(
|
|
550
|
-
(data?.synonyms || await settings.getOne(plugin.id, 'synonyms') || [])?.map((
|
|
551
|
-
{ word, synonyms },
|
|
552
|
-
) => [word, synonyms?.split(',').map(synonym => synonym.trim())]),
|
|
553
|
-
),
|
|
554
|
-
};
|
|
555
|
-
const postTask = await plugin.client.index('post').updateSettings({
|
|
556
|
-
filterableAttributes: ['tid', 'cid', 'uid', 'timestamp'],
|
|
557
|
-
sortableAttributes: ['timestamp', 'cid'],
|
|
558
|
-
searchableAttributes: ['content'],
|
|
559
|
-
pagination: {
|
|
560
|
-
maxTotalHits: data.maxDocuments,
|
|
561
|
-
},
|
|
562
|
-
rankingRules: data.rankingRules,
|
|
563
|
-
stopWords: data.stopWords,
|
|
564
|
-
typoTolerance: {
|
|
565
|
-
enabled: data.typoTolerance,
|
|
566
|
-
minWordSizeForTypos: {
|
|
567
|
-
oneTypo: data.typoToleranceMinWordSizeOneTypo,
|
|
568
|
-
twoTypos: data.typoToleranceMinWordSizeTwoTypos,
|
|
569
|
-
},
|
|
570
|
-
disableOnWords: data.typoToleranceDisableOnWords,
|
|
571
|
-
},
|
|
572
|
-
synonyms: data.synonyms,
|
|
573
|
-
});
|
|
574
|
-
const topicTask = await plugin.client.index('topic').updateSettings({
|
|
575
|
-
filterableAttributes: ['cid', 'uid', 'timestamp'],
|
|
576
|
-
sortableAttributes: ['cid', 'title', 'timestamp'],
|
|
577
|
-
searchableAttributes: ['title'],
|
|
578
|
-
pagination: {
|
|
579
|
-
maxTotalHits: data.maxDocuments,
|
|
580
|
-
},
|
|
581
|
-
rankingRules: data.rankingRules,
|
|
582
|
-
stopWords: data.stopWords,
|
|
583
|
-
typoTolerance: {
|
|
584
|
-
enabled: data.typoTolerance,
|
|
585
|
-
minWordSizeForTypos: {
|
|
586
|
-
oneTypo: data.typoToleranceMinWordSizeOneTypo,
|
|
587
|
-
twoTypos: data.typoToleranceMinWordSizeTwoTypos,
|
|
588
|
-
},
|
|
589
|
-
disableOnWords: data.typoToleranceDisableOnWords,
|
|
590
|
-
},
|
|
591
|
-
synonyms: data.synonyms,
|
|
592
|
-
});
|
|
593
|
-
const messageTask = await plugin.client.index('chat_message').updateSettings({
|
|
594
|
-
filterableAttributes: ['roomId', 'uid', 'timestamp'],
|
|
595
|
-
sortableAttributes: ['timestamp'],
|
|
596
|
-
searchableAttributes: ['content'],
|
|
597
|
-
pagination: {
|
|
598
|
-
maxTotalHits: data.maxDocuments,
|
|
599
|
-
},
|
|
600
|
-
rankingRules: data.rankingRules,
|
|
601
|
-
stopWords: data.stopWords,
|
|
602
|
-
typoTolerance: {
|
|
603
|
-
enabled: data.typoTolerance,
|
|
604
|
-
minWordSizeForTypos: {
|
|
605
|
-
oneTypo: data.typoToleranceMinWordSizeOneTypo,
|
|
606
|
-
twoTypos: data.typoToleranceMinWordSizeTwoTypos,
|
|
607
|
-
},
|
|
608
|
-
disableOnWords: data.typoToleranceDisableOnWords,
|
|
609
|
-
},
|
|
610
|
-
synonyms: data.synonyms,
|
|
611
|
-
});
|
|
612
|
-
return [postTask, topicTask, messageTask];
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
plugin.reindex = async function (force = false) {
|
|
616
|
-
if (plugin.indexing.running) {
|
|
617
|
-
winston.warn('[plugin/meilisearch] Already indexing');
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
plugin.indexing = {
|
|
621
|
-
running: true,
|
|
622
|
-
topic_progress: {
|
|
623
|
-
current: 0,
|
|
624
|
-
total: 0,
|
|
625
|
-
},
|
|
626
|
-
post_progress: {
|
|
627
|
-
current: 0,
|
|
628
|
-
total: 0,
|
|
629
|
-
},
|
|
630
|
-
message_progress: {
|
|
631
|
-
current: 0,
|
|
632
|
-
total: 0,
|
|
633
|
-
},
|
|
634
|
-
};
|
|
635
|
-
emitProgress();
|
|
636
|
-
winston.info(`[plugin/meilisearch] Indexing posts, topics and chat messages${force ? ' (forced)' : ''}`);
|
|
637
|
-
plugin._reindexPromise = runReindex(force);
|
|
638
|
-
return plugin._reindexPromise;
|
|
639
|
-
};
|
|
640
|
-
|
|
641
|
-
async function acquireReindexLock() {
|
|
642
|
-
const ttl = await db.ttl(REINDEX_LOCK_KEY);
|
|
643
|
-
if (ttl > 0) {
|
|
644
|
-
winston.warn('[plugin/meilisearch] Reindex lock held by another instance');
|
|
645
|
-
return false;
|
|
646
|
-
}
|
|
647
|
-
await db.set(REINDEX_LOCK_KEY, String(Date.now()));
|
|
648
|
-
await db.expire(REINDEX_LOCK_KEY, REINDEX_LOCK_TTL);
|
|
649
|
-
plugin.reindexLockRefresh = setInterval(() => {
|
|
650
|
-
db.expire(REINDEX_LOCK_KEY, REINDEX_LOCK_TTL).catch(err =>
|
|
651
|
-
winston.error(`[plugin/meilisearch] lock refresh failed: ${err.message}`));
|
|
652
|
-
}, LOCK_REFRESH_INTERVAL);
|
|
653
|
-
return true;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
async function releaseReindexLock() {
|
|
657
|
-
if (plugin.reindexLockRefresh) {
|
|
658
|
-
clearInterval(plugin.reindexLockRefresh);
|
|
659
|
-
plugin.reindexLockRefresh = null;
|
|
660
|
-
}
|
|
661
|
-
try {
|
|
662
|
-
await db.delete(REINDEX_LOCK_KEY);
|
|
663
|
-
} catch (err) {
|
|
664
|
-
winston.warn(`[plugin/meilisearch] Could not release reindex lock: ${err.message}`);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
async function runReindex(force) {
|
|
669
|
-
let succeeded = false;
|
|
670
|
-
let errorMsg = null;
|
|
671
|
-
let skippedDeletedTopics = 0;
|
|
672
|
-
let skippedDeletedPosts = 0;
|
|
673
|
-
let skippedDeletedMessages = 0;
|
|
674
|
-
let skippedSystemMessages = 0;
|
|
675
|
-
let skippedOrphanMessages = 0;
|
|
676
|
-
const locked = await acquireReindexLock();
|
|
677
|
-
if (!locked) {
|
|
678
|
-
plugin.indexing.running = false;
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
pubsub.publish('meilisearch:init', true);
|
|
682
|
-
plugin.initializingOnAnotherInstance = true;
|
|
683
|
-
try {
|
|
684
|
-
if (force) {
|
|
685
|
-
plugin.reindexingForced = true;
|
|
686
|
-
const postTask = await plugin.client.index('post').deleteAllDocuments();
|
|
687
|
-
const topicTask = await plugin.client.index('topic').deleteAllDocuments();
|
|
688
|
-
const messageTask = await plugin.client.index('chat_message').deleteAllDocuments();
|
|
689
|
-
await plugin.client.tasks.waitForTask(postTask.taskUid, { timeout: 600000, interval: 200 });
|
|
690
|
-
await plugin.client.tasks.waitForTask(topicTask.taskUid, { timeout: 600000, interval: 200 });
|
|
691
|
-
await plugin.client.tasks.waitForTask(messageTask.taskUid, { timeout: 600000, interval: 200 });
|
|
692
|
-
}
|
|
693
|
-
const [postSettingsTask, topicSettingsTask, messageSettingsTask] = await plugin.updateIndexSettings();
|
|
694
|
-
await plugin.client.tasks.waitForTask(postSettingsTask.taskUid, { timeout: 600000, interval: 200 });
|
|
695
|
-
await plugin.client.tasks.waitForTask(topicSettingsTask.taskUid, { timeout: 600000, interval: 200 });
|
|
696
|
-
await plugin.client.tasks.waitForTask(messageSettingsTask.taskUid, { timeout: 600000, interval: 200 });
|
|
697
|
-
plugin.indexing.topic_progress.total = await db.sortedSetCard('topics:tid') || 0;
|
|
698
|
-
plugin.indexing.post_progress.total = await db.sortedSetCard('posts:pid') || 0;
|
|
699
|
-
plugin.indexing.message_progress.total = await db.sortedSetCard('messages:mid') || 0;
|
|
700
|
-
emitProgress();
|
|
701
|
-
await Promise.all([
|
|
702
|
-
batch.processSortedSet(
|
|
703
|
-
'topics:tid',
|
|
704
|
-
async (tids) => {
|
|
705
|
-
plugin.indexing.topic_progress.current += tids.length;
|
|
706
|
-
emitProgress();
|
|
707
|
-
const topics = await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'mainPid', 'title', 'timestamp', 'deleted']);
|
|
708
|
-
const activeTopics = [];
|
|
709
|
-
const deletedTids = [];
|
|
710
|
-
topics.forEach((topic) => {
|
|
711
|
-
if (parseInt(topic.deleted, 10) === 1) {
|
|
712
|
-
deletedTids.push(topic.tid);
|
|
713
|
-
} else {
|
|
714
|
-
activeTopics.push(topic);
|
|
715
|
-
}
|
|
716
|
-
});
|
|
717
|
-
skippedDeletedTopics += deletedTids.length;
|
|
718
|
-
if (deletedTids.length) {
|
|
719
|
-
await plugin.client.index('topic').deleteDocuments(deletedTids);
|
|
720
|
-
}
|
|
721
|
-
if (!activeTopics.length) return;
|
|
722
|
-
await plugin.client.index('topic').updateDocuments(
|
|
723
|
-
activeTopics.map(topic => ({
|
|
724
|
-
tid: topic.tid,
|
|
725
|
-
cid: topic.cid,
|
|
726
|
-
uid: topic.uid,
|
|
727
|
-
mainPid: topic.mainPid,
|
|
728
|
-
title: topic.title,
|
|
729
|
-
timestamp: topic.timestamp,
|
|
730
|
-
})),
|
|
731
|
-
{ primaryKey: 'tid' },
|
|
732
|
-
);
|
|
733
|
-
},
|
|
734
|
-
{
|
|
735
|
-
batch: REINDEX_BATCH_SIZE,
|
|
736
|
-
progress: plugin.indexing.topic_progress,
|
|
737
|
-
},
|
|
738
|
-
),
|
|
739
|
-
batch.processSortedSet(
|
|
740
|
-
'posts:pid',
|
|
741
|
-
async (pids) => {
|
|
742
|
-
plugin.indexing.post_progress.current += pids.length;
|
|
743
|
-
emitProgress();
|
|
744
|
-
const posts = await Posts.getPostsFields(pids, ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
|
|
745
|
-
// Fetch topic deleted status for posts in this batch
|
|
746
|
-
const tids = [...new Set(posts.map(p => p && p.tid).filter(Boolean))];
|
|
747
|
-
const topicsData = tids.length ? await Topics.getTopicsFields(tids, ['tid', 'deleted']) : [];
|
|
748
|
-
const tidToDeleted = {};
|
|
749
|
-
topicsData.forEach((t) => { tidToDeleted[t.tid] = parseInt(t.deleted, 10) === 1; });
|
|
750
|
-
|
|
751
|
-
const activePosts = [];
|
|
752
|
-
const deletedPids = [];
|
|
753
|
-
posts.forEach((post) => {
|
|
754
|
-
if (parseInt(post.deleted, 10) === 1 || tidToDeleted[post.tid]) {
|
|
755
|
-
deletedPids.push(post.pid);
|
|
756
|
-
} else {
|
|
757
|
-
activePosts.push(post);
|
|
758
|
-
}
|
|
759
|
-
});
|
|
760
|
-
skippedDeletedPosts += deletedPids.length;
|
|
761
|
-
if (deletedPids.length) {
|
|
762
|
-
await plugin.client.index('post').deleteDocuments(deletedPids);
|
|
763
|
-
}
|
|
764
|
-
const activePids = activePosts.map(p => p.pid);
|
|
765
|
-
const cids = activePids.length ? await Posts.getCidsByPids(activePids) : [];
|
|
766
|
-
await plugin.client.index('post').updateDocuments(
|
|
767
|
-
activePosts.map((post, index) => ({
|
|
768
|
-
pid: post.pid,
|
|
769
|
-
tid: post.tid,
|
|
770
|
-
cid: cids[index],
|
|
771
|
-
uid: post.uid,
|
|
772
|
-
content: post.content,
|
|
773
|
-
timestamp: post.timestamp,
|
|
774
|
-
})),
|
|
775
|
-
{ primaryKey: 'pid' },
|
|
776
|
-
);
|
|
777
|
-
},
|
|
778
|
-
{
|
|
779
|
-
batch: REINDEX_BATCH_SIZE,
|
|
780
|
-
progress: plugin.indexing.post_progress,
|
|
781
|
-
},
|
|
782
|
-
),
|
|
783
|
-
]);
|
|
784
|
-
// Reindex chat messages SEQUENTIALLY after topics+posts (keeps Meilisearch enqueue rate bounded).
|
|
785
|
-
// Option B: cross-check each batch's roomIds against existing rooms and deindex orphan messages
|
|
786
|
-
// whose room was deleted via admin (messaging.deleteRooms fires no message hook and leaves
|
|
787
|
-
// message:${mid} objects + messages:mid entries, so they would otherwise be re-indexed as orphans).
|
|
788
|
-
await batch.processSortedSet(
|
|
789
|
-
'messages:mid',
|
|
790
|
-
async (mids) => {
|
|
791
|
-
plugin.indexing.message_progress.current += mids.length;
|
|
792
|
-
emitProgress();
|
|
793
|
-
const messages = await Messaging.getMessagesFields(mids, ['mid', 'content', 'roomId', 'fromuid', 'timestamp', 'deleted', 'system']);
|
|
794
|
-
// Resolve deleted/system messages and orphan rooms in one pass.
|
|
795
|
-
const seenRoomIds = [...new Set(messages.map(m => m && m.roomId).filter(rid => rid != null && rid !== ''))];
|
|
796
|
-
const roomsData = seenRoomIds.length ? await Messaging.getRoomsData(seenRoomIds) : [];
|
|
797
|
-
const roomIdToExists = {};
|
|
798
|
-
roomsData.forEach((room) => {
|
|
799
|
-
if (room && room.roomId != null) roomIdToExists[room.roomId] = true;
|
|
800
|
-
});
|
|
801
|
-
const activeMessages = [];
|
|
802
|
-
const deletedMids = [];
|
|
803
|
-
const systemMids = [];
|
|
804
|
-
const orphanMids = [];
|
|
805
|
-
messages.forEach((msg) => {
|
|
806
|
-
if (!msg || !msg.mid) return;
|
|
807
|
-
if (parseInt(msg.deleted, 10) === 1) {
|
|
808
|
-
deletedMids.push(msg.mid);
|
|
809
|
-
} else if (parseInt(msg.system, 10) === 1) {
|
|
810
|
-
systemMids.push(msg.mid);
|
|
811
|
-
} else if (!roomIdToExists[msg.roomId]) {
|
|
812
|
-
orphanMids.push(msg.mid);
|
|
813
|
-
} else {
|
|
814
|
-
activeMessages.push(msg);
|
|
815
|
-
}
|
|
816
|
-
});
|
|
817
|
-
skippedDeletedMessages += deletedMids.length;
|
|
818
|
-
skippedSystemMessages += systemMids.length;
|
|
819
|
-
skippedOrphanMessages += orphanMids.length;
|
|
820
|
-
const allDeindexMids = [...deletedMids, ...systemMids, ...orphanMids];
|
|
821
|
-
if (allDeindexMids.length) {
|
|
822
|
-
await plugin.client.index('chat_message').deleteDocuments(allDeindexMids);
|
|
823
|
-
}
|
|
824
|
-
if (!activeMessages.length) return;
|
|
825
|
-
await plugin.client.index('chat_message').updateDocuments(
|
|
826
|
-
activeMessages.map(msg => ({
|
|
827
|
-
mid: msg.mid,
|
|
828
|
-
roomId: msg.roomId,
|
|
829
|
-
uid: msg.fromuid,
|
|
830
|
-
content: msg.content,
|
|
831
|
-
timestamp: msg.timestamp,
|
|
832
|
-
})),
|
|
833
|
-
{ primaryKey: 'mid' },
|
|
834
|
-
);
|
|
835
|
-
},
|
|
836
|
-
{ batch: REINDEX_BATCH_SIZE, progress: plugin.indexing.message_progress },
|
|
837
|
-
);
|
|
838
|
-
succeeded = true;
|
|
839
|
-
winston.info('[plugin/meilisearch] Indexing of posts, topics and chat messages complete');
|
|
840
|
-
} catch (err) {
|
|
841
|
-
winston.error(`[plugin/meilisearch] Indexing failed: ${err.message}`);
|
|
842
|
-
errorMsg = err.message;
|
|
843
|
-
} finally {
|
|
844
|
-
plugin.indexing.running = false;
|
|
845
|
-
plugin.reindexingForced = false;
|
|
846
|
-
plugin.initializingOnAnotherInstance = false;
|
|
847
|
-
pubsub.publish('meilisearch:init', false);
|
|
848
|
-
await releaseReindexLock();
|
|
849
|
-
emitProgress.flush();
|
|
850
|
-
const pending = plugin.pendingDuringReindex.splice(0);
|
|
851
|
-
for (const { op, payload } of pending) {
|
|
852
|
-
if (!REPLAY_OPS.includes(op)) continue;
|
|
853
|
-
try {
|
|
854
|
-
await plugin[op](payload);
|
|
855
|
-
} catch (err) {
|
|
856
|
-
winston.error(`[plugin/meilisearch] replay ${op} failed: ${err.message}`);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
const lastIndexResult = {
|
|
860
|
-
success: succeeded,
|
|
861
|
-
finishedAt: Date.now(),
|
|
862
|
-
topic_progress: {
|
|
863
|
-
current: plugin.indexing.topic_progress.current,
|
|
864
|
-
total: plugin.indexing.topic_progress.total,
|
|
865
|
-
},
|
|
866
|
-
post_progress: {
|
|
867
|
-
current: plugin.indexing.post_progress.current,
|
|
868
|
-
total: plugin.indexing.post_progress.total,
|
|
869
|
-
},
|
|
870
|
-
message_progress: {
|
|
871
|
-
current: plugin.indexing.message_progress.current,
|
|
872
|
-
total: plugin.indexing.message_progress.total,
|
|
873
|
-
},
|
|
874
|
-
skippedDeletedTopics,
|
|
875
|
-
skippedDeletedPosts,
|
|
876
|
-
skippedDeletedMessages,
|
|
877
|
-
skippedSystemMessages,
|
|
878
|
-
skippedOrphanMessages,
|
|
879
|
-
error: errorMsg,
|
|
880
|
-
};
|
|
881
|
-
try {
|
|
882
|
-
await settings.set(plugin.id, { lastReindexResult: lastIndexResult }, true);
|
|
883
|
-
if (succeeded) {
|
|
884
|
-
await settings.set(plugin.id, { indexed: true }, true);
|
|
885
|
-
}
|
|
886
|
-
} catch (err) {
|
|
887
|
-
winston.error(`[plugin/meilisearch] Failed to persist reindex result: ${err.message}`);
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
plugin.indexPost = async function ({ post, tid: newTid }) {
|
|
893
|
-
const deferPayload = { post, tid: newTid };
|
|
894
|
-
if (await maybeDefer('indexPost', deferPayload)) return;
|
|
895
|
-
// #3: On post.move, post.tid is stale and content may be missing.
|
|
896
|
-
// Re-fetch to get fresh tid + content from DB (already updated before action hook fires).
|
|
897
|
-
if (newTid !== undefined || !post || post.content === undefined) {
|
|
898
|
-
if (post && post.pid) {
|
|
899
|
-
post = await Posts.getPostData(post.pid);
|
|
900
|
-
}
|
|
901
|
-
if (!post) return;
|
|
902
|
-
}
|
|
903
|
-
// Issue 3: Skip deleted posts (admin editing a deleted post should not re-index it).
|
|
904
|
-
if (parseInt(post.deleted, 10) === 1) {
|
|
905
|
-
winston.debug(`[plugin/meilisearch] Skipping deleted post ${post.pid}`);
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
// Point 1: Skip posts in deleted or scheduled topics.
|
|
909
|
-
const topicData = await Topics.getTopicFields(post.tid, ['deleted', 'timestamp']);
|
|
910
|
-
if (parseInt(topicData?.deleted, 10) === 1) {
|
|
911
|
-
winston.debug(`[plugin/meilisearch] Skipping post ${post.pid} in deleted topic ${post.tid}`);
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
if (topicData?.timestamp && topicData.timestamp > Date.now()) {
|
|
915
|
-
winston.debug(`[plugin/meilisearch] Skipping post ${post.pid} in scheduled topic ${post.tid}`);
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
if (!post.cid) {
|
|
919
|
-
post.cid = await Posts.getCidByPid(post.pid);
|
|
920
|
-
}
|
|
921
|
-
const doc = {
|
|
922
|
-
pid: post.pid,
|
|
923
|
-
tid: post.tid,
|
|
924
|
-
cid: post.cid,
|
|
925
|
-
uid: post.uid,
|
|
926
|
-
content: post.content,
|
|
927
|
-
timestamp: post.timestamp,
|
|
928
|
-
};
|
|
929
|
-
await meiliWrite('indexPost', { post: { pid: post.pid } }, () =>
|
|
930
|
-
plugin.client.index('post').updateDocuments([doc], { primaryKey: 'pid' }));
|
|
931
|
-
};
|
|
932
|
-
|
|
933
|
-
plugin.deindexPost = async function ({ post }) {
|
|
934
|
-
if (await maybeDefer('deindexPost', { post })) return;
|
|
935
|
-
await meiliWrite('deindexPost', { post }, () =>
|
|
936
|
-
plugin.client.index('post').deleteDocument(post.pid));
|
|
937
|
-
};
|
|
938
|
-
|
|
939
|
-
plugin.indexTopic = async function ({ topic, tid, fromCid, post }) {
|
|
940
|
-
const isMove = fromCid !== undefined;
|
|
941
|
-
if (await maybeDefer('indexTopic', { topic, tid, fromCid, post })) return;
|
|
942
|
-
// #2: On topic.move, no 'topic' key is provided — re-fetch by tid.
|
|
943
|
-
if (!topic && tid) {
|
|
944
|
-
topic = await Topics.getTopicData(tid);
|
|
945
|
-
}
|
|
946
|
-
if (!topic) {
|
|
947
|
-
winston.warn('[plugin/meilisearch] indexTopic: no topic data');
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
// Issue 3: Skip deleted topics (admin editing/moving a deleted topic should not re-index it).
|
|
951
|
-
if (parseInt(topic.deleted, 10) === 1) {
|
|
952
|
-
winston.debug(`[plugin/meilisearch] Skipping deleted topic ${topic.tid}`);
|
|
953
|
-
return;
|
|
954
|
-
}
|
|
955
|
-
// Issue 5: Skip scheduled topics (timestamp in future) — indexed on publish via onScheduledPublish.
|
|
956
|
-
// action:topic.post fires AFTER action:post.save, so the main post may already be in Meili.
|
|
957
|
-
// Deindex it here to prevent full-search from finding scheduled content prematurely.
|
|
958
|
-
if (topic.scheduled || (topic.timestamp && topic.timestamp > Date.now())) {
|
|
959
|
-
const mainPid = post ? post.pid : (parseInt(topic.mainPid, 10) > 0 ? topic.mainPid : null);
|
|
960
|
-
if (mainPid) {
|
|
961
|
-
await plugin.client.index('post').deleteDocument(mainPid).catch(() => {});
|
|
962
|
-
}
|
|
963
|
-
winston.debug(`[plugin/meilisearch] Skipping scheduled topic ${topic.tid}, deindexed main post ${mainPid || 'N/A'}`);
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
await meiliWrite('indexTopic', { topic, tid, fromCid, post }, () =>
|
|
967
|
-
plugin.client.index('topic').updateDocuments([{
|
|
968
|
-
tid: topic.tid,
|
|
969
|
-
cid: topic.cid,
|
|
970
|
-
uid: topic.uid,
|
|
971
|
-
mainPid: topic.mainPid,
|
|
972
|
-
title: topic.title,
|
|
973
|
-
timestamp: topic.timestamp,
|
|
974
|
-
}], { primaryKey: 'tid' }));
|
|
975
|
-
// #4: On topic move, re-index child posts' cids (fire-and-forget with fallback).
|
|
976
|
-
if (isMove && topic.tid) {
|
|
977
|
-
if (plugin.healthy) {
|
|
978
|
-
plugin.reindexTopicPosts({ tid: topic.tid }).catch(err =>
|
|
979
|
-
winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`));
|
|
980
|
-
} else {
|
|
981
|
-
await enqueuePending('reindexTopicPosts', { tid: topic.tid });
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
};
|
|
985
|
-
|
|
986
|
-
plugin.reindexTopicPosts = async function ({ tid }) {
|
|
987
|
-
if (!tid) return;
|
|
988
|
-
const postIndex = plugin.client.index('post');
|
|
989
|
-
try {
|
|
990
|
-
// Bug A: mainPid is NOT in tid:${tid}:posts — index it separately.
|
|
991
|
-
const mainPid = await Topics.getTopicField(tid, 'mainPid');
|
|
992
|
-
if (mainPid) {
|
|
993
|
-
const [mainPost] = await Posts.getPostsFields([mainPid], ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
|
|
994
|
-
if (mainPost && parseInt(mainPost.deleted, 10) !== 1) {
|
|
995
|
-
const [mainCid] = await Posts.getCidsByPids([mainPid]);
|
|
996
|
-
await postIndex.updateDocuments([{
|
|
997
|
-
pid: mainPost.pid,
|
|
998
|
-
tid: mainPost.tid,
|
|
999
|
-
cid: mainCid,
|
|
1000
|
-
uid: mainPost.uid,
|
|
1001
|
-
content: mainPost.content,
|
|
1002
|
-
timestamp: mainPost.timestamp,
|
|
1003
|
-
}], { primaryKey: 'pid' });
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
// Bug B: skip individually-deleted posts in the batch loop.
|
|
1007
|
-
await batch.processSortedSet(
|
|
1008
|
-
`tid:${tid}:posts`,
|
|
1009
|
-
async (pids) => {
|
|
1010
|
-
if (!pids.length) return;
|
|
1011
|
-
const posts = await Posts.getPostsFields(pids, ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
|
|
1012
|
-
const activePosts = posts.filter(post => post && parseInt(post.deleted, 10) !== 1);
|
|
1013
|
-
if (!activePosts.length) return;
|
|
1014
|
-
const activePids = activePosts.map(p => p.pid);
|
|
1015
|
-
const cids = await Posts.getCidsByPids(activePids);
|
|
1016
|
-
await postIndex.updateDocuments(
|
|
1017
|
-
activePosts.map((post, index) => ({
|
|
1018
|
-
pid: post.pid,
|
|
1019
|
-
tid: post.tid,
|
|
1020
|
-
cid: cids[index],
|
|
1021
|
-
uid: post.uid,
|
|
1022
|
-
content: post.content,
|
|
1023
|
-
timestamp: post.timestamp,
|
|
1024
|
-
})),
|
|
1025
|
-
{ primaryKey: 'pid' },
|
|
1026
|
-
);
|
|
1027
|
-
},
|
|
1028
|
-
{ batch: REINDEX_BATCH_SIZE },
|
|
1029
|
-
);
|
|
1030
|
-
} catch (err) {
|
|
1031
|
-
winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`);
|
|
1032
|
-
plugin.healthy = false;
|
|
1033
|
-
await enqueuePending('reindexTopicPosts', { tid });
|
|
1034
|
-
}
|
|
1035
|
-
};
|
|
1036
|
-
|
|
1037
|
-
plugin.deindexTopic = async function ({ topic, tid }) {
|
|
1038
|
-
if (await maybeDefer('deindexTopic', { topic, tid })) return;
|
|
1039
|
-
// Tolerate topic.move-style payloads (no 'topic' key) — extract tid.
|
|
1040
|
-
const topicTid = topic ? topic.tid : tid;
|
|
1041
|
-
if (!topicTid) {
|
|
1042
|
-
winston.warn('[plugin/meilisearch] deindexTopic: no tid');
|
|
1043
|
-
return;
|
|
1044
|
-
}
|
|
1045
|
-
await meiliWrite('deindexTopic', { topic, tid }, () =>
|
|
1046
|
-
plugin.client.index('topic').deleteDocument(topicTid));
|
|
1047
|
-
// Also deindex the topic's posts from the post index so admin /search
|
|
1048
|
-
// (which queries both indexes and bypasses NodeBB's relevance-sort
|
|
1049
|
-
// deleted-content filter for admins) can't surface deleted-topic content.
|
|
1050
|
-
if (plugin.healthy) {
|
|
1051
|
-
plugin.deindexTopicPosts({ tid: topicTid }).catch(err =>
|
|
1052
|
-
winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`));
|
|
1053
|
-
} else {
|
|
1054
|
-
await enqueuePending('deindexTopicPosts', { tid: topicTid });
|
|
1055
|
-
}
|
|
1056
|
-
};
|
|
1057
|
-
|
|
1058
|
-
plugin.deindexTopicPosts = async function ({ tid }) {
|
|
1059
|
-
if (!tid) return;
|
|
1060
|
-
const postIndex = plugin.client.index('post');
|
|
1061
|
-
try {
|
|
1062
|
-
// mainPid is NOT in tid:${tid}:posts — delete it separately.
|
|
1063
|
-
const mainPid = await Topics.getTopicField(tid, 'mainPid');
|
|
1064
|
-
const mainPidArr = mainPid ? [mainPid] : [];
|
|
1065
|
-
await batch.processSortedSet(
|
|
1066
|
-
`tid:${tid}:posts`,
|
|
1067
|
-
async (pids) => {
|
|
1068
|
-
if (!pids.length) return;
|
|
1069
|
-
await postIndex.deleteDocuments(pids);
|
|
1070
|
-
},
|
|
1071
|
-
{ batch: REINDEX_BATCH_SIZE },
|
|
1072
|
-
);
|
|
1073
|
-
if (mainPidArr.length) {
|
|
1074
|
-
await postIndex.deleteDocuments(mainPidArr);
|
|
1075
|
-
}
|
|
1076
|
-
} catch (err) {
|
|
1077
|
-
winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`);
|
|
1078
|
-
plugin.healthy = false;
|
|
1079
|
-
await enqueuePending('deindexTopicPosts', { tid });
|
|
1080
|
-
}
|
|
1081
|
-
};
|
|
1082
|
-
|
|
1083
|
-
plugin.restoreTopic = async function ({ topic, tid }) {
|
|
1084
|
-
const topicTid = topic ? topic.tid : tid;
|
|
1085
|
-
if (await maybeDefer('restoreTopic', { topic, tid })) return;
|
|
1086
|
-
// Re-fetch topic if missing (defensive — same pattern as indexTopic).
|
|
1087
|
-
if (!topic && topicTid) {
|
|
1088
|
-
topic = await Topics.getTopicData(topicTid);
|
|
1089
|
-
}
|
|
1090
|
-
if (!topic) {
|
|
1091
|
-
winston.warn('[plugin/meilisearch] restoreTopic: no topic data');
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
// Re-index the topic document.
|
|
1095
|
-
await meiliWrite('restoreTopic', { topic, tid }, () =>
|
|
1096
|
-
plugin.client.index('topic').updateDocuments([{
|
|
1097
|
-
tid: topic.tid,
|
|
1098
|
-
cid: topic.cid,
|
|
1099
|
-
uid: topic.uid,
|
|
1100
|
-
mainPid: topic.mainPid,
|
|
1101
|
-
title: topic.title,
|
|
1102
|
-
timestamp: topic.timestamp,
|
|
1103
|
-
}], { primaryKey: 'tid' }));
|
|
1104
|
-
// Re-index the topic's posts (they were deindexed on delete).
|
|
1105
|
-
if (plugin.healthy) {
|
|
1106
|
-
plugin.reindexTopicPosts({ tid: topic.tid }).catch(err =>
|
|
1107
|
-
winston.error(`[plugin/meilisearch] restoreTopic reindexTopicPosts failed: ${err.message}`));
|
|
1108
|
-
} else {
|
|
1109
|
-
await enqueuePending('reindexTopicPosts', { tid: topic.tid });
|
|
1110
|
-
}
|
|
1111
|
-
};
|
|
1112
|
-
|
|
1113
|
-
plugin.changePostOwner = async function ({ posts, toUid }) {
|
|
1114
|
-
if (await maybeDefer('changePostOwner', { posts, toUid })) return;
|
|
1115
|
-
// posts have OLD uid — re-fetch from DB to get fresh uid.
|
|
1116
|
-
// Skip deleted posts (changeOwner can be called on deleted posts).
|
|
1117
|
-
const validPids = (Array.isArray(posts) ? posts : [])
|
|
1118
|
-
.filter(p => p && p.pid && parseInt(p.deleted, 10) !== 1)
|
|
1119
|
-
.map(p => p.pid);
|
|
1120
|
-
if (!validPids.length) return;
|
|
1121
|
-
const freshPosts = await Posts.getPostsFields(validPids, ['pid', 'tid', 'uid', 'content', 'timestamp']);
|
|
1122
|
-
// Fix 7: Skip posts in deleted or scheduled topics.
|
|
1123
|
-
const tids = [...new Set(freshPosts.map(p => p && p.tid).filter(Boolean))];
|
|
1124
|
-
const topicsData = tids.length ? await Topics.getTopicsFields(tids, ['tid', 'deleted', 'timestamp']) : [];
|
|
1125
|
-
const tidToDeleted = {};
|
|
1126
|
-
const tidToScheduled = {};
|
|
1127
|
-
topicsData.forEach((t) => {
|
|
1128
|
-
tidToDeleted[t.tid] = parseInt(t.deleted, 10) === 1;
|
|
1129
|
-
tidToScheduled[t.tid] = t.timestamp && t.timestamp > Date.now();
|
|
1130
|
-
});
|
|
1131
|
-
const activePosts = freshPosts.filter(post => post && !tidToDeleted[post.tid] && !tidToScheduled[post.tid]);
|
|
1132
|
-
if (!activePosts.length) return;
|
|
1133
|
-
const activePids = activePosts.map(p => p.pid);
|
|
1134
|
-
const cids = await Posts.getCidsByPids(activePids);
|
|
1135
|
-
const docs = activePosts.map((post, index) => ({
|
|
1136
|
-
pid: post.pid,
|
|
1137
|
-
tid: post.tid,
|
|
1138
|
-
cid: cids[index],
|
|
1139
|
-
uid: post.uid,
|
|
1140
|
-
content: post.content,
|
|
1141
|
-
timestamp: post.timestamp,
|
|
1142
|
-
}));
|
|
1143
|
-
await meiliWrite('changePostOwner', { posts: activePids.map(pid => ({ pid })), toUid }, () =>
|
|
1144
|
-
plugin.client.index('post').updateDocuments(docs, { primaryKey: 'pid' }));
|
|
1145
|
-
};
|
|
1146
|
-
|
|
1147
|
-
plugin.changeTopicOwner = async function ({ topics, toUid }) {
|
|
1148
|
-
if (await maybeDefer('changeTopicOwner', { topics, toUid })) return;
|
|
1149
|
-
// topics have OLD uid — re-fetch from DB to get fresh uid.
|
|
1150
|
-
// Skip deleted or scheduled topics.
|
|
1151
|
-
const validTids = (Array.isArray(topics) ? topics : [])
|
|
1152
|
-
.filter(t => t && t.tid && parseInt(t.deleted, 10) !== 1)
|
|
1153
|
-
.map(t => t.tid);
|
|
1154
|
-
if (!validTids.length) return;
|
|
1155
|
-
const freshTopics = await Topics.getTopicsFields(validTids, ['tid', 'cid', 'uid', 'mainPid', 'title', 'timestamp']);
|
|
1156
|
-
// Fix 7: Skip scheduled topics (timestamp in future).
|
|
1157
|
-
const activeTopics = freshTopics.filter(t => t && !(t.timestamp && t.timestamp > Date.now()));
|
|
1158
|
-
if (!activeTopics.length) return;
|
|
1159
|
-
const docs = activeTopics.map(topic => ({
|
|
1160
|
-
tid: topic.tid,
|
|
1161
|
-
cid: topic.cid,
|
|
1162
|
-
uid: topic.uid,
|
|
1163
|
-
mainPid: topic.mainPid,
|
|
1164
|
-
title: topic.title,
|
|
1165
|
-
timestamp: topic.timestamp,
|
|
1166
|
-
}));
|
|
1167
|
-
await meiliWrite('changeTopicOwner', { topics: activeTopics.map(t => ({ tid: t.tid })), toUid }, () =>
|
|
1168
|
-
plugin.client.index('topic').updateDocuments(docs, { primaryKey: 'tid' }));
|
|
1169
|
-
};
|
|
1170
|
-
|
|
1171
|
-
plugin.onTopicMerge = async function ({ otherTids, mergeIntoTid }) {
|
|
1172
|
-
if (await maybeDefer('onTopicMerge', { otherTids, mergeIntoTid })) return;
|
|
1173
|
-
const postIndex = plugin.client.index('post');
|
|
1174
|
-
// Deindex merged-out topics + their posts (Topics.delete in merge fires no action:topic.delete).
|
|
1175
|
-
for (const tid of otherTids) {
|
|
1176
|
-
await plugin.deindexTopic({ tid });
|
|
1177
|
-
// Merge zeros mainPid before Topics.delete, so deindexTopicPosts can't find the main post.
|
|
1178
|
-
// Fallback: search Meili for any orphaned posts still carrying this tid.
|
|
1179
|
-
try {
|
|
1180
|
-
const result = await postIndex.search('', { filter: `tid = ${parseInt(tid, 10)}`, limit: 1000 });
|
|
1181
|
-
if (result.hits && result.hits.length) {
|
|
1182
|
-
const orphanPids = result.hits.map(h => h.pid);
|
|
1183
|
-
await meiliWrite('deindexPostsPurge', { posts: orphanPids.map(pid => ({ pid })) }, () =>
|
|
1184
|
-
postIndex.deleteDocuments(orphanPids));
|
|
1185
|
-
}
|
|
1186
|
-
} catch (err) {
|
|
1187
|
-
winston.error(`[plugin/meilisearch] onTopicMerge orphan cleanup for tid ${tid} failed: ${err.message}`);
|
|
1188
|
-
plugin.healthy = false;
|
|
1189
|
-
// Don't enqueue — orphans are cleaned by next reindex. Continue to next tid.
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
// Re-index the merge target (postcount/lastpost changed; posts already moved via action:post.move).
|
|
1193
|
-
await plugin.indexTopic({ tid: mergeIntoTid });
|
|
1194
|
-
if (plugin.healthy) {
|
|
1195
|
-
plugin.reindexTopicPosts({ tid: mergeIntoTid }).catch(err =>
|
|
1196
|
-
winston.error(`[plugin/meilisearch] onTopicMerge reindexTopicPosts failed: ${err.message}`));
|
|
1197
|
-
}
|
|
1198
|
-
};
|
|
1199
|
-
|
|
1200
|
-
plugin.onScheduledPublish = async function ({ topics }) {
|
|
1201
|
-
const tids = (Array.isArray(topics) ? topics : []).filter(t => t && t.tid).map(t => t.tid);
|
|
1202
|
-
if (!tids.length) return;
|
|
1203
|
-
if (await maybeDefer('onScheduledPublish', { topics: tids.map(tid => ({ tid })) })) return;
|
|
1204
|
-
// Re-index each published topic + its posts (DB deleted=0, timestamp in past now).
|
|
1205
|
-
for (const tid of tids) {
|
|
1206
|
-
await plugin.indexTopic({ tid });
|
|
1207
|
-
if (plugin.healthy) {
|
|
1208
|
-
plugin.reindexTopicPosts({ tid }).catch(err =>
|
|
1209
|
-
winston.error(`[plugin/meilisearch] onScheduledPublish reindexTopicPosts failed: ${err.message}`));
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
};
|
|
1213
|
-
|
|
1214
|
-
plugin.deindexPostsPurge = async function ({ posts }) {
|
|
1215
|
-
if (await maybeDefer('deindexPostsPurge', { posts })) return;
|
|
1216
|
-
const pids = Array.isArray(posts) ? posts.map(p => p && p.pid).filter(Boolean) : [];
|
|
1217
|
-
if (!pids.length) {
|
|
1218
|
-
return;
|
|
1219
|
-
}
|
|
1220
|
-
const reducedPayload = { posts: pids.map(pid => ({ pid })) };
|
|
1221
|
-
await meiliWrite('deindexPostsPurge', reducedPayload, () =>
|
|
1222
|
-
plugin.client.index('post').deleteDocuments(pids));
|
|
1223
|
-
};
|
|
1224
|
-
|
|
1225
|
-
plugin.deindexTopicsPurge = async function ({ topics }) {
|
|
1226
|
-
if (await maybeDefer('deindexTopicsPurge', { topics })) return;
|
|
1227
|
-
const topicsArr = Array.isArray(topics) ? topics.filter(t => t && t.tid) : [];
|
|
1228
|
-
if (!topicsArr.length) {
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
const tids = topicsArr.map(t => t.tid);
|
|
1232
|
-
const mainPids = topicsArr.map(t => t.mainPid).filter(Boolean);
|
|
1233
|
-
const postIndex = plugin.client.index('post');
|
|
1234
|
-
const reducedPayload = { topics: topicsArr.map(t => ({ tid: t.tid, mainPid: t.mainPid })) };
|
|
1235
|
-
try {
|
|
1236
|
-
await Promise.all([
|
|
1237
|
-
plugin.client.index('topic').deleteDocuments(tids),
|
|
1238
|
-
mainPids.length ? postIndex.deleteDocuments(mainPids) : Promise.resolve(),
|
|
1239
|
-
]);
|
|
1240
|
-
await Promise.all(topicsArr.map(async (t) => {
|
|
1241
|
-
await batch.processSortedSet(
|
|
1242
|
-
`tid:${t.tid}:posts`,
|
|
1243
|
-
async (pids) => {
|
|
1244
|
-
if (!pids.length) {
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
await postIndex.deleteDocuments(pids);
|
|
1248
|
-
},
|
|
1249
|
-
{ batch: REINDEX_BATCH_SIZE },
|
|
1250
|
-
);
|
|
1251
|
-
}));
|
|
1252
|
-
} catch (err) {
|
|
1253
|
-
winston.error(`[plugin/meilisearch] deindexTopicsPurge failed: ${err.message}`);
|
|
1254
|
-
plugin.healthy = false;
|
|
1255
|
-
await enqueuePending('deindexTopicsPurge', reducedPayload);
|
|
1256
|
-
}
|
|
1257
|
-
};
|
|
1258
|
-
|
|
1259
|
-
plugin.checkConflict = function () {
|
|
1260
|
-
const hooksToCheck = [
|
|
1261
|
-
'filter:search.query',
|
|
1262
|
-
'filter:topic.search',
|
|
1263
|
-
'filter:messaging.searchMessages',
|
|
1264
|
-
];
|
|
1265
|
-
// blacklist, in case someone makes a plugin using these hooks that doesn't conflict.
|
|
1266
|
-
// also, outside of dbsearch the user is expected to realize they installed two search plugins.
|
|
1267
|
-
const conflictingPlugins = [
|
|
1268
|
-
'nodebb-plugin-dbsearch',
|
|
1269
|
-
'nodebb-plugin-solr',
|
|
1270
|
-
'nodebb-plugin-elasticsearch',
|
|
1271
|
-
'nodebb-plugin-search-elasticsearch',
|
|
1272
|
-
];
|
|
1273
|
-
for (const hook of hooksToCheck) {
|
|
1274
|
-
if ((plugins.loadedHooks[hook] || []).filter(hookData => conflictingPlugins.includes(hookData.id)).length >= 1) {
|
|
1275
|
-
return true;
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
return false;
|
|
1279
|
-
};
|
|
1280
|
-
|
|
1281
|
-
plugin.indexMessage = async function ({ message }) {
|
|
1282
|
-
const deferPayload = { message };
|
|
1283
|
-
if (await maybeDefer('indexMessage', deferPayload)) return;
|
|
1284
|
-
if (!message || !message.mid) return;
|
|
1285
|
-
// Re-fetch from DB if content/roomId/fromuid/timestamp missing or if this is an edit (payload may be partial).
|
|
1286
|
-
// Restore payload (delete.js) omits timestamp; save/edit payloads carry it. Re-fetch fills the gap.
|
|
1287
|
-
if (message.content === undefined || message.roomId === undefined || message.fromuid === undefined || message.timestamp === undefined) {
|
|
1288
|
-
const fresh = await Messaging.getMessageFields(message.mid, ['mid', 'content', 'roomId', 'fromuid', 'timestamp', 'deleted', 'system']);
|
|
1289
|
-
// Bail if message was hard-purged mid-flight (fresh=null or stub with mid=0) — avoids indexing a phantom doc.
|
|
1290
|
-
if (!fresh || !fresh.mid) return;
|
|
1291
|
-
message = { ...fresh, ...message };
|
|
1292
|
-
}
|
|
1293
|
-
// Skip deleted messages (admin editing a deleted message should not re-index it).
|
|
1294
|
-
if (parseInt(message.deleted, 10) === 1) {
|
|
1295
|
-
winston.debug(`[plugin/meilisearch] Skipping deleted message ${message.mid}`);
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
// Skip system messages (join/leave/rename etc. — no searchable content, mirrors dbsearch).
|
|
1299
|
-
if (parseInt(message.system, 10) === 1) {
|
|
1300
|
-
winston.debug(`[plugin/meilisearch] Skipping system message ${message.mid}`);
|
|
1301
|
-
return;
|
|
1302
|
-
}
|
|
1303
|
-
const doc = {
|
|
1304
|
-
mid: message.mid,
|
|
1305
|
-
roomId: message.roomId,
|
|
1306
|
-
uid: message.fromuid,
|
|
1307
|
-
content: message.content,
|
|
1308
|
-
timestamp: message.timestamp,
|
|
1309
|
-
};
|
|
1310
|
-
await meiliWrite('indexMessage', { message: { mid: message.mid } }, () =>
|
|
1311
|
-
plugin.client.index('chat_message').updateDocuments([doc], { primaryKey: 'mid' }));
|
|
1312
|
-
};
|
|
1313
|
-
|
|
1314
|
-
plugin.deindexMessage = async function ({ message }) {
|
|
1315
|
-
const deferPayload = { message };
|
|
1316
|
-
if (await maybeDefer('deindexMessage', deferPayload)) return;
|
|
1317
|
-
if (!message || !message.mid) return;
|
|
1318
|
-
await meiliWrite('deindexMessage', { message }, () =>
|
|
1319
|
-
plugin.client.index('chat_message').deleteDocument(message.mid));
|
|
1320
|
-
};
|
|
1321
|
-
|
|
1322
|
-
plugin.searchMessages = async function (data) {
|
|
1323
|
-
if (!data || !data.content) {
|
|
1324
|
-
return data;
|
|
1325
|
-
}
|
|
1326
|
-
if (plugin.checkConflict()) {
|
|
1327
|
-
winston.warn('[plugin/meilisearch] Another search plugin (most likely dbsearch) is enabled, so chat search via Meilisearch was aborted.');
|
|
1328
|
-
return data;
|
|
1329
|
-
}
|
|
1330
|
-
if (!plugin.healthy && !(await plugin.checkHealth())) {
|
|
1331
|
-
winston.warn('[plugin/meilisearch] Meilisearch instance did not return a healthy response, so chat search via Meilisearch was aborted.');
|
|
1332
|
-
return data;
|
|
1333
|
-
}
|
|
1334
|
-
const rawQuery = String(data.content).trim();
|
|
1335
|
-
const minTermLength = await resolveMinTermLength();
|
|
1336
|
-
if (!rawQuery || !rawQuery.split(' ').some(word => word.length >= minTermLength)) {
|
|
1337
|
-
winston.debug(`[plugin/meilisearch] Skipping chat search: no query word >= ${minTermLength} char(s)`);
|
|
1338
|
-
return data;
|
|
1339
|
-
}
|
|
1340
|
-
const num = (v) => {
|
|
1341
|
-
const n = parseInt(v, 10);
|
|
1342
|
-
return Number.isFinite(n) ? n : null;
|
|
1343
|
-
};
|
|
1344
|
-
const filter = [];
|
|
1345
|
-
// Core passes roomId/uid as arrays (filter:messaging.searchMessages payload in src/api/search.js).
|
|
1346
|
-
const roomIds = Array.isArray(data.roomId) ? data.roomId : [data.roomId];
|
|
1347
|
-
const roomNums = roomIds.map(num).filter(r => r !== null);
|
|
1348
|
-
// Fail closed: if no valid roomId, return no results rather than searching globally across all rooms.
|
|
1349
|
-
if (!roomNums.length) {
|
|
1350
|
-
winston.warn('[plugin/meilisearch] searchMessages called with no valid roomId; returning no results');
|
|
1351
|
-
return data;
|
|
1352
|
-
}
|
|
1353
|
-
filter.push(roomNums.map(rid => `roomId = ${rid}`));
|
|
1354
|
-
// uid is an optional sender filter (message author, not the searcher); never populated by core callers.
|
|
1355
|
-
const uids = Array.isArray(data.uid) ? data.uid : [data.uid];
|
|
1356
|
-
const uidNums = uids.map(num).filter(u => u !== null);
|
|
1357
|
-
if (uidNums.length) {
|
|
1358
|
-
filter.push(uidNums.map(u => `uid = ${u}`));
|
|
1359
|
-
}
|
|
1360
|
-
const limit = parseInt(await settings.getOne(plugin.id, 'maxDocuments') || 100, 10);
|
|
1361
|
-
winston.debug(`[plugin/meilisearch] Searching chat messages for "${rawQuery}" in room ${roomNums.join(',')}`);
|
|
1362
|
-
const result = await plugin.client.index('chat_message').search(rawQuery, {
|
|
1363
|
-
attributesToRetrieve: ['mid'],
|
|
1364
|
-
limit: Math.min(limit, 100),
|
|
1365
|
-
filter: filter.length ? filter : undefined,
|
|
1366
|
-
matchingStrategy: data.matchWords === 'all' ? 'all' : 'last',
|
|
1367
|
-
});
|
|
1368
|
-
data.ids = data.ids.concat(result.hits.map(hit => hit.mid));
|
|
1369
|
-
return data;
|
|
1370
|
-
};
|
|
1371
|
-
|
|
1372
|
-
plugin.search = async function (data) {
|
|
1373
|
-
if (plugin.checkConflict()) {
|
|
1374
|
-
// The dbsearch plugin was detected, abort search!
|
|
1375
|
-
winston.warn(
|
|
1376
|
-
'[plugin/meilisearch] Another search plugin (most likely dbsearch) is enabled, so search via Meilisearch was aborted.',
|
|
1377
|
-
);
|
|
1378
|
-
return data;
|
|
1379
|
-
}
|
|
1380
|
-
if (!plugin.healthy && !(await plugin.checkHealth())) {
|
|
1381
|
-
winston.warn(
|
|
1382
|
-
'[plugin/meilisearch] Meilisearch instance did not return a healthy response, so search via Meilisearch was aborted.',
|
|
1383
|
-
);
|
|
1384
|
-
return data;
|
|
1385
|
-
}
|
|
1386
|
-
const rawQuery = (data.term || data.content || '').trim();
|
|
1387
|
-
const minTermLength = await resolveMinTermLength();
|
|
1388
|
-
if (!rawQuery || !rawQuery.split(' ').some(word => word.length >= minTermLength)) {
|
|
1389
|
-
winston.debug(`[plugin/meilisearch] Skipping search: no query word >= ${minTermLength} char(s)`);
|
|
1390
|
-
return data;
|
|
1391
|
-
}
|
|
1392
|
-
// #6: Use local variables instead of mutating the shared payload.
|
|
1393
|
-
const content = data.term || data.content;
|
|
1394
|
-
const searchData = data.term ? { tid: data.tid } : data?.searchData;
|
|
1395
|
-
const rawIndex = Array.isArray(data?.index) ? data.index[0] : data.index;
|
|
1396
|
-
const ALLOWED_INDEXES = { post: 'pid', topic: 'tid' };
|
|
1397
|
-
const index = ALLOWED_INDEXES[rawIndex] ? rawIndex : 'post';
|
|
1398
|
-
const id = ALLOWED_INDEXES[index];
|
|
1399
|
-
winston.debug(`[plugin/meilisearch] Searching for ${content} in ${index}`);
|
|
1400
|
-
const result = await plugin.client.index(index).search(content, {
|
|
1401
|
-
attributesToRetrieve: [id],
|
|
1402
|
-
limit: parseInt(await settings.getOne(plugin.id, 'maxDocuments') || 500, 10),
|
|
1403
|
-
filter: plugin.buildFilter(
|
|
1404
|
-
data.cid,
|
|
1405
|
-
data.uid,
|
|
1406
|
-
searchData?.timeFilter,
|
|
1407
|
-
searchData?.timeRange,
|
|
1408
|
-
searchData?.tid,
|
|
1409
|
-
),
|
|
1410
|
-
sort: plugin.buildSort(searchData?.sortBy, searchData?.sortDirection),
|
|
1411
|
-
matchingStrategy: data.matchWords === 'all' ? 'all' : 'last',
|
|
1412
|
-
});
|
|
1413
|
-
data.ids = result.hits.map(hit => hit[id]);
|
|
1414
|
-
return data;
|
|
1415
|
-
};
|
|
1416
|
-
|
|
1417
|
-
plugin.buildFilter = function (categories, postedBy, timeFilter, timeRange, tid) {
|
|
1418
|
-
const num = (v) => {
|
|
1419
|
-
const n = parseInt(v, 10);
|
|
1420
|
-
return Number.isFinite(n) ? n : null;
|
|
1421
|
-
};
|
|
1422
|
-
const filter = [];
|
|
1423
|
-
if (categories?.length) {
|
|
1424
|
-
const cids = categories.map(num).filter(c => c !== null);
|
|
1425
|
-
if (cids.length) filter.push(cids.map(cid => `cid = ${cid}`));
|
|
1426
|
-
}
|
|
1427
|
-
if (postedBy?.length) {
|
|
1428
|
-
const uids = postedBy.map(num).filter(u => u !== null);
|
|
1429
|
-
if (uids.length) filter.push(uids.map(uid => `uid = ${uid}`));
|
|
1430
|
-
}
|
|
1431
|
-
if (timeFilter && timeRange) {
|
|
1432
|
-
const range = num(timeRange);
|
|
1433
|
-
if (range !== null) {
|
|
1434
|
-
filter.push(`timestamp ${timeFilter === 'newer' ? '>' : '<'} ${Date.now() - (range * 1000)}`);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
const numericTid = num(tid);
|
|
1438
|
-
if (numericTid !== null) {
|
|
1439
|
-
filter.push(`tid = ${numericTid}`);
|
|
1440
|
-
}
|
|
1441
|
-
return filter.length ? filter : undefined;
|
|
1442
|
-
};
|
|
1443
|
-
|
|
1444
|
-
plugin.buildSort = function (sortBy, sortDirection) {
|
|
1445
|
-
let field = '';
|
|
1446
|
-
switch (sortBy) {
|
|
1447
|
-
case 'timestamp':
|
|
1448
|
-
field = 'timestamp';
|
|
1449
|
-
break;
|
|
1450
|
-
case 'topic.title':
|
|
1451
|
-
field = 'title';
|
|
1452
|
-
break;
|
|
1453
|
-
case 'category':
|
|
1454
|
-
field = 'cid';
|
|
1455
|
-
break;
|
|
1456
|
-
default:
|
|
1457
|
-
return undefined;
|
|
1458
|
-
}
|
|
1459
|
-
return [`${field}:${sortDirection === 'ascending' ? 'asc' : 'desc'}`];
|
|
1460
|
-
};
|
|
1461
|
-
|
|
1462
|
-
plugin.saveSettings = async (data) => {
|
|
1463
|
-
if (data.plugin === plugin.id && !data.quiet && plugin.initialized) {
|
|
1464
|
-
try {
|
|
1465
|
-
if (data.settings && Object.prototype.hasOwnProperty.call(data.settings, 'searchMinTermLength')) {
|
|
1466
|
-
data.settings.searchMinTermLength = clampMinTermLength(data.settings.searchMinTermLength);
|
|
1467
|
-
}
|
|
1468
|
-
// #8: Only re-connect when connection settings changed.
|
|
1469
|
-
const connChanged = await connectionSettingsChanged(data.settings);
|
|
1470
|
-
if (connChanged) {
|
|
1471
|
-
// Fix 10: If host changed, clear indexed so prepareSearch auto-reindexes the new (empty) host.
|
|
1472
|
-
const hostChanged = await hostSettingChanged(data.settings);
|
|
1473
|
-
if (hostChanged) {
|
|
1474
|
-
await settings.set(plugin.id, { indexed: false }, true);
|
|
1475
|
-
}
|
|
1476
|
-
await plugin.prepareSearch(data.settings, true);
|
|
1477
|
-
}
|
|
1478
|
-
const changed = (await Promise.all(
|
|
1479
|
-
Object.entries(data.settings).map(([k, v]) => isBreaking([k, v])),
|
|
1480
|
-
)).some(Boolean);
|
|
1481
|
-
if (changed) {
|
|
1482
|
-
winston.info('settings changed, updating index');
|
|
1483
|
-
await plugin.updateIndexSettings(data.settings);
|
|
1484
|
-
}
|
|
1485
|
-
} catch (err) {
|
|
1486
|
-
// #7: Don't throw — let settings persist to DB even when Meili is unreachable.
|
|
1487
|
-
winston.error(`[plugin/meilisearch] Error while saving settings: ${err.message}`);
|
|
1488
|
-
plugin.healthy = false;
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
return data;
|
|
1492
|
-
};
|
|
1493
|
-
|
|
1494
|
-
async function ensureIndex(uid, primaryKey) {
|
|
1495
|
-
try {
|
|
1496
|
-
await plugin.client.getIndex(uid);
|
|
1497
|
-
} catch (e) {
|
|
1498
|
-
await plugin.client.createIndex(uid, { primaryKey });
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
async function connectionSettingsChanged(newSettings) {
|
|
1503
|
-
if (!newSettings) return false;
|
|
1504
|
-
const keys = ['host', 'apiKey', 'healthCheckInterval'];
|
|
1505
|
-
for (const k of keys) {
|
|
1506
|
-
if (!Object.prototype.hasOwnProperty.call(newSettings, k)) continue;
|
|
1507
|
-
const stored = await settings.getOne(plugin.id, k);
|
|
1508
|
-
if (String(stored || '') !== String(newSettings[k] || '')) return true;
|
|
1509
|
-
}
|
|
1510
|
-
return false;
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
async function hostSettingChanged(newSettings) {
|
|
1514
|
-
if (!newSettings || !Object.prototype.hasOwnProperty.call(newSettings, 'host')) return false;
|
|
1515
|
-
const stored = await settings.getOne(plugin.id, 'host');
|
|
1516
|
-
return String(stored || '') !== String(newSettings.host || '');
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
async function isBreaking([setting, value]) {
|
|
1520
|
-
if (!plugin.breakingSettings.includes(setting)) {
|
|
1521
|
-
return false;
|
|
1522
|
-
}
|
|
1523
|
-
const stored = await settings.getOne(plugin.id, setting);
|
|
1524
|
-
const changed = !deepCompare(stored, value);
|
|
1525
|
-
if (changed) {
|
|
1526
|
-
winston.info(`[plugin/meilisearch] ${setting} changed: ${JSON.stringify(stored)} -> ${JSON.stringify(value)}`);
|
|
1527
|
-
}
|
|
1528
|
-
return changed;
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
async function resolveMinTermLength() {
|
|
1532
|
-
const v = parseInt(await settings.getOne(plugin.id, 'searchMinTermLength'), 10);
|
|
1533
|
-
return Number.isFinite(v) && v >= 2 ? v : 2;
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
function clampMinTermLength(value) {
|
|
1537
|
-
const v = parseInt(value, 10);
|
|
1538
|
-
return Number.isFinite(v) && v >= 2 ? v : 2;
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
function deepCompare(a, b) {
|
|
1542
|
-
if (a === null || b === null) return a === b;
|
|
1543
|
-
if (typeof a !== typeof b) return false;
|
|
1544
|
-
switch (typeof a) {
|
|
1545
|
-
case 'object':
|
|
1546
|
-
return Object.keys(a).length === Object.keys(b).length &&
|
|
1547
|
-
Object.keys(a).every(key => deepCompare(a[key], b[key]));
|
|
1548
|
-
case 'string':
|
|
1549
|
-
case 'number':
|
|
1550
|
-
case 'boolean':
|
|
1551
|
-
default:
|
|
1552
|
-
return a === b;
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
module.exports = plugin;
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const plugin = {};
|
|
4
|
+
|
|
5
|
+
require('./lib/state')(plugin);
|
|
6
|
+
require('./lib/alerts')(plugin);
|
|
7
|
+
require('./lib/client')(plugin);
|
|
8
|
+
require('./lib/reindex')(plugin);
|
|
9
|
+
require('./lib/hooks/posts')(plugin);
|
|
10
|
+
require('./lib/hooks/topics')(plugin);
|
|
11
|
+
require('./lib/hooks/messages')(plugin);
|
|
12
|
+
require('./lib/search')(plugin);
|
|
13
|
+
require('./lib/chat-search-global')(plugin);
|
|
14
|
+
require('./lib/settings')(plugin);
|
|
15
|
+
require('./lib/routes')(plugin);
|
|
16
|
+
|
|
17
|
+
module.exports = plugin;
|