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 { Snippet } from "@/types/snippet";
|
||||||
|
import type { TagTreeNode } from "@/types/tagTree";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { SnippetCard } from "@/components/SnippetCard";
|
import { SnippetCard } from "@/components/SnippetCard";
|
||||||
|
import { TagTreeDialog } from "@/components/TagTreeDialog";
|
||||||
|
|
||||||
const SORT_OPTIONS = [
|
const SORT_OPTIONS = [
|
||||||
{ value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" },
|
{ value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" },
|
||||||
@@ -22,6 +25,9 @@ export function NotesApp() {
|
|||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tagTreeOpen, setTagTreeOpen] = useState(false);
|
||||||
|
const [tagTreeNodes, setTagTreeNodes] = useState<TagTreeNode[]>([]);
|
||||||
|
const [snippetListVersion, setSnippetListVersion] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = window.setTimeout(() => setDebouncedQuery(query), 300);
|
const t = window.setTimeout(() => setDebouncedQuery(query), 300);
|
||||||
@@ -30,7 +36,7 @@ export function NotesApp() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
void (async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +56,37 @@ export function NotesApp() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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) => {
|
const replaceSnippet = useCallback((s: Snippet) => {
|
||||||
setSnippets(prev => prev.map(x => (x.id === s.id ? s : x)));
|
setSnippets(prev => prev.map(x => (x.id === s.id ? s : x)));
|
||||||
@@ -90,7 +126,7 @@ export function NotesApp() {
|
|||||||
id="search"
|
id="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
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"
|
autoComplete="off"
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
@@ -112,13 +148,25 @@ export function NotesApp() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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()}>
|
<Button type="button" className="h-9 w-full sm:w-auto" onClick={() => void createNew()}>
|
||||||
Neu
|
Neu
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<TagTreeDialog
|
||||||
|
open={tagTreeOpen}
|
||||||
|
onOpenChange={setTagTreeOpen}
|
||||||
|
onChanged={() => {
|
||||||
|
void fetchTagTree();
|
||||||
|
setSnippetListVersion(v => v + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
{loading ? <p className="text-sm text-muted-foreground">Lade Snippets …</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>
|
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{snippets.map(s => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Snippet } from "@/types/snippet";
|
import type { Snippet } from "@/types/snippet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
@@ -21,6 +21,47 @@ function tagsFromString(s: string): string[] {
|
|||||||
.filter(Boolean);
|
.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 {
|
function formatWhen(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
@@ -31,11 +72,13 @@ function formatWhen(iso: string): string {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
|
treeColorByName: Record<string, string | null>;
|
||||||
|
treeDisplayByName: Record<string, string>;
|
||||||
onReplace: (s: Snippet) => void;
|
onReplace: (s: Snippet) => void;
|
||||||
onRemove: (id: number) => void;
|
onRemove: (id: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SnippetCard({ snippet, onReplace, onRemove }: Props) {
|
export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) {
|
||||||
const pristineEmpty =
|
const pristineEmpty =
|
||||||
!stringFieldForForm(snippet.body) &&
|
!stringFieldForForm(snippet.body) &&
|
||||||
(snippet.title == null || snippet.title.trim() === "") &&
|
(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 [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(
|
const viewHtml = useMemo(
|
||||||
() => marked.parse(stringFieldForForm(snippet.body)) as string,
|
() => 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>
|
<span className="text-sm font-normal italic tracking-wide text-muted-foreground">(Kein Titel)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-end">
|
<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>
|
<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"
|
placeholder="Optionaler Titel"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
|
<div className="relative shrink-0">
|
||||||
<Input
|
<Input
|
||||||
value={tagsStr}
|
value={tagsStr}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
markDirty();
|
markDirty();
|
||||||
setTagsStr(e.target.value);
|
setTagsStr(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Tags, kommagetrennt"
|
onFocus={() => setTagFieldFocused(true)}
|
||||||
className="shrink-0"
|
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
|
<div
|
||||||
className={
|
className={
|
||||||
showPreview
|
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 index from "./index.html";
|
||||||
import { getDb } from "./server/db";
|
import { getDb } from "./server/db";
|
||||||
import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "./server/snippets";
|
import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "./server/snippets";
|
||||||
|
import {
|
||||||
|
createTagTreeNode,
|
||||||
|
deleteTagTreeNode,
|
||||||
|
listTagTreeNodes,
|
||||||
|
suggestTagTreeItems,
|
||||||
|
updateTagTreeNode,
|
||||||
|
} from "./server/tagTree";
|
||||||
|
|
||||||
getDb();
|
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": {
|
"/api/snippets/:id": {
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
const id = Number(req.params.id);
|
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)
|
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);`);
|
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+78
-18
@@ -1,5 +1,15 @@
|
|||||||
import type { Database } from "bun:sqlite";
|
import type { Database } from "bun:sqlite";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import {
|
||||||
|
colorsForTagNames,
|
||||||
|
decodeSnippetTagDisplay,
|
||||||
|
encodeTreeTagStorage,
|
||||||
|
escapeLike,
|
||||||
|
expandExactTreeTagNames,
|
||||||
|
matchingTreeTagNamesForLikePattern,
|
||||||
|
treeStorageRefsForDisplayNames,
|
||||||
|
treeTagDisplayForCanonicalName,
|
||||||
|
} from "./tagTree";
|
||||||
|
|
||||||
export type SnippetRow = {
|
export type SnippetRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,20 +19,20 @@ export type SnippetRow = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
tags: 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. */
|
/** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */
|
||||||
function parseSearchQuery(raw: string): {
|
function parseSearchQuery(raw: string): {
|
||||||
defaultText: string | null;
|
defaultText: string | null;
|
||||||
tagTerms: string[];
|
tagTermsRaw: string[];
|
||||||
titleTerms: string[];
|
titleTerms: string[];
|
||||||
contentTerms: string[];
|
contentTerms: string[];
|
||||||
} {
|
} {
|
||||||
const tagTerms: string[] = [];
|
const tagTermsRaw: string[] = [];
|
||||||
const titleTerms: string[] = [];
|
const titleTerms: string[] = [];
|
||||||
const contentTerms: string[] = [];
|
const contentTerms: string[] = [];
|
||||||
const re = /(?:^|\s)(tag|title|content):(\S+)/gi;
|
const re = /(?:^|\s)(tag|title|content):(\S+)/gi;
|
||||||
@@ -33,7 +43,7 @@ function parseSearchQuery(raw: string): {
|
|||||||
const val = m[2];
|
const val = m[2];
|
||||||
if (!val) continue;
|
if (!val) continue;
|
||||||
const escaped = escapeLike(val);
|
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 === "title") titleTerms.push(escaped);
|
||||||
else if (kind === "content") contentTerms.push(escaped);
|
else if (kind === "content") contentTerms.push(escaped);
|
||||||
spans.push([m.index, m.index + m[0].length]);
|
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 defaultTrim = rest.replace(/\s+/g, " ").trim();
|
||||||
const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim);
|
const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim);
|
||||||
return { defaultText, tagTerms, titleTerms, contentTerms };
|
return { defaultText, tagTermsRaw, titleTerms, contentTerms };
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
@@ -89,6 +99,7 @@ function loadTagsForSnippetIds(database: Database, ids: number[]): Map<number, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rowToPublic(
|
function rowToPublic(
|
||||||
|
database: Database,
|
||||||
row: {
|
row: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -97,8 +108,15 @@ function rowToPublic(
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
},
|
},
|
||||||
tags: string[],
|
tagsRaw: string[],
|
||||||
): SnippetRow {
|
): 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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
@@ -107,6 +125,8 @@ function rowToPublic(
|
|||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
tags,
|
tags,
|
||||||
|
tag_display,
|
||||||
|
tag_colors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,18 +158,56 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
|||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
|
|
||||||
if (parsed.defaultText !== null) {
|
if (parsed.defaultText !== null) {
|
||||||
conditions.push(`(
|
const treeTagNames = matchingTreeTagNamesForLikePattern(database, parsed.defaultText);
|
||||||
(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')
|
const parts: string[] = [
|
||||||
OR EXISTS (
|
`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`,
|
||||||
|
`EXISTS (
|
||||||
SELECT 1 FROM snippet_tags st
|
SELECT 1 FROM snippet_tags st
|
||||||
JOIN tags t ON t.id = st.tag_id
|
JOIN tags t ON t.id = st.tag_id
|
||||||
WHERE st.snippet_id = s.id
|
WHERE st.snippet_id = s.id
|
||||||
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
|
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
|
||||||
)
|
)`,
|
||||||
)`);
|
];
|
||||||
params.push(parsed.defaultText, parsed.defaultText);
|
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 (
|
conditions.push(`EXISTS (
|
||||||
SELECT 1 FROM snippet_tags st
|
SELECT 1 FROM snippet_tags st
|
||||||
JOIN tags t ON t.id = st.tag_id
|
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);
|
params.push(term);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for (const term of parsed.titleTerms) {
|
for (const term of parsed.titleTerms) {
|
||||||
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
|
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
|
||||||
params.push(term);
|
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 ids = rows.map(r => r.id);
|
||||||
const tagMap = loadTagsForSnippetIds(database, ids);
|
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 {
|
export function getSnippetById(id: number): SnippetRow | null {
|
||||||
@@ -211,7 +270,7 @@ export function getSnippetById(id: number): SnippetRow | null {
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const tagMap = loadTagsForSnippetIds(database, [row.id]);
|
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 {
|
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;
|
if (unique.length === 0) return;
|
||||||
const insertLink = database.prepare(`INSERT OR IGNORE INTO snippet_tags (snippet_id, tag_id) VALUES (?, ?)`);
|
const insertLink = database.prepare(`INSERT OR IGNORE INTO snippet_tags (snippet_id, tag_id) VALUES (?, ?)`);
|
||||||
for (const name of unique) {
|
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);
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
tags: 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