@crowi/plugin-search-mongo 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2013 Sotaro KARASAWA <sotaro.k@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @crowi/plugin-search-mongo
2
+
3
+ The default, **infra-free** search driver for Crowi 2.0. It searches live
4
+ data — the `Page` collection and each page's current `Revision` — with a
5
+ case-insensitive MongoDB `$regex` over **path / title / body**. No external
6
+ service, no separate search index: MongoDB alone powers search.
7
+
8
+ This is what makes the slim deployment (local file storage + mongo search)
9
+ searchable out of the box, with MongoDB as the only required infrastructure.
10
+
11
+ ## When to use
12
+
13
+ - ✅ Small / mid-size wikis that don't want to run Elasticsearch / OpenSearch.
14
+ - ✅ Japanese (and other CJK) content: a `$regex` substring match is more
15
+ practical than MongoDB `$text`, whose CJK tokenisation is weak.
16
+ - ❌ Large installs. A non-anchored `$regex` cannot use an index, so search
17
+ scans the collection. Run
18
+ [`@crowi/plugin-search-elasticsearch`](../plugin-search-elasticsearch)
19
+ instead at scale.
20
+
21
+ ## How it works
22
+
23
+ - **No index maintenance.** `index()` and `remove()` (called from the
24
+ page-saved hook) are no-ops; there is nothing to keep in sync because the
25
+ page body already lives in `Page` / `Revision`. `rebuild()` is omitted.
26
+ - **Two-pass query.** Path/title hits (the page title is its path) are
27
+ found first, then body hits are resolved via a single bulk
28
+ `Revision.find({ revision: { $in }, body: regex })` over the
29
+ viewer-visible candidate pages (capped at `CANDIDATE_CAP = 5000` to bound
30
+ the scan). Path hits rank ahead of body-only hits.
31
+ - **Grant-aware.** Results respect page visibility: anonymous viewers see
32
+ public pages only, admins see everything, and other viewers see public
33
+ pages plus pages they created or that are shared with them. Drafts,
34
+ deleted pages and redirects are always excluded.
35
+ - **Snippets.** Best-effort substring around the first match, with the
36
+ match wrapped in `<mark>`. Not HTML-escaped — the web client sanitises
37
+ before render.
38
+
39
+ ## Configuration
40
+
41
+ None. The driver uses the same MongoDB connection as the rest of the app,
42
+ so its `configSchema` is empty. Select it with `search.driver: 'mongo'` in
43
+ the runner's `crowi.config.json` (this is the schema default).
@@ -0,0 +1,166 @@
1
+ import { z } from 'zod/v3';
2
+ import { PluginContext, SearchDriver, SearchQueryViewer, SearchPageType, CrowiPlugin } from '@crowi/plugin-api';
3
+
4
+ /**
5
+ * `createMongoSearchDriver` — the `'mongo'` SearchDriver. It searches live
6
+ * data (`Page` + the page's current `Revision`) with a case-insensitive
7
+ * `$regex`, so there is NO separate search index to maintain:
8
+ *
9
+ * - `index()` / `remove()` are no-ops. The page body already lives in
10
+ * Page / Revision, so the page-saved event hook has nothing extra to
11
+ * persist. They never throw, so wiring the driver into the save path
12
+ * can never fail a page write.
13
+ * - `rebuild()` is omitted (there is no index to rebuild).
14
+ * - `query()` runs the actual search.
15
+ *
16
+ * Body-fetch strategy (spec open question 2):
17
+ * A two-pass approach keyed off the path/title pass, chosen over a single
18
+ * `$lookup` aggregation for readability on the slim / small-deployment
19
+ * target this driver serves:
20
+ * 1. PATH pass — pages whose `path` matches the keyword. These are the
21
+ * strongest hits (the page title is its path) and are returned first.
22
+ * 2. BODY pass — among the viewer-visible candidate pages (capped at
23
+ * CANDIDATE_CAP to bound the non-anchored `$regex` collection scan),
24
+ * look up their current revisions whose `body` matches the keyword in
25
+ * one bulk `Revision.find({ revision: { $in }, body: regex })`, then
26
+ * map the matched revisions back to their pages.
27
+ * The two result sets are merged (path hits first, body-only hits after),
28
+ * de-duplicated by page id, then paged with skip/limit. `total` is the
29
+ * size of the merged set (capped by CANDIDATE_CAP on the body side).
30
+ *
31
+ * Ranking / score (spec open questions 3 & 4): a simple 2-value score —
32
+ * path/title hits score 2, body-only hits score 1 — expressed by ordering
33
+ * path hits ahead of body hits. No function score / field boosting.
34
+ *
35
+ * Snippet (spec open question 3): best-effort substring around the first
36
+ * match, with the matched span wrapped in `<mark>`. NOT HTML-escaped — the
37
+ * search route passes snippets through verbatim and the web client
38
+ * sanitises before render (same contract as the ES driver's highlight).
39
+ */
40
+
41
+ /**
42
+ * Upper bound on candidate pages scanned by the body pass. A non-anchored
43
+ * `$regex` cannot use an index, so we bound the scan: beyond this many
44
+ * visible candidates the body pass is truncated and path/title hits are
45
+ * preferred. Generous for the small / mid deployments this driver targets;
46
+ * larger installs should run the Elasticsearch driver.
47
+ */
48
+ declare const CANDIDATE_CAP = 5000;
49
+ /**
50
+ * Build a best-effort snippet: a window around the first case-insensitive
51
+ * match of `keyword` in `text`, with the match wrapped in `<mark>`.
52
+ * Returns undefined when the text has no match (e.g. a path-only hit).
53
+ */
54
+ declare function buildSnippet(text: string, keyword: RegExp): string | undefined;
55
+ declare function createMongoSearchDriver(ctx: PluginContext): SearchDriver;
56
+
57
+ /**
58
+ * Build the MongoDB filter that the `'mongo'` search driver runs against
59
+ * the `Page` collection, plus the small helpers that the driver reuses
60
+ * for the `Revision` body lookup.
61
+ *
62
+ * The driver searches live data — there is no separate search index — so
63
+ * the filter must reproduce the visibility rules that the rest of the app
64
+ * applies when reading pages. Everything is expressed as plain Mongo query
65
+ * conditions (`$regex`, `$or`, `$in`, ...) so the generated filter stays
66
+ * easy to assert in unit tests without touching a real database.
67
+ *
68
+ * Design notes:
69
+ * - Grant filter mirrors `packages/api/src/models/page.ts`
70
+ * (`visiblePageGrantOr`): a non-public page (RESTRICTED / SPECIFIED /
71
+ * OWNER) is hidden unless the viewer created it (`creator`) or is
72
+ * listed in `grantedUsers`. Anonymous viewers see public pages only;
73
+ * admins see everything.
74
+ * - Status filter always drops drafts / deleted pages / redirects so a
75
+ * keyword search can never leak another user's draft. The search route
76
+ * has no per-viewer draft filter, so we exclude all drafts here rather
77
+ * than trying to admit the author's own (matching the ES driver's
78
+ * `shouldIndex`, which also drops every draft).
79
+ * - Type filter (portal / public / user) reproduces the legacy
80
+ * path-shape rules as `$regex` / prefix conditions.
81
+ */
82
+
83
+ declare const GRANT_PUBLIC = 1;
84
+ declare const GRANT_RESTRICTED = 2;
85
+ declare const GRANT_SPECIFIED = 3;
86
+ declare const GRANT_OWNER = 4;
87
+ declare const DEFAULT_LIMIT = 50;
88
+ declare const MAX_LIMIT = 200;
89
+ /** Clamp a requested page size into the [1, MAX_LIMIT] range. */
90
+ declare function clampLimit(limit?: number): number;
91
+ /** 1-based page → zero-based skip. */
92
+ declare function pageToSkip(page: number | undefined, limit: number): number;
93
+ /**
94
+ * Escape a user-supplied string so it can be embedded into a `$regex`
95
+ * verbatim (treating every char literally — this is substring search,
96
+ * not a regex DSL exposed to the user).
97
+ */
98
+ declare function escapeRegex(input: string): string;
99
+ /**
100
+ * `RegExp` matching the query as a case-insensitive substring. Returns
101
+ * `null` for an empty / whitespace-only query so the caller can decide to
102
+ * return zero hits without running a collection scan.
103
+ */
104
+ declare function keywordRegex(q: string): RegExp | null;
105
+ type MongoCondition = Record<string, unknown>;
106
+ /**
107
+ * `$or` clause restricting results to pages the viewer may read. Mirrors
108
+ * `visiblePageGrantOr` but additionally honours the anonymous / admin
109
+ * cases the search route distinguishes.
110
+ */
111
+ declare function grantFilter(viewer?: SearchQueryViewer): MongoCondition[] | null;
112
+ /**
113
+ * Per-type path condition.
114
+ * - `portal`: path ends with `/`, excluding `/user/*`
115
+ * - `public`: path does NOT end with `/`, excluding `/user/*`
116
+ * - `user`: `/user/*` prefix
117
+ */
118
+ declare function typeFilter(type: SearchPageType): MongoCondition;
119
+ /** Prefix condition. Normalises a trailing slash, then anchors `^prefix/`. */
120
+ declare function pathPrefixFilter(pathPrefix: string): MongoCondition;
121
+ interface BuildPageFilterParams {
122
+ /** Keyword regex (case-insensitive substring). Null = no path match. */
123
+ keyword: RegExp | null;
124
+ viewer?: SearchQueryViewer;
125
+ type?: SearchPageType;
126
+ pathPrefix?: string;
127
+ /** When true, the filter constrains `path` to the keyword too. */
128
+ matchPath: boolean;
129
+ }
130
+ /**
131
+ * Compose the base visibility + scope filter shared by the path-match and
132
+ * body-match passes. `matchPath` toggles whether the keyword is applied to
133
+ * `path` (the path / title pass) or left out (the body pass narrows by
134
+ * `_id` separately).
135
+ */
136
+ declare function buildPageFilter(params: BuildPageFilterParams): MongoCondition;
137
+
138
+ /**
139
+ * @crowi/plugin-search-mongo — the default, infra-free search driver.
140
+ *
141
+ * Registers `'mongo'` against the SearchRegistry. It searches live data
142
+ * (`Page` + the page's current `Revision`) with a case-insensitive
143
+ * `$regex` over path / title / body, so it needs NO external service and
144
+ * NO separate search index — MongoDB alone is enough. This makes the slim
145
+ * deployment (local storage + mongo search) searchable out of the box.
146
+ *
147
+ * Positioning: small / mid-size wikis that don't want to run Elasticsearch.
148
+ * A non-anchored `$regex` cannot use an index, so large installs should run
149
+ * `@crowi/plugin-search-elasticsearch` instead — see README.
150
+ *
151
+ * Activation: set `search.driver: 'mongo'` in the runner's
152
+ * `crowi.config.json` (the schema default) and ensure this plugin is loaded
153
+ * (it is an implicit default in the runner). No connection config — the
154
+ * config schema is empty.
155
+ */
156
+
157
+ /**
158
+ * The mongo driver has no connection settings — it searches the same
159
+ * Mongo the rest of the app uses. The schema is intentionally empty so the
160
+ * admin UI renders the driver with no config fields.
161
+ */
162
+ declare const MongoSearchConfigSchema: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
163
+ type MongoSearchConfig = z.infer<typeof MongoSearchConfigSchema>;
164
+ declare const plugin: CrowiPlugin;
165
+
166
+ export { CANDIDATE_CAP, DEFAULT_LIMIT, GRANT_OWNER, GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, MAX_LIMIT, type MongoSearchConfig, MongoSearchConfigSchema, buildPageFilter, buildSnippet, clampLimit, createMongoSearchDriver, plugin as default, escapeRegex, grantFilter, keywordRegex, pageToSkip, pathPrefixFilter, typeFilter };
@@ -0,0 +1,166 @@
1
+ import { z } from 'zod/v3';
2
+ import { PluginContext, SearchDriver, SearchQueryViewer, SearchPageType, CrowiPlugin } from '@crowi/plugin-api';
3
+
4
+ /**
5
+ * `createMongoSearchDriver` — the `'mongo'` SearchDriver. It searches live
6
+ * data (`Page` + the page's current `Revision`) with a case-insensitive
7
+ * `$regex`, so there is NO separate search index to maintain:
8
+ *
9
+ * - `index()` / `remove()` are no-ops. The page body already lives in
10
+ * Page / Revision, so the page-saved event hook has nothing extra to
11
+ * persist. They never throw, so wiring the driver into the save path
12
+ * can never fail a page write.
13
+ * - `rebuild()` is omitted (there is no index to rebuild).
14
+ * - `query()` runs the actual search.
15
+ *
16
+ * Body-fetch strategy (spec open question 2):
17
+ * A two-pass approach keyed off the path/title pass, chosen over a single
18
+ * `$lookup` aggregation for readability on the slim / small-deployment
19
+ * target this driver serves:
20
+ * 1. PATH pass — pages whose `path` matches the keyword. These are the
21
+ * strongest hits (the page title is its path) and are returned first.
22
+ * 2. BODY pass — among the viewer-visible candidate pages (capped at
23
+ * CANDIDATE_CAP to bound the non-anchored `$regex` collection scan),
24
+ * look up their current revisions whose `body` matches the keyword in
25
+ * one bulk `Revision.find({ revision: { $in }, body: regex })`, then
26
+ * map the matched revisions back to their pages.
27
+ * The two result sets are merged (path hits first, body-only hits after),
28
+ * de-duplicated by page id, then paged with skip/limit. `total` is the
29
+ * size of the merged set (capped by CANDIDATE_CAP on the body side).
30
+ *
31
+ * Ranking / score (spec open questions 3 & 4): a simple 2-value score —
32
+ * path/title hits score 2, body-only hits score 1 — expressed by ordering
33
+ * path hits ahead of body hits. No function score / field boosting.
34
+ *
35
+ * Snippet (spec open question 3): best-effort substring around the first
36
+ * match, with the matched span wrapped in `<mark>`. NOT HTML-escaped — the
37
+ * search route passes snippets through verbatim and the web client
38
+ * sanitises before render (same contract as the ES driver's highlight).
39
+ */
40
+
41
+ /**
42
+ * Upper bound on candidate pages scanned by the body pass. A non-anchored
43
+ * `$regex` cannot use an index, so we bound the scan: beyond this many
44
+ * visible candidates the body pass is truncated and path/title hits are
45
+ * preferred. Generous for the small / mid deployments this driver targets;
46
+ * larger installs should run the Elasticsearch driver.
47
+ */
48
+ declare const CANDIDATE_CAP = 5000;
49
+ /**
50
+ * Build a best-effort snippet: a window around the first case-insensitive
51
+ * match of `keyword` in `text`, with the match wrapped in `<mark>`.
52
+ * Returns undefined when the text has no match (e.g. a path-only hit).
53
+ */
54
+ declare function buildSnippet(text: string, keyword: RegExp): string | undefined;
55
+ declare function createMongoSearchDriver(ctx: PluginContext): SearchDriver;
56
+
57
+ /**
58
+ * Build the MongoDB filter that the `'mongo'` search driver runs against
59
+ * the `Page` collection, plus the small helpers that the driver reuses
60
+ * for the `Revision` body lookup.
61
+ *
62
+ * The driver searches live data — there is no separate search index — so
63
+ * the filter must reproduce the visibility rules that the rest of the app
64
+ * applies when reading pages. Everything is expressed as plain Mongo query
65
+ * conditions (`$regex`, `$or`, `$in`, ...) so the generated filter stays
66
+ * easy to assert in unit tests without touching a real database.
67
+ *
68
+ * Design notes:
69
+ * - Grant filter mirrors `packages/api/src/models/page.ts`
70
+ * (`visiblePageGrantOr`): a non-public page (RESTRICTED / SPECIFIED /
71
+ * OWNER) is hidden unless the viewer created it (`creator`) or is
72
+ * listed in `grantedUsers`. Anonymous viewers see public pages only;
73
+ * admins see everything.
74
+ * - Status filter always drops drafts / deleted pages / redirects so a
75
+ * keyword search can never leak another user's draft. The search route
76
+ * has no per-viewer draft filter, so we exclude all drafts here rather
77
+ * than trying to admit the author's own (matching the ES driver's
78
+ * `shouldIndex`, which also drops every draft).
79
+ * - Type filter (portal / public / user) reproduces the legacy
80
+ * path-shape rules as `$regex` / prefix conditions.
81
+ */
82
+
83
+ declare const GRANT_PUBLIC = 1;
84
+ declare const GRANT_RESTRICTED = 2;
85
+ declare const GRANT_SPECIFIED = 3;
86
+ declare const GRANT_OWNER = 4;
87
+ declare const DEFAULT_LIMIT = 50;
88
+ declare const MAX_LIMIT = 200;
89
+ /** Clamp a requested page size into the [1, MAX_LIMIT] range. */
90
+ declare function clampLimit(limit?: number): number;
91
+ /** 1-based page → zero-based skip. */
92
+ declare function pageToSkip(page: number | undefined, limit: number): number;
93
+ /**
94
+ * Escape a user-supplied string so it can be embedded into a `$regex`
95
+ * verbatim (treating every char literally — this is substring search,
96
+ * not a regex DSL exposed to the user).
97
+ */
98
+ declare function escapeRegex(input: string): string;
99
+ /**
100
+ * `RegExp` matching the query as a case-insensitive substring. Returns
101
+ * `null` for an empty / whitespace-only query so the caller can decide to
102
+ * return zero hits without running a collection scan.
103
+ */
104
+ declare function keywordRegex(q: string): RegExp | null;
105
+ type MongoCondition = Record<string, unknown>;
106
+ /**
107
+ * `$or` clause restricting results to pages the viewer may read. Mirrors
108
+ * `visiblePageGrantOr` but additionally honours the anonymous / admin
109
+ * cases the search route distinguishes.
110
+ */
111
+ declare function grantFilter(viewer?: SearchQueryViewer): MongoCondition[] | null;
112
+ /**
113
+ * Per-type path condition.
114
+ * - `portal`: path ends with `/`, excluding `/user/*`
115
+ * - `public`: path does NOT end with `/`, excluding `/user/*`
116
+ * - `user`: `/user/*` prefix
117
+ */
118
+ declare function typeFilter(type: SearchPageType): MongoCondition;
119
+ /** Prefix condition. Normalises a trailing slash, then anchors `^prefix/`. */
120
+ declare function pathPrefixFilter(pathPrefix: string): MongoCondition;
121
+ interface BuildPageFilterParams {
122
+ /** Keyword regex (case-insensitive substring). Null = no path match. */
123
+ keyword: RegExp | null;
124
+ viewer?: SearchQueryViewer;
125
+ type?: SearchPageType;
126
+ pathPrefix?: string;
127
+ /** When true, the filter constrains `path` to the keyword too. */
128
+ matchPath: boolean;
129
+ }
130
+ /**
131
+ * Compose the base visibility + scope filter shared by the path-match and
132
+ * body-match passes. `matchPath` toggles whether the keyword is applied to
133
+ * `path` (the path / title pass) or left out (the body pass narrows by
134
+ * `_id` separately).
135
+ */
136
+ declare function buildPageFilter(params: BuildPageFilterParams): MongoCondition;
137
+
138
+ /**
139
+ * @crowi/plugin-search-mongo — the default, infra-free search driver.
140
+ *
141
+ * Registers `'mongo'` against the SearchRegistry. It searches live data
142
+ * (`Page` + the page's current `Revision`) with a case-insensitive
143
+ * `$regex` over path / title / body, so it needs NO external service and
144
+ * NO separate search index — MongoDB alone is enough. This makes the slim
145
+ * deployment (local storage + mongo search) searchable out of the box.
146
+ *
147
+ * Positioning: small / mid-size wikis that don't want to run Elasticsearch.
148
+ * A non-anchored `$regex` cannot use an index, so large installs should run
149
+ * `@crowi/plugin-search-elasticsearch` instead — see README.
150
+ *
151
+ * Activation: set `search.driver: 'mongo'` in the runner's
152
+ * `crowi.config.json` (the schema default) and ensure this plugin is loaded
153
+ * (it is an implicit default in the runner). No connection config — the
154
+ * config schema is empty.
155
+ */
156
+
157
+ /**
158
+ * The mongo driver has no connection settings — it searches the same
159
+ * Mongo the rest of the app uses. The schema is intentionally empty so the
160
+ * admin UI renders the driver with no config fields.
161
+ */
162
+ declare const MongoSearchConfigSchema: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
163
+ type MongoSearchConfig = z.infer<typeof MongoSearchConfigSchema>;
164
+ declare const plugin: CrowiPlugin;
165
+
166
+ export { CANDIDATE_CAP, DEFAULT_LIMIT, GRANT_OWNER, GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, MAX_LIMIT, type MongoSearchConfig, MongoSearchConfigSchema, buildPageFilter, buildSnippet, clampLimit, createMongoSearchDriver, plugin as default, escapeRegex, grantFilter, keywordRegex, pageToSkip, pathPrefixFilter, typeFilter };
package/dist/index.js ADDED
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ CANDIDATE_CAP: () => CANDIDATE_CAP,
24
+ DEFAULT_LIMIT: () => DEFAULT_LIMIT,
25
+ GRANT_OWNER: () => GRANT_OWNER,
26
+ GRANT_PUBLIC: () => GRANT_PUBLIC,
27
+ GRANT_RESTRICTED: () => GRANT_RESTRICTED,
28
+ GRANT_SPECIFIED: () => GRANT_SPECIFIED,
29
+ MAX_LIMIT: () => MAX_LIMIT,
30
+ MongoSearchConfigSchema: () => MongoSearchConfigSchema,
31
+ buildPageFilter: () => buildPageFilter,
32
+ buildSnippet: () => buildSnippet,
33
+ clampLimit: () => clampLimit,
34
+ createMongoSearchDriver: () => createMongoSearchDriver,
35
+ default: () => index_default,
36
+ escapeRegex: () => escapeRegex,
37
+ grantFilter: () => grantFilter,
38
+ keywordRegex: () => keywordRegex,
39
+ pageToSkip: () => pageToSkip,
40
+ pathPrefixFilter: () => pathPrefixFilter,
41
+ typeFilter: () => typeFilter
42
+ });
43
+ module.exports = __toCommonJS(index_exports);
44
+ var import_v3 = require("zod/v3");
45
+
46
+ // src/query-builder.ts
47
+ var GRANT_PUBLIC = 1;
48
+ var GRANT_RESTRICTED = 2;
49
+ var GRANT_SPECIFIED = 3;
50
+ var GRANT_OWNER = 4;
51
+ var STATUS_DELETED = "deleted";
52
+ var STATUS_DRAFT = "draft";
53
+ var DEFAULT_LIMIT = 50;
54
+ var MAX_LIMIT = 200;
55
+ function clampLimit(limit) {
56
+ if (!limit || limit < 1) return DEFAULT_LIMIT;
57
+ return Math.min(limit, MAX_LIMIT);
58
+ }
59
+ function pageToSkip(page, limit) {
60
+ const p = page && page > 0 ? page : 1;
61
+ return (p - 1) * limit;
62
+ }
63
+ function escapeRegex(input) {
64
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
65
+ }
66
+ function keywordRegex(q) {
67
+ const trimmed = q.trim();
68
+ if (trimmed === "") return null;
69
+ return new RegExp(escapeRegex(trimmed), "i");
70
+ }
71
+ function grantFilter(viewer) {
72
+ if (!viewer) {
73
+ return [{ grant: null }, { grant: GRANT_PUBLIC }];
74
+ }
75
+ if (viewer.isAdmin) {
76
+ return null;
77
+ }
78
+ return [
79
+ { grant: null },
80
+ { grant: GRANT_PUBLIC },
81
+ { grant: GRANT_RESTRICTED, grantedUsers: viewer.id },
82
+ { grant: GRANT_SPECIFIED, grantedUsers: viewer.id },
83
+ { grant: GRANT_OWNER, grantedUsers: viewer.id },
84
+ // The creator can always find their own restricted / owner pages even
85
+ // if they are not listed in grantedUsers.
86
+ { grant: { $ne: GRANT_PUBLIC }, creator: viewer.id }
87
+ ];
88
+ }
89
+ function typeFilter(type) {
90
+ switch (type) {
91
+ case "portal":
92
+ return { path: { $regex: /\/$/ }, $nor: [{ path: { $regex: /^\/user\// } }] };
93
+ case "public":
94
+ return { path: { $not: /\/$/ }, $nor: [{ path: { $regex: /^\/user\// } }] };
95
+ case "user":
96
+ return { path: { $regex: /^\/user\// } };
97
+ }
98
+ }
99
+ function pathPrefixFilter(pathPrefix) {
100
+ const trimmed = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
101
+ return { path: { $regex: new RegExp(`^${escapeRegex(trimmed)}/`) } };
102
+ }
103
+ function buildPageFilter(params) {
104
+ const { keyword, viewer, type, pathPrefix, matchPath } = params;
105
+ const and = [];
106
+ and.push({ status: { $nin: [STATUS_DRAFT, STATUS_DELETED] } });
107
+ and.push({ redirectTo: { $in: [null, ""] } });
108
+ if (matchPath && keyword) {
109
+ and.push({ path: { $regex: keyword } });
110
+ }
111
+ const grant = grantFilter(viewer);
112
+ if (grant) {
113
+ and.push({ $or: grant });
114
+ }
115
+ if (type) {
116
+ and.push(typeFilter(type));
117
+ }
118
+ if (pathPrefix) {
119
+ and.push(pathPrefixFilter(pathPrefix));
120
+ }
121
+ return and.length === 1 ? and[0] : { $and: and };
122
+ }
123
+
124
+ // src/driver.ts
125
+ var CANDIDATE_CAP = 5e3;
126
+ var SNIPPET_RADIUS = 60;
127
+ function buildSnippet(text, keyword) {
128
+ const match = keyword.exec(text);
129
+ if (!match) return void 0;
130
+ const start = Math.max(0, match.index - SNIPPET_RADIUS);
131
+ const end = Math.min(text.length, match.index + match[0].length + SNIPPET_RADIUS);
132
+ const before = text.slice(start, match.index);
133
+ const hit = text.slice(match.index, match.index + match[0].length);
134
+ const after = text.slice(match.index + match[0].length, end);
135
+ const prefix = start > 0 ? "\u2026" : "";
136
+ const suffix = end < text.length ? "\u2026" : "";
137
+ return `${prefix}${before}<mark>${hit}</mark>${after}${suffix}`;
138
+ }
139
+ function createMongoSearchDriver(ctx) {
140
+ const Page = ctx.model("Page");
141
+ const Revision = ctx.model("Revision");
142
+ return {
143
+ // Live-regex driver: nothing to index. Kept as resolved no-ops so the
144
+ // page-saved hook can call them unconditionally without error.
145
+ async index(_doc) {
146
+ },
147
+ async remove(_id) {
148
+ },
149
+ async query(q) {
150
+ const startedAt = Date.now();
151
+ const keyword = keywordRegex(q.q);
152
+ const limit = clampLimit(q.limit);
153
+ const skip = pageToSkip(q.page, limit);
154
+ if (!keyword) {
155
+ return { total: 0, hits: [], took: Date.now() - startedAt };
156
+ }
157
+ const type = q.grants?.types && q.grants.types.length > 0 ? q.grants.types[0] : void 0;
158
+ const scope = { keyword, viewer: q.viewer, type, pathPrefix: q.pathPrefix };
159
+ const revIdOf = (page) => page.revision ? page.revision.toString() : null;
160
+ const [pathPages, candidatePages] = await Promise.all([
161
+ Page.find(buildPageFilter({ ...scope, matchPath: true })).select("_id path revision").limit(CANDIDATE_CAP).lean().exec(),
162
+ Page.find(buildPageFilter({ ...scope, matchPath: false })).select("_id path revision").limit(CANDIDATE_CAP).lean().exec()
163
+ ]);
164
+ const pathHitIds = new Set(pathPages.map((p) => p._id.toString()));
165
+ const revisionToPage = /* @__PURE__ */ new Map();
166
+ for (const page of candidatePages) {
167
+ if (pathHitIds.has(page._id.toString())) continue;
168
+ const revId = revIdOf(page);
169
+ if (revId) revisionToPage.set(revId, page);
170
+ }
171
+ const bodyHits = [];
172
+ const bodyById = /* @__PURE__ */ new Map();
173
+ if (revisionToPage.size > 0) {
174
+ const revisions = await Revision.find({ _id: { $in: Array.from(revisionToPage.keys()) }, body: { $regex: keyword } }).select("_id body").lean().exec();
175
+ for (const rev of revisions) {
176
+ const page = revisionToPage.get(rev._id.toString());
177
+ if (page) {
178
+ bodyHits.push(page);
179
+ bodyById.set(rev._id.toString(), rev.body);
180
+ }
181
+ }
182
+ }
183
+ const merged = [
184
+ ...pathPages.map((page) => ({ page, score: 2 })),
185
+ ...bodyHits.map((page) => ({ page, score: 1 }))
186
+ ];
187
+ const total = merged.length;
188
+ const windowed = merged.slice(skip, skip + limit);
189
+ const hits = windowed.map(({ page, score }) => {
190
+ const revId = revIdOf(page);
191
+ const body = revId ? bodyById.get(revId) : void 0;
192
+ const snippet = buildSnippet(page.path, keyword) ?? (body ? buildSnippet(body, keyword) : void 0);
193
+ return {
194
+ id: page._id.toString(),
195
+ path: page.path,
196
+ score,
197
+ ...snippet ? { snippet } : {}
198
+ };
199
+ });
200
+ return { total, hits, took: Date.now() - startedAt };
201
+ }
202
+ // rebuild intentionally omitted — there is no persistent index.
203
+ };
204
+ }
205
+
206
+ // src/index.ts
207
+ var PLUGIN_NAME = "@crowi/plugin-search-mongo";
208
+ var MongoSearchConfigSchema = import_v3.z.object({}).strict();
209
+ var plugin = {
210
+ name: PLUGIN_NAME,
211
+ version: "0.1.0-dev",
212
+ configSchema: MongoSearchConfigSchema,
213
+ adminPlacement: {
214
+ label: "MongoDB",
215
+ icon: "database"
216
+ // section omitted: derived from registerSearch -> 'search'
217
+ },
218
+ registerSearch: (registry, ctx) => {
219
+ const driver = createMongoSearchDriver(ctx);
220
+ registry.register("mongo", driver);
221
+ ctx.log.debug("registered mongo search driver (live $regex over Page/Revision)");
222
+ }
223
+ };
224
+ var index_default = plugin;
225
+ // Annotate the CommonJS export names for ESM import in node:
226
+ 0 && (module.exports = {
227
+ CANDIDATE_CAP,
228
+ DEFAULT_LIMIT,
229
+ GRANT_OWNER,
230
+ GRANT_PUBLIC,
231
+ GRANT_RESTRICTED,
232
+ GRANT_SPECIFIED,
233
+ MAX_LIMIT,
234
+ MongoSearchConfigSchema,
235
+ buildPageFilter,
236
+ buildSnippet,
237
+ clampLimit,
238
+ createMongoSearchDriver,
239
+ escapeRegex,
240
+ grantFilter,
241
+ keywordRegex,
242
+ pageToSkip,
243
+ pathPrefixFilter,
244
+ typeFilter
245
+ });
246
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/query-builder.ts","../src/driver.ts"],"sourcesContent":["/**\n * @crowi/plugin-search-mongo — the default, infra-free search driver.\n *\n * Registers `'mongo'` against the SearchRegistry. It searches live data\n * (`Page` + the page's current `Revision`) with a case-insensitive\n * `$regex` over path / title / body, so it needs NO external service and\n * NO separate search index — MongoDB alone is enough. This makes the slim\n * deployment (local storage + mongo search) searchable out of the box.\n *\n * Positioning: small / mid-size wikis that don't want to run Elasticsearch.\n * A non-anchored `$regex` cannot use an index, so large installs should run\n * `@crowi/plugin-search-elasticsearch` instead — see README.\n *\n * Activation: set `search.driver: 'mongo'` in the runner's\n * `crowi.config.json` (the schema default) and ensure this plugin is loaded\n * (it is an implicit default in the runner). No connection config — the\n * config schema is empty.\n */\n\nimport { z } from 'zod/v3';\nimport type { CrowiPlugin } from '@crowi/plugin-api';\n\nimport { createMongoSearchDriver } from './driver';\n\nexport { createMongoSearchDriver, buildSnippet, CANDIDATE_CAP } from './driver';\nexport {\n buildPageFilter,\n grantFilter,\n typeFilter,\n pathPrefixFilter,\n keywordRegex,\n escapeRegex,\n clampLimit,\n pageToSkip,\n GRANT_PUBLIC,\n GRANT_RESTRICTED,\n GRANT_SPECIFIED,\n GRANT_OWNER,\n DEFAULT_LIMIT,\n MAX_LIMIT,\n} from './query-builder';\n\nconst PLUGIN_NAME = '@crowi/plugin-search-mongo';\n\n/**\n * The mongo driver has no connection settings — it searches the same\n * Mongo the rest of the app uses. The schema is intentionally empty so the\n * admin UI renders the driver with no config fields.\n */\nexport const MongoSearchConfigSchema = z.object({}).strict();\n\nexport type MongoSearchConfig = z.infer<typeof MongoSearchConfigSchema>;\n\nconst plugin: CrowiPlugin = {\n name: PLUGIN_NAME,\n version: '0.1.0-dev',\n configSchema: MongoSearchConfigSchema,\n adminPlacement: {\n label: 'MongoDB',\n icon: 'database',\n // section omitted: derived from registerSearch -> 'search'\n },\n\n registerSearch: (registry, ctx) => {\n const driver = createMongoSearchDriver(ctx);\n registry.register('mongo', driver);\n ctx.log.debug('registered mongo search driver (live $regex over Page/Revision)');\n },\n};\n\nexport default plugin;\n","/**\n * Build the MongoDB filter that the `'mongo'` search driver runs against\n * the `Page` collection, plus the small helpers that the driver reuses\n * for the `Revision` body lookup.\n *\n * The driver searches live data — there is no separate search index — so\n * the filter must reproduce the visibility rules that the rest of the app\n * applies when reading pages. Everything is expressed as plain Mongo query\n * conditions (`$regex`, `$or`, `$in`, ...) so the generated filter stays\n * easy to assert in unit tests without touching a real database.\n *\n * Design notes:\n * - Grant filter mirrors `packages/api/src/models/page.ts`\n * (`visiblePageGrantOr`): a non-public page (RESTRICTED / SPECIFIED /\n * OWNER) is hidden unless the viewer created it (`creator`) or is\n * listed in `grantedUsers`. Anonymous viewers see public pages only;\n * admins see everything.\n * - Status filter always drops drafts / deleted pages / redirects so a\n * keyword search can never leak another user's draft. The search route\n * has no per-viewer draft filter, so we exclude all drafts here rather\n * than trying to admit the author's own (matching the ES driver's\n * `shouldIndex`, which also drops every draft).\n * - Type filter (portal / public / user) reproduces the legacy\n * path-shape rules as `$regex` / prefix conditions.\n */\n\nimport type { SearchPageType, SearchQueryViewer } from '@crowi/plugin-api';\n\n// Page grant constants — mirror `packages/api/src/models/page.ts`.\n// Hard-coded here because the plugin must not import from @crowi/api\n// (that would invert the dependency direction and force a runner\n// rebuild on every plugin change).\nexport const GRANT_PUBLIC = 1;\nexport const GRANT_RESTRICTED = 2;\nexport const GRANT_SPECIFIED = 3;\nexport const GRANT_OWNER = 4;\n\n// Page status values — mirror `packages/api/src/models/page.ts`.\nexport const STATUS_PUBLISHED = 'published';\nexport const STATUS_DELETED = 'deleted';\nexport const STATUS_DRAFT = 'draft';\n\nexport const DEFAULT_LIMIT = 50;\nexport const MAX_LIMIT = 200;\n\n/** Clamp a requested page size into the [1, MAX_LIMIT] range. */\nexport function clampLimit(limit?: number): number {\n if (!limit || limit < 1) return DEFAULT_LIMIT;\n return Math.min(limit, MAX_LIMIT);\n}\n\n/** 1-based page → zero-based skip. */\nexport function pageToSkip(page: number | undefined, limit: number): number {\n const p = page && page > 0 ? page : 1;\n return (p - 1) * limit;\n}\n\n/**\n * Escape a user-supplied string so it can be embedded into a `$regex`\n * verbatim (treating every char literally — this is substring search,\n * not a regex DSL exposed to the user).\n */\nexport function escapeRegex(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * `RegExp` matching the query as a case-insensitive substring. Returns\n * `null` for an empty / whitespace-only query so the caller can decide to\n * return zero hits without running a collection scan.\n */\nexport function keywordRegex(q: string): RegExp | null {\n const trimmed = q.trim();\n if (trimmed === '') return null;\n return new RegExp(escapeRegex(trimmed), 'i');\n}\n\ntype MongoCondition = Record<string, unknown>;\n\n/**\n * `$or` clause restricting results to pages the viewer may read. Mirrors\n * `visiblePageGrantOr` but additionally honours the anonymous / admin\n * cases the search route distinguishes.\n */\nexport function grantFilter(viewer?: SearchQueryViewer): MongoCondition[] | null {\n if (!viewer) {\n // Anonymous viewer: public pages only. (legacy-null grant is treated\n // as public.)\n return [{ grant: null }, { grant: GRANT_PUBLIC }];\n }\n if (viewer.isAdmin) {\n // Admin sees everything; no grant constraint.\n return null;\n }\n // Non-admin authenticated viewer: public OR pages they created OR pages\n // explicitly shared with them.\n return [\n { grant: null },\n { grant: GRANT_PUBLIC },\n { grant: GRANT_RESTRICTED, grantedUsers: viewer.id },\n { grant: GRANT_SPECIFIED, grantedUsers: viewer.id },\n { grant: GRANT_OWNER, grantedUsers: viewer.id },\n // The creator can always find their own restricted / owner pages even\n // if they are not listed in grantedUsers.\n { grant: { $ne: GRANT_PUBLIC }, creator: viewer.id },\n ];\n}\n\n/**\n * Per-type path condition.\n * - `portal`: path ends with `/`, excluding `/user/*`\n * - `public`: path does NOT end with `/`, excluding `/user/*`\n * - `user`: `/user/*` prefix\n */\nexport function typeFilter(type: SearchPageType): MongoCondition {\n switch (type) {\n case 'portal':\n return { path: { $regex: /\\/$/ }, $nor: [{ path: { $regex: /^\\/user\\// } }] };\n case 'public':\n return { path: { $not: /\\/$/ }, $nor: [{ path: { $regex: /^\\/user\\// } }] };\n case 'user':\n return { path: { $regex: /^\\/user\\// } };\n }\n}\n\n/** Prefix condition. Normalises a trailing slash, then anchors `^prefix/`. */\nexport function pathPrefixFilter(pathPrefix: string): MongoCondition {\n const trimmed = pathPrefix.endsWith('/') ? pathPrefix.slice(0, -1) : pathPrefix;\n return { path: { $regex: new RegExp(`^${escapeRegex(trimmed)}/`) } };\n}\n\nexport interface BuildPageFilterParams {\n /** Keyword regex (case-insensitive substring). Null = no path match. */\n keyword: RegExp | null;\n viewer?: SearchQueryViewer;\n type?: SearchPageType;\n pathPrefix?: string;\n /** When true, the filter constrains `path` to the keyword too. */\n matchPath: boolean;\n}\n\n/**\n * Compose the base visibility + scope filter shared by the path-match and\n * body-match passes. `matchPath` toggles whether the keyword is applied to\n * `path` (the path / title pass) or left out (the body pass narrows by\n * `_id` separately).\n */\nexport function buildPageFilter(params: BuildPageFilterParams): MongoCondition {\n const { keyword, viewer, type, pathPrefix, matchPath } = params;\n const and: MongoCondition[] = [];\n\n // Always exclude drafts, deleted pages and redirects. `$in: [null, '']`\n // also matches documents where `redirectTo` is unset (Mongo treats a\n // missing field as `null` for `$in`), so a redirect is anything with a\n // non-empty target.\n and.push({ status: { $nin: [STATUS_DRAFT, STATUS_DELETED] } });\n and.push({ redirectTo: { $in: [null, ''] } });\n\n if (matchPath && keyword) {\n and.push({ path: { $regex: keyword } });\n }\n\n const grant = grantFilter(viewer);\n if (grant) {\n and.push({ $or: grant });\n }\n\n if (type) {\n and.push(typeFilter(type));\n }\n\n if (pathPrefix) {\n and.push(pathPrefixFilter(pathPrefix));\n }\n\n return and.length === 1 ? and[0] : { $and: and };\n}\n","/**\n * `createMongoSearchDriver` — the `'mongo'` SearchDriver. It searches live\n * data (`Page` + the page's current `Revision`) with a case-insensitive\n * `$regex`, so there is NO separate search index to maintain:\n *\n * - `index()` / `remove()` are no-ops. The page body already lives in\n * Page / Revision, so the page-saved event hook has nothing extra to\n * persist. They never throw, so wiring the driver into the save path\n * can never fail a page write.\n * - `rebuild()` is omitted (there is no index to rebuild).\n * - `query()` runs the actual search.\n *\n * Body-fetch strategy (spec open question 2):\n * A two-pass approach keyed off the path/title pass, chosen over a single\n * `$lookup` aggregation for readability on the slim / small-deployment\n * target this driver serves:\n * 1. PATH pass — pages whose `path` matches the keyword. These are the\n * strongest hits (the page title is its path) and are returned first.\n * 2. BODY pass — among the viewer-visible candidate pages (capped at\n * CANDIDATE_CAP to bound the non-anchored `$regex` collection scan),\n * look up their current revisions whose `body` matches the keyword in\n * one bulk `Revision.find({ revision: { $in }, body: regex })`, then\n * map the matched revisions back to their pages.\n * The two result sets are merged (path hits first, body-only hits after),\n * de-duplicated by page id, then paged with skip/limit. `total` is the\n * size of the merged set (capped by CANDIDATE_CAP on the body side).\n *\n * Ranking / score (spec open questions 3 & 4): a simple 2-value score —\n * path/title hits score 2, body-only hits score 1 — expressed by ordering\n * path hits ahead of body hits. No function score / field boosting.\n *\n * Snippet (spec open question 3): best-effort substring around the first\n * match, with the matched span wrapped in `<mark>`. NOT HTML-escaped — the\n * search route passes snippets through verbatim and the web client\n * sanitises before render (same contract as the ES driver's highlight).\n */\n\nimport type { PluginContext, SearchableDoc, SearchDriver, SearchHit, SearchHits, SearchQuery } from '@crowi/plugin-api';\n\nimport { buildPageFilter, clampLimit, keywordRegex, pageToSkip } from './query-builder';\n\n/**\n * Upper bound on candidate pages scanned by the body pass. A non-anchored\n * `$regex` cannot use an index, so we bound the scan: beyond this many\n * visible candidates the body pass is truncated and path/title hits are\n * preferred. Generous for the small / mid deployments this driver targets;\n * larger installs should run the Elasticsearch driver.\n */\nexport const CANDIDATE_CAP = 5000;\n\n/** Characters of context to include on each side of a snippet match. */\nconst SNIPPET_RADIUS = 60;\n\ninterface PageDoc {\n _id: { toString(): string };\n path: string;\n revision?: { toString(): string } | null;\n}\n\n/**\n * Minimal Mongoose model surface the driver touches. `ctx.model()` returns\n * `unknown`; we narrow to just the query methods we call.\n */\ninterface PageModelLike {\n find(filter: Record<string, unknown>): {\n select(projection: string): {\n limit(n: number): {\n lean(): { exec(): Promise<PageDoc[]> };\n };\n };\n };\n}\n\ninterface RevisionModelLike {\n find(filter: Record<string, unknown>): {\n select(projection: string): {\n lean(): { exec(): Promise<Array<{ _id: { toString(): string }; body: string }>> };\n };\n };\n}\n\n/**\n * Build a best-effort snippet: a window around the first case-insensitive\n * match of `keyword` in `text`, with the match wrapped in `<mark>`.\n * Returns undefined when the text has no match (e.g. a path-only hit).\n */\nexport function buildSnippet(text: string, keyword: RegExp): string | undefined {\n const match = keyword.exec(text);\n if (!match) return undefined;\n const start = Math.max(0, match.index - SNIPPET_RADIUS);\n const end = Math.min(text.length, match.index + match[0].length + SNIPPET_RADIUS);\n const before = text.slice(start, match.index);\n const hit = text.slice(match.index, match.index + match[0].length);\n const after = text.slice(match.index + match[0].length, end);\n const prefix = start > 0 ? '…' : '';\n const suffix = end < text.length ? '…' : '';\n return `${prefix}${before}<mark>${hit}</mark>${after}${suffix}`;\n}\n\nexport function createMongoSearchDriver(ctx: PluginContext): SearchDriver {\n const Page = ctx.model('Page') as PageModelLike;\n const Revision = ctx.model('Revision') as RevisionModelLike;\n\n return {\n // Live-regex driver: nothing to index. Kept as resolved no-ops so the\n // page-saved hook can call them unconditionally without error.\n async index(_doc: SearchableDoc): Promise<void> {\n // no-op\n },\n async remove(_id: string): Promise<void> {\n // no-op\n },\n\n async query(q: SearchQuery): Promise<SearchHits> {\n const startedAt = Date.now();\n const keyword = keywordRegex(q.q);\n const limit = clampLimit(q.limit);\n const skip = pageToSkip(q.page, limit);\n\n // Empty / whitespace-only query: no hits (avoid a full scan).\n if (!keyword) {\n return { total: 0, hits: [], took: Date.now() - startedAt };\n }\n\n const type = q.grants?.types && q.grants.types.length > 0 ? q.grants.types[0] : undefined;\n const scope = { keyword, viewer: q.viewer, type, pathPrefix: q.pathPrefix } as const;\n const revIdOf = (page: PageDoc): string | null => (page.revision ? page.revision.toString() : null);\n\n // PATH pass (pages whose path matches the keyword) and BODY candidate\n // pass (viewer-visible pages whose revision we will match) are\n // independent Mongo reads — run them together.\n const [pathPages, candidatePages] = await Promise.all([\n Page.find(buildPageFilter({ ...scope, matchPath: true }))\n .select('_id path revision')\n .limit(CANDIDATE_CAP)\n .lean()\n .exec(),\n Page.find(buildPageFilter({ ...scope, matchPath: false }))\n .select('_id path revision')\n .limit(CANDIDATE_CAP)\n .lean()\n .exec(),\n ]);\n\n const pathHitIds = new Set(pathPages.map((p) => p._id.toString()));\n\n // Map currentRevision -> page, skipping pages already matched by path\n // (those are stronger hits) and pages with no revision pointer.\n const revisionToPage = new Map<string, PageDoc>();\n for (const page of candidatePages) {\n if (pathHitIds.has(page._id.toString())) continue;\n const revId = revIdOf(page);\n if (revId) revisionToPage.set(revId, page);\n }\n\n // Bulk body match. Keep the matched bodies so the snippet pass can use\n // them directly instead of re-fetching the same revisions.\n const bodyHits: PageDoc[] = [];\n const bodyById = new Map<string, string>();\n if (revisionToPage.size > 0) {\n const revisions = await Revision.find({ _id: { $in: Array.from(revisionToPage.keys()) }, body: { $regex: keyword } })\n .select('_id body')\n .lean()\n .exec();\n for (const rev of revisions) {\n const page = revisionToPage.get(rev._id.toString());\n if (page) {\n bodyHits.push(page);\n bodyById.set(rev._id.toString(), rev.body);\n }\n }\n }\n\n // Merge: path hits (score 2) first, body-only hits (score 1) after.\n const merged: Array<{ page: PageDoc; score: number }> = [\n ...pathPages.map((page) => ({ page, score: 2 })),\n ...bodyHits.map((page) => ({ page, score: 1 })),\n ];\n\n const total = merged.length;\n const windowed = merged.slice(skip, skip + limit);\n\n const hits: SearchHit[] = windowed.map(({ page, score }) => {\n const revId = revIdOf(page);\n // Path hits always snippet off the path (the match is in the title);\n // body-only hits read from the body captured by the body pass.\n const body = revId ? bodyById.get(revId) : undefined;\n // Prefer a path snippet (the match is in the title); fall back to\n // the body for body-only hits.\n const snippet = buildSnippet(page.path, keyword) ?? (body ? buildSnippet(body, keyword) : undefined);\n return {\n id: page._id.toString(),\n path: page.path,\n score,\n ...(snippet ? { snippet } : {}),\n };\n });\n\n return { total, hits, took: Date.now() - startedAt };\n },\n\n // rebuild intentionally omitted — there is no persistent index.\n };\n}\n"],"mappings":"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","names":[]}
package/dist/index.mjs ADDED
@@ -0,0 +1,204 @@
1
+ // src/index.ts
2
+ import { z } from "zod/v3";
3
+
4
+ // src/query-builder.ts
5
+ var GRANT_PUBLIC = 1;
6
+ var GRANT_RESTRICTED = 2;
7
+ var GRANT_SPECIFIED = 3;
8
+ var GRANT_OWNER = 4;
9
+ var STATUS_DELETED = "deleted";
10
+ var STATUS_DRAFT = "draft";
11
+ var DEFAULT_LIMIT = 50;
12
+ var MAX_LIMIT = 200;
13
+ function clampLimit(limit) {
14
+ if (!limit || limit < 1) return DEFAULT_LIMIT;
15
+ return Math.min(limit, MAX_LIMIT);
16
+ }
17
+ function pageToSkip(page, limit) {
18
+ const p = page && page > 0 ? page : 1;
19
+ return (p - 1) * limit;
20
+ }
21
+ function escapeRegex(input) {
22
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ }
24
+ function keywordRegex(q) {
25
+ const trimmed = q.trim();
26
+ if (trimmed === "") return null;
27
+ return new RegExp(escapeRegex(trimmed), "i");
28
+ }
29
+ function grantFilter(viewer) {
30
+ if (!viewer) {
31
+ return [{ grant: null }, { grant: GRANT_PUBLIC }];
32
+ }
33
+ if (viewer.isAdmin) {
34
+ return null;
35
+ }
36
+ return [
37
+ { grant: null },
38
+ { grant: GRANT_PUBLIC },
39
+ { grant: GRANT_RESTRICTED, grantedUsers: viewer.id },
40
+ { grant: GRANT_SPECIFIED, grantedUsers: viewer.id },
41
+ { grant: GRANT_OWNER, grantedUsers: viewer.id },
42
+ // The creator can always find their own restricted / owner pages even
43
+ // if they are not listed in grantedUsers.
44
+ { grant: { $ne: GRANT_PUBLIC }, creator: viewer.id }
45
+ ];
46
+ }
47
+ function typeFilter(type) {
48
+ switch (type) {
49
+ case "portal":
50
+ return { path: { $regex: /\/$/ }, $nor: [{ path: { $regex: /^\/user\// } }] };
51
+ case "public":
52
+ return { path: { $not: /\/$/ }, $nor: [{ path: { $regex: /^\/user\// } }] };
53
+ case "user":
54
+ return { path: { $regex: /^\/user\// } };
55
+ }
56
+ }
57
+ function pathPrefixFilter(pathPrefix) {
58
+ const trimmed = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
59
+ return { path: { $regex: new RegExp(`^${escapeRegex(trimmed)}/`) } };
60
+ }
61
+ function buildPageFilter(params) {
62
+ const { keyword, viewer, type, pathPrefix, matchPath } = params;
63
+ const and = [];
64
+ and.push({ status: { $nin: [STATUS_DRAFT, STATUS_DELETED] } });
65
+ and.push({ redirectTo: { $in: [null, ""] } });
66
+ if (matchPath && keyword) {
67
+ and.push({ path: { $regex: keyword } });
68
+ }
69
+ const grant = grantFilter(viewer);
70
+ if (grant) {
71
+ and.push({ $or: grant });
72
+ }
73
+ if (type) {
74
+ and.push(typeFilter(type));
75
+ }
76
+ if (pathPrefix) {
77
+ and.push(pathPrefixFilter(pathPrefix));
78
+ }
79
+ return and.length === 1 ? and[0] : { $and: and };
80
+ }
81
+
82
+ // src/driver.ts
83
+ var CANDIDATE_CAP = 5e3;
84
+ var SNIPPET_RADIUS = 60;
85
+ function buildSnippet(text, keyword) {
86
+ const match = keyword.exec(text);
87
+ if (!match) return void 0;
88
+ const start = Math.max(0, match.index - SNIPPET_RADIUS);
89
+ const end = Math.min(text.length, match.index + match[0].length + SNIPPET_RADIUS);
90
+ const before = text.slice(start, match.index);
91
+ const hit = text.slice(match.index, match.index + match[0].length);
92
+ const after = text.slice(match.index + match[0].length, end);
93
+ const prefix = start > 0 ? "\u2026" : "";
94
+ const suffix = end < text.length ? "\u2026" : "";
95
+ return `${prefix}${before}<mark>${hit}</mark>${after}${suffix}`;
96
+ }
97
+ function createMongoSearchDriver(ctx) {
98
+ const Page = ctx.model("Page");
99
+ const Revision = ctx.model("Revision");
100
+ return {
101
+ // Live-regex driver: nothing to index. Kept as resolved no-ops so the
102
+ // page-saved hook can call them unconditionally without error.
103
+ async index(_doc) {
104
+ },
105
+ async remove(_id) {
106
+ },
107
+ async query(q) {
108
+ const startedAt = Date.now();
109
+ const keyword = keywordRegex(q.q);
110
+ const limit = clampLimit(q.limit);
111
+ const skip = pageToSkip(q.page, limit);
112
+ if (!keyword) {
113
+ return { total: 0, hits: [], took: Date.now() - startedAt };
114
+ }
115
+ const type = q.grants?.types && q.grants.types.length > 0 ? q.grants.types[0] : void 0;
116
+ const scope = { keyword, viewer: q.viewer, type, pathPrefix: q.pathPrefix };
117
+ const revIdOf = (page) => page.revision ? page.revision.toString() : null;
118
+ const [pathPages, candidatePages] = await Promise.all([
119
+ Page.find(buildPageFilter({ ...scope, matchPath: true })).select("_id path revision").limit(CANDIDATE_CAP).lean().exec(),
120
+ Page.find(buildPageFilter({ ...scope, matchPath: false })).select("_id path revision").limit(CANDIDATE_CAP).lean().exec()
121
+ ]);
122
+ const pathHitIds = new Set(pathPages.map((p) => p._id.toString()));
123
+ const revisionToPage = /* @__PURE__ */ new Map();
124
+ for (const page of candidatePages) {
125
+ if (pathHitIds.has(page._id.toString())) continue;
126
+ const revId = revIdOf(page);
127
+ if (revId) revisionToPage.set(revId, page);
128
+ }
129
+ const bodyHits = [];
130
+ const bodyById = /* @__PURE__ */ new Map();
131
+ if (revisionToPage.size > 0) {
132
+ const revisions = await Revision.find({ _id: { $in: Array.from(revisionToPage.keys()) }, body: { $regex: keyword } }).select("_id body").lean().exec();
133
+ for (const rev of revisions) {
134
+ const page = revisionToPage.get(rev._id.toString());
135
+ if (page) {
136
+ bodyHits.push(page);
137
+ bodyById.set(rev._id.toString(), rev.body);
138
+ }
139
+ }
140
+ }
141
+ const merged = [
142
+ ...pathPages.map((page) => ({ page, score: 2 })),
143
+ ...bodyHits.map((page) => ({ page, score: 1 }))
144
+ ];
145
+ const total = merged.length;
146
+ const windowed = merged.slice(skip, skip + limit);
147
+ const hits = windowed.map(({ page, score }) => {
148
+ const revId = revIdOf(page);
149
+ const body = revId ? bodyById.get(revId) : void 0;
150
+ const snippet = buildSnippet(page.path, keyword) ?? (body ? buildSnippet(body, keyword) : void 0);
151
+ return {
152
+ id: page._id.toString(),
153
+ path: page.path,
154
+ score,
155
+ ...snippet ? { snippet } : {}
156
+ };
157
+ });
158
+ return { total, hits, took: Date.now() - startedAt };
159
+ }
160
+ // rebuild intentionally omitted — there is no persistent index.
161
+ };
162
+ }
163
+
164
+ // src/index.ts
165
+ var PLUGIN_NAME = "@crowi/plugin-search-mongo";
166
+ var MongoSearchConfigSchema = z.object({}).strict();
167
+ var plugin = {
168
+ name: PLUGIN_NAME,
169
+ version: "0.1.0-dev",
170
+ configSchema: MongoSearchConfigSchema,
171
+ adminPlacement: {
172
+ label: "MongoDB",
173
+ icon: "database"
174
+ // section omitted: derived from registerSearch -> 'search'
175
+ },
176
+ registerSearch: (registry, ctx) => {
177
+ const driver = createMongoSearchDriver(ctx);
178
+ registry.register("mongo", driver);
179
+ ctx.log.debug("registered mongo search driver (live $regex over Page/Revision)");
180
+ }
181
+ };
182
+ var index_default = plugin;
183
+ export {
184
+ CANDIDATE_CAP,
185
+ DEFAULT_LIMIT,
186
+ GRANT_OWNER,
187
+ GRANT_PUBLIC,
188
+ GRANT_RESTRICTED,
189
+ GRANT_SPECIFIED,
190
+ MAX_LIMIT,
191
+ MongoSearchConfigSchema,
192
+ buildPageFilter,
193
+ buildSnippet,
194
+ clampLimit,
195
+ createMongoSearchDriver,
196
+ index_default as default,
197
+ escapeRegex,
198
+ grantFilter,
199
+ keywordRegex,
200
+ pageToSkip,
201
+ pathPrefixFilter,
202
+ typeFilter
203
+ };
204
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/query-builder.ts","../src/driver.ts"],"sourcesContent":["/**\n * @crowi/plugin-search-mongo — the default, infra-free search driver.\n *\n * Registers `'mongo'` against the SearchRegistry. It searches live data\n * (`Page` + the page's current `Revision`) with a case-insensitive\n * `$regex` over path / title / body, so it needs NO external service and\n * NO separate search index — MongoDB alone is enough. This makes the slim\n * deployment (local storage + mongo search) searchable out of the box.\n *\n * Positioning: small / mid-size wikis that don't want to run Elasticsearch.\n * A non-anchored `$regex` cannot use an index, so large installs should run\n * `@crowi/plugin-search-elasticsearch` instead — see README.\n *\n * Activation: set `search.driver: 'mongo'` in the runner's\n * `crowi.config.json` (the schema default) and ensure this plugin is loaded\n * (it is an implicit default in the runner). No connection config — the\n * config schema is empty.\n */\n\nimport { z } from 'zod/v3';\nimport type { CrowiPlugin } from '@crowi/plugin-api';\n\nimport { createMongoSearchDriver } from './driver';\n\nexport { createMongoSearchDriver, buildSnippet, CANDIDATE_CAP } from './driver';\nexport {\n buildPageFilter,\n grantFilter,\n typeFilter,\n pathPrefixFilter,\n keywordRegex,\n escapeRegex,\n clampLimit,\n pageToSkip,\n GRANT_PUBLIC,\n GRANT_RESTRICTED,\n GRANT_SPECIFIED,\n GRANT_OWNER,\n DEFAULT_LIMIT,\n MAX_LIMIT,\n} from './query-builder';\n\nconst PLUGIN_NAME = '@crowi/plugin-search-mongo';\n\n/**\n * The mongo driver has no connection settings — it searches the same\n * Mongo the rest of the app uses. The schema is intentionally empty so the\n * admin UI renders the driver with no config fields.\n */\nexport const MongoSearchConfigSchema = z.object({}).strict();\n\nexport type MongoSearchConfig = z.infer<typeof MongoSearchConfigSchema>;\n\nconst plugin: CrowiPlugin = {\n name: PLUGIN_NAME,\n version: '0.1.0-dev',\n configSchema: MongoSearchConfigSchema,\n adminPlacement: {\n label: 'MongoDB',\n icon: 'database',\n // section omitted: derived from registerSearch -> 'search'\n },\n\n registerSearch: (registry, ctx) => {\n const driver = createMongoSearchDriver(ctx);\n registry.register('mongo', driver);\n ctx.log.debug('registered mongo search driver (live $regex over Page/Revision)');\n },\n};\n\nexport default plugin;\n","/**\n * Build the MongoDB filter that the `'mongo'` search driver runs against\n * the `Page` collection, plus the small helpers that the driver reuses\n * for the `Revision` body lookup.\n *\n * The driver searches live data — there is no separate search index — so\n * the filter must reproduce the visibility rules that the rest of the app\n * applies when reading pages. Everything is expressed as plain Mongo query\n * conditions (`$regex`, `$or`, `$in`, ...) so the generated filter stays\n * easy to assert in unit tests without touching a real database.\n *\n * Design notes:\n * - Grant filter mirrors `packages/api/src/models/page.ts`\n * (`visiblePageGrantOr`): a non-public page (RESTRICTED / SPECIFIED /\n * OWNER) is hidden unless the viewer created it (`creator`) or is\n * listed in `grantedUsers`. Anonymous viewers see public pages only;\n * admins see everything.\n * - Status filter always drops drafts / deleted pages / redirects so a\n * keyword search can never leak another user's draft. The search route\n * has no per-viewer draft filter, so we exclude all drafts here rather\n * than trying to admit the author's own (matching the ES driver's\n * `shouldIndex`, which also drops every draft).\n * - Type filter (portal / public / user) reproduces the legacy\n * path-shape rules as `$regex` / prefix conditions.\n */\n\nimport type { SearchPageType, SearchQueryViewer } from '@crowi/plugin-api';\n\n// Page grant constants — mirror `packages/api/src/models/page.ts`.\n// Hard-coded here because the plugin must not import from @crowi/api\n// (that would invert the dependency direction and force a runner\n// rebuild on every plugin change).\nexport const GRANT_PUBLIC = 1;\nexport const GRANT_RESTRICTED = 2;\nexport const GRANT_SPECIFIED = 3;\nexport const GRANT_OWNER = 4;\n\n// Page status values — mirror `packages/api/src/models/page.ts`.\nexport const STATUS_PUBLISHED = 'published';\nexport const STATUS_DELETED = 'deleted';\nexport const STATUS_DRAFT = 'draft';\n\nexport const DEFAULT_LIMIT = 50;\nexport const MAX_LIMIT = 200;\n\n/** Clamp a requested page size into the [1, MAX_LIMIT] range. */\nexport function clampLimit(limit?: number): number {\n if (!limit || limit < 1) return DEFAULT_LIMIT;\n return Math.min(limit, MAX_LIMIT);\n}\n\n/** 1-based page → zero-based skip. */\nexport function pageToSkip(page: number | undefined, limit: number): number {\n const p = page && page > 0 ? page : 1;\n return (p - 1) * limit;\n}\n\n/**\n * Escape a user-supplied string so it can be embedded into a `$regex`\n * verbatim (treating every char literally — this is substring search,\n * not a regex DSL exposed to the user).\n */\nexport function escapeRegex(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * `RegExp` matching the query as a case-insensitive substring. Returns\n * `null` for an empty / whitespace-only query so the caller can decide to\n * return zero hits without running a collection scan.\n */\nexport function keywordRegex(q: string): RegExp | null {\n const trimmed = q.trim();\n if (trimmed === '') return null;\n return new RegExp(escapeRegex(trimmed), 'i');\n}\n\ntype MongoCondition = Record<string, unknown>;\n\n/**\n * `$or` clause restricting results to pages the viewer may read. Mirrors\n * `visiblePageGrantOr` but additionally honours the anonymous / admin\n * cases the search route distinguishes.\n */\nexport function grantFilter(viewer?: SearchQueryViewer): MongoCondition[] | null {\n if (!viewer) {\n // Anonymous viewer: public pages only. (legacy-null grant is treated\n // as public.)\n return [{ grant: null }, { grant: GRANT_PUBLIC }];\n }\n if (viewer.isAdmin) {\n // Admin sees everything; no grant constraint.\n return null;\n }\n // Non-admin authenticated viewer: public OR pages they created OR pages\n // explicitly shared with them.\n return [\n { grant: null },\n { grant: GRANT_PUBLIC },\n { grant: GRANT_RESTRICTED, grantedUsers: viewer.id },\n { grant: GRANT_SPECIFIED, grantedUsers: viewer.id },\n { grant: GRANT_OWNER, grantedUsers: viewer.id },\n // The creator can always find their own restricted / owner pages even\n // if they are not listed in grantedUsers.\n { grant: { $ne: GRANT_PUBLIC }, creator: viewer.id },\n ];\n}\n\n/**\n * Per-type path condition.\n * - `portal`: path ends with `/`, excluding `/user/*`\n * - `public`: path does NOT end with `/`, excluding `/user/*`\n * - `user`: `/user/*` prefix\n */\nexport function typeFilter(type: SearchPageType): MongoCondition {\n switch (type) {\n case 'portal':\n return { path: { $regex: /\\/$/ }, $nor: [{ path: { $regex: /^\\/user\\// } }] };\n case 'public':\n return { path: { $not: /\\/$/ }, $nor: [{ path: { $regex: /^\\/user\\// } }] };\n case 'user':\n return { path: { $regex: /^\\/user\\// } };\n }\n}\n\n/** Prefix condition. Normalises a trailing slash, then anchors `^prefix/`. */\nexport function pathPrefixFilter(pathPrefix: string): MongoCondition {\n const trimmed = pathPrefix.endsWith('/') ? pathPrefix.slice(0, -1) : pathPrefix;\n return { path: { $regex: new RegExp(`^${escapeRegex(trimmed)}/`) } };\n}\n\nexport interface BuildPageFilterParams {\n /** Keyword regex (case-insensitive substring). Null = no path match. */\n keyword: RegExp | null;\n viewer?: SearchQueryViewer;\n type?: SearchPageType;\n pathPrefix?: string;\n /** When true, the filter constrains `path` to the keyword too. */\n matchPath: boolean;\n}\n\n/**\n * Compose the base visibility + scope filter shared by the path-match and\n * body-match passes. `matchPath` toggles whether the keyword is applied to\n * `path` (the path / title pass) or left out (the body pass narrows by\n * `_id` separately).\n */\nexport function buildPageFilter(params: BuildPageFilterParams): MongoCondition {\n const { keyword, viewer, type, pathPrefix, matchPath } = params;\n const and: MongoCondition[] = [];\n\n // Always exclude drafts, deleted pages and redirects. `$in: [null, '']`\n // also matches documents where `redirectTo` is unset (Mongo treats a\n // missing field as `null` for `$in`), so a redirect is anything with a\n // non-empty target.\n and.push({ status: { $nin: [STATUS_DRAFT, STATUS_DELETED] } });\n and.push({ redirectTo: { $in: [null, ''] } });\n\n if (matchPath && keyword) {\n and.push({ path: { $regex: keyword } });\n }\n\n const grant = grantFilter(viewer);\n if (grant) {\n and.push({ $or: grant });\n }\n\n if (type) {\n and.push(typeFilter(type));\n }\n\n if (pathPrefix) {\n and.push(pathPrefixFilter(pathPrefix));\n }\n\n return and.length === 1 ? and[0] : { $and: and };\n}\n","/**\n * `createMongoSearchDriver` — the `'mongo'` SearchDriver. It searches live\n * data (`Page` + the page's current `Revision`) with a case-insensitive\n * `$regex`, so there is NO separate search index to maintain:\n *\n * - `index()` / `remove()` are no-ops. The page body already lives in\n * Page / Revision, so the page-saved event hook has nothing extra to\n * persist. They never throw, so wiring the driver into the save path\n * can never fail a page write.\n * - `rebuild()` is omitted (there is no index to rebuild).\n * - `query()` runs the actual search.\n *\n * Body-fetch strategy (spec open question 2):\n * A two-pass approach keyed off the path/title pass, chosen over a single\n * `$lookup` aggregation for readability on the slim / small-deployment\n * target this driver serves:\n * 1. PATH pass — pages whose `path` matches the keyword. These are the\n * strongest hits (the page title is its path) and are returned first.\n * 2. BODY pass — among the viewer-visible candidate pages (capped at\n * CANDIDATE_CAP to bound the non-anchored `$regex` collection scan),\n * look up their current revisions whose `body` matches the keyword in\n * one bulk `Revision.find({ revision: { $in }, body: regex })`, then\n * map the matched revisions back to their pages.\n * The two result sets are merged (path hits first, body-only hits after),\n * de-duplicated by page id, then paged with skip/limit. `total` is the\n * size of the merged set (capped by CANDIDATE_CAP on the body side).\n *\n * Ranking / score (spec open questions 3 & 4): a simple 2-value score —\n * path/title hits score 2, body-only hits score 1 — expressed by ordering\n * path hits ahead of body hits. No function score / field boosting.\n *\n * Snippet (spec open question 3): best-effort substring around the first\n * match, with the matched span wrapped in `<mark>`. NOT HTML-escaped — the\n * search route passes snippets through verbatim and the web client\n * sanitises before render (same contract as the ES driver's highlight).\n */\n\nimport type { PluginContext, SearchableDoc, SearchDriver, SearchHit, SearchHits, SearchQuery } from '@crowi/plugin-api';\n\nimport { buildPageFilter, clampLimit, keywordRegex, pageToSkip } from './query-builder';\n\n/**\n * Upper bound on candidate pages scanned by the body pass. A non-anchored\n * `$regex` cannot use an index, so we bound the scan: beyond this many\n * visible candidates the body pass is truncated and path/title hits are\n * preferred. Generous for the small / mid deployments this driver targets;\n * larger installs should run the Elasticsearch driver.\n */\nexport const CANDIDATE_CAP = 5000;\n\n/** Characters of context to include on each side of a snippet match. */\nconst SNIPPET_RADIUS = 60;\n\ninterface PageDoc {\n _id: { toString(): string };\n path: string;\n revision?: { toString(): string } | null;\n}\n\n/**\n * Minimal Mongoose model surface the driver touches. `ctx.model()` returns\n * `unknown`; we narrow to just the query methods we call.\n */\ninterface PageModelLike {\n find(filter: Record<string, unknown>): {\n select(projection: string): {\n limit(n: number): {\n lean(): { exec(): Promise<PageDoc[]> };\n };\n };\n };\n}\n\ninterface RevisionModelLike {\n find(filter: Record<string, unknown>): {\n select(projection: string): {\n lean(): { exec(): Promise<Array<{ _id: { toString(): string }; body: string }>> };\n };\n };\n}\n\n/**\n * Build a best-effort snippet: a window around the first case-insensitive\n * match of `keyword` in `text`, with the match wrapped in `<mark>`.\n * Returns undefined when the text has no match (e.g. a path-only hit).\n */\nexport function buildSnippet(text: string, keyword: RegExp): string | undefined {\n const match = keyword.exec(text);\n if (!match) return undefined;\n const start = Math.max(0, match.index - SNIPPET_RADIUS);\n const end = Math.min(text.length, match.index + match[0].length + SNIPPET_RADIUS);\n const before = text.slice(start, match.index);\n const hit = text.slice(match.index, match.index + match[0].length);\n const after = text.slice(match.index + match[0].length, end);\n const prefix = start > 0 ? '…' : '';\n const suffix = end < text.length ? '…' : '';\n return `${prefix}${before}<mark>${hit}</mark>${after}${suffix}`;\n}\n\nexport function createMongoSearchDriver(ctx: PluginContext): SearchDriver {\n const Page = ctx.model('Page') as PageModelLike;\n const Revision = ctx.model('Revision') as RevisionModelLike;\n\n return {\n // Live-regex driver: nothing to index. Kept as resolved no-ops so the\n // page-saved hook can call them unconditionally without error.\n async index(_doc: SearchableDoc): Promise<void> {\n // no-op\n },\n async remove(_id: string): Promise<void> {\n // no-op\n },\n\n async query(q: SearchQuery): Promise<SearchHits> {\n const startedAt = Date.now();\n const keyword = keywordRegex(q.q);\n const limit = clampLimit(q.limit);\n const skip = pageToSkip(q.page, limit);\n\n // Empty / whitespace-only query: no hits (avoid a full scan).\n if (!keyword) {\n return { total: 0, hits: [], took: Date.now() - startedAt };\n }\n\n const type = q.grants?.types && q.grants.types.length > 0 ? q.grants.types[0] : undefined;\n const scope = { keyword, viewer: q.viewer, type, pathPrefix: q.pathPrefix } as const;\n const revIdOf = (page: PageDoc): string | null => (page.revision ? page.revision.toString() : null);\n\n // PATH pass (pages whose path matches the keyword) and BODY candidate\n // pass (viewer-visible pages whose revision we will match) are\n // independent Mongo reads — run them together.\n const [pathPages, candidatePages] = await Promise.all([\n Page.find(buildPageFilter({ ...scope, matchPath: true }))\n .select('_id path revision')\n .limit(CANDIDATE_CAP)\n .lean()\n .exec(),\n Page.find(buildPageFilter({ ...scope, matchPath: false }))\n .select('_id path revision')\n .limit(CANDIDATE_CAP)\n .lean()\n .exec(),\n ]);\n\n const pathHitIds = new Set(pathPages.map((p) => p._id.toString()));\n\n // Map currentRevision -> page, skipping pages already matched by path\n // (those are stronger hits) and pages with no revision pointer.\n const revisionToPage = new Map<string, PageDoc>();\n for (const page of candidatePages) {\n if (pathHitIds.has(page._id.toString())) continue;\n const revId = revIdOf(page);\n if (revId) revisionToPage.set(revId, page);\n }\n\n // Bulk body match. Keep the matched bodies so the snippet pass can use\n // them directly instead of re-fetching the same revisions.\n const bodyHits: PageDoc[] = [];\n const bodyById = new Map<string, string>();\n if (revisionToPage.size > 0) {\n const revisions = await Revision.find({ _id: { $in: Array.from(revisionToPage.keys()) }, body: { $regex: keyword } })\n .select('_id body')\n .lean()\n .exec();\n for (const rev of revisions) {\n const page = revisionToPage.get(rev._id.toString());\n if (page) {\n bodyHits.push(page);\n bodyById.set(rev._id.toString(), rev.body);\n }\n }\n }\n\n // Merge: path hits (score 2) first, body-only hits (score 1) after.\n const merged: Array<{ page: PageDoc; score: number }> = [\n ...pathPages.map((page) => ({ page, score: 2 })),\n ...bodyHits.map((page) => ({ page, score: 1 })),\n ];\n\n const total = merged.length;\n const windowed = merged.slice(skip, skip + limit);\n\n const hits: SearchHit[] = windowed.map(({ page, score }) => {\n const revId = revIdOf(page);\n // Path hits always snippet off the path (the match is in the title);\n // body-only hits read from the body captured by the body pass.\n const body = revId ? bodyById.get(revId) : undefined;\n // Prefer a path snippet (the match is in the title); fall back to\n // the body for body-only hits.\n const snippet = buildSnippet(page.path, keyword) ?? (body ? buildSnippet(body, keyword) : undefined);\n return {\n id: page._id.toString(),\n path: page.path,\n score,\n ...(snippet ? { snippet } : {}),\n };\n });\n\n return { total, hits, took: Date.now() - startedAt };\n },\n\n // rebuild intentionally omitted — there is no persistent index.\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@crowi/plugin-search-mongo",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Infra-free MongoDB $regex search driver for Crowi 2.0.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "peerDependencies": {
23
+ "zod": "^4.4.3"
24
+ },
25
+ "dependencies": {
26
+ "@crowi/plugin-api": "^0.1.0-alpha.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/jest": "^29.5.14",
30
+ "@types/node": "^24",
31
+ "jest": "^29.7.0",
32
+ "mongodb-memory-server": "^10.1.4",
33
+ "mongoose": "^8.24.0",
34
+ "ts-jest": "^29.3.4",
35
+ "tsup": "^8.3.5",
36
+ "typescript": "^5.8.3",
37
+ "zod": "^4.4.3",
38
+ "@crowi/plugin-api": "0.1.0-alpha.0",
39
+ "@crowi/tsconfig": "0.1.0-alpha.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "dev": "tsup --watch --no-clean",
44
+ "type-check": "tsc --noEmit",
45
+ "test": "jest"
46
+ }
47
+ }