| 1 | /** |
| 2 | * Full-text search over pages (D1's FTS5): turning what someone typed into |
| 3 | * a safe query, and deciding which spaces a search may look in. Pure. |
| 4 | */ |
| 5 | |
| 6 | /** |
| 7 | * A query as FTS5 understands it: each word quoted (so `-`, `:` and |
| 8 | * operators are text, never syntax), every word required, the last one a |
| 9 | * prefix so results come as you type. Null when nothing searchable is left. |
| 10 | */ |
| 11 | export function ftsQuery(text: string): string | null { |
| 12 | const words = String(text ?? "") |
| 13 | .toLowerCase() |
| 14 | .split(/[^\p{L}\p{N}_]+/u) |
| 15 | .filter(Boolean) |
| 16 | .slice(0, 12); |
| 17 | if (!words.length) return null; |
| 18 | return words.map((w, i) => `"${w.replace(/"/g, "")}"${i === words.length - 1 ? "*" : ""}`).join(" "); |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * Which spaces a search looks in: those the reader may read, narrowed to |
| 23 | * one space when asked. An empty list means search nothing. |
| 24 | */ |
| 25 | export function searchSpaces(readable: string[], only: string | null | undefined): string[] { |
| 26 | if (!only) return readable; |
| 27 | return readable.includes(only) ? [only] : []; |
| 28 | } |
| 29 | |
| 30 | /** Whether a page belongs under a project filter: it, or its space, is linked to the project. */ |
| 31 | export function inProject(project: string | null | undefined, pageProjects: string[], spaceProjects: string[]): boolean { |
| 32 | if (!project) return true; |
| 33 | const want = project.toLowerCase(); |
| 34 | return pageProjects.some((p) => p.toLowerCase() === want) || spaceProjects.some((p) => p.toLowerCase() === want); |
| 35 | } |
| 36 | |
| 37 | /** A project reference as stored: `owner/name`, lowercased; null when it is not one. */ |
| 38 | export function projectRef(value: string): string | null { |
| 39 | const s = String(value ?? "").trim().replace(/^\/+|\/+$/g, "").toLowerCase(); |
| 40 | return /^[a-z0-9][a-z0-9._-]{0,99}\/[a-z0-9._-]{1,100}$/.test(s) ? s : null; |
| 41 | } |