From 12b9641adc4acf99f9dd4b658306ac8630d5ff35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 00:42:27 +0000 Subject: [PATCH] soulseek: make the folder filter match what you can type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the filter lied about the cache. First, input went into ILIKE raw, so its metacharacters were live: a typed '_' matched all 18,295 folders, '50%' matched 126 unrelated ones, and — worst — a pasted path fragment matched nothing at all, since backslash is LIKE's escape character and every name here is a backslash-delimited remote path. Second, these names come off strangers' filesystems and are full of punctuation no keyboard produces, so "Hell's Ditch", "1984-1985" and "Say... Pogue" each returned zero against stored U+2019, U+2013 and U+2026. The U+2010 HYPHEN is the nastiest of those: identical to '-' on screen, so "B-Sides" quietly found 15 of 23 folders and looked like it had worked. Both sides are now folded to ASCII and the term is escaped. The fold rides on the existing scan — a leading wildcard already ruled out the btree — but translate() over ~18k rows does cost something: a filtered page went 16ms to 88ms. Still well under the input debounce, and a normalized column with a trigram index is there if it ever stops being true. Co-Authored-By: Claude Opus 4.8 --- .../officer_db/src/queries/soulseek.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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([