pgkiln 0.68.0 → 0.69.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/db/migrations/135_fulltext_search.sql +60 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +7 -0
- package/docs/guide/04-pages-and-regions.md +46 -1
- package/docs/guide/12-development.md +10 -0
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_71_fulltext_search.sql +43 -0
- package/package.json +1 -1
- package/src/builder/advisor.ts +39 -3
- package/src/builder/components.ts +14 -1
- package/src/builder/fulltext.ts +160 -0
- package/src/builder/i18n/de.ts +20 -0
- package/src/builder/i18n/en.ts +21 -0
- package/src/builder/i18n/es.ts +20 -0
- package/src/builder/i18n/fr.ts +20 -0
- package/src/builder/i18n/it.ts +20 -0
- package/src/builder/i18n/ja.ts +20 -0
- package/src/builder/i18n/ko.ts +20 -0
- package/src/builder/i18n/nl.ts +20 -0
- package/src/builder/i18n/pt.ts +20 -0
- package/src/builder/i18n/zh-cn.ts +20 -0
- package/src/builder/i18n/zh-tw.ts +20 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/sql.ts +3 -0
- package/src/builder/table-editor.ts +1 -0
- package/src/i18n/ar.ts +1 -0
- package/src/i18n/cs.ts +1 -0
- package/src/i18n/da.ts +1 -0
- package/src/i18n/de.ts +1 -0
- package/src/i18n/el.ts +1 -0
- package/src/i18n/es.ts +1 -0
- package/src/i18n/fi.ts +1 -0
- package/src/i18n/fr.ts +1 -0
- package/src/i18n/he.ts +1 -0
- package/src/i18n/it.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/nb.ts +1 -0
- package/src/i18n/pl.ts +1 -0
- package/src/i18n/pt.ts +1 -0
- package/src/i18n/ru.ts +1 -0
- package/src/i18n/sv.ts +1 -0
- package/src/i18n/tr.ts +1 -0
- package/src/i18n/uk.ts +1 -0
- package/src/i18n/zh.ts +1 -0
- package/src/i18n.ts +2 -0
- package/src/runtime/report.ts +26 -2
- package/src/runtime/search.ts +184 -21
package/src/runtime/search.ts
CHANGED
|
@@ -10,8 +10,15 @@
|
|
|
10
10
|
// - sql: a SELECT (binds allowed), wrapped as a subquery;
|
|
11
11
|
// - list: the entries of a list (Shared Components → Lists) the user may see.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
// columns (ILIKE, % and _ escaped, the text as a query
|
|
13
|
+
// Search type standard: every word of the search text must be found in one
|
|
14
|
+
// of the searchable columns (ILIKE, % and _ escaped, the text as a query
|
|
15
|
+
// parameter). Search type fulltext (135): the source's tsvector matched
|
|
16
|
+
// with websearch_to_tsquery (quotes, or, -word), the best match first
|
|
17
|
+
// (ts_rank_cd), title, subtitle and description highlighted by ts_headline
|
|
18
|
+
// (between private-use markers that only then become <mark>, everything
|
|
19
|
+
// else escaped); the text search configuration is checked against
|
|
20
|
+
// pg_ts_config and passed as a parameter. With pg_trgm installed, a
|
|
21
|
+
// configuration may suggest the closest words when nothing is found. The
|
|
15
22
|
// queries run as the application's role in the page's transaction, each in a
|
|
16
23
|
// savepoint, so one configuration that fails shows its error and the others
|
|
17
24
|
// still answer. Without JavaScript the field is a GET form; app.js searches
|
|
@@ -42,6 +49,14 @@ export interface SearchConfig {
|
|
|
42
49
|
icon: string | null;
|
|
43
50
|
link: { page?: number; items?: Record<string, string> } | null;
|
|
44
51
|
max_results: number;
|
|
52
|
+
/** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search (later: vector, hybrid) */
|
|
53
|
+
search_type?: 'standard' | 'fulltext';
|
|
54
|
+
/** (135) fulltext: a tsvector column or expression; empty: to_tsvector over the searchable columns */
|
|
55
|
+
fts_vector?: string | null;
|
|
56
|
+
/** (135) fulltext: the text search configuration; empty: the application language's */
|
|
57
|
+
fts_config?: string | null;
|
|
58
|
+
/** (135) with pg_trgm: suggest the closest words when nothing is found */
|
|
59
|
+
did_you_mean?: boolean;
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
export interface SearchRegionConfig {
|
|
@@ -58,6 +73,54 @@ const MAX_TEXT = 200;
|
|
|
58
73
|
const COLUMN = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
59
74
|
const TABLE = /^([A-Za-z_][A-Za-z0-9_$]{0,62}\.)?[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
60
75
|
|
|
76
|
+
/** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
|
|
77
|
+
type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
|
|
78
|
+
|
|
79
|
+
/** (135) The search types (later: vector, hybrid). */
|
|
80
|
+
export const SEARCH_TYPES = ['standard', 'fulltext'] as const;
|
|
81
|
+
const TS_CONFIG = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
82
|
+
|
|
83
|
+
/** (135) The text search configuration of a language: pgkiln's languages that PostgreSQL has a stemmer for. */
|
|
84
|
+
const TS_BY_LANGUAGE: Record<string, string> = {
|
|
85
|
+
ar: 'arabic', ca: 'catalan', da: 'danish', de: 'german', el: 'greek', en: 'english', es: 'spanish', eu: 'basque', fi: 'finnish',
|
|
86
|
+
fr: 'french', ga: 'irish', hi: 'hindi', hu: 'hungarian', hy: 'armenian', id: 'indonesian', it: 'italian', lt: 'lithuanian',
|
|
87
|
+
nb: 'norwegian', ne: 'nepali', nl: 'dutch', nn: 'norwegian', no: 'norwegian', pt: 'portuguese', ro: 'romanian', ru: 'russian',
|
|
88
|
+
sr: 'serbian', sv: 'swedish', ta: 'tamil', tr: 'turkish', yi: 'yiddish',
|
|
89
|
+
};
|
|
90
|
+
export const tsConfigFor = (language: string | null | undefined) => TS_BY_LANGUAGE[String(language ?? '').toLowerCase().split(/[-_]/)[0]] ?? 'simple';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* (135) The text search configuration to use: the one asked for, else the language's, if it exists in
|
|
94
|
+
* pg_ts_config; else simple. Only a name that exists is returned (and it is still passed as a parameter).
|
|
95
|
+
*/
|
|
96
|
+
export async function textSearchConfig(db: Queryable, wanted: string | null | undefined, language: string | null | undefined): Promise<string> {
|
|
97
|
+
const names = [wanted, tsConfigFor(language)].filter((x): x is string => !!x && TS_CONFIG.test(x));
|
|
98
|
+
if (!names.length) return 'simple';
|
|
99
|
+
const found = (await db.query('select cfgname from pg_catalog.pg_ts_config where cfgname = any($1::text[])', [names])).rows.map((r: { cfgname: string }) => r.cfgname);
|
|
100
|
+
return names.find((n) => found.includes(n)) ?? 'simple';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** (135) The schema of pg_trgm when it is installed (its functions are called schema-qualified), else null. */
|
|
104
|
+
export async function trigramSchema(db: Queryable): Promise<string | null> {
|
|
105
|
+
return (await db.query(`select n.nspname as s from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_trgm'`)).rows[0]?.s ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** (135) The markers ts_headline puts around a matched word (private-use characters, removed from the text first). */
|
|
109
|
+
export const HL_START = '\uE000';
|
|
110
|
+
export const HL_STOP = '\uE001';
|
|
111
|
+
|
|
112
|
+
/** (135) A ts_headline result as HTML: everything escaped, only the text between the markers in <mark>. */
|
|
113
|
+
export function headlined(value: string): Raw {
|
|
114
|
+
const parts: Raw[] = [];
|
|
115
|
+
let last = 0;
|
|
116
|
+
for (const m of value.matchAll(/\uE000([^\uE000\uE001]*)\uE001/g)) {
|
|
117
|
+
parts.push(html`${value.slice(last, m.index).replace(/[\uE000\uE001]/g, '')}${m[1] ? html`<mark>${m[1]}</mark>` : ''}`);
|
|
118
|
+
last = m.index! + m[0].length;
|
|
119
|
+
}
|
|
120
|
+
parts.push(html`${value.slice(last).replace(/[\uE000\uE001]/g, '')}`);
|
|
121
|
+
return html`${parts}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
61
124
|
/** The columns a configuration names (for the Advisor's check of its source). */
|
|
62
125
|
export const configColumns = (c: Pick<SearchConfig, 'title_column' | 'subtitle_column' | 'description_column' | 'badge_column' | 'searchable_columns' | 'icon'>) =>
|
|
63
126
|
[...new Set([c.title_column, c.subtitle_column, c.description_column, c.badge_column, ...(c.searchable_columns ?? []), /^#(.+)#$/.exec(c.icon ?? '')?.[1]]
|
|
@@ -87,6 +150,12 @@ export function searchConfigProblems(c: Partial<SearchConfig>): string[] {
|
|
|
87
150
|
if (c.source_type === 'table' && !TABLE.test((c.source ?? '').trim())) out.push('The source of a table configuration is a table or view name, e.g. sales.customers.');
|
|
88
151
|
if (c.icon && !/^(#[A-Za-z_][A-Za-z0-9_$]*#|[a-z0-9-]{1,40})$/.test(c.icon)) out.push('The icon is an icon name, or #COLUMN# for a column that holds icon names.');
|
|
89
152
|
if (c.link && (typeof c.link !== 'object' || !Number.isInteger(c.link.page))) out.push('The link is {"page": 3, "items": {"P3_ID": "#ID#"}}.');
|
|
153
|
+
if (c.search_type && !SEARCH_TYPES.includes(c.search_type)) out.push(`The search type is one of ${SEARCH_TYPES.join(', ')}.`);
|
|
154
|
+
if (c.search_type === 'fulltext') {
|
|
155
|
+
if (c.source_type === 'list') out.push('Full-text search needs a table or SQL source.');
|
|
156
|
+
if (c.fts_config && !TS_CONFIG.test(c.fts_config)) out.push('The text search configuration is a name, e.g. english, dutch or simple.');
|
|
157
|
+
if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
|
|
158
|
+
}
|
|
90
159
|
return out;
|
|
91
160
|
}
|
|
92
161
|
|
|
@@ -97,7 +166,8 @@ async function configsOf(ctx: PageContext, r: Region): Promise<SearchConfig[]> {
|
|
|
97
166
|
return (
|
|
98
167
|
await runtime.query<SearchConfig>(
|
|
99
168
|
`select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
|
|
100
|
-
badge_column, icon, link, max_results
|
|
169
|
+
badge_column, icon, link, max_results,
|
|
170
|
+
search_type, fts_vector, fts_config, did_you_mean
|
|
101
171
|
from meta.search_config
|
|
102
172
|
where app_id = $1 and meta.build_option_on(app_id, build_option) and (cardinality($2::text[]) = 0 or name = any($2))
|
|
103
173
|
order by seq, name`,
|
|
@@ -113,6 +183,8 @@ interface Hit {
|
|
|
113
183
|
badge: string | null;
|
|
114
184
|
icon: string | null;
|
|
115
185
|
href: Raw | null;
|
|
186
|
+
/** (135) full-text search: title, subtitle and description as highlighted by ts_headline */
|
|
187
|
+
headlines?: { title: Raw; subtitle: Raw | null; description: Raw | null };
|
|
116
188
|
}
|
|
117
189
|
|
|
118
190
|
const text = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
@@ -124,21 +196,58 @@ function col(row: Record<string, unknown>, name: string | null) {
|
|
|
124
196
|
return key === undefined ? null : text(row[key]);
|
|
125
197
|
}
|
|
126
198
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let from: string;
|
|
199
|
+
/** The columns a configuration searches: its searchable columns, else title, subtitle and description. */
|
|
200
|
+
const searchedColumns = (c: SearchConfig) =>
|
|
201
|
+
(c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter((x): x is string => !!x);
|
|
202
|
+
|
|
203
|
+
/** The FROM of a table or SQL configuration (the source as `t`). */
|
|
204
|
+
async function sourceFrom(ctx: PageContext, c: SearchConfig) {
|
|
134
205
|
if (c.source_type === 'table') {
|
|
135
206
|
const table = (await ctx.client!.query('select $1::regclass::text as t', [c.source])).rows[0].t as string;
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
207
|
+
return `${table} t`;
|
|
208
|
+
}
|
|
209
|
+
return `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* (135) The tsvector of a full-text configuration as SQL over `t`: its column, its expression, or to_tsvector
|
|
214
|
+
* over the searchable columns (`cfg` is the placeholder of the text search configuration).
|
|
215
|
+
*/
|
|
216
|
+
export function tsvectorSql(c: Pick<SearchConfig, 'fts_vector' | 'searchable_columns' | 'title_column' | 'subtitle_column' | 'description_column'>, cfg: string) {
|
|
217
|
+
const v = (c.fts_vector ?? '').trim();
|
|
218
|
+
if (v) return COLUMN.test(v) ? `t.${ident(v)}` : `(${v})`;
|
|
219
|
+
const cols = searchedColumns(c as SearchConfig);
|
|
220
|
+
return `to_tsvector(${cfg}, ${cols.map((x) => `coalesce(t.${ident(x)}::text, '')`).join(` || ' ' || `) || `''`})`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One configuration's results on a table or a query: up to max_results, and whether there are more. */
|
|
224
|
+
async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], query: string): Promise<{ hits: Hit[]; more: boolean }> {
|
|
225
|
+
const from = await sourceFrom(ctx, c);
|
|
226
|
+
let rows: Record<string, unknown>[];
|
|
227
|
+
const fulltext = c.search_type === 'fulltext';
|
|
228
|
+
if (fulltext) {
|
|
229
|
+
// $1 the text search configuration (a name from pg_ts_config), $2 the user's text, $3 the headline options,
|
|
230
|
+
// $4 the marker characters (taken out of the values first, so only ts_headline's markers become markup)
|
|
231
|
+
const cfg = await textSearchConfig(ctx.client!, c.fts_config, ctx.app.language);
|
|
232
|
+
const vec = tsvectorSql(c, '$1::regconfig');
|
|
233
|
+
const hl = (column: string | null, alias: string, options: string) =>
|
|
234
|
+
column ? `, ts_headline($1::regconfig, translate(s.${ident(column)}::text, $4, ''), "pgkiln$q".query, ${options}) as "pgkiln$${alias}"` : '';
|
|
235
|
+
const sql = `select s.*${hl(c.title_column, 'title', '$3::text || \', HighlightAll=true\'')}${hl(c.subtitle_column, 'subtitle', '$3::text || \', HighlightAll=true\'')}${hl(c.description_column, 'description', '$3::text || \', MaxWords=35, MinWords=15, MaxFragments=2, FragmentDelimiter=" … "\'')}
|
|
236
|
+
from (select t.*, ts_rank_cd(${vec}, "pgkiln$q".query) as "pgkiln$rank"
|
|
237
|
+
from ${from}, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
|
|
238
|
+
where ${vec} @@ "pgkiln$q".query
|
|
239
|
+
order by "pgkiln$rank" desc${c.title_column ? `, t.${ident(c.title_column)}` : ''}
|
|
240
|
+
limit ${c.max_results + 1}) s, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
|
|
241
|
+
order by s."pgkiln$rank" desc${c.title_column ? `, s.${ident(c.title_column)}` : ''}`;
|
|
242
|
+
rows = (await ctx.client!.query(sql, [cfg, query, `StartSel="${HL_START}", StopSel="${HL_STOP}"`, HL_START + HL_STOP])).rows;
|
|
243
|
+
} else {
|
|
244
|
+
const searched = searchedColumns(c);
|
|
245
|
+
const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
|
|
246
|
+
const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
|
|
247
|
+
rows = (await ctx.client!.query(sql, words.map(likePattern))).rows;
|
|
248
|
+
}
|
|
141
249
|
const iconCol = /^#(.+)#$/.exec(c.icon ?? '')?.[1] ?? null;
|
|
250
|
+
const h = (row: Record<string, unknown>, alias: string) => (typeof row[`pgkiln$${alias}`] === 'string' ? headlined(row[`pgkiln$${alias}`] as string) : null);
|
|
142
251
|
return {
|
|
143
252
|
more: rows.length > c.max_results,
|
|
144
253
|
hits: rows.slice(0, c.max_results).map((row) => ({
|
|
@@ -151,10 +260,37 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): P
|
|
|
151
260
|
c.link && Number.isInteger(c.link.page)
|
|
152
261
|
? linkAttrs(ctx, c.link.page!, fillItems(c.link.items, (name) => col(row, name) ?? undefined))
|
|
153
262
|
: null,
|
|
263
|
+
...(fulltext ? { headlines: { title: h(row, 'title') ?? html`${col(row, c.title_column) ?? ''}`, subtitle: h(row, 'subtitle'), description: h(row, 'description') } } : {}),
|
|
154
264
|
})),
|
|
155
265
|
};
|
|
156
266
|
}
|
|
157
267
|
|
|
268
|
+
/**
|
|
269
|
+
* (135) "Did you mean": with pg_trgm installed, each search word that is not a word of the configuration's
|
|
270
|
+
* searched columns replaced by the most similar one (similarity 0.3 or more), from the first 10,000 rows of the
|
|
271
|
+
* source. Null when there is nothing better to suggest.
|
|
272
|
+
*/
|
|
273
|
+
async function suggestion(ctx: PageContext, c: SearchConfig, words: string[], trgm: string): Promise<string | null> {
|
|
274
|
+
const cols = searchedColumns(c);
|
|
275
|
+
if (!cols.length || !words.length) return null;
|
|
276
|
+
const sim = `${ident(trgm)}.similarity`;
|
|
277
|
+
const res = await ctx.client!.query<{ word: string; best: string | null; known: boolean }>(
|
|
278
|
+
`with v as (select distinct w from (select concat_ws(' ', ${cols.map((x) => `t.${ident(x)}::text`).join(', ')}) as doc from ${await sourceFrom(ctx, c)} limit 10000) d,
|
|
279
|
+
regexp_split_to_table(lower(d.doc), '[^[:alnum:]]+') w where length(w) between 2 and 40)
|
|
280
|
+
select u.word, exists (select from v where v.w = lower(u.word)) as known,
|
|
281
|
+
(select v.w from v where ${sim}(v.w, lower(u.word)) >= 0.3 order by ${sim}(v.w, lower(u.word)) desc, v.w limit 1) as best
|
|
282
|
+
from unnest($1::text[]) with ordinality u(word, n) order by u.n`,
|
|
283
|
+
[words],
|
|
284
|
+
);
|
|
285
|
+
let changed = false;
|
|
286
|
+
const out = res.rows.map((r) => {
|
|
287
|
+
if (r.known || !r.best) return r.word;
|
|
288
|
+
changed = true;
|
|
289
|
+
return r.best;
|
|
290
|
+
});
|
|
291
|
+
return changed ? out.join(' ') : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
158
294
|
/** One configuration's results on a list: the entries (and their children) the user may see. */
|
|
159
295
|
async function searchList(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
|
|
160
296
|
const nodes = (await listTree(ctx, c.source)) ?? [];
|
|
@@ -204,18 +340,23 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
|
|
|
204
340
|
const minLength = Number.isInteger(conf.min_length) && conf.min_length! > 0 ? conf.min_length! : 1;
|
|
205
341
|
if (!words.length || query.trim().length < minLength) return html``;
|
|
206
342
|
const groups: Raw[] = [];
|
|
207
|
-
|
|
343
|
+
const configs = await configsOf(ctx, r);
|
|
344
|
+
for (const c of configs) {
|
|
208
345
|
let body: Raw;
|
|
209
346
|
try {
|
|
210
347
|
const problems = searchConfigProblems(c);
|
|
211
348
|
if (problems.length) throw new Error(`Search configuration ${c.name}: ${problems.join(' ')}`);
|
|
212
|
-
const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words)));
|
|
349
|
+
const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words, query.trim())));
|
|
213
350
|
if (!hits.length) continue;
|
|
214
351
|
body = html`<ul class="search-hits">${hits.map((h) => {
|
|
352
|
+
// (135) full-text results: ts_headline's highlights (stemmed words: "running" marks "run"); else the words
|
|
353
|
+
const title = h.headlines?.title ?? marked(h.title, words);
|
|
354
|
+
const subtitle = h.subtitle ? (h.headlines?.subtitle ?? marked(h.subtitle, words)) : null;
|
|
355
|
+
const description = h.description ? (h.headlines?.description ?? marked(h.description, words)) : null;
|
|
215
356
|
const inner = html`${h.icon ? html`<span class="search-icon">${icon(h.icon)}</span>` : ''}
|
|
216
|
-
<span class="search-text"><span class="search-title">${
|
|
217
|
-
|
|
218
|
-
|
|
357
|
+
<span class="search-text"><span class="search-title">${title}</span>${
|
|
358
|
+
subtitle ? html`<span class="search-subtitle">${subtitle}</span>` : ''}${
|
|
359
|
+
description ? html`<span class="search-description">${description}</span>` : ''}</span>
|
|
219
360
|
${h.badge ? html`<span class="badge-pill">${marked(h.badge, words)}</span>` : ''}`;
|
|
220
361
|
return html`<li>${h.href ? html`<a class="search-hit" ${h.href}>${inner}</a>` : html`<div class="search-hit">${inner}</div>`}</li>`;
|
|
221
362
|
})}</ul>${more ? html`<p class="muted search-more">${ctx.locale.t('region.first_rows', { n: String(c.max_results) })}</p>` : ''}`;
|
|
@@ -226,7 +367,29 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
|
|
|
226
367
|
groups.push(html`<section class="search-group"><h3>${ctx.locale.tr(c.label)}</h3>${body}</section>`);
|
|
227
368
|
}
|
|
228
369
|
// (a configuration that failed shows its error as a group of its own)
|
|
229
|
-
|
|
370
|
+
if (groups.length) return html`${groups}`;
|
|
371
|
+
return html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>${await didYouMean(ctx, r, configs, words)}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** (135) Nothing found: the first suggestion of a configuration that asks for one (pg_trgm installed), as a link. */
|
|
375
|
+
async function didYouMean(ctx: PageContext, r: Region, configs: SearchConfig[], words: string[]): Promise<Raw | ''> {
|
|
376
|
+
const asking = configs.filter((c) => c.did_you_mean && c.source_type !== 'list' && !searchConfigProblems(c).length);
|
|
377
|
+
if (!asking.length) return '';
|
|
378
|
+
const trgm = await trigramSchema(ctx.client!);
|
|
379
|
+
if (!trgm) return '';
|
|
380
|
+
for (const c of asking) {
|
|
381
|
+
// a configuration that fails here suggests nothing (its error was shown, or there was none to show)
|
|
382
|
+
const s = await savepoint(ctx.client!, () => suggestion(ctx, c, words, trgm)).catch(() => null);
|
|
383
|
+
if (!s) continue;
|
|
384
|
+
const conf = (r.config ?? {}) as SearchRegionConfig;
|
|
385
|
+
const label = ctx.locale.t('search.did_you_mean');
|
|
386
|
+
// the region's own field: a link that searches for the suggestion; a page item: the words only
|
|
387
|
+
if (typeof conf.item === 'string' && conf.item) return html`<p class="muted search-suggestion">${label} <b>${s}</b></p>`;
|
|
388
|
+
const params = new URLSearchParams([...ctx.params].filter(([k]) => k !== searchParam(r)));
|
|
389
|
+
params.set(searchParam(r), s);
|
|
390
|
+
return html`<p class="muted search-suggestion">${label} <a href="${ctx.base}/${ctx.page.page_no}?${params}">${s}</a></p>`;
|
|
391
|
+
}
|
|
392
|
+
return '';
|
|
230
393
|
}
|
|
231
394
|
|
|
232
395
|
export async function renderSearch(ctx: PageContext, r: Region): Promise<Raw> {
|