@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 +21 -0
- package/README.md +43 -0
- package/dist/index.d.mts +166 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +246 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +204 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +47 -0
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).
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|