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
+61 -6
View File
@@ -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<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tagTreeOpen, setTagTreeOpen] = useState(false);
const [tagTreeNodes, setTagTreeNodes] = useState<TagTreeNode[]>([]);
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<string, string | null> = {};
for (const n of tagTreeNodes) {
m[n.name] = n.color;
}
return m;
}, [tagTreeNodes]);
const treeDisplayByName = useMemo(() => {
const m: Record<string, string> = {};
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() {
</SelectContent>
</Select>
</div>
<div className="flex sm:shrink-0">
<div className="flex flex-wrap gap-2 sm:shrink-0">
<Button type="button" variant="outline" className="h-9 w-full sm:w-auto" onClick={() => setTagTreeOpen(true)}>
Tag-Baum
</Button>
<Button type="button" className="h-9 w-full sm:w-auto" onClick={() => void createNew()}>
Neu
</Button>
</div>
</header>
<TagTreeDialog
open={tagTreeOpen}
onOpenChange={setTagTreeOpen}
onChanged={() => {
void fetchTagTree();
setSnippetListVersion(v => v + 1);
}}
/>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading ? <p className="text-sm text-muted-foreground">Lade Snippets …</p> : null}
@@ -127,7 +175,14 @@ export function NotesApp() {
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
) : null}
{snippets.map(s => (
<SnippetCard key={s.id} snippet={s} onReplace={replaceSnippet} onRemove={removeSnippet} />
<SnippetCard
key={s.id}
snippet={s}
treeColorByName={treeColorByName}
treeDisplayByName={treeDisplayByName}
onReplace={replaceSnippet}
onRemove={removeSnippet}
/>
))}
</div>
</div>
+168 -5
View File
@@ -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<string, string | null>,
snippetColors: Record<string, string | null>,
): 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 (
<span
className={
has
? "inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-xs font-medium"
: "inline-flex max-w-full items-center rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground"
}
style={
has
? {
borderColor: hex!,
backgroundColor: `${hex}33`,
}
: undefined
}
title={tagKey === label ? tagKey : `${label} (${tagKey})`}
>
<span className="truncate">{label}</span>
</span>
);
}
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<string, string | null>;
treeDisplayByName: Record<string, string>;
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<string | null>(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) {
<span className="text-sm font-normal italic tracking-wide text-muted-foreground">(Kein Titel)</span>
)}
</div>
<div className="text-muted-foreground">{snippet.tags.length ? snippet.tags.join(", ") : "Keine Tags"}</div>
<div className="flex min-h-[1.5rem] flex-wrap items-center gap-1.5">
{snippet.tags.length ? (
snippet.tags.map((t, i) => (
<TagPill
key={t}
tagKey={t}
label={snippet.tag_display?.[i] ?? treeDisplayByName[t] ?? t}
hex={colorForTagName(t, treeColorByName, snippet.tag_colors ?? {})}
/>
))
) : (
<span className="text-muted-foreground">Keine Tags</span>
)}
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-end">
<div className="text-xs text-muted-foreground">Geändert: {formatWhen(snippet.updated_at)}</div>
@@ -204,15 +308,74 @@ export function SnippetCard({ snippet, onReplace, onRemove }: Props) {
placeholder="Optionaler Titel"
className="shrink-0"
/>
<div className="relative shrink-0">
<Input
value={tagsStr}
onChange={e => {
markDirty();
setTagsStr(e.target.value);
}}
placeholder="Tags, kommagetrennt"
className="shrink-0"
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 ? (
<ul
role="listbox"
className="absolute z-50 mt-1 max-h-40 w-full overflow-auto rounded-md border bg-popover py-1 text-sm shadow-md"
>
{tagSuggest.map((item, i) => (
<li key={`${item.name}-${i}`} role="option" aria-selected={i === tagSuggestIndex}>
<button
type="button"
className={`flex w-full cursor-pointer flex-col gap-0.5 px-3 py-1.5 text-left hover:bg-accent ${
i === tagSuggestIndex ? "bg-accent" : ""
}`}
onMouseDown={e => e.preventDefault()}
onClick={() => applyTagSuggestion(item.name)}
>
<span className="text-xs">{item.display}</span>
{item.display !== item.name ? (
<span className="font-mono text-[10px] text-muted-foreground">{item.name}</span>
) : null}
</button>
</li>
))}
</ul>
) : null}
</div>
<div className="flex min-h-[1.5rem] flex-wrap gap-1.5">
{tagsFromString(tagsStr).map((t, i) => (
<TagPill
key={`${i}-${t}`}
tagKey={t}
label={treeDisplayByName[t] ?? t}
hex={colorForTagName(t, treeColorByName, snippet.tag_colors ?? {})}
/>
))}
</div>
<div
className={
showPreview
+301
View File
@@ -0,0 +1,301 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import type { TagTreeNode } from "@/types/tagTree";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged?: () => void;
};
function orderedWithDepth(nodes: TagTreeNode[]): { node: TagTreeNode; depth: number }[] {
const byParent = new Map<number | null, TagTreeNode[]>();
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<TagTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [name, setName] = useState("");
const [parentIdStr, setParentIdStr] = useState<string>("__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<number>();
if (editingId != null) {
blocked.add(editingId);
const children = new Map<number | null, number[]>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] gap-0 overflow-hidden p-0 sm:max-w-lg">
<DialogHeader className="shrink-0 space-y-1 border-b px-6 py-4">
<DialogTitle>Tag-Baum</DialogTitle>
<DialogDescription>
Hierarchie für zusammengehörige Tags. Optionaler Anzeige-Prefix (z.&nbsp;B.{" "}
<code className="text-xs">Projekt:</code>) ergibt die Darstellung{" "}
<code className="text-xs">Projekt: sp-migrator</code> bei Name{" "}
<code className="text-xs">sp-migrator</code>. Im Snippet und in der Suche gilt weiter der kurze Name.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto px-6 py-4">
{loading ? <p className="text-sm text-muted-foreground">Lade …</p> : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="space-y-1 rounded-md border bg-muted/20 p-2 text-sm">
{flatOrdered.length === 0 && !loading ? (
<p className="px-2 py-3 text-muted-foreground">Noch keine Knoten.</p>
) : (
flatOrdered.map(({ node: n, depth }) => (
<div
key={n.id}
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/60"
style={{ paddingLeft: `${8 + depth * 14}px` }}
>
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
title={`${n.name}${n.label_prefix ? ` · Prefix: ${n.label_prefix}` : ""}`}
style={
n.color
? { borderLeft: `3px solid ${n.color}`, paddingLeft: 6 }
: { paddingLeft: 9 }
}
>
{formatTreeTagDisplay(n.name, n.label_prefix)}
</span>
<Button type="button" size="sm" variant="secondary" className="h-7 shrink-0 px-2 text-xs" onClick={() => startEdit(n)}>
Bearbeiten
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 shrink-0 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => void remove(n.id)}
disabled={busy}
>
Löschen
</Button>
</div>
))
)}
</div>
<div className="space-y-3 border-t pt-3">
<p className="text-xs font-medium text-muted-foreground">{editingId == null ? "Neuer Knoten" : `Knoten #${editingId} bearbeiten`}</p>
<div className="space-y-1.5">
<Label htmlFor="tt-name">Name (Referenz im Snippet / Intellisense)</Label>
<Input id="tt-name" value={name} onChange={e => setName(e.target.value)} className="font-mono text-sm" autoComplete="off" />
</div>
<div className="space-y-1.5">
<Label htmlFor="tt-prefix">Anzeige-Prefix (optional, z.&nbsp;B. Projekt:)</Label>
<Input
id="tt-prefix"
value={labelPrefix}
onChange={e => setLabelPrefix(e.target.value)}
placeholder="Projekt:"
className="font-mono text-sm"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Vorschau:{" "}
<span className="font-mono text-foreground">
{name.trim() ? formatTreeTagDisplay(name.trim(), labelPrefix) : "—"}
</span>
</p>
</div>
<div className="space-y-1.5">
<Label>Parent</Label>
<Select value={parentIdStr} onValueChange={setParentIdStr}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Ohne Parent (Wurzel)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__root__">(Wurzel)</SelectItem>
{parentOptions.map(n => (
<SelectItem key={n.id} value={String(n.id)}>
{formatTreeTagDisplay(n.name, n.label_prefix)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="tt-color">Farbe (#rrggbb, optional)</Label>
<div className="flex gap-2">
<Input
id="tt-color"
value={color}
onChange={e => setColor(e.target.value)}
placeholder="#3b82f6"
className="font-mono text-sm"
autoComplete="off"
/>
<Input
type="color"
className="h-9 w-12 shrink-0 cursor-pointer p-1"
value={/^#[0-9A-Fa-f]{6}$/.test(color) ? color : "#64748b"}
onChange={e => setColor(e.target.value)}
aria-label="Farbe wählen"
/>
</div>
</div>
</div>
</div>
<DialogFooter className="shrink-0 flex-row flex-wrap gap-2 border-t px-6 py-4">
<Button type="button" variant="outline" disabled={busy} onClick={resetForm}>
Formular leeren
</Button>
<div className="flex flex-1 flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" disabled={busy} onClick={() => onOpenChange(false)}>
Schließen
</Button>
<Button type="button" disabled={busy || !name.trim()} onClick={() => void save()}>
Speichern
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+108
View File
@@ -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<string, unknown>;
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<string, unknown>;
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);
+6
View File
@@ -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();
}
+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);`);
}
+78 -18
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,18 +158,56 @@ 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);
}
for (const term of parsed.tagTerms) {
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 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
@@ -158,6 +216,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
)`);
params.push(term);
}
}
for (const term of parsed.titleTerms) {
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
params.push(term);
@@ -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 };
+3
View File
@@ -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<string, string | null>;
};
+13
View File
@@ -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;
};