@crowi/plugin-search-opensearch 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,805 @@
1
+ // src/index.ts
2
+ import { z } from "zod/v3";
3
+
4
+ // src/driver.ts
5
+ import { Client } from "@opensearch-project/opensearch";
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 createOpenSearchDriver(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
+ body: 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 payload = response.body ?? {};
437
+ const totalRaw = payload.hits?.total;
438
+ const total = typeof totalRaw === "number" ? totalRaw : totalRaw?.value ?? 0;
439
+ const rawHits = payload.hits?.hits ?? [];
440
+ const hits = rawHits.map((h) => {
441
+ const source = h._source ?? {};
442
+ const snippet = pickSnippet(h.highlight);
443
+ return {
444
+ id: String(h._id),
445
+ path: source.path ?? "",
446
+ score: typeof h._score === "number" ? h._score : void 0,
447
+ ...snippet ? { snippet } : {}
448
+ };
449
+ });
450
+ return { total, hits };
451
+ },
452
+ async rebuild() {
453
+ const { client, aliasName, baseIndexName, analyzer } = snapshot(state2);
454
+ if (!deps.iteratePages || !deps.countAllPages || !deps.getBookmarkCountsBulk) {
455
+ throw new Error("@crowi/plugin-search-opensearch: rebuild() requires iteratePages / countAllPages / getBookmarkCountsBulk deps.");
456
+ }
457
+ const newIndexName = createTimestampedIndexName(baseIndexName);
458
+ log?.info("rebuild: creating index %s", newIndexName);
459
+ const mapping = loadMapping(analyzer);
460
+ await client.indices.create({ index: newIndexName, body: mapping });
461
+ log?.info("rebuild: prefetching bookmark counts");
462
+ const bookmarkCounts = await deps.getBookmarkCountsBulk();
463
+ log?.info("rebuild: indexing all pages");
464
+ await indexAllPages({
465
+ client,
466
+ indexTarget: newIndexName,
467
+ iteratePages: deps.iteratePages,
468
+ countAllPages: deps.countAllPages,
469
+ bookmarkCounts,
470
+ log
471
+ });
472
+ log?.info("rebuild: switching alias %s -> %s", aliasName, newIndexName);
473
+ await switchAlias(client, aliasName, newIndexName);
474
+ log?.info("rebuild: cleaning up old indices");
475
+ await deleteOldIndices(client, baseIndexName, newIndexName);
476
+ }
477
+ };
478
+ return driver;
479
+ }
480
+ var SEARCH_NOT_CONFIGURED = "@crowi/plugin-search-opensearch: Search not configured (OpenSearch url is empty).";
481
+ function requireClient(client) {
482
+ if (!client) {
483
+ throw new Error(SEARCH_NOT_CONFIGURED);
484
+ }
485
+ return client;
486
+ }
487
+ function snapshot(state2) {
488
+ return {
489
+ client: requireClient(state2.client),
490
+ aliasName: state2.aliasName,
491
+ baseIndexName: state2.baseIndexName,
492
+ analyzer: state2.analyzer
493
+ };
494
+ }
495
+ var DEFAULT_LIMIT = 50;
496
+ var MAX_LIMIT = 200;
497
+ function clampLimit(limit) {
498
+ if (!limit || limit <= 0) return DEFAULT_LIMIT;
499
+ return Math.min(limit, MAX_LIMIT);
500
+ }
501
+ function parseUri(uri) {
502
+ if (!uri.startsWith("http")) {
503
+ throw new Error("URL for OpenSearch should starts with http/https");
504
+ }
505
+ const osUrl = new URL(uri);
506
+ const auth = osUrl.username && osUrl.password ? `${osUrl.username}:${osUrl.password}@` : "";
507
+ const node = `${osUrl.protocol}//${auth}${osUrl.host}`;
508
+ const indexName = osUrl.pathname && osUrl.pathname !== "/" ? osUrl.pathname.substring(1) : "crowi";
509
+ return { node, indexName };
510
+ }
511
+ function createTimestampedIndexName(base) {
512
+ const d = /* @__PURE__ */ new Date();
513
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
514
+ const ts = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}${pad(d.getUTCMilliseconds(), 3)}`;
515
+ const rnd = Math.random().toString(36).slice(2, 6).padEnd(4, "0");
516
+ return `${base}-${ts}-${rnd}`;
517
+ }
518
+ var TS_INDEX_RE = /^.+-\d{17}-[a-z0-9]{4}$/;
519
+ function loadMapping(analyzer) {
520
+ const base = default_default;
521
+ if (analyzer === "default") return base;
522
+ const overlay = analyzer === "kuromoji" ? kuromoji_default : sudachi_default;
523
+ return deepMergeMappings(base, overlay);
524
+ }
525
+ function isPlainObject(v) {
526
+ return typeof v === "object" && v !== null && !Array.isArray(v);
527
+ }
528
+ function deepMergeMappings(a, b) {
529
+ const out = { ...a };
530
+ for (const key of Object.keys(b)) {
531
+ const av = a[key];
532
+ const bv = b[key];
533
+ if (isPlainObject(av) && isPlainObject(bv)) {
534
+ out[key] = deepMergeMappings(av, bv);
535
+ } else {
536
+ out[key] = bv;
537
+ }
538
+ }
539
+ return out;
540
+ }
541
+ async function indexAllPages(ctx) {
542
+ const allPageCount = await ctx.countAllPages();
543
+ let operations = [];
544
+ let total = 0;
545
+ let skipped = 0;
546
+ const flush = async () => {
547
+ if (operations.length === 0) return;
548
+ try {
549
+ const response = await ctx.client.bulk({
550
+ body: operations,
551
+ timeout: "1d"
552
+ });
553
+ const payload = response.body ?? {};
554
+ if (payload.errors) {
555
+ ctx.log?.warn("rebuild: bulk had item-level errors (took=%dms)", payload.took);
556
+ }
557
+ } catch (err) {
558
+ ctx.log?.error("rebuild: bulk failed: %o", err);
559
+ }
560
+ operations = [];
561
+ };
562
+ await ctx.iteratePages(async (doc) => {
563
+ if (!doc.creator || !doc.revision || !shouldIndex(doc)) {
564
+ skipped++;
565
+ return;
566
+ }
567
+ total++;
568
+ const id = typeof doc._id === "string" ? doc._id : doc._id.toString();
569
+ const bookmarkCount = ctx.bookmarkCounts.get(id) ?? 0;
570
+ const source = pageStreamDocToEsSource(doc, bookmarkCount);
571
+ operations.push({ index: { _index: ctx.indexTarget, _id: id } });
572
+ operations.push(source);
573
+ if (operations.length >= 4e3) {
574
+ await flush();
575
+ }
576
+ });
577
+ await flush();
578
+ ctx.log?.info("rebuild: indexed total=%d skipped=%d (allPageCount=%d)", total, skipped, allPageCount);
579
+ }
580
+ function shouldIndex(doc) {
581
+ if (doc.redirectTo !== null && doc.redirectTo !== void 0) return false;
582
+ if (doc.status === "deleted") return false;
583
+ if (doc.status === "draft") return false;
584
+ return true;
585
+ }
586
+ async function switchAlias(client, aliasName, newIndex) {
587
+ const aliasInfo = await getCurrentAliasInfo(client, aliasName);
588
+ const actions = [{ add: { index: newIndex, alias: aliasName } }];
589
+ if (aliasInfo) {
590
+ actions.push({ remove: { index: aliasInfo.index, alias: aliasName } });
591
+ }
592
+ await client.indices.updateAliases({ body: { actions } });
593
+ }
594
+ async function getCurrentAliasInfo(client, aliasName) {
595
+ try {
596
+ const exists = await client.indices.existsAlias({ name: aliasName });
597
+ if (!exists.body) return null;
598
+ } catch {
599
+ return null;
600
+ }
601
+ const aliases = await client.cat.aliases({ name: aliasName, format: "json" });
602
+ const list = aliases.body ?? [];
603
+ return list.length > 0 ? { alias: list[0].alias, index: list[0].index } : null;
604
+ }
605
+ async function deleteOldIndices(client, baseIndexName, keepIndexName) {
606
+ const indices = await client.cat.indices({ index: `${baseIndexName}-*`, format: "json" });
607
+ const list = indices.body ?? [];
608
+ const toDelete = list.map((i) => i.index).filter((name) => name.startsWith(`${baseIndexName}-`) && name !== keepIndexName && TS_INDEX_RE.test(name));
609
+ if (toDelete.length === 0) return;
610
+ await client.indices.delete({ index: toDelete });
611
+ }
612
+ function isNotFoundError(err) {
613
+ if (!err || typeof err !== "object") return false;
614
+ const e = err;
615
+ return e.statusCode === 404 || e.meta?.statusCode === 404;
616
+ }
617
+ function docToEsSource(doc) {
618
+ const meta = doc.meta ?? {};
619
+ const source = {
620
+ path: doc.path,
621
+ body: doc.body
622
+ };
623
+ const username = readString(meta.username);
624
+ if (username !== void 0) source.username = username;
625
+ const grant = readNumber(meta.grant);
626
+ if (grant !== void 0) source.grant = grant;
627
+ const grantedUsers = readStringArray(meta.granted_users ?? meta.grantedUsers);
628
+ if (grantedUsers !== void 0) source.granted_users = grantedUsers;
629
+ const commentCount = readNumber(meta.comment_count ?? meta.commentCount);
630
+ if (commentCount !== void 0) source.comment_count = commentCount;
631
+ const bookmarkCount = readNumber(meta.bookmark_count ?? meta.bookmarkCount);
632
+ if (bookmarkCount !== void 0) source.bookmark_count = bookmarkCount;
633
+ const likeCount = readNumber(meta.like_count ?? meta.likeCount);
634
+ if (likeCount !== void 0) source.like_count = likeCount;
635
+ const createdAt = readDateLike(meta.created_at ?? meta.createdAt);
636
+ if (createdAt !== void 0) source.created_at = createdAt;
637
+ const updatedAt = readDateLike(meta.updated_at ?? meta.updatedAt);
638
+ if (updatedAt !== void 0) source.updated_at = updatedAt;
639
+ return source;
640
+ }
641
+ function pageStreamDocToEsSource(doc, bookmarkCount) {
642
+ const grantedUsers = (doc.grantedUsers ?? []).map((u) => typeof u === "string" ? u : u.toString());
643
+ const searchable = {
644
+ id: typeof doc._id === "string" ? doc._id : doc._id.toString(),
645
+ path: doc.path,
646
+ body: doc.revision?.body ?? "",
647
+ meta: {
648
+ username: doc.creator?.username,
649
+ grant: doc.grant,
650
+ granted_users: grantedUsers,
651
+ comment_count: doc.commentCount ?? 0,
652
+ bookmark_count: bookmarkCount,
653
+ like_count: doc.liker?.length ?? 0,
654
+ created_at: doc.createdAt,
655
+ updated_at: doc.updatedAt
656
+ }
657
+ };
658
+ return docToEsSource(searchable);
659
+ }
660
+ function pickSnippet(highlight) {
661
+ if (!highlight) return void 0;
662
+ for (const field of ["body.ja", "body", "path.ja", "body.en", "path.en"]) {
663
+ const fragments = highlight[field];
664
+ if (fragments && fragments.length > 0) return fragments[0];
665
+ }
666
+ return void 0;
667
+ }
668
+ function readString(value) {
669
+ return typeof value === "string" && value.length > 0 ? value : void 0;
670
+ }
671
+ function readNumber(value) {
672
+ if (typeof value === "number" && Number.isFinite(value)) return value;
673
+ return void 0;
674
+ }
675
+ function readStringArray(value) {
676
+ if (!Array.isArray(value)) return void 0;
677
+ const out = [];
678
+ for (const v of value) {
679
+ if (typeof v === "string") out.push(v);
680
+ else if (v && typeof v === "object" && typeof v.toString === "function") {
681
+ out.push(v.toString());
682
+ }
683
+ }
684
+ return out;
685
+ }
686
+ function readDateLike(value) {
687
+ if (value instanceof Date) return value;
688
+ if (typeof value === "string") return value;
689
+ return void 0;
690
+ }
691
+
692
+ // src/index.ts
693
+ var OpenSearchConfigSchema = z.object({
694
+ /**
695
+ * `https://[user:pass@]host[:port][/indexName]`. Empty string keeps
696
+ * the driver registered but disabled — `query()` will throw a
697
+ * helpful error and `index()` becomes a no-op.
698
+ *
699
+ * Marked `@sensitive` because the URL embeds the cluster password
700
+ * (Bonsai-style `https://USER:PASS@HOST/INDEX`); we don't want
701
+ * Mongo to keep it in plaintext.
702
+ */
703
+ url: z.string().describe("@sensitive OpenSearch endpoint (https://USER:PASS@HOST/INDEX format).").default(""),
704
+ /**
705
+ * Base index name. Used as the `indexName` if not provided in the
706
+ * URL path. The runtime alias `${indexName}-current` is what the
707
+ * driver actually targets for read / write.
708
+ */
709
+ indexName: z.string().default("crowi"),
710
+ requestTimeout: z.number().int().positive().default(5e3),
711
+ /**
712
+ * Mapping flavour. Cluster requirements:
713
+ * - `default`: no extra OpenSearch plugin.
714
+ * - `kuromoji`: `analysis-kuromoji` plugin (Apache 2.0, a
715
+ * separate distribution from OpenSearch core — install via
716
+ * `bin/opensearch-plugin install analysis-kuromoji`).
717
+ * - `sudachi`: `analysis-sudachi` (OpenSearch-compatible fork
718
+ * from WorksApplications) + dictionary; operators must build
719
+ * a derived image. Picking this without the plugin makes
720
+ * `rebuild()` fail.
721
+ */
722
+ analyzer: z.enum(["default", "kuromoji", "sudachi"]).describe("default / kuromoji (analysis-kuromoji plugin) / sudachi (analysis-sudachi plugin + dictionary, custom image required)").default("default")
723
+ }).strict();
724
+ var PLUGIN_NAME = "@crowi/plugin-search-opensearch";
725
+ var state = null;
726
+ function toDriverConfig(config) {
727
+ return {
728
+ url: config.url,
729
+ indexName: config.indexName,
730
+ requestTimeout: config.requestTimeout,
731
+ analyzer: config.analyzer
732
+ };
733
+ }
734
+ var plugin = {
735
+ name: PLUGIN_NAME,
736
+ version: "0.1.0-dev",
737
+ configSchema: OpenSearchConfigSchema,
738
+ adminPlacement: {
739
+ label: "OpenSearch",
740
+ icon: "search"
741
+ // section omitted: derived from registerSearch -> 'shared' fallback
742
+ },
743
+ registerSearch: (registry, ctx) => {
744
+ const config = ctx.config();
745
+ if (!config.url) {
746
+ ctx.log.warn("url is empty; the opensearch search driver is disabled until configured.");
747
+ return;
748
+ }
749
+ state = applyConfig(toDriverConfig(config));
750
+ const driver = buildDriver(state, ctx);
751
+ registry.register("opensearch", driver);
752
+ ctx.log.debug("registered opensearch search driver (node=%s, indexName=%s, analyzer=%s)", driver.node, driver.baseIndexName, config.analyzer);
753
+ },
754
+ reconfigure: (ctx) => {
755
+ if (!state) {
756
+ ctx.log.warn("reconfigure: driver was not registered at boot (url was empty); a server restart is required to enable OpenSearch search.");
757
+ return;
758
+ }
759
+ const config = ctx.config();
760
+ if (!config.url) {
761
+ ctx.log.warn('reconfigure: url cleared; search requests will fail with a "Search not configured" error until a url is set.');
762
+ }
763
+ const { oldClient } = applyConfigInPlace(state, toDriverConfig(config));
764
+ if (oldClient) {
765
+ void oldClient.close().catch((err) => {
766
+ ctx.log.warn("reconfigure: closing the previous OpenSearch client failed: %o", err);
767
+ });
768
+ }
769
+ ctx.log.debug("reconfigured opensearch search driver (node=%s, index=%s, analyzer=%s)", state.node || "<unset>", state.baseIndexName, config.analyzer);
770
+ }
771
+ };
772
+ var index_default = plugin;
773
+ function buildDriver(driverState, ctx) {
774
+ const Page = ctx.model("Page");
775
+ const Bookmark = ctx.model("Bookmark");
776
+ const User = ctx.model("User");
777
+ return createOpenSearchDriver(driverState, {
778
+ log: ctx.log,
779
+ iteratePages: async (handler) => {
780
+ const cursor = Page.getStreamOfFindAll({ publicOnly: false });
781
+ await cursor.eachAsync(handler);
782
+ },
783
+ countAllPages: () => Page.allPageCount(),
784
+ getBookmarkCountsBulk: async () => {
785
+ const rows = await Bookmark.aggregate([{ $group: { _id: "$page", n: { $sum: 1 } } }]);
786
+ const map = /* @__PURE__ */ new Map();
787
+ for (const row of rows) {
788
+ const key = typeof row._id === "string" ? row._id : row._id.toString();
789
+ map.set(key, row.n);
790
+ }
791
+ return map;
792
+ },
793
+ countUsers: () => User.countDocuments({}).exec()
794
+ });
795
+ }
796
+ export {
797
+ OpenSearchConfigSchema,
798
+ applyConfig,
799
+ applyConfigInPlace,
800
+ buildSearchBody,
801
+ createOpenSearchDriver,
802
+ index_default as default,
803
+ parseQuery
804
+ };
805
+ //# sourceMappingURL=index.mjs.map