@jasonwch/nodebb-plugin-meilisearch-r 1.0.0

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/library.js ADDED
@@ -0,0 +1,1136 @@
1
+ 'use strict';
2
+
3
+ const winston = nodebb.require('winston');
4
+ const _ = nodebb.require('lodash');
5
+
6
+ const routeHelpers = nodebb.require('./src/routes/helpers');
7
+ const settings = nodebb.require('./src/meta/settings');
8
+ const Posts = nodebb.require('./src/posts');
9
+ const Topics = nodebb.require('./src/topics');
10
+ const batch = nodebb.require('./src/batch');
11
+ const Sockets = nodebb.require('./src/socket.io');
12
+ const pubsub = nodebb.require('./src/pubsub');
13
+ const plugins = nodebb.require('./src/plugins');
14
+ const db = nodebb.require('./src/database');
15
+
16
+ const plugin = {};
17
+
18
+ plugin.id = 'meilisearch';
19
+
20
+ plugin.healthy = false;
21
+
22
+ const REINDEX_BATCH_SIZE = 500;
23
+ const PENDING_KEY = 'plugin:meilisearch:pending';
24
+ const PENDING_MAX = 100000;
25
+ const DRAIN_MAX = 500;
26
+ const REINDEX_LOCK_KEY = 'plugin:meilisearch:reindex:lock';
27
+ const REINDEX_LOCK_TTL = 3600;
28
+ const LOCK_REFRESH_INTERVAL = 5 * 60 * 1000;
29
+ const REPLAY_OPS = ['indexPost', 'deindexPost', 'indexTopic', 'deindexTopic', 'deindexPostsPurge', 'deindexTopicsPurge', 'reindexTopicPosts', 'deindexTopicPosts', 'restoreTopic', 'changePostOwner', 'changeTopicOwner', 'onTopicMerge', 'onScheduledPublish'];
30
+
31
+ const emitProgress = _.throttle(() => {
32
+ pubsub.publish('meilisearch:reindex', plugin.indexing);
33
+ }, 500, { leading: true, trailing: true });
34
+
35
+ plugin.indexing = {
36
+ running: false,
37
+ topic_progress: {
38
+ total: null,
39
+ current: null,
40
+ },
41
+ post_progress: {
42
+ total: null,
43
+ current: null,
44
+ },
45
+ };
46
+
47
+ plugin.reindexingForced = false;
48
+ plugin.pendingDuringReindex = [];
49
+ plugin.reindexLockRefresh = null;
50
+
51
+ /** @type {import('meilisearch').Meilisearch} */
52
+ plugin.client = undefined;
53
+ plugin.defaults = {
54
+ host: 'http://localhost:7700',
55
+ apiKey: undefined,
56
+ maxDocuments: undefined,
57
+ indexed: false,
58
+ rankingRules: [
59
+ { rule: 'words' },
60
+ { rule: 'typo' },
61
+ { rule: 'proximity' },
62
+ { rule: 'attribute' },
63
+ { rule: 'sort' },
64
+ { rule: 'exactness' },
65
+ ],
66
+ stopWords: [],
67
+ typoTolerance: 'on',
68
+ typoToleranceMinWordSizeOneTypo: 5,
69
+ typoToleranceMinWordSizeTwoTypos: 9,
70
+ typoToleranceDisableOnWords: [],
71
+ synonyms: [],
72
+ healthCheckInterval: 60,
73
+ searchMinTermLength: 2,
74
+ lastReindexResult: {
75
+ success: false,
76
+ finishedAt: null,
77
+ topic_progress: { current: null, total: null },
78
+ post_progress: { current: null, total: null },
79
+ skippedDeletedTopics: 0,
80
+ skippedDeletedPosts: 0,
81
+ error: null,
82
+ },
83
+ };
84
+
85
+ plugin.breakingSettings = [
86
+ 'maxDocuments',
87
+ 'rankingRules',
88
+ 'stopWords',
89
+ 'typoTolerance',
90
+ 'typoToleranceMinWordSizeOneTypo',
91
+ 'typoToleranceMinWordSizeTwoTypos',
92
+ 'typoToleranceDisableOnWords',
93
+ 'typoToleranceDisableOnAttributes',
94
+ 'synonyms',
95
+ ];
96
+ plugin.initialized = false;
97
+
98
+ plugin.initializingOnAnotherInstance = false;
99
+
100
+ plugin.healthCheckTask = null;
101
+
102
+ pubsub.on('meilisearch:reindex', (indexing) => {
103
+ plugin.indexing = indexing;
104
+ Sockets.server.to('admin/plugins/meilisearch').emit('plugins.meilisearch.reindex', plugin.indexing);
105
+ });
106
+ pubsub.on('meilisearch:init', (initializing) => {
107
+ plugin.initializingOnAnotherInstance = initializing;
108
+ });
109
+
110
+ plugin.init = async function (params) {
111
+ const { router } = params;
112
+ winston.debug('[plugin/meilisearch] Initializing MeiliSearch plugin');
113
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/meilisearch', [], async (req, res) => {
114
+ const topicTotal = Number(plugin.indexing.topic_progress.total) || 0;
115
+ const topicCurrent = Number(plugin.indexing.topic_progress.current) || 0;
116
+ const postTotal = Number(plugin.indexing.post_progress.total) || 0;
117
+ const postCurrent = Number(plugin.indexing.post_progress.current) || 0;
118
+ const topicPercent = topicTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * topicCurrent / topicTotal))) : 0;
119
+ const postPercent = postTotal > 0 ? Math.min(100, Math.max(0, Math.round(100 * postCurrent / postTotal))) : 0;
120
+ const lastReindexResult = await settings.getOne(plugin.id, 'lastReindexResult') || {};
121
+ res.render('admin/plugins/meilisearch', {
122
+ indexing: plugin.indexing,
123
+ topicPercent,
124
+ postPercent,
125
+ lastReindexResult,
126
+ });
127
+ });
128
+ await settings.setOnEmpty(plugin.id, plugin.defaults);
129
+ await plugin.prepareSearch();
130
+ plugin.patchTopicsSearch();
131
+ plugin.initialized = true;
132
+ };
133
+
134
+ // Guard core Topics.search against empty-term throws (core bug: in:topic-<tid> + trailing space yields empty cleanedTerm).
135
+ plugin.patchTopicsSearch = function () {
136
+ if (Topics.__meiliOriginalSearch) { return; }
137
+ Topics.__meiliOriginalSearch = Topics.search;
138
+ Topics.search = async function (tid, term) {
139
+ if (!tid || !term || !String(term).trim()) { return []; }
140
+ return Topics.__meiliOriginalSearch.call(Topics, tid, term);
141
+ };
142
+ };
143
+
144
+ plugin.prepareSearch = async function (data, connectionChanged = false) {
145
+ winston.debug(`[plugin/meilisearch] Connecting to MeiliSearch host: ${data?.host || await settings.getOne(plugin.id, 'host')}`);
146
+ const { Meilisearch } = await import('meilisearch');
147
+ plugin.client = new Meilisearch({
148
+ host: data?.host || await settings.getOne(plugin.id, 'host'),
149
+ apiKey: data?.apiKey || await settings.getOne(plugin.id, 'apiKey') || undefined,
150
+ });
151
+ if (plugin.healthCheckTask) clearInterval(plugin.healthCheckTask);
152
+ const intervalRaw = parseInt(data?.healthCheckInterval || await settings.getOne(plugin.id, 'healthCheckInterval'), 10);
153
+ const intervalSeconds = Number.isFinite(intervalRaw) && intervalRaw > 0
154
+ ? Math.min(Math.max(intervalRaw, 10), 3600)
155
+ : 60;
156
+ plugin.healthCheckTask = setInterval(plugin.checkHealth, intervalSeconds * 1000);
157
+ // #14: Don't auto-reindex after a failed reindex unless connection settings changed.
158
+ const indexed = await settings.getOne(plugin.id, 'indexed');
159
+ if (indexed) return;
160
+ const healthy = await plugin.checkHealth();
161
+ if (!healthy || plugin.initializingOnAnotherInstance) return;
162
+ const lastResult = await settings.getOne(plugin.id, 'lastReindexResult') || {};
163
+ const allowAutoReindex = connectionChanged || lastResult.success !== false;
164
+ if (!allowAutoReindex) {
165
+ winston.warn('[plugin/meilisearch] Skipping auto-reindex: last reindex failed. Trigger manually from ACP.');
166
+ return;
167
+ }
168
+ await plugin.updateIndexSettings();
169
+ await plugin.reindex(false);
170
+ };
171
+
172
+ plugin.addRoutes = async function ({ router, middleware, helpers }) {
173
+ const middlewares = [
174
+ middleware.ensureLoggedIn,
175
+ middleware.admin.checkPrivileges,
176
+ ];
177
+ routeHelpers.setupApiRoute(router, 'get', '/meilisearch/reindex', middlewares, async (req, res) => {
178
+ helpers.formatApiResponse(200, res, {
179
+ indexing: plugin.indexing,
180
+ healthy: plugin.healthy,
181
+ lastReindexResult: await settings.getOne(plugin.id, 'lastReindexResult') || {},
182
+ });
183
+ });
184
+
185
+ routeHelpers.setupApiRoute(router, 'post', '/meilisearch/reindex', middlewares, async (req, res) => {
186
+ await settings.set(plugin.id, { indexed: false }, true);
187
+ plugin.reindex(false);
188
+ helpers.formatApiResponse(202, res, plugin.indexing);
189
+ });
190
+ routeHelpers.setupApiRoute(router, 'delete', '/meilisearch/reindex', middlewares, async (req, res) => {
191
+ await settings.set(plugin.id, { indexed: false }, true);
192
+ plugin.reindex(true);
193
+ helpers.formatApiResponse(202, res, plugin.indexing);
194
+ });
195
+ };
196
+
197
+ plugin.addAdminNavigation = function (header) {
198
+ header.plugins.push({
199
+ route: '/plugins/meilisearch',
200
+ icon: 'fa-tint',
201
+ name: 'Meilisearch',
202
+ });
203
+
204
+ return header;
205
+ };
206
+
207
+ plugin.checkHealth = async function () {
208
+ try {
209
+ plugin.healthy = await plugin.client.isHealthy();
210
+ if (!plugin.healthy) {
211
+ winston.warn('[plugin/meilisearch] MeiliSearch host is unhealthy');
212
+ } else {
213
+ drainPending().catch(err => winston.error(`[plugin/meilisearch] drain failed: ${err.message}`));
214
+ }
215
+ return plugin.healthy;
216
+ } catch (err) {
217
+ plugin.healthy = false;
218
+ winston.warn(`[plugin/meilisearch] ${err.message}`);
219
+ return false;
220
+ }
221
+ };
222
+
223
+ async function maybeDefer(op, payload) {
224
+ if (plugin.reindexingForced) {
225
+ if (plugin.pendingDuringReindex.length >= PENDING_MAX) {
226
+ plugin.pendingDuringReindex.shift();
227
+ }
228
+ plugin.pendingDuringReindex.push({ op, payload });
229
+ return true;
230
+ }
231
+ if (!plugin.healthy && !(await plugin.checkHealth())) {
232
+ await enqueuePending(op, payload);
233
+ return true;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ async function enqueuePending(op, payload) {
239
+ const count = await db.sortedSetCard(PENDING_KEY);
240
+ if (count >= PENDING_MAX) {
241
+ const oldest = await db.getSortedSetRange(PENDING_KEY, 0, 0);
242
+ if (oldest.length) await db.sortedSetRemove(PENDING_KEY, ...oldest);
243
+ }
244
+ await db.sortedSetAdd(PENDING_KEY, Date.now(), JSON.stringify({ op, payload }));
245
+ }
246
+
247
+ async function meiliWrite(op, payload, fn) {
248
+ try {
249
+ await fn();
250
+ } catch (err) {
251
+ winston.error(`[plugin/meilisearch] ${op} write failed: ${err.message}`);
252
+ plugin.healthy = false;
253
+ await enqueuePending(op, payload);
254
+ }
255
+ }
256
+
257
+ async function drainPending() {
258
+ if (!plugin.healthy || plugin.reindexingForced) return;
259
+ let drained = 0;
260
+ for (;;) {
261
+ if (!plugin.healthy) break;
262
+ const members = await db.getSortedSetRange(PENDING_KEY, 0, 49);
263
+ if (!members.length) break;
264
+ await db.sortedSetRemove(PENDING_KEY, ...members);
265
+ for (const member of members) {
266
+ try {
267
+ const { op, payload } = JSON.parse(member);
268
+ if (op && REPLAY_OPS.includes(op)) {
269
+ await plugin[op](payload);
270
+ }
271
+ } catch (err) {
272
+ winston.error(`[plugin/meilisearch] replay failed: ${err.message}`);
273
+ }
274
+ drained += 1;
275
+ }
276
+ if (drained >= DRAIN_MAX) break;
277
+ }
278
+ }
279
+
280
+ plugin.getNotices = async function (notices) {
281
+ const checkHealth = await plugin.checkHealth();
282
+ notices.push({
283
+ done: checkHealth,
284
+ doneText: 'MeiliSearch connection OK',
285
+ notDoneText: 'Could not connect to MeiliSearch',
286
+ });
287
+ return notices;
288
+ };
289
+
290
+ plugin.updateIndexSettings = async (data) => {
291
+ await ensureIndex('post', 'pid');
292
+ await ensureIndex('topic', 'tid');
293
+ data = {
294
+ maxDocuments: parseInt(data?.maxDocuments || await settings.getOne(plugin.id, 'maxDocuments') || 500, 10),
295
+ rankingRules: (data?.rankingRules || await settings.getOne(plugin.id, 'rankingRules'))?.map(value => value.rule),
296
+ stopWords: (data?.stopWords || await settings.getOne(plugin.id, 'stopWords'))?.map(value => value.word),
297
+ typoTolerance: ['on', true].includes(
298
+ data?.typoTolerance || await settings.getOne(plugin.id, 'typoTolerance') || undefined,
299
+ ),
300
+ typoToleranceMinWordSizeOneTypo: parseInt(
301
+ data?.typoToleranceMinWordSizeOneTypo ||
302
+ await settings.getOne(plugin.id, 'typoToleranceMinWordSizeOneTypo') || 5,
303
+ 10,
304
+ ),
305
+ typoToleranceMinWordSizeTwoTypos: parseInt(
306
+ data?.typoToleranceMinWordSizeTwoTypos ||
307
+ await settings.getOne(plugin.id, 'typoToleranceMinWordSizeTwoTypos') || 9,
308
+ 10,
309
+ ),
310
+ typoToleranceDisableOnWords:
311
+ (data?.typoToleranceDisableOnWords || await settings.getOne(plugin.id, 'typoToleranceDisableOnWords') ||
312
+ undefined)?.map(value => value.word),
313
+ synonyms: Object.fromEntries(
314
+ (data?.synonyms || await settings.getOne(plugin.id, 'synonyms') || [])?.map((
315
+ { word, synonyms },
316
+ ) => [word, synonyms?.split(',').map(synonym => synonym.trim())]),
317
+ ),
318
+ };
319
+ const postTask = await plugin.client.index('post').updateSettings({
320
+ filterableAttributes: ['tid', 'cid', 'uid', 'timestamp'],
321
+ sortableAttributes: ['timestamp', 'cid'],
322
+ searchableAttributes: ['content'],
323
+ pagination: {
324
+ maxTotalHits: data.maxDocuments,
325
+ },
326
+ rankingRules: data.rankingRules,
327
+ stopWords: data.stopWords,
328
+ typoTolerance: {
329
+ enabled: data.typoTolerance,
330
+ minWordSizeForTypos: {
331
+ oneTypo: data.typoToleranceMinWordSizeOneTypo,
332
+ twoTypos: data.typoToleranceMinWordSizeTwoTypos,
333
+ },
334
+ disableOnWords: data.typoToleranceDisableOnWords,
335
+ },
336
+ synonyms: data.synonyms,
337
+ });
338
+ const topicTask = await plugin.client.index('topic').updateSettings({
339
+ filterableAttributes: ['cid', 'uid', 'timestamp'],
340
+ sortableAttributes: ['cid', 'title', 'timestamp'],
341
+ searchableAttributes: ['title'],
342
+ pagination: {
343
+ maxTotalHits: data.maxDocuments,
344
+ },
345
+ rankingRules: data.rankingRules,
346
+ stopWords: data.stopWords,
347
+ typoTolerance: {
348
+ enabled: data.typoTolerance,
349
+ minWordSizeForTypos: {
350
+ oneTypo: data.typoToleranceMinWordSizeOneTypo,
351
+ twoTypos: data.typoToleranceMinWordSizeTwoTypos,
352
+ },
353
+ disableOnWords: data.typoToleranceDisableOnWords,
354
+ },
355
+ synonyms: data.synonyms,
356
+ });
357
+ return [postTask, topicTask];
358
+ };
359
+
360
+ plugin.reindex = async function (force = false) {
361
+ if (plugin.indexing.running) {
362
+ winston.warn('[plugin/meilisearch] Already indexing');
363
+ return;
364
+ }
365
+ plugin.indexing = {
366
+ running: true,
367
+ topic_progress: {
368
+ current: 0,
369
+ total: 0,
370
+ },
371
+ post_progress: {
372
+ current: 0,
373
+ total: 0,
374
+ },
375
+ };
376
+ emitProgress();
377
+ winston.info(`[plugin/meilisearch] Indexing posts and topics${force ? ' (forced)' : ''}`);
378
+ plugin._reindexPromise = runReindex(force);
379
+ return plugin._reindexPromise;
380
+ };
381
+
382
+ async function acquireReindexLock() {
383
+ const ttl = await db.ttl(REINDEX_LOCK_KEY);
384
+ if (ttl > 0) {
385
+ winston.warn('[plugin/meilisearch] Reindex lock held by another instance');
386
+ return false;
387
+ }
388
+ await db.set(REINDEX_LOCK_KEY, String(Date.now()));
389
+ await db.expire(REINDEX_LOCK_KEY, REINDEX_LOCK_TTL);
390
+ plugin.reindexLockRefresh = setInterval(() => {
391
+ db.expire(REINDEX_LOCK_KEY, REINDEX_LOCK_TTL).catch(err =>
392
+ winston.error(`[plugin/meilisearch] lock refresh failed: ${err.message}`));
393
+ }, LOCK_REFRESH_INTERVAL);
394
+ return true;
395
+ }
396
+
397
+ async function releaseReindexLock() {
398
+ if (plugin.reindexLockRefresh) {
399
+ clearInterval(plugin.reindexLockRefresh);
400
+ plugin.reindexLockRefresh = null;
401
+ }
402
+ try {
403
+ await db.delete(REINDEX_LOCK_KEY);
404
+ } catch (err) {
405
+ winston.warn(`[plugin/meilisearch] Could not release reindex lock: ${err.message}`);
406
+ }
407
+ }
408
+
409
+ async function runReindex(force) {
410
+ let succeeded = false;
411
+ let errorMsg = null;
412
+ let skippedDeletedTopics = 0;
413
+ let skippedDeletedPosts = 0;
414
+ const locked = await acquireReindexLock();
415
+ if (!locked) {
416
+ plugin.indexing.running = false;
417
+ return;
418
+ }
419
+ pubsub.publish('meilisearch:init', true);
420
+ plugin.initializingOnAnotherInstance = true;
421
+ try {
422
+ if (force) {
423
+ plugin.reindexingForced = true;
424
+ const postTask = await plugin.client.index('post').deleteAllDocuments();
425
+ const topicTask = await plugin.client.index('topic').deleteAllDocuments();
426
+ await plugin.client.tasks.waitForTask(postTask.taskUid);
427
+ await plugin.client.tasks.waitForTask(topicTask.taskUid);
428
+ }
429
+ const [postSettingsTask, topicSettingsTask] = await plugin.updateIndexSettings();
430
+ await plugin.client.tasks.waitForTask(postSettingsTask.taskUid);
431
+ await plugin.client.tasks.waitForTask(topicSettingsTask.taskUid);
432
+ plugin.indexing.topic_progress.total = await db.sortedSetCard('topics:tid') || 0;
433
+ plugin.indexing.post_progress.total = await db.sortedSetCard('posts:pid') || 0;
434
+ emitProgress();
435
+ await Promise.all([
436
+ batch.processSortedSet(
437
+ 'topics:tid',
438
+ async (tids) => {
439
+ plugin.indexing.topic_progress.current += tids.length;
440
+ emitProgress();
441
+ const topics = await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'mainPid', 'title', 'timestamp', 'deleted']);
442
+ const activeTopics = [];
443
+ const deletedTids = [];
444
+ topics.forEach((topic) => {
445
+ if (parseInt(topic.deleted, 10) === 1) {
446
+ deletedTids.push(topic.tid);
447
+ } else {
448
+ activeTopics.push(topic);
449
+ }
450
+ });
451
+ skippedDeletedTopics += deletedTids.length;
452
+ if (deletedTids.length) {
453
+ await plugin.client.index('topic').deleteDocuments(deletedTids);
454
+ }
455
+ if (!activeTopics.length) return;
456
+ await plugin.client.index('topic').updateDocuments(
457
+ activeTopics.map(topic => ({
458
+ tid: topic.tid,
459
+ cid: topic.cid,
460
+ uid: topic.uid,
461
+ mainPid: topic.mainPid,
462
+ title: topic.title,
463
+ timestamp: topic.timestamp,
464
+ })),
465
+ { primaryKey: 'tid' },
466
+ );
467
+ },
468
+ {
469
+ batch: REINDEX_BATCH_SIZE,
470
+ progress: plugin.indexing.topic_progress,
471
+ },
472
+ ),
473
+ batch.processSortedSet(
474
+ 'posts:pid',
475
+ async (pids) => {
476
+ plugin.indexing.post_progress.current += pids.length;
477
+ emitProgress();
478
+ const posts = await Posts.getPostsFields(pids, ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
479
+ // Fetch topic deleted status for posts in this batch
480
+ const tids = [...new Set(posts.map(p => p && p.tid).filter(Boolean))];
481
+ const topicsData = tids.length ? await Topics.getTopicsFields(tids, ['tid', 'deleted']) : [];
482
+ const tidToDeleted = {};
483
+ topicsData.forEach((t) => { tidToDeleted[t.tid] = parseInt(t.deleted, 10) === 1; });
484
+
485
+ const activePosts = [];
486
+ const deletedPids = [];
487
+ posts.forEach((post) => {
488
+ if (parseInt(post.deleted, 10) === 1 || tidToDeleted[post.tid]) {
489
+ deletedPids.push(post.pid);
490
+ } else {
491
+ activePosts.push(post);
492
+ }
493
+ });
494
+ skippedDeletedPosts += deletedPids.length;
495
+ if (deletedPids.length) {
496
+ await plugin.client.index('post').deleteDocuments(deletedPids);
497
+ }
498
+ const activePids = activePosts.map(p => p.pid);
499
+ const cids = activePids.length ? await Posts.getCidsByPids(activePids) : [];
500
+ await plugin.client.index('post').updateDocuments(
501
+ activePosts.map((post, index) => ({
502
+ pid: post.pid,
503
+ tid: post.tid,
504
+ cid: cids[index],
505
+ uid: post.uid,
506
+ content: post.content,
507
+ timestamp: post.timestamp,
508
+ })),
509
+ { primaryKey: 'pid' },
510
+ );
511
+ },
512
+ {
513
+ batch: REINDEX_BATCH_SIZE,
514
+ progress: plugin.indexing.post_progress,
515
+ },
516
+ ),
517
+ ]);
518
+ succeeded = true;
519
+ winston.info('[plugin/meilisearch] Indexing complete');
520
+ } catch (err) {
521
+ winston.error(`[plugin/meilisearch] Indexing failed: ${err.message}`);
522
+ errorMsg = err.message;
523
+ } finally {
524
+ plugin.indexing.running = false;
525
+ plugin.reindexingForced = false;
526
+ plugin.initializingOnAnotherInstance = false;
527
+ pubsub.publish('meilisearch:init', false);
528
+ await releaseReindexLock();
529
+ emitProgress.flush();
530
+ const pending = plugin.pendingDuringReindex.splice(0);
531
+ for (const { op, payload } of pending) {
532
+ if (!REPLAY_OPS.includes(op)) continue;
533
+ try {
534
+ await plugin[op](payload);
535
+ } catch (err) {
536
+ winston.error(`[plugin/meilisearch] replay ${op} failed: ${err.message}`);
537
+ }
538
+ }
539
+ const lastIndexResult = {
540
+ success: succeeded,
541
+ finishedAt: Date.now(),
542
+ topic_progress: {
543
+ current: plugin.indexing.topic_progress.current,
544
+ total: plugin.indexing.topic_progress.total,
545
+ },
546
+ post_progress: {
547
+ current: plugin.indexing.post_progress.current,
548
+ total: plugin.indexing.post_progress.total,
549
+ },
550
+ skippedDeletedTopics,
551
+ skippedDeletedPosts,
552
+ error: errorMsg,
553
+ };
554
+ try {
555
+ await settings.set(plugin.id, { lastReindexResult: lastIndexResult }, true);
556
+ if (succeeded) {
557
+ await settings.set(plugin.id, { indexed: true }, true);
558
+ }
559
+ } catch (err) {
560
+ winston.error(`[plugin/meilisearch] Failed to persist reindex result: ${err.message}`);
561
+ }
562
+ }
563
+ }
564
+
565
+ plugin.indexPost = async function ({ post, tid: newTid }) {
566
+ const deferPayload = { post, tid: newTid };
567
+ if (await maybeDefer('indexPost', deferPayload)) return;
568
+ // #3: On post.move, post.tid is stale and content may be missing.
569
+ // Re-fetch to get fresh tid + content from DB (already updated before action hook fires).
570
+ if (newTid !== undefined || !post || post.content === undefined) {
571
+ if (post && post.pid) {
572
+ post = await Posts.getPostData(post.pid);
573
+ }
574
+ if (!post) return;
575
+ }
576
+ // Issue 3: Skip deleted posts (admin editing a deleted post should not re-index it).
577
+ if (parseInt(post.deleted, 10) === 1) {
578
+ winston.debug(`[plugin/meilisearch] Skipping deleted post ${post.pid}`);
579
+ return;
580
+ }
581
+ // Point 1: Skip posts in deleted or scheduled topics.
582
+ const topicData = await Topics.getTopicFields(post.tid, ['deleted', 'timestamp']);
583
+ if (parseInt(topicData?.deleted, 10) === 1) {
584
+ winston.debug(`[plugin/meilisearch] Skipping post ${post.pid} in deleted topic ${post.tid}`);
585
+ return;
586
+ }
587
+ if (topicData?.timestamp && topicData.timestamp > Date.now()) {
588
+ winston.debug(`[plugin/meilisearch] Skipping post ${post.pid} in scheduled topic ${post.tid}`);
589
+ return;
590
+ }
591
+ if (!post.cid) {
592
+ post.cid = await Posts.getCidByPid(post.pid);
593
+ }
594
+ const doc = {
595
+ pid: post.pid,
596
+ tid: post.tid,
597
+ cid: post.cid,
598
+ uid: post.uid,
599
+ content: post.content,
600
+ timestamp: post.timestamp,
601
+ };
602
+ await meiliWrite('indexPost', { post: { pid: post.pid } }, () =>
603
+ plugin.client.index('post').updateDocuments([doc], { primaryKey: 'pid' }));
604
+ };
605
+
606
+ plugin.deindexPost = async function ({ post }) {
607
+ if (await maybeDefer('deindexPost', { post })) return;
608
+ await meiliWrite('deindexPost', { post }, () =>
609
+ plugin.client.index('post').deleteDocument(post.pid));
610
+ };
611
+
612
+ plugin.indexTopic = async function ({ topic, tid, fromCid, post }) {
613
+ const isMove = fromCid !== undefined;
614
+ if (await maybeDefer('indexTopic', { topic, tid, fromCid, post })) return;
615
+ // #2: On topic.move, no 'topic' key is provided — re-fetch by tid.
616
+ if (!topic && tid) {
617
+ topic = await Topics.getTopicData(tid);
618
+ }
619
+ if (!topic) {
620
+ winston.warn('[plugin/meilisearch] indexTopic: no topic data');
621
+ return;
622
+ }
623
+ // Issue 3: Skip deleted topics (admin editing/moving a deleted topic should not re-index it).
624
+ if (parseInt(topic.deleted, 10) === 1) {
625
+ winston.debug(`[plugin/meilisearch] Skipping deleted topic ${topic.tid}`);
626
+ return;
627
+ }
628
+ // Issue 5: Skip scheduled topics (timestamp in future) — indexed on publish via onScheduledPublish.
629
+ // action:topic.post fires AFTER action:post.save, so the main post may already be in Meili.
630
+ // Deindex it here to prevent full-search from finding scheduled content prematurely.
631
+ if (topic.scheduled || (topic.timestamp && topic.timestamp > Date.now())) {
632
+ const mainPid = post ? post.pid : (parseInt(topic.mainPid, 10) > 0 ? topic.mainPid : null);
633
+ if (mainPid) {
634
+ await plugin.client.index('post').deleteDocument(mainPid).catch(() => {});
635
+ }
636
+ winston.debug(`[plugin/meilisearch] Skipping scheduled topic ${topic.tid}, deindexed main post ${mainPid || 'N/A'}`);
637
+ return;
638
+ }
639
+ await meiliWrite('indexTopic', { topic, tid, fromCid, post }, () =>
640
+ plugin.client.index('topic').updateDocuments([{
641
+ tid: topic.tid,
642
+ cid: topic.cid,
643
+ uid: topic.uid,
644
+ mainPid: topic.mainPid,
645
+ title: topic.title,
646
+ timestamp: topic.timestamp,
647
+ }], { primaryKey: 'tid' }));
648
+ // #4: On topic move, re-index child posts' cids (fire-and-forget with fallback).
649
+ if (isMove && topic.tid) {
650
+ if (plugin.healthy) {
651
+ plugin.reindexTopicPosts({ tid: topic.tid }).catch(err =>
652
+ winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`));
653
+ } else {
654
+ await enqueuePending('reindexTopicPosts', { tid: topic.tid });
655
+ }
656
+ }
657
+ };
658
+
659
+ plugin.reindexTopicPosts = async function ({ tid }) {
660
+ if (!tid) return;
661
+ const postIndex = plugin.client.index('post');
662
+ try {
663
+ // Bug A: mainPid is NOT in tid:${tid}:posts — index it separately.
664
+ const mainPid = await Topics.getTopicField(tid, 'mainPid');
665
+ if (mainPid) {
666
+ const [mainPost] = await Posts.getPostsFields([mainPid], ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
667
+ if (mainPost && parseInt(mainPost.deleted, 10) !== 1) {
668
+ const [mainCid] = await Posts.getCidsByPids([mainPid]);
669
+ await postIndex.updateDocuments([{
670
+ pid: mainPost.pid,
671
+ tid: mainPost.tid,
672
+ cid: mainCid,
673
+ uid: mainPost.uid,
674
+ content: mainPost.content,
675
+ timestamp: mainPost.timestamp,
676
+ }], { primaryKey: 'pid' });
677
+ }
678
+ }
679
+ // Bug B: skip individually-deleted posts in the batch loop.
680
+ await batch.processSortedSet(
681
+ `tid:${tid}:posts`,
682
+ async (pids) => {
683
+ if (!pids.length) return;
684
+ const posts = await Posts.getPostsFields(pids, ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
685
+ const activePosts = posts.filter(post => post && parseInt(post.deleted, 10) !== 1);
686
+ if (!activePosts.length) return;
687
+ const activePids = activePosts.map(p => p.pid);
688
+ const cids = await Posts.getCidsByPids(activePids);
689
+ await postIndex.updateDocuments(
690
+ activePosts.map((post, index) => ({
691
+ pid: post.pid,
692
+ tid: post.tid,
693
+ cid: cids[index],
694
+ uid: post.uid,
695
+ content: post.content,
696
+ timestamp: post.timestamp,
697
+ })),
698
+ { primaryKey: 'pid' },
699
+ );
700
+ },
701
+ { batch: REINDEX_BATCH_SIZE },
702
+ );
703
+ } catch (err) {
704
+ winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`);
705
+ plugin.healthy = false;
706
+ await enqueuePending('reindexTopicPosts', { tid });
707
+ }
708
+ };
709
+
710
+ plugin.deindexTopic = async function ({ topic, tid }) {
711
+ if (await maybeDefer('deindexTopic', { topic, tid })) return;
712
+ // Tolerate topic.move-style payloads (no 'topic' key) — extract tid.
713
+ const topicTid = topic ? topic.tid : tid;
714
+ if (!topicTid) {
715
+ winston.warn('[plugin/meilisearch] deindexTopic: no tid');
716
+ return;
717
+ }
718
+ await meiliWrite('deindexTopic', { topic, tid }, () =>
719
+ plugin.client.index('topic').deleteDocument(topicTid));
720
+ // Also deindex the topic's posts from the post index so admin /search
721
+ // (which queries both indexes and bypasses NodeBB's relevance-sort
722
+ // deleted-content filter for admins) can't surface deleted-topic content.
723
+ if (plugin.healthy) {
724
+ plugin.deindexTopicPosts({ tid: topicTid }).catch(err =>
725
+ winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`));
726
+ } else {
727
+ await enqueuePending('deindexTopicPosts', { tid: topicTid });
728
+ }
729
+ };
730
+
731
+ plugin.deindexTopicPosts = async function ({ tid }) {
732
+ if (!tid) return;
733
+ const postIndex = plugin.client.index('post');
734
+ try {
735
+ // mainPid is NOT in tid:${tid}:posts — delete it separately.
736
+ const mainPid = await Topics.getTopicField(tid, 'mainPid');
737
+ const mainPidArr = mainPid ? [mainPid] : [];
738
+ await batch.processSortedSet(
739
+ `tid:${tid}:posts`,
740
+ async (pids) => {
741
+ if (!pids.length) return;
742
+ await postIndex.deleteDocuments(pids);
743
+ },
744
+ { batch: REINDEX_BATCH_SIZE },
745
+ );
746
+ if (mainPidArr.length) {
747
+ await postIndex.deleteDocuments(mainPidArr);
748
+ }
749
+ } catch (err) {
750
+ winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`);
751
+ plugin.healthy = false;
752
+ await enqueuePending('deindexTopicPosts', { tid });
753
+ }
754
+ };
755
+
756
+ plugin.restoreTopic = async function ({ topic, tid }) {
757
+ const topicTid = topic ? topic.tid : tid;
758
+ if (await maybeDefer('restoreTopic', { topic, tid })) return;
759
+ // Re-fetch topic if missing (defensive — same pattern as indexTopic).
760
+ if (!topic && topicTid) {
761
+ topic = await Topics.getTopicData(topicTid);
762
+ }
763
+ if (!topic) {
764
+ winston.warn('[plugin/meilisearch] restoreTopic: no topic data');
765
+ return;
766
+ }
767
+ // Re-index the topic document.
768
+ await meiliWrite('restoreTopic', { topic, tid }, () =>
769
+ plugin.client.index('topic').updateDocuments([{
770
+ tid: topic.tid,
771
+ cid: topic.cid,
772
+ uid: topic.uid,
773
+ mainPid: topic.mainPid,
774
+ title: topic.title,
775
+ timestamp: topic.timestamp,
776
+ }], { primaryKey: 'tid' }));
777
+ // Re-index the topic's posts (they were deindexed on delete).
778
+ if (plugin.healthy) {
779
+ plugin.reindexTopicPosts({ tid: topic.tid }).catch(err =>
780
+ winston.error(`[plugin/meilisearch] restoreTopic reindexTopicPosts failed: ${err.message}`));
781
+ } else {
782
+ await enqueuePending('reindexTopicPosts', { tid: topic.tid });
783
+ }
784
+ };
785
+
786
+ plugin.changePostOwner = async function ({ posts, toUid }) {
787
+ if (await maybeDefer('changePostOwner', { posts, toUid })) return;
788
+ // posts have OLD uid — re-fetch from DB to get fresh uid.
789
+ // Skip deleted posts (changeOwner can be called on deleted posts).
790
+ const validPids = (Array.isArray(posts) ? posts : [])
791
+ .filter(p => p && p.pid && parseInt(p.deleted, 10) !== 1)
792
+ .map(p => p.pid);
793
+ if (!validPids.length) return;
794
+ const freshPosts = await Posts.getPostsFields(validPids, ['pid', 'tid', 'uid', 'content', 'timestamp']);
795
+ // Fix 7: Skip posts in deleted or scheduled topics.
796
+ const tids = [...new Set(freshPosts.map(p => p && p.tid).filter(Boolean))];
797
+ const topicsData = tids.length ? await Topics.getTopicsFields(tids, ['tid', 'deleted', 'timestamp']) : [];
798
+ const tidToDeleted = {};
799
+ const tidToScheduled = {};
800
+ topicsData.forEach((t) => {
801
+ tidToDeleted[t.tid] = parseInt(t.deleted, 10) === 1;
802
+ tidToScheduled[t.tid] = t.timestamp && t.timestamp > Date.now();
803
+ });
804
+ const activePosts = freshPosts.filter(post => post && !tidToDeleted[post.tid] && !tidToScheduled[post.tid]);
805
+ if (!activePosts.length) return;
806
+ const activePids = activePosts.map(p => p.pid);
807
+ const cids = await Posts.getCidsByPids(activePids);
808
+ const docs = activePosts.map((post, index) => ({
809
+ pid: post.pid,
810
+ tid: post.tid,
811
+ cid: cids[index],
812
+ uid: post.uid,
813
+ content: post.content,
814
+ timestamp: post.timestamp,
815
+ }));
816
+ await meiliWrite('changePostOwner', { posts: activePids.map(pid => ({ pid })), toUid }, () =>
817
+ plugin.client.index('post').updateDocuments(docs, { primaryKey: 'pid' }));
818
+ };
819
+
820
+ plugin.changeTopicOwner = async function ({ topics, toUid }) {
821
+ if (await maybeDefer('changeTopicOwner', { topics, toUid })) return;
822
+ // topics have OLD uid — re-fetch from DB to get fresh uid.
823
+ // Skip deleted or scheduled topics.
824
+ const validTids = (Array.isArray(topics) ? topics : [])
825
+ .filter(t => t && t.tid && parseInt(t.deleted, 10) !== 1)
826
+ .map(t => t.tid);
827
+ if (!validTids.length) return;
828
+ const freshTopics = await Topics.getTopicsFields(validTids, ['tid', 'cid', 'uid', 'mainPid', 'title', 'timestamp']);
829
+ // Fix 7: Skip scheduled topics (timestamp in future).
830
+ const activeTopics = freshTopics.filter(t => t && !(t.timestamp && t.timestamp > Date.now()));
831
+ if (!activeTopics.length) return;
832
+ const docs = activeTopics.map(topic => ({
833
+ tid: topic.tid,
834
+ cid: topic.cid,
835
+ uid: topic.uid,
836
+ mainPid: topic.mainPid,
837
+ title: topic.title,
838
+ timestamp: topic.timestamp,
839
+ }));
840
+ await meiliWrite('changeTopicOwner', { topics: activeTopics.map(t => ({ tid: t.tid })), toUid }, () =>
841
+ plugin.client.index('topic').updateDocuments(docs, { primaryKey: 'tid' }));
842
+ };
843
+
844
+ plugin.onTopicMerge = async function ({ otherTids, mergeIntoTid }) {
845
+ if (await maybeDefer('onTopicMerge', { otherTids, mergeIntoTid })) return;
846
+ const postIndex = plugin.client.index('post');
847
+ // Deindex merged-out topics + their posts (Topics.delete in merge fires no action:topic.delete).
848
+ for (const tid of otherTids) {
849
+ await plugin.deindexTopic({ tid });
850
+ // Merge zeros mainPid before Topics.delete, so deindexTopicPosts can't find the main post.
851
+ // Fallback: search Meili for any orphaned posts still carrying this tid.
852
+ try {
853
+ const result = await postIndex.search('', { filter: `tid = ${parseInt(tid, 10)}`, limit: 1000 });
854
+ if (result.hits && result.hits.length) {
855
+ const orphanPids = result.hits.map(h => h.pid);
856
+ await meiliWrite('deindexPostsPurge', { posts: orphanPids.map(pid => ({ pid })) }, () =>
857
+ postIndex.deleteDocuments(orphanPids));
858
+ }
859
+ } catch (err) {
860
+ winston.error(`[plugin/meilisearch] onTopicMerge orphan cleanup for tid ${tid} failed: ${err.message}`);
861
+ plugin.healthy = false;
862
+ // Don't enqueue — orphans are cleaned by next reindex. Continue to next tid.
863
+ }
864
+ }
865
+ // Re-index the merge target (postcount/lastpost changed; posts already moved via action:post.move).
866
+ await plugin.indexTopic({ tid: mergeIntoTid });
867
+ if (plugin.healthy) {
868
+ plugin.reindexTopicPosts({ tid: mergeIntoTid }).catch(err =>
869
+ winston.error(`[plugin/meilisearch] onTopicMerge reindexTopicPosts failed: ${err.message}`));
870
+ }
871
+ };
872
+
873
+ plugin.onScheduledPublish = async function ({ topics }) {
874
+ const tids = (Array.isArray(topics) ? topics : []).filter(t => t && t.tid).map(t => t.tid);
875
+ if (!tids.length) return;
876
+ if (await maybeDefer('onScheduledPublish', { topics: tids.map(tid => ({ tid })) })) return;
877
+ // Re-index each published topic + its posts (DB deleted=0, timestamp in past now).
878
+ for (const tid of tids) {
879
+ await plugin.indexTopic({ tid });
880
+ if (plugin.healthy) {
881
+ plugin.reindexTopicPosts({ tid }).catch(err =>
882
+ winston.error(`[plugin/meilisearch] onScheduledPublish reindexTopicPosts failed: ${err.message}`));
883
+ }
884
+ }
885
+ };
886
+
887
+ plugin.deindexPostsPurge = async function ({ posts }) {
888
+ if (await maybeDefer('deindexPostsPurge', { posts })) return;
889
+ const pids = Array.isArray(posts) ? posts.map(p => p && p.pid).filter(Boolean) : [];
890
+ if (!pids.length) {
891
+ return;
892
+ }
893
+ const reducedPayload = { posts: pids.map(pid => ({ pid })) };
894
+ await meiliWrite('deindexPostsPurge', reducedPayload, () =>
895
+ plugin.client.index('post').deleteDocuments(pids));
896
+ };
897
+
898
+ plugin.deindexTopicsPurge = async function ({ topics }) {
899
+ if (await maybeDefer('deindexTopicsPurge', { topics })) return;
900
+ const topicsArr = Array.isArray(topics) ? topics.filter(t => t && t.tid) : [];
901
+ if (!topicsArr.length) {
902
+ return;
903
+ }
904
+ const tids = topicsArr.map(t => t.tid);
905
+ const mainPids = topicsArr.map(t => t.mainPid).filter(Boolean);
906
+ const postIndex = plugin.client.index('post');
907
+ const reducedPayload = { topics: topicsArr.map(t => ({ tid: t.tid, mainPid: t.mainPid })) };
908
+ try {
909
+ await Promise.all([
910
+ plugin.client.index('topic').deleteDocuments(tids),
911
+ mainPids.length ? postIndex.deleteDocuments(mainPids) : Promise.resolve(),
912
+ ]);
913
+ await Promise.all(topicsArr.map(async (t) => {
914
+ await batch.processSortedSet(
915
+ `tid:${t.tid}:posts`,
916
+ async (pids) => {
917
+ if (!pids.length) {
918
+ return;
919
+ }
920
+ await postIndex.deleteDocuments(pids);
921
+ },
922
+ { batch: REINDEX_BATCH_SIZE },
923
+ );
924
+ }));
925
+ } catch (err) {
926
+ winston.error(`[plugin/meilisearch] deindexTopicsPurge failed: ${err.message}`);
927
+ plugin.healthy = false;
928
+ await enqueuePending('deindexTopicsPurge', reducedPayload);
929
+ }
930
+ };
931
+
932
+ plugin.checkConflict = function () {
933
+ const hooksToCheck = [
934
+ 'filter:search.query',
935
+ 'filter:topic.search',
936
+ ];
937
+ // blacklist, in case someone makes a plugin using these hooks that doesn't conflict.
938
+ // also, outside of dbsearch the user is expected to realize they installed two search plugins.
939
+ const conflictingPlugins = [
940
+ 'nodebb-plugin-dbsearch',
941
+ 'nodebb-plugin-solr',
942
+ 'nodebb-plugin-elasticsearch',
943
+ 'nodebb-plugin-search-elasticsearch',
944
+ ];
945
+ for (const hook of hooksToCheck) {
946
+ if ((plugins.loadedHooks[hook] || []).filter(hookData => conflictingPlugins.includes(hookData.id)).length >= 1) {
947
+ return true;
948
+ }
949
+ }
950
+ return false;
951
+ };
952
+
953
+ plugin.search = async function (data) {
954
+ if (plugin.checkConflict()) {
955
+ // The dbsearch plugin was detected, abort search!
956
+ winston.warn(
957
+ '[plugin/meilisearch] Another search plugin (most likely dbsearch) is enabled, so search via Meilisearch was aborted.',
958
+ );
959
+ return data;
960
+ }
961
+ if (!plugin.healthy && !(await plugin.checkHealth())) {
962
+ winston.warn(
963
+ '[plugin/meilisearch] Meilisearch instance did not return a healthy response, so search via Meilisearch was aborted.',
964
+ );
965
+ return data;
966
+ }
967
+ const rawQuery = (data.term || data.content || '').trim();
968
+ const minTermLength = await resolveMinTermLength();
969
+ if (!rawQuery || !rawQuery.split(' ').some(word => word.length >= minTermLength)) {
970
+ winston.debug(`[plugin/meilisearch] Skipping search: no query word >= ${minTermLength} char(s)`);
971
+ return data;
972
+ }
973
+ // #6: Use local variables instead of mutating the shared payload.
974
+ const content = data.term || data.content;
975
+ const searchData = data.term ? { tid: data.tid } : data?.searchData;
976
+ const rawIndex = Array.isArray(data?.index) ? data.index[0] : data.index;
977
+ const ALLOWED_INDEXES = { post: 'pid', topic: 'tid' };
978
+ const index = ALLOWED_INDEXES[rawIndex] ? rawIndex : 'post';
979
+ const id = ALLOWED_INDEXES[index];
980
+ winston.debug(`[plugin/meilisearch] Searching for ${content} in ${index}`);
981
+ const result = await plugin.client.index(index).search(content, {
982
+ attributesToRetrieve: [id],
983
+ limit: parseInt(await settings.getOne(plugin.id, 'maxDocuments') || 500, 10),
984
+ filter: plugin.buildFilter(
985
+ data.cid,
986
+ data.uid,
987
+ searchData?.timeFilter,
988
+ searchData?.timeRange,
989
+ searchData?.tid,
990
+ ),
991
+ sort: plugin.buildSort(searchData?.sortBy, searchData?.sortDirection),
992
+ matchingStrategy: data.matchWords === 'all' ? 'all' : 'last',
993
+ });
994
+ data.ids = result.hits.map(hit => hit[id]);
995
+ return data;
996
+ };
997
+
998
+ plugin.buildFilter = function (categories, postedBy, timeFilter, timeRange, tid) {
999
+ const num = (v) => {
1000
+ const n = parseInt(v, 10);
1001
+ return Number.isFinite(n) ? n : null;
1002
+ };
1003
+ const filter = [];
1004
+ if (categories?.length) {
1005
+ const cids = categories.map(num).filter(c => c !== null);
1006
+ if (cids.length) filter.push(cids.map(cid => `cid = ${cid}`));
1007
+ }
1008
+ if (postedBy?.length) {
1009
+ const uids = postedBy.map(num).filter(u => u !== null);
1010
+ if (uids.length) filter.push(uids.map(uid => `uid = ${uid}`));
1011
+ }
1012
+ if (timeFilter && timeRange) {
1013
+ const range = num(timeRange);
1014
+ if (range !== null) {
1015
+ filter.push(`timestamp ${timeFilter === 'newer' ? '>' : '<'} ${Date.now() - (range * 1000)}`);
1016
+ }
1017
+ }
1018
+ const numericTid = num(tid);
1019
+ if (numericTid !== null) {
1020
+ filter.push(`tid = ${numericTid}`);
1021
+ }
1022
+ return filter.length ? filter : undefined;
1023
+ };
1024
+
1025
+ plugin.buildSort = function (sortBy, sortDirection) {
1026
+ let field = '';
1027
+ switch (sortBy) {
1028
+ case 'timestamp':
1029
+ field = 'timestamp';
1030
+ break;
1031
+ case 'topic.title':
1032
+ field = 'title';
1033
+ break;
1034
+ case 'category':
1035
+ field = 'cid';
1036
+ break;
1037
+ default:
1038
+ return undefined;
1039
+ }
1040
+ return [`${field}:${sortDirection === 'ascending' ? 'asc' : 'desc'}`];
1041
+ };
1042
+
1043
+ plugin.saveSettings = async (data) => {
1044
+ if (data.plugin === plugin.id && !data.quiet && plugin.initialized) {
1045
+ try {
1046
+ if (data.settings && Object.prototype.hasOwnProperty.call(data.settings, 'searchMinTermLength')) {
1047
+ data.settings.searchMinTermLength = clampMinTermLength(data.settings.searchMinTermLength);
1048
+ }
1049
+ // #8: Only re-connect when connection settings changed.
1050
+ const connChanged = await connectionSettingsChanged(data.settings);
1051
+ if (connChanged) {
1052
+ // Fix 10: If host changed, clear indexed so prepareSearch auto-reindexes the new (empty) host.
1053
+ const hostChanged = await hostSettingChanged(data.settings);
1054
+ if (hostChanged) {
1055
+ await settings.set(plugin.id, { indexed: false }, true);
1056
+ }
1057
+ await plugin.prepareSearch(data.settings, true);
1058
+ }
1059
+ const changed = (await Promise.all(
1060
+ Object.entries(data.settings).map(([k, v]) => isBreaking([k, v])),
1061
+ )).some(Boolean);
1062
+ if (changed) {
1063
+ winston.info('settings changed, updating index');
1064
+ await plugin.updateIndexSettings(data.settings);
1065
+ }
1066
+ } catch (err) {
1067
+ // #7: Don't throw — let settings persist to DB even when Meili is unreachable.
1068
+ winston.error(`[plugin/meilisearch] Error while saving settings: ${err.message}`);
1069
+ plugin.healthy = false;
1070
+ }
1071
+ }
1072
+ return data;
1073
+ };
1074
+
1075
+ async function ensureIndex(uid, primaryKey) {
1076
+ try {
1077
+ await plugin.client.getIndex(uid);
1078
+ } catch (e) {
1079
+ await plugin.client.createIndex(uid, { primaryKey });
1080
+ }
1081
+ }
1082
+
1083
+ async function connectionSettingsChanged(newSettings) {
1084
+ if (!newSettings) return false;
1085
+ const keys = ['host', 'apiKey', 'healthCheckInterval'];
1086
+ for (const k of keys) {
1087
+ if (!Object.prototype.hasOwnProperty.call(newSettings, k)) continue;
1088
+ const stored = await settings.getOne(plugin.id, k);
1089
+ if (String(stored || '') !== String(newSettings[k] || '')) return true;
1090
+ }
1091
+ return false;
1092
+ }
1093
+
1094
+ async function hostSettingChanged(newSettings) {
1095
+ if (!newSettings || !Object.prototype.hasOwnProperty.call(newSettings, 'host')) return false;
1096
+ const stored = await settings.getOne(plugin.id, 'host');
1097
+ return String(stored || '') !== String(newSettings.host || '');
1098
+ }
1099
+
1100
+ async function isBreaking([setting, value]) {
1101
+ if (!plugin.breakingSettings.includes(setting)) {
1102
+ return false;
1103
+ }
1104
+ const stored = await settings.getOne(plugin.id, setting);
1105
+ const changed = !deepCompare(stored, value);
1106
+ if (changed) {
1107
+ winston.info(`[plugin/meilisearch] ${setting} changed: ${JSON.stringify(stored)} -> ${JSON.stringify(value)}`);
1108
+ }
1109
+ return changed;
1110
+ }
1111
+
1112
+ async function resolveMinTermLength() {
1113
+ const v = parseInt(await settings.getOne(plugin.id, 'searchMinTermLength'), 10);
1114
+ return Number.isFinite(v) && v >= 2 ? v : 2;
1115
+ }
1116
+
1117
+ function clampMinTermLength(value) {
1118
+ const v = parseInt(value, 10);
1119
+ return Number.isFinite(v) && v >= 2 ? v : 2;
1120
+ }
1121
+
1122
+ function deepCompare(a, b) {
1123
+ if (a === null || b === null) return a === b;
1124
+ if (typeof a !== typeof b) return false;
1125
+ switch (typeof a) {
1126
+ case 'object':
1127
+ return Object.keys(a).length === Object.keys(b).length &&
1128
+ Object.keys(a).every(key => deepCompare(a[key], b[key]));
1129
+ case 'string':
1130
+ case 'number':
1131
+ case 'boolean':
1132
+ default:
1133
+ return a === b;
1134
+ }
1135
+ }
1136
+ module.exports = plugin;