mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
460 lines
17 KiB
460 lines
17 KiB
import { sql } from 'drizzle-orm'
|
|
import type { AccessActor } from './groups.ts'
|
|
|
|
/**
|
|
* Locale to PostgreSQL text search dictionary, for the languages postgres ships a snowball stemmer
|
|
* for. Anything not listed here falls back to `simple`, which indexes words without stemming — still
|
|
* searchable, just without matching plurals and conjugations.
|
|
*
|
|
* An operator can override or extend this from the admin area, which is what `dictOverrides` is for.
|
|
*/
|
|
export const DEFAULT_DICTIONARIES: Record<string, string> = {
|
|
ar: 'arabic',
|
|
ca: 'catalan',
|
|
da: 'danish',
|
|
de: 'german',
|
|
el: 'greek',
|
|
en: 'english',
|
|
es: 'spanish',
|
|
et: 'estonian',
|
|
eu: 'basque',
|
|
fi: 'finnish',
|
|
fr: 'french',
|
|
ga: 'irish',
|
|
hi: 'hindi',
|
|
hu: 'hungarian',
|
|
hy: 'armenian',
|
|
id: 'indonesian',
|
|
it: 'italian',
|
|
lt: 'lithuanian',
|
|
ne: 'nepali',
|
|
nl: 'dutch',
|
|
no: 'norwegian',
|
|
pt: 'portuguese',
|
|
ro: 'romanian',
|
|
ru: 'russian',
|
|
sr: 'serbian',
|
|
sv: 'swedish',
|
|
ta: 'tamil',
|
|
tr: 'turkish',
|
|
yi: 'yiddish'
|
|
}
|
|
|
|
/** The dictionary used when a locale has no mapping, or when its mapping is not installed. */
|
|
export const FALLBACK_DICTIONARY = 'simple'
|
|
|
|
export interface SearchConfig {
|
|
termHighlighting: boolean
|
|
dictOverrides: Record<string, string>
|
|
}
|
|
|
|
/** What a rebuild did, per locale, so the caller can report something concrete. */
|
|
export interface RebuildResult {
|
|
pages: number
|
|
locales: { locale: string; dictionary: string; pages: number }[]
|
|
}
|
|
|
|
export const SEARCH_ORDER_BY = ['relevancy', 'title', 'updatedAt'] as const
|
|
export type SearchOrderBy = (typeof SEARCH_ORDER_BY)[number]
|
|
|
|
export interface SearchResult {
|
|
id: string
|
|
path: string
|
|
locale: string
|
|
title: string
|
|
description: string | null
|
|
icon: string | null
|
|
tags: string[]
|
|
updatedAt: string
|
|
relevancy: number
|
|
highlight: string | null
|
|
}
|
|
|
|
export interface SearchPagesResult {
|
|
results: SearchResult[]
|
|
totalHits: number
|
|
}
|
|
|
|
export interface SearchPagesParams {
|
|
siteId: string
|
|
query?: string
|
|
path?: string
|
|
locales?: string[]
|
|
tags?: string[]
|
|
editor?: string
|
|
publishState?: string
|
|
orderBy?: SearchOrderBy
|
|
orderByDirection?: 'asc' | 'desc'
|
|
offset?: number
|
|
limit?: number
|
|
/** Restrict to what a reader with no session may see: published pages. */
|
|
publicOnly?: boolean
|
|
/** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */
|
|
includeDrafts?: boolean
|
|
/**
|
|
* Who is searching, so that a result they could not open never reaches them.
|
|
*
|
|
* Applied to the rows rather than in the query: which pages a rule covers can depend on a regular
|
|
* expression or on a page's tags, neither of which a `WHERE` clause here could express.
|
|
*/
|
|
actor?: AccessActor
|
|
/**
|
|
* Keep a password-protected page's *body* out of the results, for a searcher who would have to enter
|
|
* the password to read it. The page itself still appears — its title and description are not what
|
|
* the password covers — but it can only be matched on those, and comes back with no excerpt.
|
|
*/
|
|
hideProtectedContent?: boolean
|
|
}
|
|
|
|
/**
|
|
* Markers `ts_headline` wraps a matched term in.
|
|
*
|
|
* Control characters, because the excerpt is page text that may itself contain anything: it is HTML
|
|
* escaped before these are turned into tags, so a page whose text reads `<script>` cannot come back as
|
|
* markup. Anything that could occur in real text would defeat that.
|
|
*/
|
|
const HL_START = '\u0002'
|
|
const HL_STOP = '\u0003'
|
|
|
|
/** Escape the LIKE wildcards, so that a path filter is a prefix rather than a pattern. */
|
|
function escapeLikePrefix(value: string): string {
|
|
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
}
|
|
|
|
/**
|
|
* Search model
|
|
*
|
|
* Search is postgres full-text: every page carries a `ts` tsvector, indexed with GIN. Which
|
|
* dictionary builds that vector depends on the page's locale, which is why the mapping is
|
|
* configurable — using the wrong stemmer for a language quietly degrades results rather than
|
|
* failing.
|
|
*/
|
|
class Search {
|
|
/**
|
|
* The search configuration, with the shape the API and the admin area expect
|
|
*/
|
|
getConfig(): SearchConfig {
|
|
return {
|
|
termHighlighting: WIKI.config.search?.termHighlighting === true,
|
|
dictOverrides: (WIKI.config.search?.dictOverrides ?? {}) as Record<string, string>
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The text search configurations this postgres actually has, e.g. `english`, `simple`.
|
|
*
|
|
* Used to validate what an operator maps a locale to: a name postgres does not know would make
|
|
* every `to_tsvector` call fail at rebuild time, long after the setting was saved.
|
|
*/
|
|
async getAvailableDictionaries(): Promise<string[]> {
|
|
const rows = await WIKI.db.execute(sql`SELECT cfgname FROM pg_ts_config ORDER BY cfgname`)
|
|
return (rows.rows ?? rows).map((r: any) => r.cfgname as string)
|
|
}
|
|
|
|
/**
|
|
* The dictionary to index a locale with, preferring the operator's override
|
|
*
|
|
* @param available Dictionary names postgres knows; an unknown mapping degrades to the fallback
|
|
*/
|
|
dictionaryForLocale(locale: string, available: string[]): string {
|
|
const { dictOverrides } = this.getConfig()
|
|
// -> Locales can be regional (`en-US`), while dictionaries are per language
|
|
const language = locale.split(/[-_]/)[0] ?? locale
|
|
const wanted =
|
|
dictOverrides[locale] ?? dictOverrides[language] ?? DEFAULT_DICTIONARIES[language]
|
|
if (wanted && available.includes(wanted)) {
|
|
return wanted
|
|
}
|
|
if (wanted) {
|
|
WIKI.logger.warn(
|
|
`Text search dictionary "${wanted}" for locale ${locale} is not installed — falling back to ${FALLBACK_DICTIONARY}.`
|
|
)
|
|
}
|
|
return FALLBACK_DICTIONARY
|
|
}
|
|
|
|
/**
|
|
* A SQL expression giving the text search dictionary to use for each row.
|
|
*
|
|
* The vector on a page was built with its own locale's dictionary, so the query has to be parsed
|
|
* with the same one — an English query stemmed as French matches nothing. Postgres accepts a
|
|
* `regconfig` expression, so the mapping travels with the row rather than being fixed per query.
|
|
*
|
|
* @param locales Locales the search covers, which is what the CASE needs arms for
|
|
* @param available Dictionary names postgres knows
|
|
*/
|
|
private dictionaryExpression(locales: string[], available: string[]) {
|
|
const arms = locales.map((locale) => {
|
|
const dictionary = this.dictionaryForLocale(locale, available)
|
|
// -> Both sides are checked values: the locale is compared as a parameter, and the dictionary
|
|
// name is one postgres itself reported
|
|
return sql`WHEN ${locale} THEN ${sql.raw(`'${dictionary}'`)}`
|
|
})
|
|
if (arms.length < 1) {
|
|
return sql`${sql.raw(`'${FALLBACK_DICTIONARY}'`)}::regconfig`
|
|
}
|
|
return sql`(CASE p.locale ${sql.join(arms, sql` `)} ELSE ${sql.raw(`'${FALLBACK_DICTIONARY}'`)} END)::regconfig`
|
|
}
|
|
|
|
/**
|
|
* Full-text search over the pages of a site.
|
|
*
|
|
* The text query is optional: with only tags or filters this is a browse rather than a search, which
|
|
* is what a query of nothing but `#tags` amounts to. Ranking needs matched terms, so ordering by
|
|
* relevancy without a query falls back to the most recently updated.
|
|
*
|
|
* `isSearchable` is honoured for everyone — a page excluded from search was excluded on purpose.
|
|
*/
|
|
async searchPages({
|
|
siteId,
|
|
query = '',
|
|
path = '',
|
|
locales = [],
|
|
tags = [],
|
|
editor = '',
|
|
publishState = '',
|
|
orderBy = 'relevancy',
|
|
orderByDirection = 'desc',
|
|
offset = 0,
|
|
limit = 25,
|
|
publicOnly = false,
|
|
includeDrafts = false,
|
|
hideProtectedContent = true,
|
|
actor
|
|
}: SearchPagesParams): Promise<SearchPagesResult> {
|
|
const terms = query.trim()
|
|
const hasQuery = terms.length > 0
|
|
|
|
// -> Only the locales in play need an arm in the dictionary CASE
|
|
const siteLocales: string[] = WIKI.sites[siteId]?.config?.locales?.active ?? ['en']
|
|
const searchedLocales = locales.length > 0 ? locales : siteLocales
|
|
/*
|
|
No terms means no query to parse, and therefore no dictionary to parse it with.
|
|
|
|
Both arguments are withheld together on purpose. Passing the locales while claiming nothing is
|
|
installed -- which is what an empty `available` says -- made every locale resolve to the
|
|
fallback and warn that its dictionary was missing, on a code path that never uses the answer.
|
|
That warning was the one in the logs: `english` is installed, nobody had looked.
|
|
*/
|
|
const dict = hasQuery
|
|
? this.dictionaryExpression(searchedLocales, await this.getAvailableDictionaries())
|
|
: this.dictionaryExpression([], [])
|
|
const tsQuery = sql`websearch_to_tsquery(${dict}, ${terms})`
|
|
|
|
const conditions = [sql`p."siteId" = ${siteId}`, sql`p."isSearchable" = true`]
|
|
if (hasQuery) {
|
|
conditions.push(sql`p.ts @@ ${tsQuery}`)
|
|
}
|
|
if (publicOnly) {
|
|
// -> Matches what a page view shows an anonymous reader, so that search cannot surface a page
|
|
// that could not then be opened
|
|
conditions.push(sql`p."publishState" = 'published'`)
|
|
} else if (!includeDrafts) {
|
|
conditions.push(sql`p."publishState" <> 'draft'`)
|
|
}
|
|
if (hideProtectedContent && hasQuery) {
|
|
/*
|
|
A protected page is findable by name, not by what it says.
|
|
|
|
`indexPage` stores the three parts of a page under distinct weights — title `A`, description
|
|
`B`, body `C` — so `ts_filter` can drop the body and ask whether the query still matches. A
|
|
protected page therefore surfaces when the terms are in its title or description, both of which
|
|
it shows to everyone anyway, and stays out when they are only in the text behind the password.
|
|
Otherwise a search for a distinctive phrase would confirm the phrase is in there, which is the
|
|
thing the password is for.
|
|
|
|
Written with the cheap test first: for a page with no password the OR short-circuits and
|
|
`ts_filter` never runs.
|
|
*/
|
|
conditions.push(sql`(p.password IS NULL OR ts_filter(p.ts, '{a,b}') @@ ${tsQuery})`)
|
|
}
|
|
if (publishState) {
|
|
conditions.push(sql`p."publishState" = ${publishState}`)
|
|
}
|
|
if (path) {
|
|
conditions.push(sql`p.path LIKE ${`${escapeLikePrefix(path)}%`}`)
|
|
}
|
|
if (locales.length > 0) {
|
|
// -> `sql.param`, because a bare array is expanded into a list of placeholders rather than
|
|
// bound as one array value
|
|
conditions.push(sql`p.locale = ANY(${sql.param(locales)}::text[])`)
|
|
}
|
|
if (tags.length > 0) {
|
|
conditions.push(sql`p.tags @> ${sql.param(tags)}::text[]`)
|
|
}
|
|
if (editor) {
|
|
conditions.push(sql`p.editor = ${editor}`)
|
|
}
|
|
|
|
const direction = orderByDirection === 'asc' ? sql`ASC` : sql`DESC`
|
|
// -> Every page ranks 0 without a query, which would leave the order down to the planner
|
|
const effectiveOrderBy = orderBy === 'relevancy' && !hasQuery ? 'updatedAt' : orderBy
|
|
const ordering = {
|
|
relevancy: sql`relevancy ${direction}, p."updatedAt" DESC`,
|
|
title: sql`p.title ${direction}`,
|
|
updatedAt: sql`p."updatedAt" ${direction}`
|
|
}[effectiveOrderBy]
|
|
|
|
const { termHighlighting } = this.getConfig()
|
|
const headline = sql`ts_headline(${dict}, coalesce(p."searchContent", ''), ${tsQuery},
|
|
${`StartSel=${HL_START},StopSel=${HL_STOP},MaxWords=25,MinWords=10,MaxFragments=1`})`
|
|
/*
|
|
The excerpt is cut from the page's own text, so a protected page has none to give a searcher who
|
|
would be shown a lock screen on the page itself. `CASE` rather than a filter on the rows: the page
|
|
still belongs in the results, it just arrives without the part the password covers.
|
|
*/
|
|
const highlight =
|
|
!hasQuery || !termHighlighting
|
|
? sql`NULL`
|
|
: hideProtectedContent
|
|
? sql`CASE WHEN p.password IS NULL THEN ${headline} ELSE NULL END`
|
|
: headline
|
|
|
|
const rows = await WIKI.db.execute(sql`
|
|
SELECT
|
|
p.id,
|
|
p.path,
|
|
p.locale,
|
|
p.title,
|
|
p.description,
|
|
p.icon,
|
|
p.tags,
|
|
to_char(p."updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "updatedAt",
|
|
${hasQuery ? sql`ts_rank(p.ts, ${tsQuery})` : sql`0`} AS relevancy,
|
|
${highlight} AS highlight,
|
|
COUNT(*) OVER() AS "totalHits"
|
|
FROM pages p
|
|
WHERE ${sql.join(conditions, sql` AND `)}
|
|
ORDER BY ${ordering}
|
|
LIMIT ${limit} OFFSET ${offset}
|
|
`)
|
|
|
|
/*
|
|
Filtered here rather than in SQL: a page rule can be a regular expression or a set of tags, so
|
|
the deciding rule is only knowable per row. Search must not be a way around page permissions —
|
|
a title and an excerpt are content too.
|
|
*/
|
|
const visible = actor
|
|
? ((rows.rows ?? rows) as any[]).filter((row) =>
|
|
WIKI.models.groups.checkAccess(actor, 'read:pages', {
|
|
path: row.path as string,
|
|
locale: row.locale as string,
|
|
tags: (row.tags ?? []) as string[]
|
|
})
|
|
)
|
|
: ((rows.rows ?? rows) as any[])
|
|
|
|
const result = visible.map((row) => ({
|
|
id: row.id as string,
|
|
path: row.path as string,
|
|
locale: row.locale as string,
|
|
title: row.title as string,
|
|
description: row.description ?? null,
|
|
icon: row.icon ?? null,
|
|
tags: (row.tags ?? []) as string[],
|
|
updatedAt: row.updatedAt as string,
|
|
relevancy: Number(row.relevancy ?? 0),
|
|
// -> Escaped first, so the only markup that survives is the emphasis postgres marked
|
|
highlight: row.highlight
|
|
? escapeHtml(row.highlight as string)
|
|
.replaceAll(HL_START, '<b>')
|
|
.replaceAll(HL_STOP, '</b>')
|
|
: null
|
|
}))
|
|
|
|
return {
|
|
results: result,
|
|
/*
|
|
The count postgres reported, less whatever the rules just removed from this page of results.
|
|
Not exact when rows are dropped -- the window function counted every match, including ones on
|
|
later pages this reader may not see -- but a total that ignored the filtering entirely would
|
|
promise results that do not exist.
|
|
*/
|
|
totalHits: Math.max(
|
|
0,
|
|
Number((rows.rows ?? rows)[0]?.totalHits ?? 0) -
|
|
((rows.rows ?? rows) as any[]).length +
|
|
visible.length
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recompute the search vector of every page.
|
|
*
|
|
* Grouped by locale, since the dictionary is chosen per locale. Title and description are weighted
|
|
* above the body so that a page whose title matches outranks one that merely mentions the term.
|
|
*
|
|
* Runs over every page rather than only searchable ones: whether a page shows up in results is
|
|
* decided at query time by `isSearchableComputed`, and keeping the vector current means flipping a
|
|
* page back to searchable needs no reindex.
|
|
*/
|
|
async rebuildIndex(): Promise<RebuildResult> {
|
|
const available = await this.getAvailableDictionaries()
|
|
const localeRows = await WIKI.db.execute(sql`SELECT DISTINCT locale FROM pages ORDER BY locale`)
|
|
const locales = ((localeRows.rows ?? localeRows) as any[]).map((r) => r.locale as string)
|
|
|
|
WIKI.logger.info(`Rebuilding the search index for ${locales.length} locale(s)...`)
|
|
const result: RebuildResult = { pages: 0, locales: [] }
|
|
|
|
for (const locale of locales) {
|
|
const dictionary = this.dictionaryForLocale(locale, available)
|
|
// -> The dictionary name is an identifier in `to_tsvector`, and it is only ever one of the
|
|
// names postgres itself reported, so it cannot carry anything unexpected
|
|
const updated = await WIKI.db.execute(sql`
|
|
UPDATE pages SET ts =
|
|
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce(title, '')), 'A') ||
|
|
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce(description, '')), 'B') ||
|
|
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce("searchContent", '')), 'C')
|
|
WHERE locale = ${locale}
|
|
`)
|
|
const pages = updated.rowCount ?? 0
|
|
result.pages += pages
|
|
result.locales.push({ locale, dictionary, pages })
|
|
WIKI.logger.info(
|
|
`Reindexed ${pages} page(s) in ${locale} using the ${dictionary} dictionary.`
|
|
)
|
|
}
|
|
|
|
WIKI.logger.info(`Search index rebuild completed: ${result.pages} page(s) [ OK ]`)
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Recompute one page's search vector, after it was created or edited.
|
|
*
|
|
* Same weighting as a full rebuild — title above description above body — so that a page saved
|
|
* today ranks against pages last indexed by a rebuild rather than alongside them.
|
|
*
|
|
* Never throws: a page that saved correctly must not report failure because its index entry could
|
|
* not be written, and the next rebuild puts it right.
|
|
*/
|
|
async indexPage(id: string, locale: string): Promise<void> {
|
|
try {
|
|
const dictionary = this.dictionaryForLocale(locale, await this.getAvailableDictionaries())
|
|
// -> The dictionary name is an identifier in `to_tsvector`, and it is only ever one of the
|
|
// names postgres itself reported, so it cannot carry anything unexpected
|
|
const dict = sql.raw(`'${dictionary}'`)
|
|
await WIKI.db.execute(sql`
|
|
UPDATE pages SET ts =
|
|
setweight(to_tsvector(${dict}, coalesce(title, '')), 'A') ||
|
|
setweight(to_tsvector(${dict}, coalesce(description, '')), 'B') ||
|
|
setweight(to_tsvector(${dict}, coalesce("searchContent", '')), 'C')
|
|
WHERE id = ${id}
|
|
`)
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(`Failed to update the search index for page ${id}: ${err.message}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const search = new Search()
|