From 95a28640e141f6cd5359c2ab241200e4c69f6333 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 25 Oct 2026 16:03:00 +0100 Subject: [PATCH 1/2] feat(katalog): set aus einem link uebernehmen Amazon, idealo, geizhals, Hersteller-Shops und Set-Datenbanken: Produkt- daten aus der Seite (schema.org, Open Graph, Amazon-Produktseite), sonst aus dem Link selbst, ergaenzt ueber Rebrickable (Lego) und Merlins Bricks (BlueBrixx, Cobi). Fuellt das Formular vor, das Bild wird beim Speichern lokal abgelegt. Abrufe nur an oeffentliche Adressen (safeFetch). Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/katalog/actions.ts | 26 +- src/app/(app)/katalog/link-actions.ts | 225 +++++++++++++++ src/app/(app)/katalog/neu/page.tsx | 42 +-- src/components/katalog/link-import.tsx | 155 ++++++++++ src/components/katalog/set-form.tsx | 40 ++- src/lib/link-import.ts | 380 +++++++++++++++++++++++++ src/lib/net-guard.ts | 66 +++++ src/lib/safe-fetch.ts | 167 +++++++++++ tests/unit/link-import.test.ts | 209 ++++++++++++++ 9 files changed, 1271 insertions(+), 39 deletions(-) create mode 100644 src/app/(app)/katalog/link-actions.ts create mode 100644 src/components/katalog/link-import.tsx create mode 100644 src/lib/link-import.ts create mode 100644 src/lib/net-guard.ts create mode 100644 src/lib/safe-fetch.ts create mode 100644 tests/unit/link-import.test.ts diff --git a/src/app/(app)/katalog/actions.ts b/src/app/(app)/katalog/actions.ts index 82c180d..44cecc1 100644 --- a/src/app/(app)/katalog/actions.ts +++ b/src/app/(app)/katalog/actions.ts @@ -5,8 +5,10 @@ import { and, eq, ne } from 'drizzle-orm'; import { db } from '@/db'; import { instruction, photo, set } from '@/db/schema'; import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result'; +import { env } from '@/env'; +import { safeFetch, SafeFetchError } from '@/lib/safe-fetch'; import { requireUser } from '@/lib/session'; -import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads'; +import { deleteStoredFile, storeImage, storeImageBytes, storePdf, UploadError } from '@/lib/uploads'; import { optionalText, optionalUrl, setSchema } from '@/lib/validation'; import { z } from 'zod'; @@ -20,6 +22,23 @@ function revalidateSet(id?: string) { if (id) revalidatePath(`/katalog/${id}`); } +async function cacheRemoteImage(url: string, setId: string): Promise { + try { + const response = await safeFetch(url, { + accept: 'image/webp,image/png,image/jpeg;q=0.9,*/*;q=0.5', + maxBytes: env.MAX_UPLOAD_BYTES, + }); + if (response.status !== 200) return null; + return (await storeImageBytes(response.body, 'set-image', setId)).filePath; + } catch (error) { + if (error instanceof SafeFetchError || error instanceof UploadError) { + console.error('[katalog] Bild konnte nicht übernommen werden', url, error.message); + return null; + } + throw error; + } +} + function fileFrom(formData: FormData, field: string): File | null { const value = formData.get(field); return value instanceof File && value.size > 0 ? value : null; @@ -60,12 +79,17 @@ export async function createSet(formData: FormData): Promise /^https?:\/\//i.test(value), 'Nur http- und https-Links.'), +}); + +/** Manufacturer website for the ones we create on the fly. */ +const WEBSITES: Record = { + Lego: 'https://www.lego.com/de-de', + Playmobil: 'https://www.playmobil.de', + Cobi: 'https://cobi.pl', + BlueBrixx: 'https://www.bluebrixx.com', + 'Mega Bloks': 'https://www.megabrands.com', +}; + +/** Merlins Bricks keys its set pages by a brand prefix. */ +const MERLIN_PREFIX: Record = { BlueBrixx: 'bb', Cobi: 'cobi' }; + +function key(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function ensureManufacturer(name: string): string { + const rows = db + .select({ id: manufacturer.id, name: manufacturer.name, slug: manufacturer.slug }) + .from(manufacturer) + .all(); + + const match = rows.find((row) => key(row.name) === key(name) || row.slug === key(name)); + if (match) return match.id; + + const id = crypto.randomUUID(); + const now = new Date(); + db.insert(manufacturer) + .values({ + id, + name, + slug: uniqueSlug(name, rows.map((row) => row.slug)), + website: WEBSITES[name] ?? null, + createdAt: now, + updatedAt: now, + }) + .run(); + return id; +} + +async function readPage(url: URL): Promise<{ hints: LinkHints | null; finalUrl: URL; blocked: boolean }> { + const response = await safeFetch(url); + const finalUrl = new URL(response.url); + const isHtml = /html|xml/i.test(response.contentType); + + if (response.status !== 200 || !isHtml) return { hints: null, finalUrl, blocked: true }; + return { hints: hintsFromHtml(response.body.toString('utf8'), finalUrl), finalUrl, blocked: false }; +} + +async function fromRebrickable(setNumber: string): Promise<(LinkHints & { minifigCount?: number }) | null> { + if (!isRebrickableEnabled()) return null; + try { + const remote = await fetchSet(setNumber); + if (!remote) return null; + const minifigs = await fetchMinifigCount(remote.setNumber); + return { + name: remote.name, + year: remote.year ?? undefined, + pieceCount: remote.pieceCount ?? undefined, + imageUrl: remote.imageUrl ?? undefined, + minifigCount: minifigs ?? undefined, + }; + } catch (error) { + if (error instanceof RebrickableError) return null; + throw error; + } +} + +async function fromMerlinsBricks(brand: string, setNumber: string): Promise { + const prefix = MERLIN_PREFIX[brand]; + if (!prefix || !/^[\w-]+$/.test(setNumber)) return null; + try { + const page = await readPage(new URL(`https://www.merlinsbricks.com/sets/${prefix}-${setNumber}/`)); + return page.hints; + } catch { + return null; + } +} + +export async function previewSetFromLink(input: unknown): Promise> { + await requireUser(); + + const parsed = inputSchema.safeParse(input); + if (!parsed.success) return actionError(parsed.error.issues[0]?.message ?? 'Ungültiger Link.'); + + const url = new URL(parsed.data.url); + const sources: string[] = []; + + // 1. The page itself, if it lets us in. A short link (amzn.eu/...) still + // tells us where it leads, even when the target then refuses. + let pageHints: LinkHints | null = null; + let finalUrl = url; + let pageBlocked = true; + try { + const page = await readPage(url); + pageHints = page.hints; + finalUrl = page.finalUrl; + pageBlocked = page.blocked; + } catch (error) { + if (!(error instanceof SafeFetchError)) throw error; + if (/intern/i.test(error.message)) return actionError(error.message); + } + if (pageHints) sources.push(sourceLabel(finalUrl)); + + // 2. The address. + const addressHints = mergeHints(hintsFromUrl(finalUrl), hintsFromUrl(url)); + if (!pageHints || pageBlocked) sources.push(`Link (${sourceLabel(finalUrl)})`); + + let hints = mergeHints(pageHints, addressHints); + let minifigCount: number | null = null; + + // 3. A set database for the brand. + if (hints.manufacturer === 'Lego' && hints.setNumber) { + const remote = await fromRebrickable(hints.setNumber); + if (remote) { + // The shop's German name beats Rebrickable's English one; Rebrickable's + // facts and clean picture beat the shop's. + hints = mergeHints({ name: pageHints?.name ?? addressHints.name, ean: hints.ean }, remote, hints); + minifigCount = remote.minifigCount ?? null; + sources.push('Rebrickable'); + } + } else if (hints.manufacturer && hints.setNumber && !/merlinsbricks\./.test(finalUrl.hostname)) { + const merlin = await fromMerlinsBricks(hints.manufacturer, hints.setNumber); + if (merlin) { + hints = mergeHints(hints, merlin); + sources.push('Merlins Bricks'); + } + } + + if (!hints.name && !hints.setNumber) { + return actionError( + pageBlocked + ? `${sourceLabel(finalUrl)} lässt keine Abfrage zu, und der Link selbst verrät nichts über das Set. Versuch es mit einem anderen Link oder trag das Set von Hand ein.` + : 'Auf der Seite stehen keine Produktdaten, mit denen sich etwas anfangen lässt.', + ); + } + + const manufacturerName = hints.manufacturer ?? null; + const manufacturerId = manufacturerName ? ensureManufacturer(manufacturerName) : null; + const setNumber = hints.setNumber ?? ''; + + const existing = + manufacturerId && setNumber + ? db + .select({ id: set.id }) + .from(set) + .where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber))) + .get() + : undefined; + + return actionData({ + manufacturerId, + manufacturerName, + setNumber, + name: hints.name ? cleanSetName(hints.name, manufacturerName ?? undefined, setNumber) : '', + year: hints.year ?? null, + pieceCount: hints.pieceCount ?? null, + minifigCount, + ean: hints.ean ?? '', + imageUrl: hints.imageUrl ?? '', + sourceUrl: url.toString(), + sources, + pageBlocked, + existingSetId: existing?.id ?? null, + }); +} diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx index 8abdeab..e19c47d 100644 --- a/src/app/(app)/katalog/neu/page.tsx +++ b/src/app/(app)/katalog/neu/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { NewSetEntry } from '@/components/katalog/link-import'; import { RebrickableImport } from '@/components/katalog/rebrickable-import'; -import { SetForm } from '@/components/katalog/set-form'; import { Button } from '@/components/ui/button'; import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { isRebrickableEnabled } from '@/lib/rebrickable'; @@ -25,26 +25,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea const importEnabled = isRebrickableEnabled(); - if (manufacturers.length === 0) { - return ( -
-

Neues Set

- - {/* The Lego import creates its manufacturer itself, so it works even - on a completely empty instance. */} - - -

- Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder - was auch immer im Kinderzimmer steht. -

- -
- ); - } - + // One layout for both cases: the link import may create the first + // manufacturer, and its draft must survive the refresh that follows. return (
@@ -54,15 +36,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea Sammlung hinzufügen“ fest.

