snapshot
This commit is contained in:
+80
-20
@@ -1,5 +1,15 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { getDb } from "./db";
|
||||
import {
|
||||
colorsForTagNames,
|
||||
decodeSnippetTagDisplay,
|
||||
encodeTreeTagStorage,
|
||||
escapeLike,
|
||||
expandExactTreeTagNames,
|
||||
matchingTreeTagNamesForLikePattern,
|
||||
treeStorageRefsForDisplayNames,
|
||||
treeTagDisplayForCanonicalName,
|
||||
} from "./tagTree";
|
||||
|
||||
export type SnippetRow = {
|
||||
id: number;
|
||||
@@ -9,20 +19,20 @@ export type SnippetRow = {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: string[];
|
||||
/** Anzeige-Label pro Tag (Prefix + Name), gleiche Reihenfolge wie `tags`. */
|
||||
tag_display: string[];
|
||||
/** Farben aus dem Tag-Baum (Schlüssel = Tag-String wie am Snippet, Wert = #rrggbb oder null). */
|
||||
tag_colors: Record<string, string | null>;
|
||||
};
|
||||
|
||||
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[];
|
||||
tagTermsRaw: string[];
|
||||
titleTerms: string[];
|
||||
contentTerms: string[];
|
||||
} {
|
||||
const tagTerms: string[] = [];
|
||||
const tagTermsRaw: string[] = [];
|
||||
const titleTerms: string[] = [];
|
||||
const contentTerms: string[] = [];
|
||||
const re = /(?:^|\s)(tag|title|content):(\S+)/gi;
|
||||
@@ -33,7 +43,7 @@ function parseSearchQuery(raw: string): {
|
||||
const val = m[2];
|
||||
if (!val) continue;
|
||||
const escaped = escapeLike(val);
|
||||
if (kind === "tag") tagTerms.push(escaped);
|
||||
if (kind === "tag") tagTermsRaw.push(val);
|
||||
else if (kind === "title") titleTerms.push(escaped);
|
||||
else if (kind === "content") contentTerms.push(escaped);
|
||||
spans.push([m.index, m.index + m[0].length]);
|
||||
@@ -44,7 +54,7 @@ function parseSearchQuery(raw: string): {
|
||||
}
|
||||
const defaultTrim = rest.replace(/\s+/g, " ").trim();
|
||||
const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim);
|
||||
return { defaultText, tagTerms, titleTerms, contentTerms };
|
||||
return { defaultText, tagTermsRaw, titleTerms, contentTerms };
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
@@ -89,6 +99,7 @@ function loadTagsForSnippetIds(database: Database, ids: number[]): Map<number, s
|
||||
}
|
||||
|
||||
function rowToPublic(
|
||||
database: Database,
|
||||
row: {
|
||||
id: number;
|
||||
title: string | null;
|
||||
@@ -97,8 +108,15 @@ function rowToPublic(
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
},
|
||||
tags: string[],
|
||||
tagsRaw: string[],
|
||||
): SnippetRow {
|
||||
const tags = tagsRaw.map(t => decodeSnippetTagDisplay(database, t));
|
||||
const tag_display = tags.map(t => treeTagDisplayForCanonicalName(database, t));
|
||||
const cmap = colorsForTagNames(database, tags);
|
||||
const tag_colors: Record<string, string | null> = {};
|
||||
for (const t of tags) {
|
||||
tag_colors[t] = cmap.get(t) ?? null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
@@ -107,6 +125,8 @@ function rowToPublic(
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
tags,
|
||||
tag_display,
|
||||
tag_colors,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,25 +158,64 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
||||
const params: string[] = [];
|
||||
|
||||
if (parsed.defaultText !== null) {
|
||||
conditions.push(`(
|
||||
(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')
|
||||
OR EXISTS (
|
||||
const treeTagNames = matchingTreeTagNamesForLikePattern(database, parsed.defaultText);
|
||||
const parts: string[] = [
|
||||
`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`,
|
||||
`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);
|
||||
if (treeTagNames.length > 0) {
|
||||
const lower = treeTagNames.map(n => n.toLowerCase());
|
||||
const phLower = lower.map(() => "?").join(", ");
|
||||
const refs = treeStorageRefsForDisplayNames(database, treeTagNames);
|
||||
const sub: string[] = [`lower(t.name) IN (${phLower})`];
|
||||
params.push(...lower);
|
||||
if (refs.length > 0) {
|
||||
const phRefs = refs.map(() => "?").join(", ");
|
||||
sub.push(`t.name IN (${phRefs})`);
|
||||
params.push(...refs);
|
||||
}
|
||||
parts.push(`EXISTS (
|
||||
SELECT 1 FROM snippet_tags st
|
||||
JOIN tags t ON t.id = st.tag_id
|
||||
WHERE st.snippet_id = s.id
|
||||
AND (${sub.join(" OR ")})
|
||||
)`);
|
||||
}
|
||||
conditions.push(`(${parts.join(" OR ")})`);
|
||||
}
|
||||
for (const term of parsed.tagTerms) {
|
||||
conditions.push(`EXISTS (
|
||||
for (const raw of parsed.tagTermsRaw) {
|
||||
const expanded = expandExactTreeTagNames(database, raw);
|
||||
if (expanded !== null && expanded.length > 0) {
|
||||
const lower = expanded.map(n => n.toLowerCase());
|
||||
const refs = treeStorageRefsForDisplayNames(database, expanded);
|
||||
const sub: string[] = [`lower(t.name) IN (${lower.map(() => "?").join(", ")})`];
|
||||
params.push(...lower);
|
||||
if (refs.length > 0) {
|
||||
sub.push(`t.name IN (${refs.map(() => "?").join(", ")})`);
|
||||
params.push(...refs);
|
||||
}
|
||||
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 (${sub.join(" OR ")})
|
||||
)`);
|
||||
} else {
|
||||
const term = escapeLike(raw.trim());
|
||||
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);
|
||||
params.push(term);
|
||||
}
|
||||
}
|
||||
for (const term of parsed.titleTerms) {
|
||||
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
|
||||
@@ -190,7 +249,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
||||
|
||||
const ids = rows.map(r => r.id);
|
||||
const tagMap = loadTagsForSnippetIds(database, ids);
|
||||
return rows.map(r => rowToPublic(r, tagMap.get(r.id) ?? []));
|
||||
return rows.map(r => rowToPublic(database, r, tagMap.get(r.id) ?? []));
|
||||
}
|
||||
|
||||
export function getSnippetById(id: number): SnippetRow | null {
|
||||
@@ -211,7 +270,7 @@ export function getSnippetById(id: number): SnippetRow | null {
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const tagMap = loadTagsForSnippetIds(database, [row.id]);
|
||||
return rowToPublic(row, tagMap.get(row.id) ?? []);
|
||||
return rowToPublic(database, row, tagMap.get(row.id) ?? []);
|
||||
}
|
||||
|
||||
function getOrCreateTagId(database: Database, name: string): number | null {
|
||||
@@ -230,7 +289,8 @@ function replaceSnippetTags(database: Database, snippetId: number, tagNames: str
|
||||
if (unique.length === 0) return;
|
||||
const insertLink = database.prepare(`INSERT OR IGNORE INTO snippet_tags (snippet_id, tag_id) VALUES (?, ?)`);
|
||||
for (const name of unique) {
|
||||
const tagId = getOrCreateTagId(database, name);
|
||||
const storage = encodeTreeTagStorage(database, name);
|
||||
const tagId = getOrCreateTagId(database, storage);
|
||||
if (tagId !== null) insertLink.run(snippetId, tagId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user