This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 14:24:53 +02:00
parent f111274262
commit d1ca72450f
10 changed files with 1105 additions and 38 deletions
+15
View File
@@ -28,6 +28,21 @@ function migrate(database: Database) {
PRIMARY KEY (snippet_id, tag_id)
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS tag_tree_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
parent_id INTEGER REFERENCES tag_tree_nodes(id) ON DELETE CASCADE,
color TEXT,
label_prefix TEXT
);
`);
try {
database.run(`ALTER TABLE tag_tree_nodes ADD COLUMN label_prefix TEXT`);
} catch {
/* Spalte existiert bereits */
}
database.run(`CREATE INDEX IF NOT EXISTS idx_tag_tree_parent ON tag_tree_nodes(parent_id);`);
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`);
}
+80 -20
View File
@@ -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);
}
}
+343
View File
@@ -0,0 +1,343 @@
import type { Database } from "bun:sqlite";
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import { getDb } from "./db";
import type { TagTreeNode, TagTreeSuggestion } from "@/types/tagTree";
export { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
/** Bekannte Baum-Tags werden als `#[tag:<nodeIdHex>]` in `tags.name` gespeichert (kein Padding, Kleinbuchstaben). */
const TAG_REF_RE = /^#\[tag:([0-9a-f]+)\]$/i;
function escapeLike(raw: string): string {
return raw.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
}
type NodeRow = {
id: number;
name: string;
parent_id: number | null;
color: string | null;
label_prefix: string | null;
};
export function treeTagDisplayForCanonicalName(database: Database, canonicalName: string): string {
const row = database
.query(`SELECT label_prefix FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`)
.get(canonicalName) as { label_prefix: string | null } | undefined;
return formatTreeTagDisplay(canonicalName, row?.label_prefix ?? null);
}
function loadNodeRows(database: Database): NodeRow[] {
return database
.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes ORDER BY name COLLATE NOCASE ASC`)
.all() as NodeRow[];
}
function buildChildrenMap(rows: NodeRow[]): Map<number | null, number[]> {
const m = new Map<number | null, number[]>();
for (const r of rows) {
const p = r.parent_id;
const list = m.get(p);
if (list) list.push(r.id);
else m.set(p, [r.id]);
}
return m;
}
function collectDescendantIds(startId: number, childrenByParent: Map<number | null, number[]>): number[] {
const out: number[] = [];
const stack = [...(childrenByParent.get(startId) ?? [])];
while (stack.length) {
const id = stack.pop()!;
out.push(id);
const kids = childrenByParent.get(id);
if (kids) for (const k of kids) stack.push(k);
}
return out;
}
export function decodeTreeTagRef(database: Database, stored: string): string | null {
const m = stored.trim().match(TAG_REF_RE);
if (!m) return null;
const id = Number.parseInt(m[1], 16);
if (!Number.isFinite(id) || id < 1) return null;
const row = database.query(`SELECT name FROM tag_tree_nodes WHERE id = ?`).get(id) as { name: string } | undefined;
return row?.name ?? null;
}
/** Anzeige-Label für einen in der DB gespeicherten Tag-Namen (refs → Baumname). */
export function decodeSnippetTagDisplay(database: Database, stored: string): string {
return decodeTreeTagRef(database, stored) ?? stored;
}
/** Speicherform: bekannte Baum-Namen → `#[tag:hexId]`, sonst unverändert; gültige Refs werden normalisiert. */
export function encodeTreeTagStorage(database: Database, input: string): string {
const t = input.trim();
if (!t) return t;
const ref = t.match(TAG_REF_RE);
if (ref) {
const id = Number.parseInt(ref[1], 16);
if (!Number.isFinite(id) || id < 1) return t;
const row = database.query(`SELECT id FROM tag_tree_nodes WHERE id = ?`).get(id) as { id: number } | undefined;
if (row) return `#[tag:${row.id.toString(16)}]`;
return t;
}
const row2 = database.query(`SELECT id FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`).get(t) as
| { id: number }
| undefined;
if (row2) return `#[tag:${row2.id.toString(16)}]`;
return t;
}
/** Kanonische Ref-Strings für Baumknoten mit diesen Anzeigenamen (für Suche auf encodierten Snippet-Tags). */
export function treeStorageRefsForDisplayNames(database: Database, displayNames: string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const n of displayNames) {
const row = database.query(`SELECT id FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`).get(n.trim()) as
| { id: number }
| undefined;
if (!row) continue;
const s = `#[tag:${row.id.toString(16)}]`;
if (!seen.has(s)) {
seen.add(s);
out.push(s);
}
}
return out;
}
/** Vorschläge nur aus dem Tag-Baum; `name` wird eingefügt, `display` für die Liste. */
export function suggestTagTreeItems(database: Database, qRaw: string, limit = 48): TagTreeSuggestion[] {
const q = qRaw.trim().toLowerCase();
const out: TagTreeSuggestion[] = [];
for (const n of loadNodeRows(database)) {
const display = formatTreeTagDisplay(n.name, n.label_prefix);
if (q === "") {
out.push({ name: n.name, display });
} else if (
n.name.toLowerCase().includes(q) ||
display.toLowerCase().includes(q) ||
(n.label_prefix?.toLowerCase().includes(q) ?? false)
) {
out.push({ name: n.name, display });
}
}
return out.sort((a, b) => a.display.localeCompare(b.display, undefined, { sensitivity: "base" })).slice(0, limit);
}
/** Wenn ein Knoten mit exakt diesem Namen existiert: Namen dieses Knotens plus aller Nachfahren (Snippet-Tags sind freitext, gleich Knotenname). */
export function expandExactTreeTagNames(database: Database, rawName: string): string[] | null {
const trimmed = rawName.trim();
if (!trimmed) return null;
const fromRef = decodeTreeTagRef(database, trimmed);
const lookupName = fromRef ?? trimmed;
const row = database
.query(`SELECT id FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`)
.get(lookupName) as { id: number } | undefined;
if (!row) return null;
const rows = loadNodeRows(database);
const byId = new Map(rows.map(r => [r.id, r]));
const children = buildChildrenMap(rows);
const ids = [row.id, ...collectDescendantIds(row.id, children)];
const names = [...new Set(ids.map(id => byId.get(id)?.name).filter((n): n is string => Boolean(n)))];
return names;
}
/** Für Freitextsuche: alle Knotennamen, die LIKE matchen, plus Namen aller ihrer Nachfahren. */
export function matchingTreeTagNamesForLikePattern(database: Database, likePatternEscaped: string): string[] {
const rows = loadNodeRows(database);
if (rows.length === 0) return [];
const matched = database
.query(
`SELECT id FROM tag_tree_nodes
WHERE name LIKE '%' || ? || '%' ESCAPE '\\'
OR IFNULL(label_prefix, '') LIKE '%' || ? || '%' ESCAPE '\\'`,
)
.all(likePatternEscaped, likePatternEscaped) as { id: number }[];
if (matched.length === 0) return [];
const children = buildChildrenMap(rows);
const byId = new Map(rows.map(r => [r.id, r]));
const nameSet = new Set<string>();
for (const { id } of matched) {
nameSet.add(byId.get(id)!.name);
for (const did of collectDescendantIds(id, children)) {
const n = byId.get(did)?.name;
if (n) nameSet.add(n);
}
}
return [...nameSet];
}
export function listTagTreeNodes(): TagTreeNode[] {
const database = getDb();
return loadNodeRows(database).map(r => ({
id: r.id,
name: r.name,
label_prefix: r.label_prefix,
parent_id: r.parent_id,
color: r.color,
}));
}
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
function normalizeColor(v: unknown): string | null {
if (v === null || v === undefined) return null;
if (typeof v !== "string") return null;
const s = v.trim();
if (s === "") return null;
if (!HEX_COLOR.test(s)) return null;
return s.toLowerCase();
}
function normalizeLabelPrefix(v: unknown): string | null {
if (v === null || v === undefined) return null;
if (typeof v !== "string") return null;
const s = v.trim();
return s === "" ? null : s;
}
function wouldCreateCycle(database: Database, nodeId: number, newParentId: number | null): boolean {
if (newParentId === null) return false;
let cur: number | null = newParentId;
const seen = new Set<number>();
while (cur !== null) {
if (cur === nodeId) return true;
if (seen.has(cur)) return true;
seen.add(cur);
const row = database.query(`SELECT parent_id FROM tag_tree_nodes WHERE id = ?`).get(cur) as
| { parent_id: number | null }
| undefined;
cur = row?.parent_id ?? null;
}
return false;
}
export function createTagTreeNode(input: {
name: string;
parent_id?: number | null;
color?: string | null;
label_prefix?: string | null;
}): TagTreeNode {
const database = getDb();
const name = String(input.name).trim();
if (!name) {
throw new Error("Name fehlt");
}
const parent_id =
input.parent_id === undefined || input.parent_id === null
? null
: Number(input.parent_id);
if (parent_id !== null && !Number.isFinite(parent_id)) {
throw new Error("Ungültige parent_id");
}
if (parent_id !== null) {
const p = database.query(`SELECT id FROM tag_tree_nodes WHERE id = ?`).get(parent_id) as { id: number } | undefined;
if (!p) throw new Error("Parent nicht gefunden");
}
const color = normalizeColor(input.color);
const label_prefix = normalizeLabelPrefix(input.label_prefix);
database.run(`INSERT INTO tag_tree_nodes (name, parent_id, color, label_prefix) VALUES (?, ?, ?, ?)`, [
name,
parent_id,
color,
label_prefix,
]);
const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number };
const id = Number(idRow.id);
const row = database
.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes WHERE id = ?`)
.get(id) as NodeRow;
return {
id: row.id,
name: row.name,
label_prefix: row.label_prefix,
parent_id: row.parent_id,
color: row.color,
};
}
export function updateTagTreeNode(
id: number,
input: { name?: string; parent_id?: number | null; color?: string | null; label_prefix?: string | null },
): TagTreeNode | null {
const database = getDb();
const existing = database
.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes WHERE id = ?`)
.get(id) as NodeRow | undefined;
if (!existing) return null;
let name = existing.name;
if (input.name !== undefined) {
const n = String(input.name).trim();
if (!n) throw new Error("Name fehlt");
name = n;
}
let parent_id = existing.parent_id;
if ("parent_id" in input) {
parent_id = input.parent_id === null || input.parent_id === undefined ? null : Number(input.parent_id);
if (parent_id !== null && !Number.isFinite(parent_id)) {
throw new Error("Ungültige parent_id");
}
if (parent_id !== null) {
const p = database.query(`SELECT id FROM tag_tree_nodes WHERE id = ?`).get(parent_id) as { id: number } | undefined;
if (!p) throw new Error("Parent nicht gefunden");
if (parent_id === id) throw new Error("Knoten kann nicht eigener Parent sein");
}
if (wouldCreateCycle(database, id, parent_id)) {
throw new Error("Zyklus im Baum nicht erlaubt");
}
}
let color = existing.color;
if ("color" in input) {
color = normalizeColor(input.color);
}
let label_prefix = existing.label_prefix;
if ("label_prefix" in input) {
label_prefix = normalizeLabelPrefix(input.label_prefix);
}
database.run(`UPDATE tag_tree_nodes SET name = ?, parent_id = ?, color = ?, label_prefix = ? WHERE id = ?`, [
name,
parent_id,
color,
label_prefix,
id,
]);
const row = database.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes WHERE id = ?`).get(id) as NodeRow;
return {
id: row.id,
name: row.name,
label_prefix: row.label_prefix,
parent_id: row.parent_id,
color: row.color,
};
}
export function deleteTagTreeNode(id: number): boolean {
const database = getDb();
const res = database.run(`DELETE FROM tag_tree_nodes WHERE id = ?`, [id]);
return res.changes > 0;
}
export function colorsForTagNames(database: Database, tagNames: string[]): Map<string, string | null> {
const out = new Map<string, string | null>();
if (tagNames.length === 0) return out;
const unique = [...new Set(tagNames.map(t => t.trim()).filter(Boolean))];
const lower = unique.map(s => s.toLowerCase());
const placeholders = lower.map(() => "?").join(", ");
const rows = database
.query(`SELECT name, color FROM tag_tree_nodes WHERE lower(name) IN (${placeholders})`)
.all(...lower) as { name: string; color: string | null }[];
for (const t of tagNames) {
const row = rows.find(r => r.name.toLowerCase() === t.toLowerCase());
if (row) out.set(t, row.color);
}
return out;
}
export { escapeLike };