pgkiln 0.67.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 +5 -5
- package/docs/guide/03-builder.md +12 -2
- package/docs/guide/04-pages-and-regions.md +46 -1
- package/docs/guide/12-development.md +17 -4
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_71_fulltext_search.sql +43 -0
- package/package.json +1 -1
- package/public/builder.css +15 -0
- 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 +22 -1
- package/src/builder/i18n/es.ts +20 -0
- package/src/builder/i18n/fr.ts +20 -0
- package/src/builder/i18n/it.ts +1914 -0
- package/src/builder/i18n/ja.ts +1914 -0
- package/src/builder/i18n/ko.ts +1914 -0
- package/src/builder/i18n/nl.ts +20 -0
- package/src/builder/i18n/pt.ts +20 -0
- package/src/builder/i18n/zh-cn.ts +1914 -0
- package/src/builder/i18n/zh-tw.ts +1914 -0
- package/src/builder/i18n.ts +13 -3
- 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/builder/i18n.ts
CHANGED
|
@@ -19,20 +19,30 @@ import { de } from './i18n/de.ts';
|
|
|
19
19
|
import { en } from './i18n/en.ts';
|
|
20
20
|
import { es } from './i18n/es.ts';
|
|
21
21
|
import { fr } from './i18n/fr.ts';
|
|
22
|
+
import { it } from './i18n/it.ts';
|
|
23
|
+
import { ja } from './i18n/ja.ts';
|
|
24
|
+
import { ko } from './i18n/ko.ts';
|
|
22
25
|
import { nl } from './i18n/nl.ts';
|
|
23
26
|
import { pt } from './i18n/pt.ts';
|
|
27
|
+
import { zhCN } from './i18n/zh-cn.ts';
|
|
28
|
+
import { zhTW } from './i18n/zh-tw.ts';
|
|
24
29
|
|
|
25
30
|
export type BuilderKey = keyof typeof en;
|
|
26
31
|
export type BuilderMessages = Record<BuilderKey, string>;
|
|
27
32
|
export type Params = Record<string, string | number | null | undefined>;
|
|
28
33
|
|
|
29
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The builder's languages, in the order of the language menu and the sign-in page's links (names in their own
|
|
36
|
+
* language): APEX's ten (sprint 134, SEC-5: Italiano, both Chinese scripts, Japanese, Korean) and Nederlands.
|
|
37
|
+
* The codes are BCP 47 tags, used as they are in <html lang>, the cookie and meta.developer.language.
|
|
38
|
+
*/
|
|
30
39
|
export const BUILDER_LANGUAGES = [
|
|
31
|
-
['de', 'Deutsch'], ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pt', 'Português'],
|
|
40
|
+
['de', 'Deutsch'], ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['it', 'Italiano'], ['nl', 'Nederlands'], ['pt', 'Português'],
|
|
41
|
+
['zh-CN', '中文 (简体)'], ['zh-TW', '中文 (繁體)'], ['ja', '日本語'], ['ko', '한국어'],
|
|
32
42
|
] as const;
|
|
33
43
|
export type BuilderLang = (typeof BUILDER_LANGUAGES)[number][0];
|
|
34
44
|
|
|
35
|
-
const MESSAGES: Record<BuilderLang, Readonly<Record<string, string>>> = { en, de, es, fr, nl, pt };
|
|
45
|
+
const MESSAGES: Record<BuilderLang, Readonly<Record<string, string>>> = { en, de, es, fr, it, nl, pt, 'zh-CN': zhCN, 'zh-TW': zhTW, ja, ko };
|
|
36
46
|
|
|
37
47
|
/** The cookie with the sign-in page's language on this device (the builder's path only). */
|
|
38
48
|
export const LANG_COOKIE = 'pgkiln_blang';
|
|
@@ -32,6 +32,7 @@ export function objectMenu(schema: string, o: MenuObj): Raw {
|
|
|
32
32
|
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
|
|
33
33
|
${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', 'Security…') : ''}
|
|
34
34
|
${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
|
|
35
|
+
${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add full-text search…') : ''}
|
|
35
36
|
${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
|
|
36
37
|
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
|
|
37
38
|
${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
|
package/src/builder/sql.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { sqlUtilityRoutes } from './sql-utilities.ts';
|
|
|
19
19
|
import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
|
|
20
20
|
import { objectActionRoutes } from './object-actions.ts';
|
|
21
21
|
import { tableEditorRoutes } from './table-editor.ts';
|
|
22
|
+
import { fulltextRoutes } from './fulltext.ts';
|
|
22
23
|
import { dataSecurityRoutes } from './data-security.ts';
|
|
23
24
|
import { dataModelerRoutes } from './data-modeler.ts';
|
|
24
25
|
import { autoRestBuilderRoutes } from './autorest.ts';
|
|
@@ -134,6 +135,8 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
134
135
|
await app.register(objectActionRoutes);
|
|
135
136
|
// (sprint 95) Table Properties: create, edit or copy a table in a form
|
|
136
137
|
await app.register(tableEditorRoutes);
|
|
138
|
+
// (sprint 135, AP-2) Table Properties › Add full-text search
|
|
139
|
+
await app.register(fulltextRoutes);
|
|
137
140
|
await app.register(dataSecurityRoutes);
|
|
138
141
|
// (sprint 98) the Data Modeler: relational diagrams, their DDL and data dictionary
|
|
139
142
|
await app.register(dataModelerRoutes);
|
|
@@ -142,6 +142,7 @@ function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schem
|
|
|
142
142
|
<button class="btn" name="mode" value="preview">${icon('eye')} ${tx('te.preview')}</button>
|
|
143
143
|
<button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('te.apply')}</button>
|
|
144
144
|
<button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
|
|
145
|
+
${editing ? html`<a class="btn" href="${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: opts.ref })}">${icon('search')} ${tx('fts.add')}…</a>` : ''}
|
|
145
146
|
<a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">${tx('common.cancel')}</a>
|
|
146
147
|
</div></form>`;
|
|
147
148
|
}
|
package/src/i18n/ar.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const ar: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "جارٍ تحميل المزيد…",
|
|
425
425
|
'da.wait': "يرجى الانتظار…",
|
|
426
426
|
'lov.no_rows': "لم يُعثر على شيء.",
|
|
427
|
+
'search.did_you_mean': "هل تقصد:",
|
|
427
428
|
'error.lov_value': "{label}: اختر قيمة من القائمة.",
|
|
428
429
|
'item.yes': "نعم",
|
|
429
430
|
'item.no': "لا",
|
package/src/i18n/cs.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const cs: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Načítání dalších…",
|
|
425
425
|
'da.wait': "Čekejte prosím…",
|
|
426
426
|
'lov.no_rows': "Nic nenalezeno.",
|
|
427
|
+
'search.did_you_mean': "Měli jste na mysli:",
|
|
427
428
|
'error.lov_value': "{label}: vyberte hodnotu ze seznamu.",
|
|
428
429
|
'item.yes': "Ano",
|
|
429
430
|
'item.no': "Ne",
|
package/src/i18n/da.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const da: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Indlæser flere…",
|
|
425
425
|
'da.wait': "Vent venligst…",
|
|
426
426
|
'lov.no_rows': "Intet fundet.",
|
|
427
|
+
'search.did_you_mean': "Mente du:",
|
|
427
428
|
'error.lov_value': "{label}: vælg en værdi på listen.",
|
|
428
429
|
'item.yes': "Ja",
|
|
429
430
|
'item.no': "Nej",
|
package/src/i18n/de.ts
CHANGED
|
@@ -421,6 +421,7 @@ export const de: Record<MessageKey, string> = {
|
|
|
421
421
|
'lov.loading': 'Weitere werden geladen…',
|
|
422
422
|
'da.wait': 'Bitte warten…',
|
|
423
423
|
'lov.no_rows': 'Nichts gefunden.',
|
|
424
|
+
'search.did_you_mean': "Meinten Sie:",
|
|
424
425
|
'error.lov_value': '{label}: Wählen Sie einen Wert aus der Liste.',
|
|
425
426
|
'item.yes': 'Ja',
|
|
426
427
|
'item.no': 'Nein',
|
package/src/i18n/el.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const el: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Φόρτωση περισσότερων…",
|
|
425
425
|
'da.wait': "Παρακαλώ περιμένετε…",
|
|
426
426
|
'lov.no_rows': "Δεν βρέθηκε τίποτα.",
|
|
427
|
+
'search.did_you_mean': "Μήπως εννοούσατε:",
|
|
427
428
|
'error.lov_value': "{label}: επιλέξτε τιμή από τη λίστα.",
|
|
428
429
|
'item.yes': "Ναι",
|
|
429
430
|
'item.no': "Όχι",
|
package/src/i18n/es.ts
CHANGED
|
@@ -421,6 +421,7 @@ export const es: Record<MessageKey, string> = {
|
|
|
421
421
|
'lov.loading': 'Cargando más…',
|
|
422
422
|
'da.wait': 'Espere, por favor…',
|
|
423
423
|
'lov.no_rows': 'No se ha encontrado nada.',
|
|
424
|
+
'search.did_you_mean': "Quizás quiso decir:",
|
|
424
425
|
'error.lov_value': '{label}: elija un valor de la lista.',
|
|
425
426
|
'item.yes': 'Sí',
|
|
426
427
|
'item.no': 'No',
|
package/src/i18n/fi.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const fi: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Ladataan lisää…",
|
|
425
425
|
'da.wait': "Odota hetki…",
|
|
426
426
|
'lov.no_rows': "Mitään ei löytynyt.",
|
|
427
|
+
'search.did_you_mean': "Tarkoititko:",
|
|
427
428
|
'error.lov_value': "{label}: valitse arvo luettelosta.",
|
|
428
429
|
'item.yes': "Kyllä",
|
|
429
430
|
'item.no': "Ei",
|
package/src/i18n/fr.ts
CHANGED
|
@@ -421,6 +421,7 @@ export const fr: Record<MessageKey, string> = {
|
|
|
421
421
|
'lov.loading': 'Chargement de la suite…',
|
|
422
422
|
'da.wait': 'Veuillez patienter…',
|
|
423
423
|
'lov.no_rows': 'Aucun résultat.',
|
|
424
|
+
'search.did_you_mean': "Vouliez-vous dire :",
|
|
424
425
|
'error.lov_value': '{label} : choisissez une valeur dans la liste.',
|
|
425
426
|
'item.yes': 'Oui',
|
|
426
427
|
'item.no': 'Non',
|
package/src/i18n/he.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const he: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "טוען עוד…",
|
|
425
425
|
'da.wait': "נא להמתין…",
|
|
426
426
|
'lov.no_rows': "לא נמצא דבר.",
|
|
427
|
+
'search.did_you_mean': "האם התכוונת ל:",
|
|
427
428
|
'error.lov_value': "{label}: בחרו ערך מהרשימה.",
|
|
428
429
|
'item.yes': "כן",
|
|
429
430
|
'item.no': "לא",
|
package/src/i18n/it.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const it: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Caricamento di altri…",
|
|
425
425
|
'da.wait': "Attendere prego…",
|
|
426
426
|
'lov.no_rows': "Nessun risultato.",
|
|
427
|
+
'search.did_you_mean': "Forse cercavi:",
|
|
427
428
|
'error.lov_value': "{label}: scegli un valore dall'elenco.",
|
|
428
429
|
'item.yes': "Sì",
|
|
429
430
|
'item.no': "No",
|
package/src/i18n/ja.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const ja: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "さらに読み込み中…",
|
|
425
425
|
'da.wait': "お待ちください…",
|
|
426
426
|
'lov.no_rows': "見つかりません。",
|
|
427
|
+
'search.did_you_mean': "もしかして:",
|
|
427
428
|
'error.lov_value': "{label}: 一覧から値を選んでください。",
|
|
428
429
|
'item.yes': "はい",
|
|
429
430
|
'item.no': "いいえ",
|
package/src/i18n/ko.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const ko: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "더 불러오는 중…",
|
|
425
425
|
'da.wait': "잠시 기다려 주세요…",
|
|
426
426
|
'lov.no_rows': "찾은 항목이 없습니다.",
|
|
427
|
+
'search.did_you_mean': "이것을 찾으셨나요:",
|
|
427
428
|
'error.lov_value': "{label}: 목록에서 값을 선택하세요.",
|
|
428
429
|
'item.yes': "예",
|
|
429
430
|
'item.no': "아니요",
|
package/src/i18n/nb.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const nb: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Laster inn flere…",
|
|
425
425
|
'da.wait': "Vent litt…",
|
|
426
426
|
'lov.no_rows': "Fant ingenting.",
|
|
427
|
+
'search.did_you_mean': "Mente du:",
|
|
427
428
|
'error.lov_value': "{label}: velg en verdi fra listen.",
|
|
428
429
|
'item.yes': "Ja",
|
|
429
430
|
'item.no': "Nei",
|
package/src/i18n/pl.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const pl: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Wczytywanie kolejnych…",
|
|
425
425
|
'da.wait': "Proszę czekać…",
|
|
426
426
|
'lov.no_rows': "Nic nie znaleziono.",
|
|
427
|
+
'search.did_you_mean': "Czy chodziło o:",
|
|
427
428
|
'error.lov_value': "{label}: wybierz wartość z listy.",
|
|
428
429
|
'item.yes': "Tak",
|
|
429
430
|
'item.no': "Nie",
|
package/src/i18n/pt.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const pt: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "A carregar mais…",
|
|
425
425
|
'da.wait': "Aguarde, por favor…",
|
|
426
426
|
'lov.no_rows': "Nada encontrado.",
|
|
427
|
+
'search.did_you_mean': "Queria dizer:",
|
|
427
428
|
'error.lov_value': "{label}: escolha um valor da lista.",
|
|
428
429
|
'item.yes': "Sim",
|
|
429
430
|
'item.no': "Não",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const ru: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Загрузка…",
|
|
425
425
|
'da.wait': "Подождите…",
|
|
426
426
|
'lov.no_rows': "Ничего не найдено.",
|
|
427
|
+
'search.did_you_mean': "Возможно, вы имели в виду:",
|
|
427
428
|
'error.lov_value': "{label}: выберите значение из списка.",
|
|
428
429
|
'item.yes': "Да",
|
|
429
430
|
'item.no': "Нет",
|
package/src/i18n/sv.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const sv: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Läser in fler…",
|
|
425
425
|
'da.wait': "Vänta…",
|
|
426
426
|
'lov.no_rows': "Inget hittades.",
|
|
427
|
+
'search.did_you_mean': "Menade du:",
|
|
427
428
|
'error.lov_value': "{label}: välj ett värde i listan.",
|
|
428
429
|
'item.yes': "Ja",
|
|
429
430
|
'item.no': "Nej",
|
package/src/i18n/tr.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const tr: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Daha fazlası yükleniyor…",
|
|
425
425
|
'da.wait': "Lütfen bekleyin…",
|
|
426
426
|
'lov.no_rows': "Hiçbir şey bulunamadı.",
|
|
427
|
+
'search.did_you_mean': "Bunu mu demek istediniz:",
|
|
427
428
|
'error.lov_value': "{label}: listeden bir değer seçin.",
|
|
428
429
|
'item.yes': "Evet",
|
|
429
430
|
'item.no': "Hayır",
|
package/src/i18n/uk.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const uk: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Завантаження…",
|
|
425
425
|
'da.wait': "Зачекайте…",
|
|
426
426
|
'lov.no_rows': "Нічого не знайдено.",
|
|
427
|
+
'search.did_you_mean': "Можливо, ви мали на увазі:",
|
|
427
428
|
'error.lov_value': "{label}: виберіть значення зі списку.",
|
|
428
429
|
'item.yes': "Так",
|
|
429
430
|
'item.no': "Ні",
|
package/src/i18n/zh.ts
CHANGED
|
@@ -424,6 +424,7 @@ export const zh: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "正在加载更多…",
|
|
425
425
|
'da.wait': "请稍候…",
|
|
426
426
|
'lov.no_rows': "未找到任何内容。",
|
|
427
|
+
'search.did_you_mean': "您是不是要找:",
|
|
427
428
|
'error.lov_value': "{label}:请从列表中选择一个值。",
|
|
428
429
|
'item.yes': "是",
|
|
429
430
|
'item.no': "否",
|
package/src/i18n.ts
CHANGED
|
@@ -451,6 +451,7 @@ const en = {
|
|
|
451
451
|
'lov.loading': 'Loading more…',
|
|
452
452
|
'da.wait': 'Please wait…',
|
|
453
453
|
'lov.no_rows': 'Nothing found.',
|
|
454
|
+
'search.did_you_mean': 'Did you mean:',
|
|
454
455
|
'error.lov_value': '{label}: choose a value from the list.',
|
|
455
456
|
'item.yes': 'Yes',
|
|
456
457
|
'item.no': 'No',
|
|
@@ -1318,6 +1319,7 @@ const nl: Record<MessageKey, string> = {
|
|
|
1318
1319
|
'lov.loading': 'Meer laden…',
|
|
1319
1320
|
'da.wait': 'Even geduld…',
|
|
1320
1321
|
'lov.no_rows': 'Niets gevonden.',
|
|
1322
|
+
'search.did_you_mean': 'Bedoelde u:',
|
|
1321
1323
|
'error.lov_value': '{label}: kies een waarde uit de lijst.',
|
|
1322
1324
|
'item.yes': 'Ja',
|
|
1323
1325
|
'item.no': 'Nee',
|
package/src/runtime/report.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { REPORT_CHART_KINDS } from './charts.ts';
|
|
|
17
17
|
import { ComputeError, computeNameOk, computeSql, type Computation } from './compute.ts';
|
|
18
18
|
import { GRID_VIEWS, renderView, VIEWS, type View } from './report-views.ts';
|
|
19
19
|
import { columnTemplates } from './template-components.ts';
|
|
20
|
+
import { textSearchConfig } from './search.ts';
|
|
20
21
|
import { facetFilterSql, facetFilters, hidesColumns, reportFacetDefs, searchSql, userColumnNames } from './facet-state.ts';
|
|
21
22
|
import { resolveRestRegion } from './rest-sources.ts';
|
|
22
23
|
import { checksumValid, signText } from '../security.ts';
|
|
@@ -409,6 +410,18 @@ export async function withComputations(ctx: PageContext, r: Region, src: string,
|
|
|
409
410
|
return { src: exprs.length ? `select "__s".*, ${exprs.join(', ')} from (\n${src}\n) "__s"` : src, errors };
|
|
410
411
|
}
|
|
411
412
|
|
|
413
|
+
/**
|
|
414
|
+
* (135) The tsvector column a report's search also uses: the one its attributes name (`fulltext`, which may be a
|
|
415
|
+
* hidden column: the developer chose it), else the only tsvector column the user may see; null when there is none.
|
|
416
|
+
*/
|
|
417
|
+
export function fulltextColumn(r: Pick<Region, 'config'>, fields: { name: string; dataTypeID: number }[], userCols: Map<string, number>) {
|
|
418
|
+
const TSVECTOR = 3614;
|
|
419
|
+
const named = typeof r.config.fulltext === 'string' ? r.config.fulltext : null;
|
|
420
|
+
if (named) return fields.find((f) => f.name === named && f.dataTypeID === TSVECTOR)?.name ?? null;
|
|
421
|
+
const shown = fields.filter((f) => f.dataTypeID === TSVECTOR && userCols.has(f.name));
|
|
422
|
+
return shown.length === 1 ? shown[0].name : null;
|
|
423
|
+
}
|
|
424
|
+
|
|
412
425
|
/**
|
|
413
426
|
* The report's query with the user's search, filters and facets applied:
|
|
414
427
|
* the source, the WHERE clause, and the result's column names (only looked
|
|
@@ -422,14 +435,25 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
|
|
|
422
435
|
const p = new SqlParams();
|
|
423
436
|
const hides = hidesColumns(r);
|
|
424
437
|
const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions), undefined, appDateReader(ctx));
|
|
425
|
-
const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) ||
|
|
438
|
+
const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || st.search;
|
|
426
439
|
// column name → type oid
|
|
427
440
|
const fields = needCols ? await fieldsOf(ctx, src) : [];
|
|
428
441
|
const cols = new Map<string, number>(fields.map((f) => [f.name, f.dataTypeID]));
|
|
429
442
|
// the columns the user may choose (filters, highlights, control break, aggregates, views, sorting): not the hidden ones
|
|
430
443
|
const userCols = new Map<string, number>(userColumnNames(r, [...cols.keys()]).map((n) => [n, cols.get(n)!]));
|
|
431
444
|
// (sprint 76) the search in one column the user may see, or in all
|
|
432
|
-
if (st.search)
|
|
445
|
+
if (st.search) {
|
|
446
|
+
const oneCol = !!st.searchCol && userCols.has(st.searchCol);
|
|
447
|
+
let cond = searchSql(st.search, p, oneCol ? [st.searchCol!] : hides ? [...userCols.keys()] : undefined);
|
|
448
|
+
// (135) a tsvector column in the source: the search also finds rows by full-text search (stemmed words,
|
|
449
|
+
// "phrases", or, -word), with a text search configuration from pg_ts_config as a query parameter
|
|
450
|
+
const fts = oneCol ? null : fulltextColumn(r, fields, userCols);
|
|
451
|
+
if (fts) {
|
|
452
|
+
const cfg = await textSearchConfig(ctx.client!, typeof r.config.fulltext_config === 'string' ? r.config.fulltext_config : null, ctx.app.language);
|
|
453
|
+
cond = `(${cond} or ${q(fts)} @@ websearch_to_tsquery(${p.add(cfg, 'regconfig')}, ${p.add(st.search, 'text')}))`;
|
|
454
|
+
}
|
|
455
|
+
where.push(cond);
|
|
456
|
+
}
|
|
433
457
|
for (const f of st.filters) if (userCols.has(f.column)) where.push(OPERATORS[f.op].sql(q(f.column), f.value));
|
|
434
458
|
for (const f of facets) {
|
|
435
459
|
const cond = facetFilterSql(f, cols, p);
|
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> {
|