Support wildcards in the title search

This commit is contained in:
Fisto
2026-09-17 13:43:27 +02:00
parent 0fb93c4830
commit 8befc05591
8 changed files with 91 additions and 15 deletions
+27 -5
View File
@@ -187,6 +187,20 @@ export function normalizeTitleKey(title: string): string {
.replace(/\s+/g, " ");
}
/** Lowercase a title query and collapse punctuation, but keep `*` as a wildcard marker. */
export function normalizeTitleQuery(query: string): string {
return query
.toLowerCase()
.replace(/[^a-z0-9*]+/g, " ")
.trim()
.replace(/\s+/g, " ");
}
/** Escape LIKE metacharacters, then turn `*` into `%`. */
function titleLikePattern(value: string): string {
return value.replace(/[\\%_]/g, match => `\\${match}`).replace(/\*/g, "%");
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
@@ -803,14 +817,22 @@ export class ScrapeStore {
values.push(options.lengthGreaterThan);
}
if (options.titleQuery?.trim()) {
const query = normalizeTitleKey(options.titleQuery);
const query = normalizeTitleQuery(options.titleQuery);
if (options.titleExact) {
conditions.push("title_key LIKE ?");
values.push(`%${query}%`);
// One phrase, substring of the title; `*` still acts as a wildcard.
conditions.push("title_key LIKE ? ESCAPE '\\'");
values.push(`%${titleLikePattern(query)}%`);
} else {
for (const token of query.split(" ").filter(Boolean)) {
conditions.push("(' ' || COALESCE(title_key, '')) LIKE ?");
values.push(`% ${token}%`);
const pattern = titleLikePattern(token);
if (token.startsWith("*")) {
// Leading `*`: anywhere in a word, not just the start.
conditions.push("COALESCE(title_key, '') LIKE ? ESCAPE '\\'");
values.push(`%${pattern}%`);
} else {
conditions.push("(' ' || COALESCE(title_key, '')) LIKE ? ESCAPE '\\'");
values.push(`% ${pattern}%`);
}
}
}
}
+51
View File
@@ -31,6 +31,7 @@ import {
dirSizeBytes,
listScrapes,
normalizeTitleKey,
normalizeTitleQuery,
readScrapeMeta,
ScrapeStore,
writeScrapeMeta
@@ -1619,6 +1620,56 @@ void (async () => {
store.close();
});
await test("title wildcards match anywhere and stay literal for % and _", () => {
const dir = join(root, "title-wildcards");
mkdirSync(dir, { recursive: true });
const store = new ScrapeStore(dir);
const embed = (id: string, title: string) => ({
type: "video",
url: `https://www.youtube.com/watch?v=${id}`,
title,
provider: "YouTube",
videoUrl: `https://www.youtube.com/watch?v=${id}`,
imageUrl: null,
thumbnailUrl: null
});
store.insertPage([
message("1300", {
embeds: [
embed("w1", "Scar Tissue"),
embed("w2", "Discard the rest"),
embed("w3", "Car wash"),
embed("w4", "Cartoon physics"),
embed("w5", "Carly and the r")
]
})
]);
const ids = (options: Record<string, unknown>) =>
store
.queryMedia({ limit: 50, ...options })
.map(row => row.url.split("v=")[1])
.sort();
// Word-prefix default: car starts a word.
assert.deepEqual(ids({ titleQuery: "car" }), ["w3", "w4", "w5"]);
// Leading *: anywhere, so scar and discard join in.
assert.deepEqual(ids({ titleQuery: "*car" }), ["w1", "w2", "w3", "w4", "w5"]);
// Trailing * behaves like the prefix form.
assert.deepEqual(ids({ titleQuery: "car*" }), ["w3", "w4", "w5"]);
// In-word wildcard after a word start.
assert.deepEqual(ids({ titleQuery: "c*r" }), ["w3", "w4", "w5"]);
assert.deepEqual(ids({ titleQuery: "c*r*" }), ["w3", "w4", "w5"]);
assert.deepEqual(ids({ titleQuery: "*c*r*" }), ["w1", "w2", "w3", "w4", "w5"]);
store.close();
});
await test("normalizeTitleQuery keeps stars and drops punctuation", () => {
assert.equal(normalizeTitleQuery(" *CaR, C*r* "), "*car c*r*");
assert.equal(normalizeTitleQuery("Boat"), "boat");
});
await test("normalizeTitleKey collapses punctuation and case", () => {
assert.equal(normalizeTitleKey("Woman's Day: 3 Things!"), "woman s day 3 things");
assert.equal(normalizeTitleKey(" A---B "), "a b");