+ {/* The Lego import creates its manufacturer itself, so it works even + on a completely empty instance. */} -
- - oder von Hand - -
- - + +

+ Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder + was auch immer im Kinderzimmer steht. +

+ +
); } diff --git a/src/components/katalog/link-import.tsx b/src/components/katalog/link-import.tsx new file mode 100644 index 0000000..2db3bed --- /dev/null +++ b/src/components/katalog/link-import.tsx @@ -0,0 +1,155 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Link2, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions'; +import { SetForm, type SetPrefill } from '@/components/katalog/set-form'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +function toPrefill(preview: LinkPreview): SetPrefill { + return { + manufacturerId: preview.manufacturerId ?? undefined, + setNumber: preview.setNumber || undefined, + name: preview.name || undefined, + year: preview.year, + pieceCount: preview.pieceCount, + minifigCount: preview.minifigCount, + ean: preview.ean || null, + imageUrl: preview.imageUrl || null, + cacheImage: Boolean(preview.imageUrl), + notes: `Übernommen aus: ${preview.sourceUrl}`, + }; +} + +/** + * "Neues Set" with the link import on top: paste a link from a shop, a price + * comparison or a set database, check the prefilled form, save. + */ +export function NewSetEntry({ + manufacturers, + themes, + defaultEan, + children, +}: { + manufacturers: Array<{ id: string; name: string }>; + themes: Array<{ id: string; name: string; manufacturerId: string }>; + defaultEan?: string; + /** Shown instead of the form while there is no manufacturer yet. */ + children?: React.ReactNode; +}) { + const router = useRouter(); + const [url, setUrl] = useState(''); + const [prefill, setPrefill] = useState(undefined); + const [version, setVersion] = useState(0); + const [existing, setExisting] = useState<{ id: string; label: string } | null>(null); + const [pending, startTransition] = useTransition(); + + function submit(event: React.FormEvent) { + event.preventDefault(); + setExisting(null); + + startTransition(async () => { + const result = await previewSetFromLink({ url }); + if (!result.ok) { + toast.error(result.error); + return; + } + + const preview = result.data; + if (preview.existingSetId) { + setExisting({ + id: preview.existingSetId, + label: [preview.setNumber, preview.name].filter(Boolean).join(' · '), + }); + return; + } + + setPrefill(toPrefill(preview)); + setVersion((value) => value + 1); + // A manufacturer may just have been created for the draft. + router.refresh(); + + const missing = [ + preview.manufacturerId ? null : 'Hersteller', + preview.setNumber ? null : 'Setnummer', + preview.name ? null : 'Name', + ].filter(Boolean); + + toast.success( + missing.length === 0 + ? `Übernommen aus ${preview.sources.join(', ')}. Bitte kurz prüfen und speichern.` + : `Übernommen aus ${preview.sources.join(', ')} – ${missing.join(', ')} bitte ergänzen.`, + ); + if (preview.pageBlocked) { + toast.info('Die Seite selbst ließ sich nicht lesen – die Angaben stammen aus dem Link.'); + } + }); + } + + return ( + <> + + + Aus einem Link übernehmen + + Link zu Amazon, idealo, geizhals, zum Shop des Herstellers oder zu einer Set-Datenbank + einfügen. Die Angaben landen im Formular unten – gespeichert wird erst, wenn du es + sagst. + + + +
+ + setUrl(event.target.value)} + placeholder="https://…" + maxLength={2000} + /> + + +
+ + {existing ? ( +

+ Das Set ist schon im Katalog:{' '} + + {existing.label} + +

+ ) : null} +
+
+ +
+ + {prefill ? 'Bitte prüfen und speichern' : 'oder von Hand'} + +
+ + {manufacturers.length === 0 ? ( + children + ) : ( + + )} + + ); +} diff --git a/src/components/katalog/set-form.tsx b/src/components/katalog/set-form.tsx index 0b2ac7e..eeee908 100644 --- a/src/components/katalog/set-form.tsx +++ b/src/components/katalog/set-form.tsx @@ -47,19 +47,26 @@ export type SetFormValues = { notes: string | null; }; +/** A draft for a new set, e.g. from the link import. */ +export type SetPrefill = Partial> & { + /** Download imageUrl on save and keep it locally. */ + cacheImage?: boolean; +}; + type Props = { manufacturers: Array<{ id: string; name: string }>; themes: Array<{ id: string; name: string; manufacturerId: string }>; set?: SetFormValues; /** Prefilled EAN coming from the barcode scanner (phase 2). */ defaultEan?: string; + prefill?: SetPrefill; }; -export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { +export function SetForm({ manufacturers, themes, set, defaultEan, prefill }: Props) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [manufacturerId, setManufacturerId] = useState( - set?.manufacturerId ?? manufacturers[0]?.id ?? '', + set?.manufacturerId ?? prefill?.manufacturerId ?? manufacturers[0]?.id ?? '', ); const [duplicates, setDuplicates] = useState(null); const pendingForm = useRef(null); @@ -167,7 +174,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { @@ -204,7 +211,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { name="pieceCount" type="number" inputMode="numeric" - defaultValue={set?.pieceCount ?? ''} + defaultValue={set?.pieceCount ?? prefill?.pieceCount ?? ''} min={0} /> @@ -215,7 +222,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { name="minifigCount" type="number" inputMode="numeric" - defaultValue={set?.minifigCount ?? ''} + defaultValue={set?.minifigCount ?? prefill?.minifigCount ?? ''} min={0} /> @@ -235,7 +242,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { id="ean" name="ean" inputMode="numeric" - defaultValue={set?.ean ?? defaultEan ?? ''} + defaultValue={set?.ean ?? prefill?.ean ?? defaultEan ?? ''} maxLength={20} className="font-mono" /> @@ -256,10 +263,18 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { name="imageUrl" type="url" inputMode="url" - defaultValue={set?.imageUrl ?? ''} + defaultValue={set?.imageUrl ?? prefill?.imageUrl ?? ''} placeholder="https://…" /> + {prefill?.cacheImage && prefill.imageUrl ? ( + <> + +

+ Das Bild aus dem Link wird beim Speichern heruntergeladen und lokal abgelegt. +

+ + ) : null}
@@ -286,7 +301,12 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
-