snapshot
This commit is contained in:
@@ -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>
|
||||
|
||||
+175
-12
@@ -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"
|
||||
/>
|
||||
<Input
|
||||
value={tagsStr}
|
||||
onChange={e => {
|
||||
markDirty();
|
||||
setTagsStr(e.target.value);
|
||||
}}
|
||||
placeholder="Tags, kommagetrennt"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="relative shrink-0">
|
||||
<Input
|
||||
value={tagsStr}
|
||||
onChange={e => {
|
||||
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 ? (
|
||||
<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
|
||||
|
||||
@@ -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. 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. 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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user