diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index c783c7e..a3d36b1 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -23,6 +23,7 @@ "openid-client": "6.8.4", "pino": "10.3.1", "pino-http": "11.0.0", + "yaml": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/artifacts/api-server/src/middleware/feature.ts b/artifacts/api-server/src/middleware/feature.ts index b48282a..26556a6 100644 --- a/artifacts/api-server/src/middleware/feature.ts +++ b/artifacts/api-server/src/middleware/feature.ts @@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express"; const TIER_FEATURES: Record = { free: ["browse", "rate", "search"], - premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"], - enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "sso", "audit-export", "api-access"], + premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "tool-import"], + enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "tool-import", "sso", "audit-export", "api-access"], }; export function getEntitlements(tier: string | undefined, role: string | undefined): string[] { diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts index 49a52b6..a25fc64 100644 --- a/artifacts/api-server/src/routes/admin.ts +++ b/artifacts/api-server/src/routes/admin.ts @@ -1,9 +1,12 @@ import { Router, type IRouter } from "express"; import { eq, and, sql } from "drizzle-orm"; import { z } from "zod"; +import { parse as parseYaml } from "yaml"; import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db"; import { requireAdmin } from "../middleware/auth"; +import { requireFeature } from "../middleware/feature"; import { writeAuditLog } from "../lib/audit"; +import { CreateToolBody } from "@workspace/api-zod"; const EvaluateBody = z.object({ toolId: z.coerce.number().int().positive(), @@ -219,4 +222,184 @@ router.post("/admin/redundancy/evaluate", requireAdmin, async (req, res): Promis res.json({ ok: true }); }); +const ToolImportBody = z.object({ + format: z.enum(["auto", "csv", "json", "yaml"]).default("auto"), + delimiter: z.enum(["auto", "comma", "semicolon", "tab"]).default("auto"), + data: z.string().min(1), +}); + +const TOOL_FIELDS = ["name", "description", "category", "websiteUrl", "iconUrl", "features", "tags"] as const; + +function splitList(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map((v) => String(v).trim()).filter(Boolean); + } + if (typeof value === "string") { + return value + .split(/[|;,]/) + .map((v) => v.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeTool(raw: Record): Record { + const out: Record = {}; + for (const key of TOOL_FIELDS) { + if (key in raw) out[key] = raw[key]; + } + if ("features" in out) out.features = splitList(out.features); + if ("tags" in out) out.tags = splitList(out.tags); + return out; +} + +function parseCsv(data: string, delimiter: string): Record[] { + let sep = ","; + if (delimiter === "semicolon") sep = ";"; + else if (delimiter === "tab") sep = "\t"; + else if (delimiter === "auto") { + const firstLine = data.split(/\r?\n/)[0] ?? ""; + const candidates = [",", ";", "\t"]; + const counts = candidates.map((c) => (firstLine.match(new RegExp(`\\${c === "\t" ? "\\t" : c}`, "g")) ?? []).length); + const max = Math.max(...counts); + if (max > 0) sep = candidates[counts.indexOf(max)]; + } + + const lines = data.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length === 0) return []; + const header = parseCsvLine(lines[0], sep); + const rows: Record[] = []; + for (let i = 1; i < lines.length; i++) { + const cells = parseCsvLine(lines[i], sep); + const row: Record = {}; + header.forEach((h, idx) => { + const key = h.trim(); + if (key) row[key] = cells[idx] ?? ""; + }); + rows.push(row); + } + return rows; +} + +function parseCsvLine(line: string, sep: string): string[] { + const cells: string[] = []; + let cur = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + cur += '"'; + i++; + } else { + inQuotes = false; + } + } else { + cur += ch; + } + } else if (ch === '"') { + inQuotes = true; + } else if (ch === sep) { + cells.push(cur); + cur = ""; + } else { + cur += ch; + } + } + cells.push(cur); + return cells; +} + +router.post( + "/admin/tools/import", + requireAdmin, + requireFeature("tool-import"), + async (req, res): Promise => { + const parsed = ToolImportBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const { format, delimiter, data } = parsed.data; + + let rawRows: Record[] = []; + let effectiveFormat = format; + if (format === "auto") { + const trimmed = data.trimStart(); + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + effectiveFormat = "json"; + } else if (trimmed.includes(":") && !trimmed.startsWith("\"")) { + effectiveFormat = "yaml"; + } else { + effectiveFormat = "csv"; + } + } + + try { + if (effectiveFormat === "json") { + const json = JSON.parse(data); + if (Array.isArray(json)) { + rawRows = json.filter((r) => r && typeof r === "object") as Record[]; + } else if (json && typeof json === "object") { + rawRows = [json as Record]; + } else { + res.status(400).json({ error: "JSON data must be an object or an array of objects" }); + return; + } + } else if (effectiveFormat === "yaml") { + const yaml = parseYaml(data); + if (Array.isArray(yaml)) { + rawRows = yaml.filter((r) => r && typeof r === "object") as Record[]; + } else if (yaml && typeof yaml === "object") { + rawRows = [yaml as Record]; + } else { + res.status(400).json({ error: "YAML data must be an object or a list of objects" }); + return; + } + } else { + rawRows = parseCsv(data, delimiter); + } + } catch (err) { + res.status(400).json({ error: `Failed to parse ${effectiveFormat} data: ${(err as Error).message}` }); + return; + } + + const errors: { row: number; error: string }[] = []; + const validRows: { name: string; description: string; category: string; websiteUrl?: string; iconUrl?: string; features: string[]; tags: string[]; createdBy: string }[] = []; + const user = req.session.user!; + const createdBy = user.preferred_username || user.name || user.sub; + + rawRows.forEach((raw, idx) => { + const normalized = normalizeTool(raw); + const check = CreateToolBody.safeParse(normalized); + if (!check.success) { + errors.push({ row: idx + 1, error: check.error.message }); + return; + } + validRows.push({ + name: check.data.name, + description: check.data.description, + category: check.data.category, + websiteUrl: check.data.websiteUrl, + iconUrl: check.data.iconUrl, + features: check.data.features ?? [], + tags: check.data.tags ?? [], + createdBy, + }); + }); + + let imported = 0; + if (validRows.length > 0) { + const inserted = await db.insert(toolsTable).values(validRows).returning({ id: toolsTable.id, name: toolsTable.name, category: toolsTable.category }); + imported = inserted.length; + for (const tool of inserted) { + await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category, source: "import" }); + } + } + + res.json({ imported, total: rawRows.length, errors }); + }, +); + export default router; diff --git a/artifacts/toolrate/src/components/admin-tools-tab.tsx b/artifacts/toolrate/src/components/admin-tools-tab.tsx index 6856ea8..96ba18f 100644 --- a/artifacts/toolrate/src/components/admin-tools-tab.tsx +++ b/artifacts/toolrate/src/components/admin-tools-tab.tsx @@ -15,7 +15,6 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; -import { GuideHelp } from "@/components/guide-help"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -24,12 +23,13 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Skeleton } from "@/components/ui/skeleton"; import { useToast } from "@/hooks/use-toast"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react"; +import { ToolImportDialog } from "@/components/tool-import-dialog"; +import { Search, Trash2, ExternalLink, Pencil, Star, Upload } from "lucide-react"; import { format } from "date-fns"; export function AdminToolsTab() { const { t } = useTranslation(); - const { isAdmin, isLoading: authLoading } = useAuth(); + const { isAdmin, hasFeature, isLoading: authLoading } = useAuth(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -37,6 +37,7 @@ export function AdminToolsTab() { const [search, setSearch] = useState(""); const [selected, setSelected] = useState>(new Set()); const [confirmTrash, setConfirmTrash] = useState(false); + const [importOpen, setImportOpen] = useState(false); useEffect(() => { const t = setTimeout(() => setSearch(searchInput), 300); @@ -106,15 +107,22 @@ export function AdminToolsTab() {
- - {t("adminTools.allTools")} - - + {t("adminTools.allTools")} {selectedIds.length > 0 ? t("adminTools.selected", { count: selectedIds.length }) : t("adminTools.toolCount", { count: allTools.length })}
+ {hasFeature("tool-import") && ( + + )}
+ + { + toast({ title: t("adminTools.toastImportDone") }); + invalidate(); + }} + /> ); } diff --git a/artifacts/toolrate/src/components/field-help.tsx b/artifacts/toolrate/src/components/field-help.tsx deleted file mode 100644 index cb9654f..0000000 --- a/artifacts/toolrate/src/components/field-help.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { HelpCircle } from "lucide-react"; -import { Link } from "wouter"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; - -export function FieldHelp({ - schema, - field, - children, -}: { - schema: string; - field: string; - children?: React.ReactNode; -}) { - const label = children ?? field; - return ( - - - - - - - {label} — Details in der Dokumentation - - ); -} diff --git a/artifacts/toolrate/src/components/guide-help.tsx b/artifacts/toolrate/src/components/guide-help.tsx index 788c6d8..b30c894 100644 --- a/artifacts/toolrate/src/components/guide-help.tsx +++ b/artifacts/toolrate/src/components/guide-help.tsx @@ -1,5 +1,7 @@ -import { BookOpen } from "lucide-react"; +import { HelpCircle } from "lucide-react"; import { Link } from "wouter"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; export function GuideHelp({ @@ -9,21 +11,24 @@ export function GuideHelp({ guide: string; label: string; }) { + const { t } = useTranslation(); return ( - - - + - {label} — Anleitung in der Dokumentation + {label} — {t("common.guideTooltip")} ); } diff --git a/artifacts/toolrate/src/components/tool-import-dialog.tsx b/artifacts/toolrate/src/components/tool-import-dialog.tsx new file mode 100644 index 0000000..b441872 --- /dev/null +++ b/artifacts/toolrate/src/components/tool-import-dialog.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useImportTools, type ToolImportResponse } from "@workspace/api-client-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Upload, FileUp, CheckCircle2, AlertCircle } from "lucide-react"; + +type Format = "auto" | "csv" | "json" | "yaml"; +type Delimiter = "auto" | "comma" | "semicolon" | "tab"; + +export function ToolImportDialog({ + open, + onOpenChange, + onImported, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: () => void; +}) { + const { t } = useTranslation(); + const importTools = useImportTools(); + const [format, setFormat] = useState("auto"); + const [delimiter, setDelimiter] = useState("auto"); + const [data, setData] = useState(""); + const [result, setResult] = useState(null); + + function handleFile(file: File) { + const reader = new FileReader(); + reader.onload = () => { + setData(String(reader.result ?? "")); + }; + reader.readAsText(file); + } + + function handleImport() { + importTools.mutate( + { data: { format, delimiter, data } }, + { + onSuccess: (res) => { + setResult(res); + if (res.imported > 0) onImported(); + }, + }, + ); + } + + function handleClose(open: boolean) { + if (!importTools.isPending) { + onOpenChange(open); + if (!open) { + setData(""); + setResult(null); + setFormat("auto"); + setDelimiter("auto"); + } + } + } + + const canSubmit = data.trim().length > 0 && !importTools.isPending; + + return ( + + + + + + {t("adminTools.importTitle")} + + + +
+

{t("adminTools.importSub")}

+ + setFormat(v as Format)}> + + {t("adminTools.importFormatAuto")} + CSV + JSON + YAML + + + {t("adminTools.importFormatAutoSub")} + + +
+ + +
+

{t("adminTools.importCsvSub")}

+
+ +

{t("adminTools.importJsonSub")}

+
+ +

{t("adminTools.importYamlSub")}

+
+
+ +
+ + { + const f = e.target.files?.[0]; + if (f) handleFile(f); + e.target.value = ""; + }} + /> +
+ +