soulseek: make the folder filter match what you can type

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:42:27 +00:00
co-authored by Claude Opus 4.8
parent 80ca9aa9b6
commit 12b9641adc
@@ -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([