diff --git a/src/components/NotesApp.tsx b/src/components/NotesApp.tsx index 4523a94..93a2183 100644 --- a/src/components/NotesApp.tsx +++ b/src/components/NotesApp.tsx @@ -1,10 +1,13 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { formatTreeTagDisplay } from "@/lib/treeTagDisplay"; import type { Snippet } from "@/types/snippet"; +import type { TagTreeNode } from "@/types/tagTree"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { SnippetCard } from "@/components/SnippetCard"; +import { TagTreeDialog } from "@/components/TagTreeDialog"; const SORT_OPTIONS = [ { value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" }, @@ -22,6 +25,9 @@ export function NotesApp() { const [snippets, setSnippets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [tagTreeOpen, setTagTreeOpen] = useState(false); + const [tagTreeNodes, setTagTreeNodes] = useState([]); + const [snippetListVersion, setSnippetListVersion] = useState(0); useEffect(() => { const t = window.setTimeout(() => setDebouncedQuery(query), 300); @@ -30,7 +36,7 @@ export function NotesApp() { useEffect(() => { let cancelled = false; - (async () => { + void (async () => { setLoading(true); setError(null); try { @@ -50,7 +56,37 @@ export function NotesApp() { return () => { cancelled = true; }; - }, [debouncedQuery, sort]); + }, [debouncedQuery, sort, snippetListVersion]); + + const fetchTagTree = useCallback(async () => { + try { + const res = await fetch("/api/tag-tree"); + if (!res.ok) return; + setTagTreeNodes((await res.json()) as TagTreeNode[]); + } catch { + /* ignore */ + } + }, []); + + useEffect(() => { + void fetchTagTree(); + }, [fetchTagTree]); + + const treeColorByName = useMemo(() => { + const m: Record = {}; + for (const n of tagTreeNodes) { + m[n.name] = n.color; + } + return m; + }, [tagTreeNodes]); + + const treeDisplayByName = useMemo(() => { + const m: Record = {}; + for (const n of tagTreeNodes) { + m[n.name] = formatTreeTagDisplay(n.name, n.label_prefix ?? null); + } + return m; + }, [tagTreeNodes]); const replaceSnippet = useCallback((s: Snippet) => { setSnippets(prev => prev.map(x => (x.id === s.id ? s : x))); @@ -90,7 +126,7 @@ export function NotesApp() { id="search" value={query} onChange={e => setQuery(e.target.value)} - placeholder="Ohne Prefix: Titel & Tags · tag:x · title:y · content:z" + placeholder="Ohne Prefix: Titel, Snippet-Tags & Tag-Baum · tag:x · title:y · content:z" autoComplete="off" className="h-9" /> @@ -112,13 +148,25 @@ export function NotesApp() { -
+
+
+ { + void fetchTagTree(); + setSnippetListVersion(v => v + 1); + }} + /> + {error ?

{error}

: null} {loading ?

Lade Snippets …

: null} @@ -127,7 +175,14 @@ export function NotesApp() {

Keine Snippets. Mit „Neu“ starten.

) : null} {snippets.map(s => ( - + ))}
diff --git a/src/components/SnippetCard.tsx b/src/components/SnippetCard.tsx index 29d010f..50f956e 100644 --- a/src/components/SnippetCard.tsx +++ b/src/components/SnippetCard.tsx @@ -1,5 +1,5 @@ import { marked } from "marked"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Snippet } from "@/types/snippet"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; @@ -21,6 +21,47 @@ function tagsFromString(s: string): string[] { .filter(Boolean); } +/** Letztes Segment nach dem letzten Komma (laufender Tag-Token). */ +function splitTagsField(s: string): { committed: string; token: string } { + const lastComma = s.lastIndexOf(","); + if (lastComma === -1) { + return { committed: "", token: s.trimStart() }; + } + return { committed: s.slice(0, lastComma + 1), token: s.slice(lastComma + 1).trimStart() }; +} + +function colorForTagName( + name: string, + treeColorByName: Record, + snippetColors: Record, +): string | null { + return treeColorByName[name] ?? snippetColors[name] ?? null; +} + +function TagPill({ tagKey, label, hex }: { tagKey: string; label: string; hex: string | null }) { + const has = Boolean(hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)); + return ( + + {label} + + ); +} + function formatWhen(iso: string): string { try { return new Date(iso).toLocaleString(); @@ -31,11 +72,13 @@ function formatWhen(iso: string): string { type Props = { snippet: Snippet; + treeColorByName: Record; + treeDisplayByName: Record; onReplace: (s: Snippet) => void; onRemove: (id: number) => void; }; -export function SnippetCard({ snippet, onReplace, onRemove }: Props) { +export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) { const pristineEmpty = !stringFieldForForm(snippet.body) && (snippet.title == null || snippet.title.trim() === "") && @@ -49,6 +92,54 @@ export function SnippetCard({ snippet, onReplace, onRemove }: Props) { const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", ")); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [tagFieldFocused, setTagFieldFocused] = useState(false); + const [tagSuggest, setTagSuggest] = useState<{ name: string; display: string }[]>([]); + const [tagSuggestOpen, setTagSuggestOpen] = useState(false); + const [tagSuggestIndex, setTagSuggestIndex] = useState(0); + const tagSuggestReq = useRef(0); + + useEffect(() => { + if (!dialogOpen || !tagFieldFocused) { + setTagSuggestOpen(false); + return; + } + const { token } = splitTagsField(tagsStr); + const id = ++tagSuggestReq.current; + const t = window.setTimeout(() => { + void (async () => { + try { + const res = await fetch(`/api/tag-suggestions?q=${encodeURIComponent(token)}`); + if (tagSuggestReq.current !== id) return; + if (!res.ok) return; + const data = (await res.json()) as { items?: { name: string; display: string }[] }; + const items = Array.isArray(data.items) ? data.items : []; + setTagSuggest(items); + setTagSuggestOpen(items.length > 0); + setTagSuggestIndex(0); + } catch { + if (tagSuggestReq.current !== id) return; + setTagSuggest([]); + setTagSuggestOpen(false); + } + })(); + }, 160); + return () => window.clearTimeout(t); + }, [dialogOpen, tagFieldFocused, tagsStr]); + + useEffect(() => { + if (!dialogOpen) { + setTagFieldFocused(false); + setTagSuggest([]); + setTagSuggestOpen(false); + } + }, [dialogOpen]); + + const applyTagSuggestion = (label: string) => { + const { committed } = splitTagsField(tagsStr); + setTagsStr(`${committed}${label}, `); + setTagSuggestOpen(false); + setDirty(true); + }; const viewHtml = useMemo( () => marked.parse(stringFieldForForm(snippet.body)) as string, @@ -170,7 +261,20 @@ export function SnippetCard({ snippet, onReplace, onRemove }: Props) { (Kein Titel) )} -
{snippet.tags.length ? snippet.tags.join(", ") : "Keine Tags"}
+
+ {snippet.tags.length ? ( + snippet.tags.map((t, i) => ( + + )) + ) : ( + Keine Tags + )} +
Geändert: {formatWhen(snippet.updated_at)}
@@ -204,15 +308,74 @@ export function SnippetCard({ snippet, onReplace, onRemove }: Props) { placeholder="Optionaler Titel" className="shrink-0" /> - { - markDirty(); - setTagsStr(e.target.value); - }} - placeholder="Tags, kommagetrennt" - className="shrink-0" - /> +
+ { + markDirty(); + setTagsStr(e.target.value); + }} + onFocus={() => setTagFieldFocused(true)} + onBlur={() => { + window.setTimeout(() => setTagFieldFocused(false), 140); + window.setTimeout(() => setTagSuggestOpen(false), 160); + }} + onKeyDown={e => { + if (!tagSuggestOpen || tagSuggest.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setTagSuggestIndex(i => Math.min(tagSuggest.length - 1, i + 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setTagSuggestIndex(i => Math.max(0, i - 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + const pick = tagSuggest[tagSuggestIndex]; + if (pick) applyTagSuggestion(pick.name); + } else if (e.key === "Escape") { + e.preventDefault(); + setTagSuggestOpen(false); + } + }} + placeholder="Tags, kommagetrennt — Vorschläge mit Pfeiltasten / Enter" + autoComplete="off" + className="h-9" + /> + {tagSuggestOpen ? ( +
    + {tagSuggest.map((item, i) => ( +
  • + +
  • + ))} +
+ ) : null} +
+
+ {tagsFromString(tagsStr).map((t, i) => ( + + ))} +
void; + onChanged?: () => void; +}; + +function orderedWithDepth(nodes: TagTreeNode[]): { node: TagTreeNode; depth: number }[] { + const byParent = new Map(); + for (const n of nodes) { + const p = n.parent_id; + const list = byParent.get(p); + if (list) list.push(n); + else byParent.set(p, [n]); + } + for (const list of byParent.values()) { + list.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + } + const walk = (parentId: number | null, depth: number): { node: TagTreeNode; depth: number }[] => { + const kids = byParent.get(parentId) ?? []; + const out: { node: TagTreeNode; depth: number }[] = []; + for (const k of kids) { + out.push({ node: k, depth }); + out.push(...walk(k.id, depth + 1)); + } + return out; + }; + return walk(null, 0); +} + +export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [parentIdStr, setParentIdStr] = useState("__root__"); + const [color, setColor] = useState(""); + const [labelPrefix, setLabelPrefix] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/tag-tree"); + if (!res.ok) throw new Error(await res.text()); + setNodes((await res.json()) as TagTreeNode[]); + } catch (e) { + setError(e instanceof Error ? e.message : "Laden fehlgeschlagen"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!open) return; + void load(); + }, [open, load]); + + const flatOrdered = useMemo(() => orderedWithDepth(nodes), [nodes]); + + const resetForm = () => { + setEditingId(null); + setName(""); + setParentIdStr("__root__"); + setColor(""); + setLabelPrefix(""); + }; + + const startEdit = (n: TagTreeNode) => { + setEditingId(n.id); + setName(n.name); + setParentIdStr(n.parent_id == null ? "__root__" : String(n.parent_id)); + setColor(n.color ?? ""); + setLabelPrefix(n.label_prefix ?? ""); + }; + + const save = async () => { + setBusy(true); + setError(null); + try { + const parent_id = parentIdStr === "__root__" ? null : Number(parentIdStr); + const colorNorm = color.trim() === "" ? null : color.trim(); + const labelPrefixNorm = labelPrefix.trim() === "" ? null : labelPrefix.trim(); + if (editingId == null) { + const res = await fetch("/api/tag-tree", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, parent_id, color: colorNorm, label_prefix: labelPrefixNorm }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(err?.error ?? res.statusText); + } + } else { + const res = await fetch(`/api/tag-tree/${editingId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, parent_id, color: colorNorm, label_prefix: labelPrefixNorm }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(err?.error ?? res.statusText); + } + } + await load(); + resetForm(); + onChanged?.(); + } catch (e) { + setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen"); + } finally { + setBusy(false); + } + }; + + const remove = async (id: number) => { + if (!window.confirm("Knoten inkl. Unterknoten löschen?")) return; + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/tag-tree/${id}`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(err?.error ?? res.statusText); + } + if (editingId === id) resetForm(); + await load(); + onChanged?.(); + } catch (e) { + setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen"); + } finally { + setBusy(false); + } + }; + + const parentOptions = useMemo(() => { + const blocked = new Set(); + if (editingId != null) { + blocked.add(editingId); + const children = new Map(); + for (const n of nodes) { + const p = n.parent_id; + const list = children.get(p); + if (list) list.push(n.id); + else children.set(p, [n.id]); + } + const stack = [...(children.get(editingId) ?? [])]; + while (stack.length) { + const id = stack.pop()!; + blocked.add(id); + const kids = children.get(id); + if (kids) for (const k of kids) stack.push(k); + } + } + return nodes.filter(n => !blocked.has(n.id)); + }, [nodes, editingId]); + + return ( + + + + Tag-Baum + + Hierarchie für zusammengehörige Tags. Optionaler Anzeige-Prefix (z. B.{" "} + Projekt:) ergibt die Darstellung{" "} + Projekt: sp-migrator bei Name{" "} + sp-migrator. Im Snippet und in der Suche gilt weiter der kurze Name. + + + +
+ {loading ?

Lade …

: null} + {error ?

{error}

: null} + +
+ {flatOrdered.length === 0 && !loading ? ( +

Noch keine Knoten.

+ ) : ( + flatOrdered.map(({ node: n, depth }) => ( +
+ + {formatTreeTagDisplay(n.name, n.label_prefix)} + + + +
+ )) + )} +
+ +
+

{editingId == null ? "Neuer Knoten" : `Knoten #${editingId} bearbeiten`}

+
+ + setName(e.target.value)} className="font-mono text-sm" autoComplete="off" /> +
+
+ + setLabelPrefix(e.target.value)} + placeholder="Projekt:" + className="font-mono text-sm" + autoComplete="off" + /> +

+ Vorschau:{" "} + + {name.trim() ? formatTreeTagDisplay(name.trim(), labelPrefix) : "—"} + +

+
+
+ + +
+
+ +
+ setColor(e.target.value)} + placeholder="#3b82f6" + className="font-mono text-sm" + autoComplete="off" + /> + setColor(e.target.value)} + aria-label="Farbe wählen" + /> +
+
+
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/index.ts b/src/index.ts index 5135831..ae672f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,13 @@ import { serve } from "bun"; import index from "./index.html"; import { getDb } from "./server/db"; import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "./server/snippets"; +import { + createTagTreeNode, + deleteTagTreeNode, + listTagTreeNodes, + suggestTagTreeItems, + updateTagTreeNode, +} from "./server/tagTree"; getDb(); @@ -45,6 +52,107 @@ const server = serve({ }, }, + "/api/tag-suggestions": { + async GET(req) { + const url = new URL(req.url); + const q = url.searchParams.get("q") ?? ""; + const database = getDb(); + return Response.json({ items: suggestTagTreeItems(database, q) }); + }, + }, + + "/api/tag-tree": { + async GET() { + return Response.json(listTagTreeNodes()); + }, + async POST(req) { + const body = await readJson(req); + if (!body || typeof body !== "object") { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const o = body as Record; + try { + const node = createTagTreeNode({ + name: typeof o.name === "string" ? o.name : "", + parent_id: + o.parent_id === null || o.parent_id === undefined + ? null + : typeof o.parent_id === "number" + ? o.parent_id + : Number(o.parent_id), + color: o.color === null || o.color === undefined ? null : String(o.color), + label_prefix: + o.label_prefix === null || o.label_prefix === undefined + ? null + : typeof o.label_prefix === "string" + ? o.label_prefix + : String(o.label_prefix), + }); + return Response.json(node, { status: 201 }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Fehler"; + return Response.json({ error: msg }, { status: 400 }); + } + }, + }, + + "/api/tag-tree/:id": { + async PATCH(req) { + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + return Response.json({ error: "Invalid id" }, { status: 400 }); + } + const body = await readJson(req); + if (!body || typeof body !== "object") { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const o = body as Record; + const patch: { + name?: string; + parent_id?: number | null; + color?: string | null; + label_prefix?: string | null; + } = {}; + if ("name" in o && typeof o.name === "string") patch.name = o.name; + if ("parent_id" in o) { + patch.parent_id = + o.parent_id === null ? null : typeof o.parent_id === "number" ? o.parent_id : Number(o.parent_id); + } + if ("color" in o) { + patch.color = o.color === null || o.color === undefined ? null : String(o.color); + } + if ("label_prefix" in o) { + patch.label_prefix = + o.label_prefix === null || o.label_prefix === undefined + ? null + : typeof o.label_prefix === "string" + ? o.label_prefix + : String(o.label_prefix); + } + try { + const node = updateTagTreeNode(id, patch); + if (!node) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + return Response.json(node); + } catch (e) { + const msg = e instanceof Error ? e.message : "Fehler"; + return Response.json({ error: msg }, { status: 400 }); + } + }, + async DELETE(req) { + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + return Response.json({ error: "Invalid id" }, { status: 400 }); + } + const ok = deleteTagTreeNode(id); + if (!ok) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + return new Response(null, { status: 204 }); + }, + }, + "/api/snippets/:id": { async GET(req) { const id = Number(req.params.id); diff --git a/src/lib/treeTagDisplay.ts b/src/lib/treeTagDisplay.ts new file mode 100644 index 0000000..0f82d58 --- /dev/null +++ b/src/lib/treeTagDisplay.ts @@ -0,0 +1,6 @@ +/** Darstellung: optionaler Prefix + Leerzeichen + kanonischer Name (z. B. Projekt: sp-migrator). */ +export function formatTreeTagDisplay(name: string, labelPrefix: string | null | undefined): string { + const p = labelPrefix?.trim(); + if (!p) return name; + return `${p} ${name}`.trim(); +} diff --git a/src/server/db.ts b/src/server/db.ts index b99d3d4..f874a3a 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -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);`); } diff --git a/src/server/snippets.ts b/src/server/snippets.ts index a23b643..99f10d0 100644 --- a/src/server/snippets.ts +++ b/src/server/snippets.ts @@ -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; }; -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 decodeSnippetTagDisplay(database, t)); + const tag_display = tags.map(t => treeTagDisplayForCanonicalName(database, t)); + const cmap = colorsForTagNames(database, tags); + const tag_colors: Record = {}; + 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); } } diff --git a/src/server/tagTree.ts b/src/server/tagTree.ts new file mode 100644 index 0000000..06678d8 --- /dev/null +++ b/src/server/tagTree.ts @@ -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:]` 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 { + const m = new Map(); + 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[] { + 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(); + 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(); + 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(); + 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 { + const out = new Map(); + 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 }; diff --git a/src/types/snippet.ts b/src/types/snippet.ts index d7cc4be..30ed45a 100644 --- a/src/types/snippet.ts +++ b/src/types/snippet.ts @@ -6,4 +6,7 @@ export type Snippet = { created_at: string; updated_at: string; tags: string[]; + /** Anzeige pro Tag (Prefix + Name für Baum-Tags), gleiche Reihenfolge wie `tags`. */ + tag_display: string[]; + tag_colors: Record; }; diff --git a/src/types/tagTree.ts b/src/types/tagTree.ts new file mode 100644 index 0000000..da7278a --- /dev/null +++ b/src/types/tagTree.ts @@ -0,0 +1,13 @@ +export type TagTreeNode = { + id: number; + name: string; + /** Optional z. B. "Projekt:" → Darstellung "Projekt: sp-migrator", Referenz / Intellisense bleibt `name`. */ + label_prefix: string | null; + parent_id: number | null; + color: string | null; +}; + +export type TagTreeSuggestion = { + name: string; + display: string; +};