@crowi/plugin-search-elasticsearch 0.1.0-alpha.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/dist/index.mjs ADDED
@@ -0,0 +1,801 @@
1
+ // src/index.ts
2
+ import { z } from "zod/v3";
3
+
4
+ // src/driver.ts
5
+ import { Client } from "@elastic/elasticsearch";
6
+
7
+ // src/parse-query.ts
8
+ var normalize = (query) => {
9
+ return query.trim().replace(/\s+/g, " ");
10
+ };
11
+ var splitKeywordsAndPhrases = (query) => {
12
+ const phraseRegExp = /(-?"[^"]*")/g;
13
+ const keywords = query.replace(phraseRegExp, "").split(/\s+/g).filter(Boolean);
14
+ const phrases = (query.match(phraseRegExp) || []).map(normalize);
15
+ return { keywords, phrases };
16
+ };
17
+ var splitPositiveAndNegative = (queries) => {
18
+ const positive = [];
19
+ const negative = [];
20
+ for (const query of queries) {
21
+ const isNegative = query.startsWith("-");
22
+ const target = isNegative ? negative : positive;
23
+ const newQuery = isNegative ? query.substring(1) : query;
24
+ if (newQuery) {
25
+ target.push(newQuery);
26
+ }
27
+ }
28
+ return { positive, negative };
29
+ };
30
+ var unquote = (query) => {
31
+ return query.slice(1, -1);
32
+ };
33
+ var parseQuery = (query) => {
34
+ const { keywords, phrases } = splitKeywordsAndPhrases(normalize(query));
35
+ const { positive: positiveKeywords, negative: negativeKeywords } = splitPositiveAndNegative(keywords);
36
+ const { positive: positivePhrases, negative: negativePhrases } = splitPositiveAndNegative(phrases);
37
+ return {
38
+ keywords: {
39
+ positive: positiveKeywords,
40
+ negative: negativeKeywords
41
+ },
42
+ phrases: {
43
+ positive: positivePhrases.map(unquote).filter(Boolean),
44
+ negative: negativePhrases.map(unquote).filter(Boolean)
45
+ }
46
+ };
47
+ };
48
+
49
+ // src/query-builder.ts
50
+ var GRANT_PUBLIC = 1;
51
+ var defaultKeywordQueryFields = ["path.ja^2", "body.ja", "path.en^1.2", "body.en"];
52
+ var defaultPhraseQueryFields = ["path.raw^2", "body"];
53
+ var portalQuery = { regexp: { "path.raw": ".*/" } };
54
+ var userPathQuery = { prefix: { "path.raw": "/user/" } };
55
+ var emptyBuckets = () => ({ must: [], filter: [], should: [], must_not: [] });
56
+ var appendKeywords = (buckets, keywords, operator, kind) => {
57
+ if (keywords.length === 0) return;
58
+ buckets[kind].push({
59
+ multi_match: {
60
+ query: keywords.join(" "),
61
+ fields: defaultKeywordQueryFields,
62
+ operator
63
+ }
64
+ });
65
+ };
66
+ var appendPhrases = (buckets, phrases, operator, kind) => {
67
+ for (const phrase of phrases) {
68
+ buckets[kind].push({
69
+ multi_match: {
70
+ type: "phrase",
71
+ query: phrase,
72
+ fields: defaultPhraseQueryFields,
73
+ operator
74
+ }
75
+ });
76
+ }
77
+ };
78
+ var appendTypeFilter = (buckets, type) => {
79
+ switch (type) {
80
+ case "portal":
81
+ buckets.must_not.push(userPathQuery);
82
+ buckets.filter.push(portalQuery);
83
+ return;
84
+ case "public":
85
+ buckets.must_not.push(userPathQuery);
86
+ buckets.must_not.push(portalQuery);
87
+ return;
88
+ case "user":
89
+ buckets.filter.push(userPathQuery);
90
+ return;
91
+ }
92
+ };
93
+ var appendPathPrefix = (buckets, pathPrefix) => {
94
+ const trimmed = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
95
+ buckets.filter.push({
96
+ wildcard: {
97
+ "path.raw": `${trimmed}/*`
98
+ }
99
+ });
100
+ };
101
+ var appendGrantFilter = (buckets, viewer) => {
102
+ if (!viewer) {
103
+ buckets.filter.push({ match: { grant: GRANT_PUBLIC } });
104
+ return;
105
+ }
106
+ if (viewer.isAdmin) {
107
+ return;
108
+ }
109
+ buckets.filter.push({
110
+ bool: {
111
+ should: [{ term: { grant: GRANT_PUBLIC } }, { term: { username: viewer.username } }, { term: { granted_users: viewer.id } }],
112
+ minimum_should_match: 1
113
+ }
114
+ });
115
+ };
116
+ function buildSearchBody(params) {
117
+ const { parsed, pathPrefix, viewer, grants, functionScore, from, size } = params;
118
+ const buckets = emptyBuckets();
119
+ appendKeywords(buckets, parsed.keywords.positive, "and", "must");
120
+ appendKeywords(buckets, parsed.keywords.negative, "or", "must_not");
121
+ appendPhrases(buckets, parsed.phrases.positive, "and", "must");
122
+ appendPhrases(buckets, parsed.phrases.negative, "or", "must_not");
123
+ if (pathPrefix) {
124
+ appendPathPrefix(buckets, pathPrefix);
125
+ }
126
+ if (grants?.types && grants.types.length > 0) {
127
+ if (grants.types.length === 1) {
128
+ appendTypeFilter(buckets, grants.types[0]);
129
+ } else {
130
+ const typeShoulds = grants.types.map((t) => {
131
+ const inner = emptyBuckets();
132
+ appendTypeFilter(inner, t);
133
+ return { bool: pruneBool(inner) };
134
+ });
135
+ buckets.filter.push({
136
+ bool: { should: typeShoulds, minimum_should_match: 1 }
137
+ });
138
+ }
139
+ }
140
+ appendGrantFilter(buckets, viewer);
141
+ const baseQuery = { bool: pruneBool(buckets) };
142
+ const query = functionScore ? {
143
+ function_score: {
144
+ query: baseQuery,
145
+ field_value_factor: functionScore.fieldValueFactor,
146
+ boost_mode: functionScore.boostMode
147
+ }
148
+ } : baseQuery;
149
+ return {
150
+ from,
151
+ size,
152
+ sort: [{ _score: "desc" }],
153
+ highlight: {
154
+ pre_tags: ["<mark>"],
155
+ post_tags: ["</mark>"],
156
+ fields: {
157
+ "path.ja": {},
158
+ "body.ja": {},
159
+ body: {}
160
+ }
161
+ },
162
+ query,
163
+ _source: ["path", "bookmark_count", "username", "grant"]
164
+ };
165
+ }
166
+ function pruneBool(buckets) {
167
+ const out = {};
168
+ if (buckets.must.length > 0) out.must = buckets.must;
169
+ if (buckets.filter.length > 0) out.filter = buckets.filter;
170
+ if (buckets.should.length > 0) out.should = buckets.should;
171
+ if (buckets.must_not.length > 0) out.must_not = buckets.must_not;
172
+ return out;
173
+ }
174
+
175
+ // src/mappings/default.json
176
+ var default_default = {
177
+ settings: {
178
+ analysis: {
179
+ filter: {
180
+ english_stop: {
181
+ type: "stop",
182
+ stopwords: "_english_"
183
+ },
184
+ english_stemmer: {
185
+ type: "stemmer",
186
+ language: "english"
187
+ },
188
+ english_possessive_stemmer: {
189
+ type: "stemmer",
190
+ language: "possessive_english"
191
+ }
192
+ },
193
+ tokenizer: {
194
+ ngram_tokenizer: {
195
+ type: "ngram",
196
+ min_gram: 2,
197
+ max_gram: 3,
198
+ token_chars: ["letter", "digit"]
199
+ }
200
+ },
201
+ analyzer: {
202
+ english: {
203
+ tokenizer: "ngram_tokenizer",
204
+ filter: ["english_possessive_stemmer", "lowercase", "english_stop", "english_stemmer"]
205
+ }
206
+ }
207
+ }
208
+ },
209
+ mappings: {
210
+ properties: {
211
+ path: {
212
+ type: "text",
213
+ fields: {
214
+ raw: {
215
+ type: "text",
216
+ analyzer: "keyword"
217
+ },
218
+ en: {
219
+ type: "text",
220
+ analyzer: "english"
221
+ }
222
+ }
223
+ },
224
+ body: {
225
+ type: "text",
226
+ fields: {
227
+ en: {
228
+ type: "text",
229
+ analyzer: "english"
230
+ }
231
+ }
232
+ },
233
+ username: {
234
+ type: "keyword"
235
+ },
236
+ grant: {
237
+ type: "integer"
238
+ },
239
+ granted_users: {
240
+ type: "keyword"
241
+ },
242
+ comment_count: {
243
+ type: "integer"
244
+ },
245
+ bookmark_count: {
246
+ type: "integer"
247
+ },
248
+ like_count: {
249
+ type: "integer"
250
+ },
251
+ created_at: {
252
+ type: "date",
253
+ format: "strict_date_optional_time"
254
+ },
255
+ updated_at: {
256
+ type: "date",
257
+ format: "strict_date_optional_time"
258
+ }
259
+ }
260
+ }
261
+ };
262
+
263
+ // src/mappings/kuromoji.json
264
+ var kuromoji_default = {
265
+ mappings: {
266
+ properties: {
267
+ path: {
268
+ fields: {
269
+ ja: {
270
+ type: "text",
271
+ analyzer: "kuromoji"
272
+ }
273
+ }
274
+ },
275
+ body: {
276
+ fields: {
277
+ ja: {
278
+ type: "text",
279
+ analyzer: "kuromoji"
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+ };
286
+
287
+ // src/mappings/sudachi.json
288
+ var sudachi_default = {
289
+ settings: {
290
+ analysis: {
291
+ tokenizer: {
292
+ sudachi_tokenizer: {
293
+ type: "sudachi_tokenizer",
294
+ mode: "search"
295
+ }
296
+ },
297
+ analyzer: {
298
+ sudachi_analyzer: {
299
+ filter: [],
300
+ tokenizer: "sudachi_tokenizer",
301
+ type: "custom"
302
+ }
303
+ }
304
+ }
305
+ },
306
+ mappings: {
307
+ properties: {
308
+ path: {
309
+ fields: {
310
+ ja: {
311
+ type: "text",
312
+ analyzer: "sudachi_analyzer"
313
+ }
314
+ }
315
+ },
316
+ body: {
317
+ fields: {
318
+ ja: {
319
+ type: "text",
320
+ analyzer: "sudachi_analyzer"
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ };
327
+
328
+ // src/driver.ts
329
+ function applyConfig(config) {
330
+ if (!config.url) {
331
+ return {
332
+ client: null,
333
+ node: "",
334
+ baseIndexName: config.indexName,
335
+ aliasName: `${config.indexName}-current`,
336
+ analyzer: config.analyzer,
337
+ requestTimeout: config.requestTimeout
338
+ };
339
+ }
340
+ const { node, indexName } = parseUri(config.url);
341
+ const clientOpts = {
342
+ node,
343
+ requestTimeout: config.requestTimeout
344
+ };
345
+ return {
346
+ client: new Client(clientOpts),
347
+ node,
348
+ baseIndexName: indexName,
349
+ aliasName: `${indexName}-current`,
350
+ analyzer: config.analyzer,
351
+ requestTimeout: config.requestTimeout
352
+ };
353
+ }
354
+ function applyConfigInPlace(target, config) {
355
+ const oldClient = target.client;
356
+ Object.assign(target, applyConfig(config));
357
+ return { oldClient };
358
+ }
359
+ var USER_COUNT_TTL_MS = 5 * 60 * 1e3;
360
+ function createElasticsearchDriver(state2, deps = {}) {
361
+ const log = deps.log;
362
+ let userCountCache = null;
363
+ const getCachedUserCount = async () => {
364
+ if (!deps.countUsers) return null;
365
+ const now = Date.now();
366
+ if (userCountCache && now - userCountCache.at < USER_COUNT_TTL_MS) {
367
+ return userCountCache.value;
368
+ }
369
+ const value = await deps.countUsers();
370
+ userCountCache = { value, at: now };
371
+ return value;
372
+ };
373
+ const driver = {
374
+ // Getters off the state ref: `reconfigure` makes these mutable, so
375
+ // they must always reflect the *current* state, not a boot-time
376
+ // literal. Tests read `driver.client` to install fakes — since the
377
+ // getter returns the same object reference, mutating its methods
378
+ // still works.
379
+ get aliasName() {
380
+ return state2.aliasName;
381
+ },
382
+ get node() {
383
+ return state2.node;
384
+ },
385
+ get baseIndexName() {
386
+ return state2.baseIndexName;
387
+ },
388
+ get client() {
389
+ return requireClient(state2.client);
390
+ },
391
+ async index(doc) {
392
+ const { client, aliasName } = snapshot(state2);
393
+ const source = docToEsSource(doc);
394
+ await client.index({
395
+ index: aliasName,
396
+ id: doc.id,
397
+ document: source
398
+ });
399
+ },
400
+ async remove(id) {
401
+ const { client, aliasName } = snapshot(state2);
402
+ try {
403
+ await client.delete({ index: aliasName, id });
404
+ } catch (err) {
405
+ if (isNotFoundError(err)) return;
406
+ throw err;
407
+ }
408
+ },
409
+ async query(q) {
410
+ const { client, aliasName } = snapshot(state2);
411
+ const page = q.page && q.page > 0 ? q.page : 1;
412
+ const limit = clampLimit(q.limit);
413
+ const from = (page - 1) * limit;
414
+ let functionScore;
415
+ const userCount = await getCachedUserCount();
416
+ if (userCount !== null) {
417
+ const factor = 1e4 / (userCount || 1);
418
+ functionScore = {
419
+ fieldValueFactor: { field: "bookmark_count", modifier: "log1p", factor, missing: 0 },
420
+ boostMode: "sum"
421
+ };
422
+ }
423
+ const body = buildSearchBody({
424
+ parsed: parseQuery(q.q),
425
+ pathPrefix: q.pathPrefix,
426
+ viewer: q.viewer,
427
+ grants: q.grants,
428
+ functionScore,
429
+ from,
430
+ size: limit
431
+ });
432
+ const response = await client.search({
433
+ index: aliasName,
434
+ ...body
435
+ });
436
+ const totalRaw = response.hits?.total;
437
+ const total = typeof totalRaw === "number" ? totalRaw : totalRaw?.value ?? 0;
438
+ const rawHits = response.hits?.hits ?? [];
439
+ const hits = rawHits.map((h) => {
440
+ const source = h._source ?? {};
441
+ const snippet = pickSnippet(h.highlight);
442
+ return {
443
+ id: String(h._id),
444
+ path: source.path ?? "",
445
+ score: typeof h._score === "number" ? h._score : void 0,
446
+ ...snippet ? { snippet } : {}
447
+ };
448
+ });
449
+ return { total, hits };
450
+ },
451
+ async rebuild() {
452
+ const { client, aliasName, baseIndexName, analyzer } = snapshot(state2);
453
+ if (!deps.iteratePages || !deps.countAllPages || !deps.getBookmarkCountsBulk) {
454
+ throw new Error("@crowi/plugin-search-elasticsearch: rebuild() requires iteratePages / countAllPages / getBookmarkCountsBulk deps.");
455
+ }
456
+ const newIndexName = createTimestampedIndexName(baseIndexName);
457
+ log?.info("rebuild: creating index %s", newIndexName);
458
+ const mapping = loadMapping(analyzer);
459
+ await client.indices.create({ index: newIndexName, ...mapping });
460
+ log?.info("rebuild: prefetching bookmark counts");
461
+ const bookmarkCounts = await deps.getBookmarkCountsBulk();
462
+ log?.info("rebuild: indexing all pages");
463
+ await indexAllPages({
464
+ client,
465
+ indexTarget: newIndexName,
466
+ iteratePages: deps.iteratePages,
467
+ countAllPages: deps.countAllPages,
468
+ bookmarkCounts,
469
+ log
470
+ });
471
+ log?.info("rebuild: switching alias %s -> %s", aliasName, newIndexName);
472
+ await switchAlias(client, aliasName, newIndexName);
473
+ log?.info("rebuild: cleaning up old indices");
474
+ await deleteOldIndices(client, baseIndexName, newIndexName);
475
+ }
476
+ };
477
+ return driver;
478
+ }
479
+ var SEARCH_NOT_CONFIGURED = "@crowi/plugin-search-elasticsearch: Search not configured (Elasticsearch url is empty).";
480
+ function requireClient(client) {
481
+ if (!client) {
482
+ throw new Error(SEARCH_NOT_CONFIGURED);
483
+ }
484
+ return client;
485
+ }
486
+ function snapshot(state2) {
487
+ return {
488
+ client: requireClient(state2.client),
489
+ aliasName: state2.aliasName,
490
+ baseIndexName: state2.baseIndexName,
491
+ analyzer: state2.analyzer
492
+ };
493
+ }
494
+ var DEFAULT_LIMIT = 50;
495
+ var MAX_LIMIT = 200;
496
+ function clampLimit(limit) {
497
+ if (!limit || limit <= 0) return DEFAULT_LIMIT;
498
+ return Math.min(limit, MAX_LIMIT);
499
+ }
500
+ function parseUri(uri) {
501
+ if (!uri.startsWith("http")) {
502
+ throw new Error("URL for Elasticsearch should starts with http/https");
503
+ }
504
+ const esUrl = new URL(uri);
505
+ const auth = esUrl.username && esUrl.password ? `${esUrl.username}:${esUrl.password}@` : "";
506
+ const node = `${esUrl.protocol}//${auth}${esUrl.host}`;
507
+ const indexName = esUrl.pathname && esUrl.pathname !== "/" ? esUrl.pathname.substring(1) : "crowi";
508
+ return { node, indexName };
509
+ }
510
+ function createTimestampedIndexName(base) {
511
+ const d = /* @__PURE__ */ new Date();
512
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
513
+ const ts = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}${pad(d.getUTCMilliseconds(), 3)}`;
514
+ const rnd = Math.random().toString(36).slice(2, 6).padEnd(4, "0");
515
+ return `${base}-${ts}-${rnd}`;
516
+ }
517
+ var TS_INDEX_RE = /^.+-\d{17}-[a-z0-9]{4}$/;
518
+ function loadMapping(analyzer) {
519
+ const base = default_default;
520
+ if (analyzer === "default") return base;
521
+ const overlay = analyzer === "kuromoji" ? kuromoji_default : sudachi_default;
522
+ return deepMergeMappings(base, overlay);
523
+ }
524
+ function isPlainObject(v) {
525
+ return typeof v === "object" && v !== null && !Array.isArray(v);
526
+ }
527
+ function deepMergeMappings(a, b) {
528
+ const out = { ...a };
529
+ for (const key of Object.keys(b)) {
530
+ const av = a[key];
531
+ const bv = b[key];
532
+ if (isPlainObject(av) && isPlainObject(bv)) {
533
+ out[key] = deepMergeMappings(av, bv);
534
+ } else {
535
+ out[key] = bv;
536
+ }
537
+ }
538
+ return out;
539
+ }
540
+ async function indexAllPages(ctx) {
541
+ const allPageCount = await ctx.countAllPages();
542
+ let operations = [];
543
+ let total = 0;
544
+ let skipped = 0;
545
+ const flush = async () => {
546
+ if (operations.length === 0) return;
547
+ try {
548
+ const response = await ctx.client.bulk({
549
+ operations,
550
+ timeout: "1d"
551
+ });
552
+ if (response.errors) {
553
+ ctx.log?.warn("rebuild: bulk had item-level errors (took=%dms)", response.took);
554
+ }
555
+ } catch (err) {
556
+ ctx.log?.error("rebuild: bulk failed: %o", err);
557
+ }
558
+ operations = [];
559
+ };
560
+ await ctx.iteratePages(async (doc) => {
561
+ if (!doc.creator || !doc.revision || !shouldIndex(doc)) {
562
+ skipped++;
563
+ return;
564
+ }
565
+ total++;
566
+ const id = typeof doc._id === "string" ? doc._id : doc._id.toString();
567
+ const bookmarkCount = ctx.bookmarkCounts.get(id) ?? 0;
568
+ const source = pageStreamDocToEsSource(doc, bookmarkCount);
569
+ operations.push({ index: { _index: ctx.indexTarget, _id: id } });
570
+ operations.push(source);
571
+ if (operations.length >= 4e3) {
572
+ await flush();
573
+ }
574
+ });
575
+ await flush();
576
+ ctx.log?.info("rebuild: indexed total=%d skipped=%d (allPageCount=%d)", total, skipped, allPageCount);
577
+ }
578
+ function shouldIndex(doc) {
579
+ if (doc.redirectTo !== null && doc.redirectTo !== void 0) return false;
580
+ if (doc.status === "deleted") return false;
581
+ if (doc.status === "draft") return false;
582
+ return true;
583
+ }
584
+ async function switchAlias(client, aliasName, newIndex) {
585
+ const aliasInfo = await getCurrentAliasInfo(client, aliasName);
586
+ const actions = [{ add: { index: newIndex, alias: aliasName } }];
587
+ if (aliasInfo) {
588
+ actions.push({ remove: { index: aliasInfo.index, alias: aliasName } });
589
+ }
590
+ await client.indices.updateAliases({ actions });
591
+ }
592
+ async function getCurrentAliasInfo(client, aliasName) {
593
+ try {
594
+ const exists = await client.indices.existsAlias({ name: aliasName });
595
+ if (!exists) return null;
596
+ } catch {
597
+ return null;
598
+ }
599
+ const aliases = await client.cat.aliases({ name: aliasName, format: "json" });
600
+ const list = aliases;
601
+ return list.length > 0 ? { alias: list[0].alias, index: list[0].index } : null;
602
+ }
603
+ async function deleteOldIndices(client, baseIndexName, keepIndexName) {
604
+ const indices = await client.cat.indices({ index: `${baseIndexName}-*`, format: "json" });
605
+ const list = indices;
606
+ const toDelete = list.map((i) => i.index).filter((name) => name.startsWith(`${baseIndexName}-`) && name !== keepIndexName && TS_INDEX_RE.test(name));
607
+ if (toDelete.length === 0) return;
608
+ await client.indices.delete({ index: toDelete });
609
+ }
610
+ function isNotFoundError(err) {
611
+ if (!err || typeof err !== "object") return false;
612
+ const e = err;
613
+ return e.statusCode === 404 || e.meta?.statusCode === 404;
614
+ }
615
+ function docToEsSource(doc) {
616
+ const meta = doc.meta ?? {};
617
+ const source = {
618
+ path: doc.path,
619
+ body: doc.body
620
+ };
621
+ const username = readString(meta.username);
622
+ if (username !== void 0) source.username = username;
623
+ const grant = readNumber(meta.grant);
624
+ if (grant !== void 0) source.grant = grant;
625
+ const grantedUsers = readStringArray(meta.granted_users ?? meta.grantedUsers);
626
+ if (grantedUsers !== void 0) source.granted_users = grantedUsers;
627
+ const commentCount = readNumber(meta.comment_count ?? meta.commentCount);
628
+ if (commentCount !== void 0) source.comment_count = commentCount;
629
+ const bookmarkCount = readNumber(meta.bookmark_count ?? meta.bookmarkCount);
630
+ if (bookmarkCount !== void 0) source.bookmark_count = bookmarkCount;
631
+ const likeCount = readNumber(meta.like_count ?? meta.likeCount);
632
+ if (likeCount !== void 0) source.like_count = likeCount;
633
+ const createdAt = readDateLike(meta.created_at ?? meta.createdAt);
634
+ if (createdAt !== void 0) source.created_at = createdAt;
635
+ const updatedAt = readDateLike(meta.updated_at ?? meta.updatedAt);
636
+ if (updatedAt !== void 0) source.updated_at = updatedAt;
637
+ return source;
638
+ }
639
+ function pageStreamDocToEsSource(doc, bookmarkCount) {
640
+ const grantedUsers = (doc.grantedUsers ?? []).map((u) => typeof u === "string" ? u : u.toString());
641
+ const searchable = {
642
+ id: typeof doc._id === "string" ? doc._id : doc._id.toString(),
643
+ path: doc.path,
644
+ body: doc.revision?.body ?? "",
645
+ meta: {
646
+ username: doc.creator?.username,
647
+ grant: doc.grant,
648
+ granted_users: grantedUsers,
649
+ comment_count: doc.commentCount ?? 0,
650
+ bookmark_count: bookmarkCount,
651
+ like_count: doc.liker?.length ?? 0,
652
+ created_at: doc.createdAt,
653
+ updated_at: doc.updatedAt
654
+ }
655
+ };
656
+ return docToEsSource(searchable);
657
+ }
658
+ function pickSnippet(highlight) {
659
+ if (!highlight) return void 0;
660
+ for (const field of ["body.ja", "body", "path.ja", "body.en", "path.en"]) {
661
+ const fragments = highlight[field];
662
+ if (fragments && fragments.length > 0) return fragments[0];
663
+ }
664
+ return void 0;
665
+ }
666
+ function readString(value) {
667
+ return typeof value === "string" && value.length > 0 ? value : void 0;
668
+ }
669
+ function readNumber(value) {
670
+ if (typeof value === "number" && Number.isFinite(value)) return value;
671
+ return void 0;
672
+ }
673
+ function readStringArray(value) {
674
+ if (!Array.isArray(value)) return void 0;
675
+ const out = [];
676
+ for (const v of value) {
677
+ if (typeof v === "string") out.push(v);
678
+ else if (v && typeof v === "object" && typeof v.toString === "function") {
679
+ out.push(v.toString());
680
+ }
681
+ }
682
+ return out;
683
+ }
684
+ function readDateLike(value) {
685
+ if (value instanceof Date) return value;
686
+ if (typeof value === "string") return value;
687
+ return void 0;
688
+ }
689
+
690
+ // src/index.ts
691
+ var ElasticsearchConfigSchema = z.object({
692
+ /**
693
+ * `https://[user:pass@]host[:port][/indexName]`. Empty string keeps
694
+ * the driver registered but disabled — `query()` will throw a
695
+ * helpful error and `index()` becomes a no-op.
696
+ *
697
+ * Marked `@sensitive` because the URL embeds the cluster password
698
+ * (Bonsai-style `https://USER:PASS@HOST/INDEX`); we don't want
699
+ * Mongo to keep it in plaintext.
700
+ */
701
+ url: z.string().describe("@sensitive Elasticsearch endpoint (https://USER:PASS@HOST/INDEX format).").default(""),
702
+ /**
703
+ * Base index name. Used as the `indexName` if not provided in the
704
+ * URL path. The runtime alias `${indexName}-current` is what the
705
+ * driver actually targets for read / write.
706
+ */
707
+ indexName: z.string().default("crowi"),
708
+ requestTimeout: z.number().int().positive().default(5e3),
709
+ /**
710
+ * Mapping flavour. Cluster requirements:
711
+ * - `default`: no extra ES plugin.
712
+ * - `kuromoji`: `analysis-kuromoji` plugin (Elastic-distributed).
713
+ * The dev image (`elasticsearch.Dockerfile`) preinstalls it.
714
+ * - `sudachi`: third-party `analysis-sudachi` plugin + dictionary.
715
+ * NOT bundled in the dev image; operators must build a derived
716
+ * image. Picking this without the plugin makes `rebuild()` fail.
717
+ */
718
+ analyzer: z.enum(["default", "kuromoji", "sudachi"]).describe("default / kuromoji (analysis-kuromoji plugin) / sudachi (analysis-sudachi plugin + dictionary, custom image required)").default("default")
719
+ }).strict();
720
+ var PLUGIN_NAME = "@crowi/plugin-search-elasticsearch";
721
+ var state = null;
722
+ function toDriverConfig(config) {
723
+ return {
724
+ url: config.url,
725
+ indexName: config.indexName,
726
+ requestTimeout: config.requestTimeout,
727
+ analyzer: config.analyzer
728
+ };
729
+ }
730
+ var plugin = {
731
+ name: PLUGIN_NAME,
732
+ version: "0.1.0-dev",
733
+ configSchema: ElasticsearchConfigSchema,
734
+ adminPlacement: {
735
+ label: "Elasticsearch",
736
+ icon: "search"
737
+ // section omitted: derived from registerSearch -> 'search'
738
+ },
739
+ registerSearch: (registry, ctx) => {
740
+ const config = ctx.config();
741
+ if (!config.url) {
742
+ ctx.log.warn("url is empty; the elasticsearch search driver is disabled until configured.");
743
+ return;
744
+ }
745
+ state = applyConfig(toDriverConfig(config));
746
+ const driver = buildDriver(state, ctx);
747
+ registry.register("elasticsearch", driver);
748
+ ctx.log.debug("registered elasticsearch search driver (node=%s, indexName=%s, analyzer=%s)", driver.node, driver.baseIndexName, config.analyzer);
749
+ },
750
+ reconfigure: (ctx) => {
751
+ if (!state) {
752
+ ctx.log.warn("reconfigure: driver was not registered at boot (url was empty); a server restart is required to enable Elasticsearch search.");
753
+ return;
754
+ }
755
+ const config = ctx.config();
756
+ if (!config.url) {
757
+ ctx.log.warn('reconfigure: url cleared; search requests will fail with a "Search not configured" error until a url is set.');
758
+ }
759
+ const { oldClient } = applyConfigInPlace(state, toDriverConfig(config));
760
+ if (oldClient) {
761
+ void oldClient.close().catch((err) => {
762
+ ctx.log.warn("reconfigure: closing the previous Elasticsearch client failed: %o", err);
763
+ });
764
+ }
765
+ ctx.log.debug("reconfigured elasticsearch search driver (node=%s, index=%s, analyzer=%s)", state.node || "<unset>", state.baseIndexName, config.analyzer);
766
+ }
767
+ };
768
+ var index_default = plugin;
769
+ function buildDriver(driverState, ctx) {
770
+ const Page = ctx.model("Page");
771
+ const Bookmark = ctx.model("Bookmark");
772
+ const User = ctx.model("User");
773
+ return createElasticsearchDriver(driverState, {
774
+ log: ctx.log,
775
+ iteratePages: async (handler) => {
776
+ const cursor = Page.getStreamOfFindAll({ publicOnly: false });
777
+ await cursor.eachAsync(handler);
778
+ },
779
+ countAllPages: () => Page.allPageCount(),
780
+ getBookmarkCountsBulk: async () => {
781
+ const rows = await Bookmark.aggregate([{ $group: { _id: "$page", n: { $sum: 1 } } }]);
782
+ const map = /* @__PURE__ */ new Map();
783
+ for (const row of rows) {
784
+ const key = typeof row._id === "string" ? row._id : row._id.toString();
785
+ map.set(key, row.n);
786
+ }
787
+ return map;
788
+ },
789
+ countUsers: () => User.countDocuments({}).exec()
790
+ });
791
+ }
792
+ export {
793
+ ElasticsearchConfigSchema,
794
+ applyConfig,
795
+ applyConfigInPlace,
796
+ buildSearchBody,
797
+ createElasticsearchDriver,
798
+ index_default as default,
799
+ parseQuery
800
+ };
801
+ //# sourceMappingURL=index.mjs.map