diff --git a/src/databases/officer_db/src/queries/soulseek.ts b/src/databases/officer_db/src/queries/soulseek.ts index 0d250ecc..5b28afbe 100644 --- a/src/databases/officer_db/src/queries/soulseek.ts +++ b/src/databases/officer_db/src/queries/soulseek.ts @@ -151,6 +151,32 @@ export type BrowseDirPage = { }; type BrowseDirPageParams = { userId: number; username: string; q?: string; limit: number; offset: number }; +// These names come off strangers' filesystems, so they're littered with typographic punctuation that no +// keyboard produces: curly apostrophes, en dashes, and U+2010 HYPHEN, which is pixel-identical to '-'. +// Typing "Hell's Ditch" or "1984-1985" found nothing at all, so both sides get folded to ASCII first. +// Same character set on both sides — extend one and you must extend the other. +const FOLD_FROM = '‘’ʼ'“”‐‑‒–—―− '; +const FOLD_TO = "''''\"\"------- "; +// Not in the translate() pair because it's one character standing in for three. +const ELLIPSIS = '…'; + +const foldPunctuation = (value: string): string => { + let out = ''; + for (const ch of value) { + const at = FOLD_FROM.indexOf(ch); + out += at === -1 ? ch : FOLD_TO[at]; + } + return out.split(ELLIPSIS).join('...'); +}; + +// Postgres reads '%', '_' and '\' as LIKE metacharacters, so unescaped input meant a typed '_' matched +// every folder in the share while a pasted path — and every name here IS a backslash-delimited path — +// silently matched none. Backslash stays the escape character, which is why it's escaped first. +const likePattern = (term: string): string => `%${foldPunctuation(term).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`; + +/** The column, folded the same way `likePattern` folds the term. */ +const foldedName = sql`replace(translate(${soulseekBrowseDirs.name}, ${FOLD_FROM}, ${FOLD_TO}), ${ELLIPSIS}, '...')`; + /** * A page of a peer's cached folders, name-filtered. Files are excluded — that's the whole point of * paging here, and they're fetched per folder on expand. @@ -165,9 +191,10 @@ export async function getSoulseekBrowseDirs(params: BrowseDirPageParams): Promis if (!snap) return { dirs: [], total: 0 }; const term = q?.trim(); - // ILIKE with a leading wildcard can't use the btree index, but 18k rows per snapshot is a trivial scan. + // ILIKE with a leading wildcard can't use the btree index, so this was already a scan of the snapshot's + // ~18k rows — which is why folding the column here costs nothing on top. const where = term - ? and(eq(soulseekBrowseDirs.snapshotId, snap.id), ilike(soulseekBrowseDirs.name, `%${term}%`)) + ? and(eq(soulseekBrowseDirs.snapshotId, snap.id), sql`${foldedName} ilike ${likePattern(term)}`) : eq(soulseekBrowseDirs.snapshotId, snap.id); const [dirs, [count]] = await Promise.all([