This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 13:11:09 +02:00
parent dfbcd20ab6
commit a6c4178e0c
3 changed files with 213 additions and 74 deletions
+96 -18
View File
@@ -15,6 +15,38 @@ function escapeLike(raw: string): string {
return raw.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
}
/** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */
function parseSearchQuery(raw: string): {
defaultText: string | null;
tagTerms: string[];
titleTerms: string[];
contentTerms: string[];
} {
const tagTerms: string[] = [];
const titleTerms: string[] = [];
const contentTerms: string[] = [];
const re = /(?:^|\s)(tag|title|content):(\S+)/gi;
const spans: [number, number][] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const kind = m[1].toLowerCase();
const val = m[2];
if (!val) continue;
const escaped = escapeLike(val);
if (kind === "tag") tagTerms.push(escaped);
else if (kind === "title") titleTerms.push(escaped);
else if (kind === "content") contentTerms.push(escaped);
spans.push([m.index, m.index + m[0].length]);
}
let rest = raw;
for (const [start, end] of [...spans].sort((a, b) => b[0] - a[0])) {
rest = `${rest.slice(0, start)} ${rest.slice(end)}`;
}
const defaultTrim = rest.replace(/\s+/g, " ").trim();
const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim);
return { defaultText, tagTerms, titleTerms, contentTerms };
}
function nowIso(): string {
return new Date().toISOString();
}
@@ -101,22 +133,59 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
)
.all() as typeof rows;
} else {
const term = escapeLike(trimmed);
rows = database
.query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
FROM snippets s
WHERE s.title LIKE '%' || ?1 || '%' ESCAPE '\\'
OR s.body LIKE '%' || ?1 || '%' ESCAPE '\\'
OR EXISTS (
SELECT 1 FROM snippet_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.snippet_id = s.id
AND t.name LIKE '%' || ?1 || '%' ESCAPE '\\'
)
${order}`,
)
.all(term) as typeof rows;
const parsed = parseSearchQuery(trimmed);
const conditions: string[] = [];
const params: string[] = [];
if (parsed.defaultText !== null) {
conditions.push(`(
(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')
OR EXISTS (
SELECT 1 FROM snippet_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.snippet_id = s.id
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
)
)`);
params.push(parsed.defaultText, parsed.defaultText);
}
for (const term of parsed.tagTerms) {
conditions.push(`EXISTS (
SELECT 1 FROM snippet_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.snippet_id = s.id
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
)`);
params.push(term);
}
for (const term of parsed.titleTerms) {
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
params.push(term);
}
for (const term of parsed.contentTerms) {
conditions.push(`(s.body LIKE '%' || ? || '%' ESCAPE '\\')`);
params.push(term);
}
if (conditions.length === 0) {
rows = database
.query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
FROM snippets s
${order}`,
)
.all() as typeof rows;
} else {
const where = conditions.join(" AND ");
rows = database
.query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
FROM snippets s
WHERE ${where}
${order}`,
)
.all(...params) as typeof rows;
}
}
const ids = rows.map(r => r.id);
@@ -174,7 +243,12 @@ export function createSnippet(input: {
}): SnippetRow {
const database = getDb();
const t = nowIso();
const title = input.title === undefined || input.title === "" ? null : String(input.title);
const title =
input.title === undefined || input.title === null
? null
: String(input.title).trim() === ""
? null
: String(input.title).trim();
const body = input.body ?? "";
const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now();
@@ -210,7 +284,11 @@ export function updateSnippet(
};
const title =
input.title === undefined ? current.title : input.title === "" || input.title === null ? null : String(input.title);
input.title === undefined
? current.title
: input.title === null || String(input.title).trim() === ""
? null
: String(input.title).trim();
const body = input.body === undefined ? current.body : String(input.body);
const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order;
const t = nowIso();