Files
kochwas/docs/ARCHITECTURE.md

162 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kochwas — Architektur
## Stack
- **SvelteKit 2** + **Svelte 5 Runes** (`$state`, `$derived`, `$effect`, `$props`)
- **TypeScript strict**
- **SQLite** über `better-sqlite3` (synchron, native Binding arm64)
- **FTS5** Virtual Tables mit BM25-Ranking für Volltext-Suche
- **linkedom** für HTML-Parsing (JSON-LD-Extraktion, og:image-Enrichment)
- **zod** für API-Schema-Validierung
- Adapter: `@sveltejs/adapter-node` → Node 22 Alpine im Container
## Top-Level-Struktur
```
src/
├── app.html, app.d.ts # Shell + Env-Types
├── service-worker.ts # PWA-Shell
├── lib/
│ ├── client/ # reaktive Stores (Profile, Search, Wishlist, PWA, Network, Sync, Toast, Install, Confirm, API-Fetch-Wrapper)
│ ├── components/ # Svelte-Komponenten:
│ │ # - Recipe: RecipeView, RecipeEditor + Editor-Sub-Components
│ │ # (IngredientRow, StepList, ImageUploadBox, TimeDisplay, recipe-editor-types)
│ │ # - UI-Shell: ConfirmDialog, ProfileSwitcher, SyncIndicator, Toast, UpdateToast
│ │ # - Search: SearchFilter, SearchLoader, StarRating
│ ├── recipes/ # shared: Portionen-Scaler (Client UND Server)
│ ├── server/ # nur Server-Code (nie in Client-Bundle!)
│ │ ├── db/ # openDb, Migrations, DB-Singleton
│ │ ├── domains/ # Whitelist-Repo
│ │ ├── http.ts # fetch-Wrapper mit Timeout / maxBytes / extraHeaders
│ │ ├── images/ # Download, SHA256-Dedup, Save
│ │ ├── parsers/ # json-ld-recipe.ts, iso8601-duration.ts
│ │ ├── profiles/ # Profile-Repo
│ │ ├── recipes/ # importer, actions, repository, search-local
│ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache)
│ │ ├── wishlist/ # Repo
│ │ └── backup/ # ZIP-Export via archiver, Import via yauzl
│ ├── quotes.ts # 150 Flachwitze für die Homepage
│ └── types.ts # shared types
└── routes/
├── +layout.svelte # Header, Confirm-Dialog-Mount, Header-Search-Dropdown
├── +page.svelte # Home: Hero + Live-Search + Zuletzt-hinzugefügt
├── recipes/[id]/ # Rezept-Detail
├── preview/ # Vorschau vor dem Speichern
├── wishlist/
├── admin/ # Whitelist, Profile, Backup/Restore
├── images/[filename] # Statische Auslieferung lokaler Bilder
└── api/ # REST-Endpoints
```
## Datenfluss
### Import (User klickt auf Web-Treffer)
1. User klickt auf Web-Hit → `/preview?url=...`
2. `/api/recipes/preview``importer.ts` lädt HTML, `parseHTML` von linkedom, `json-ld-recipe.ts` extrahiert `Recipe`-Objekt mit **externer** Bild-URL
3. Preview-Seite rendert das `Recipe` via `RecipeView.svelte` (erkennt externe URL und lädt direkt vom Original-CDN)
4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `ingredient` + `step` + `recipe_tag`
5. Redirect zu `/recipes/[id]`
### Foto-Rezept-Magie (AI-Extraktion)
1. User klickt Camera-Icon im Header → `/new/from-photo` (nur gerendert wenn `GEMINI_API_KEY` gesetzt; disabled wenn offline)
2. File-Picker mit `capture="environment"` öffnet direkt die Rückkamera auf Mobile
3. Upload als `multipart/form-data``POST /api/recipes/extract-from-photo`
4. Server: MIME-Whitelist + 8 MB-Gate + Rate-Limit (10/min/IP) → `preprocessImage` (sharp, ≤1600px lange Kante, JPEG-Re-encode, Metadata-Strip) → `extractRecipeFromImage` (Gemini 2.5 Flash mit structured `responseSchema`, `temperature: 0.1`, Zod-validiert, 1× Retry bei Schema-Fehler oder 5xx) → zufällige Description aus `description-phrases.ts` (50er-Pool) → Response mit `Partial<Recipe>`
5. **Das Original-Foto wird nicht persistiert.** Der Server loggt keine Prompt/Response-Inhalte — nur Code, Dauer, Byte-Größe.
6. Client hält das Ergebnis im `PhotoUploadStore` und rendert `<RecipeEditor recipe={extracted}>`. Weil `recipe.id === null` ist, blendet der Editor den `ImageUploadBox`-Block aus und zeigt nur den Hinweis „Bild kannst du nach dem Speichern hinzufügen."
7. User editiert und klickt „Speichern" → `POST /api/recipes` (neuer Scratch-Insert-Endpoint, wrappt `insertRecipe`) → Redirect auf `/recipes/:id`
### Web-Suche
Die gesamte Live-Search-Logik ist im `SearchStore` (`src/lib/client/search.svelte.ts`) gekapselt: Debounce, Race-Guard, Pagination, Web-Fallback, Snapshot/Restore für Back-Nav. Sowohl Header-Dropdown (`+layout.svelte`) als auch Startseite (`+page.svelte`) teilen sich die Klasse mit unterschiedlicher `filterParam`-Quelle.
1. User tippt → 300 ms Debounce → `/api/recipes/search?q=...` (lokal FTS5)
2. Wenn 0 Treffer: automatisch `/api/recipes/search/web?q=...`
3. `searxng.ts` → SearXNG-API mit `site:domain OR site:domain2 ...`-Filter aus Whitelist
4. Filtert Non-Recipe-Pfade (Foren, Magazin, Listings) via `NON_RECIPE_PATH_PATTERNS`
5. Pro Treffer: parallel (max 6) `enrichThumbnail`:
- SQLite-Cache hit → return
- Sonst: Seite holen (max 512 KB, 4 s), `extractPageImage`: og:image → link rel=image_src → JSON-LD → erstes Content-img
- Ergebnis (auch null) in `thumbnail_cache` persistieren (30 Tage TTL)
- **Überschreibt** bestehendes SearXNG-Thumbnail, weil das meist LowRes ist
### Confirm / Alert
Promise-basiert statt `window.confirm`/`window.alert`:
```ts
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
if (await confirmAction({ title: 'Löschen?', destructive: true })) { /* ... */ }
await alertAction({ title: 'Fehler', message: 'xyz' });
```
Gemeinsame Komponente `ConfirmDialog.svelte` wird im Root-Layout einmal gemountet. Store (`confirmStore`) hält die Promise-Resolve-Funktion, Komponente rendert nur wenn `pending !== null`.
## Design-Entscheidungen
- **Kein Login, nur Profile**: Profile werden beim Start gewählt, in localStorage persistiert. Actions (Rating, Favorit, Cooked, Kommentar) brauchen aktives Profil → sonst Custom-Alert „Bitte Profil wählen".
- **FTS5 als Haupt-Suche**: statt externer Search-Engine-DB. Passt zu SQLite-only-Stack.
- **JSON-LD first**: Alle drei Ziel-Domains (Chefkoch, Emmi, Experimente) liefern `schema.org/Recipe` im JSON-LD. LLM-Fallback war geplant, aktuell nicht nötig.
- **SearXNG als Such-Engine**: Self-hosted, daher keine API-Keys. Das Bot-Detection-Theater wird mit gesetzten `X-Forwarded-For`-Headern aus Docker-IPs umgangen.
- **Thumbnail-Cache in SQLite**: 30 Tage TTL (per `KOCHWAS_THUMB_TTL_DAYS`). Negative Einträge (Seite ohne Bild) werden auch gecacht.
- **Svelte 5 Runes** — kein `$:` mehr, keine alten Stores außer `$app/stores`. Neue Stores via Klasse mit `$state`-Feldern. Form-lokale Snapshots in Edit-Komponenten mit `untrack()` aus `svelte`, damit Prop-Updates nicht laufende Edits überschreiben.
- **Zutaten-Sektionen** (ab Migration 012, v1.2): `ingredient.section_heading TEXT NULL`. Ist das Feld gesetzt, startet an dieser Zeile eine neue Sektion — folgende Zutaten gehören dazu, bis die nächste Zeile wieder ein Heading hat. Kein zweites Tabellen-Modell, Ordnung bleibt `position`. Importer setzt immer `null` (schema.org/Recipe hat das Konzept nicht). Editor erlaubt Inline-Insert via `Abschnitt hinzufügen`-Button vor jeder Zeile; leeres Heading wird beim Save zu `null` normalisiert.
- **Service Worker** rein zum Shell-Cachen für Offline-First-PWA, kein intelligentes Cache-Matching (keine externe Rezept-Seiten).
## Migrations-Workflow
Bei Schema-Änderung:
1. Neue Datei `src/lib/server/db/migrations/00N_beschreibung.sql` — nächste freie Nummer
2. SQL sollte nicht-destruktiv sein (nur `CREATE`, `ALTER ADD`); keine `DROP` auf bestehende Daten
3. `migrate.ts` liest via Vite-Glob und führt neue Einträge aus (über `schema_migrations`-Tabelle getrackt)
4. Tests anpassen: `db idempotent` zählt vorher/nachher — bleibt automatisch grün
## Test-Strategie
- **Unit**: `tests/unit/` — pure Funktionen + Client-Stores via jsdom (json-ld-recipe, iso8601-duration, quotes-random, scaler, ingredient-parser, SearchStore, PWA/Toast/Sync-Stores, SW-Logik).
- **Integration**: `tests/integration/` — mit `openInMemoryForTest()` fresh SQLite pro Test. Externe HTTP via `node:http`-TestServer auf Port 0 gemockt.
- **E2E local**: `tests/e2e/` — Playwright gegen `npm run preview`, deckt PWA-Offline-Lifecycle ab (`offline.spec.ts`).
- **E2E remote**: `tests/e2e/remote/` — Playwright gegen `kochwas-dev.siegeln.net` via `playwright.remote.config.ts` (`workers:1`, `serviceWorkers:block`). Testet Live-API-Verhalten, inkl. destruktiver CRUD-Flows (Recipes, Kommentare, Favoriten). Run: `npm run test:e2e:remote`. Siehe `tests/e2e/remote/fixtures/` für Profile-Setup + idempotente API-Cleanup-Helper.
- **Keine Svelte-Component-Unit-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird per E2E und manuell getestet).
- **Vor Commit**: `npm test && npm run check` muss grün sein. Vor Merge zu main: zusätzlich `npm run test:e2e:remote`.
### Service Worker (PWA)
`src/service-worker.ts` ist SvelteKits eingebauter SW-Slot. Er nutzt `$service-worker` (`build`, `files`, `version`) für den App-Shell-Cache und implementiert eigene Logik für:
- **Pre-Cache** (alle Rezepte + Bilder beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`.
- **Delta-Sync** beim App-Start (diff vs. Cache-Manifest, nur Delta laden).
- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = network-first mit 3 s-Timeout-Fallback auf Cache, Bilder = cache-first.
- **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client.
Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`):
- `src/lib/sw/cache-strategy.ts``resolveStrategy({url, method})``'shell' | 'network-first' | 'images' | 'network-only'`
- `src/lib/sw/diff-manifest.ts``diffManifest(current, cached)``{toAdd, toRemove}`
Client-Stores (SSR-safe via typeof-Guards):
- `src/lib/client/network.svelte.ts``navigator.onLine` + Events.
- `src/lib/client/sync-status.svelte.ts` — SW-Message-Spiegel, `lastSynced` in localStorage.
- `src/lib/client/toast.svelte.ts` — Toast-Queue für Offline-Fehler + Sync-Meldungen.
- `src/lib/client/install-prompt.svelte.ts` — fängt `beforeinstallprompt`, erkennt Plattform.
- `src/lib/client/sw-register.ts` — registriert den SW, leitet Messages an den Sync-Status-Store.
- `src/lib/client/require-online.ts` — Helper für Schreib-Aktionen (Toast statt stillem Fail).
UI-Komponenten:
- `src/lib/components/SyncIndicator.svelte` — Pill unten rechts (Sync-Fortschritt / Offline-Status).
- `src/lib/components/Toast.svelte` — Top-Center-Toast-Renderer.
Admin-UI: `src/routes/admin/app/+page.svelte` mit Install-Button, manuellem Sync-Trigger, Cache-Reset.
E2E-Tests: `tests/e2e/offline.spec.ts` — Playwright setzt das Netzwerk offline und prüft Navigation/Toast/Indikator-Verhalten.
## Was später kommt (laut Spec, aktuell nicht implementiert)
- LLM-Fallback für nicht-JSON-LD-Seiten
- Print-View ist nur rudimentär, könnte ein eigenes Print-CSS bekommen
- Tag-Editor im Admin
- Export-Format JSON zusätzlich zu ZIP