Compare commits
25 Commits
v1.1.0
...
cleanup-ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e953ca7870 | ||
|
|
c1789f902e | ||
|
|
02b9cdbc68 | ||
|
|
5a291a53dd | ||
|
|
98a8022ddf | ||
|
|
5a1ffee3bb | ||
|
|
9ee8efa479 | ||
|
|
2c1fd29003 | ||
|
|
cda6e77a9e | ||
|
|
85fe1312ca | ||
|
|
31c6e5cd1f | ||
|
|
6d9e79d4f0 | ||
|
|
60c8352c96 | ||
|
|
30a447a3ea | ||
|
|
ff293e9db8 | ||
|
|
739cc2d058 | ||
|
|
830c740747 | ||
|
|
2289547503 | ||
|
|
10c43c4d4a | ||
|
|
5283ab9b51 | ||
|
|
aaaf762564 | ||
|
|
dc04f5b032 | ||
|
|
2f2f7dc7e7 | ||
|
|
76ea5bed8d | ||
|
|
f89f363183 |
@@ -2,7 +2,7 @@ name: Build & Publish Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: ['**']
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,13 @@ src/
|
|||||||
│ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache)
|
│ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache)
|
||||||
│ │ ├── wishlist/ # Repo
|
│ │ ├── wishlist/ # Repo
|
||||||
│ │ └── backup/ # ZIP-Export via archiver, Import via yauzl
|
│ │ └── backup/ # ZIP-Export via archiver, Import via yauzl
|
||||||
│ ├── quotes.ts # 49 Flachwitze für die Homepage
|
│ ├── quotes.ts # 150 Flachwitze für die Homepage
|
||||||
│ └── types.ts # shared types
|
│ └── types.ts # shared types
|
||||||
└── routes/
|
└── routes/
|
||||||
├── +layout.svelte # Header, Confirm-Dialog-Mount, Header-Search-Dropdown
|
├── +layout.svelte # Header, Confirm-Dialog-Mount, Header-Search-Dropdown
|
||||||
├── +page.svelte # Home: Hero + Live-Search + Zuletzt-hinzugefügt
|
├── +page.svelte # Home: Hero + Live-Search + Zuletzt-hinzugefügt
|
||||||
├── recipes/[id]/ # Rezept-Detail
|
├── recipes/[id]/ # Rezept-Detail
|
||||||
├── preview/ # Vorschau vor dem Speichern
|
├── preview/ # Vorschau vor dem Speichern
|
||||||
├── search/ # /search (lokal), /search/web (Internet)
|
|
||||||
├── wishlist/
|
├── wishlist/
|
||||||
├── admin/ # Whitelist, Profile, Backup/Restore
|
├── admin/ # Whitelist, Profile, Backup/Restore
|
||||||
├── images/[filename] # Statische Auslieferung lokaler Bilder
|
├── images/[filename] # Statische Auslieferung lokaler Bilder
|
||||||
@@ -52,7 +51,7 @@ src/
|
|||||||
1. User klickt auf Web-Hit → `/preview?url=...`
|
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
|
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)
|
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` + `recipe_ingredient` + `recipe_step` + `recipe_tag`
|
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]`
|
5. Redirect zu `/recipes/[id]`
|
||||||
|
|
||||||
### Web-Suche
|
### Web-Suche
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ Die App hat ein eingebautes Backup unter `/admin` (ZIP-Export mit DB + Bildern).
|
|||||||
| `SEARXNG_URL` | `http://localhost:8888` | SearXNG-Endpoint, im Compose auf `http://searxng:8080` |
|
| `SEARXNG_URL` | `http://localhost:8888` | SearXNG-Endpoint, im Compose auf `http://searxng:8080` |
|
||||||
| `KOCHWAS_THUMB_TTL_DAYS` | `30` | TTL für Thumbnail-Cache in der SQLite |
|
| `KOCHWAS_THUMB_TTL_DAYS` | `30` | TTL für Thumbnail-Cache in der SQLite |
|
||||||
| `DATABASE_PATH` | `data/kochwas.db` | Pfad zur SQLite, relativ oder absolut |
|
| `DATABASE_PATH` | `data/kochwas.db` | Pfad zur SQLite, relativ oder absolut |
|
||||||
| `IMAGES_PATH` | `data/images` | Pfad für lokale Bild-Dateien |
|
| `IMAGE_DIR` | `data/images` | Pfad für lokale Bild-Dateien |
|
||||||
| `PORT` | `3000` | Node-HTTP-Port (adapter-node) |
|
| `PORT` | `3000` | Node-HTTP-Port (adapter-node) |
|
||||||
|
|
||||||
Siehe `.env.example` im Repo.
|
Siehe `.env.example` im Repo.
|
||||||
|
|||||||
153
docs/superpowers/plans/2026-04-18-review-fixes.md
Normal file
153
docs/superpowers/plans/2026-04-18-review-fixes.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Review-Fixes 2026-04-18 — Implementation Plan
|
||||||
|
|
||||||
|
> **Quelle:** `docs/superpowers/review/REVIEW-2026-04-18.md` + Sub-Reports.
|
||||||
|
> **Branch:** `review-fixes-2026-04-18`
|
||||||
|
> **Goal:** Alle HIGH/MEDIUM Findings aus dem Code-Review adressieren, bewusst verschobene Items dokumentieren.
|
||||||
|
> **Architecture:** Inkrementelle Refactors, jeder atomar committed + gepusht, Tests nach jedem Wave grün.
|
||||||
|
> **Tech-Stack:** SvelteKit, TypeScript-strict, Zod, Vitest, better-sqlite3, Service-Worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was wird angegangen (must-do)
|
||||||
|
|
||||||
|
| # | Wave | Zeit | Begründung |
|
||||||
|
|---|------|------|------------|
|
||||||
|
| 1 | Doku-Fixes (ARCHITECTURE/OPERATIONS/handoff) | 5 min | Hoher Wert, trivialer Aufwand |
|
||||||
|
| 2 | constants.ts + Image-Endpoint EN + interne Types | 30 min | Alle "Quick-Wins" aus REVIEW |
|
||||||
|
| 3 | api-helpers.ts (parsePositiveIntParam + validateBody) | 1-2 h | Refactor A — 9+11 Call-Sites |
|
||||||
|
| 4 | requireProfile() + asyncFetch Wrapper | 1 h | Profile-Guard 4× + fetch-Pattern 5× |
|
||||||
|
| 5 | Cleanup (yauzl-Doku, baseRecipe-Fixture, Console-Logs) | 30 min | Restliche LOW-Findings |
|
||||||
|
| 6 | Ingredient-Parser Edge-Cases (Refactor D) | 2-3 h | Locale-Komma, Unicode-Brüche, Bounds |
|
||||||
|
| 7 | Verifikation (test/check/build, Docker-Smoke) | 30 min | Baseline gegen Regressionen |
|
||||||
|
| 8 | Re-Review + OPEN-ISSUES-NEXT.md | 1 h | Beweis + Ausblick |
|
||||||
|
|
||||||
|
## Was bewusst NICHT angegangen wird (Begründung in OPEN-ISSUES-NEXT.md)
|
||||||
|
|
||||||
|
- **Refactor B** (Search-State-Store, halber Tag): Touch von 808-Zeilen-Page + 678-Zeilen-Layout, bricht riskant Frontend ohne UAT. Eigene Phase planen.
|
||||||
|
- **Refactor C** (RecipeEditor zerlegen): Review sagt explizit "keine Eile, solange niemand sonst drin arbeitet".
|
||||||
|
- **SearXNG Rate-Limit Recovery**: Größeres Feature, eigene Phase.
|
||||||
|
- **SW-Zombie-Cleanup Unit-Tests**: Bereits 6 pwa-store-Tests vorhanden, Erweiterung wäre Bonus.
|
||||||
|
- **JSON-LD Parser Edge-Cases** (Locales): Weniger Käse als Ingredient-Parser-Issues, eigene Iteration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 1 — Doku-Fixes
|
||||||
|
|
||||||
|
**Files:** `docs/ARCHITECTURE.md:55`, `docs/OPERATIONS.md:135`, `docs/superpowers/session-handoff-2026-04-17.md:46`
|
||||||
|
|
||||||
|
- [ ] ARCHITECTURE.md: `recipe_ingredient` + `recipe_step` → `ingredient` + `step`
|
||||||
|
- [ ] OPERATIONS.md: `IMAGES_PATH` → `IMAGE_DIR`
|
||||||
|
- [ ] session-handoff: `/api/recipes/[id]/image` (POST/DELETE) ergänzen
|
||||||
|
- [ ] Commit `docs(review): Doku-Mismatches korrigiert`
|
||||||
|
|
||||||
|
## Wave 2 — Konstanten + Cleanup
|
||||||
|
|
||||||
|
**Files:** `src/lib/constants.ts` (neu), `src/routes/+page.svelte`, `src/lib/client/pwa.svelte.ts`, `src/routes/api/recipes/[id]/image/+server.ts`, `src/lib/sw/cache-strategy.ts`, `src/lib/sw/diff-manifest.ts`
|
||||||
|
|
||||||
|
- [ ] `src/lib/constants.ts` mit `SW_VERSION_QUERY_TIMEOUT_MS = 1500`, `SW_UPDATE_POLL_INTERVAL_MS = 30 * 60_000`
|
||||||
|
- [ ] Image-Endpoint: deutsche Fehlermeldungen → englisch (Konsistenz)
|
||||||
|
- [ ] `RequestShape` / `ManifestDiff`: `export` weg wenn rein intern
|
||||||
|
- [ ] Test + check, Commit
|
||||||
|
|
||||||
|
## Wave 3 — api-helpers.ts (TDD)
|
||||||
|
|
||||||
|
**Files:** `src/lib/server/api-helpers.ts` (neu), `tests/unit/api-helpers.test.ts` (neu), `src/lib/types.ts` (ErrorResponse)
|
||||||
|
|
||||||
|
### 3a Helper bauen
|
||||||
|
- [ ] Test: `parsePositiveIntParam("42", "id")` → 42
|
||||||
|
- [ ] Test: `parsePositiveIntParam("0", "id")` wirft 400
|
||||||
|
- [ ] Test: `parsePositiveIntParam("abc", "id")` wirft 400
|
||||||
|
- [ ] Test: `parsePositiveIntParam(null, "id")` wirft 400
|
||||||
|
- [ ] Test: `validateBody(invalid, schema)` wirft 400 mit issues
|
||||||
|
- [ ] Test: `validateBody(valid, schema)` returns parsed
|
||||||
|
- [ ] Implement helpers
|
||||||
|
- [ ] Tests grün, Commit
|
||||||
|
|
||||||
|
### 3b Migration parseId → parsePositiveIntParam (9 Sites)
|
||||||
|
Files (jeder Endpoint):
|
||||||
|
- `src/routes/api/recipes/[id]/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/favorite/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/rating/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/cooked/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/comments/+server.ts`
|
||||||
|
- `src/routes/api/recipes/[id]/image/+server.ts`
|
||||||
|
- `src/routes/api/profiles/[id]/+server.ts`
|
||||||
|
- `src/routes/api/domains/[id]/+server.ts`
|
||||||
|
- `src/routes/api/wishlist/[recipe_id]/+server.ts`
|
||||||
|
|
||||||
|
- [ ] Pro Endpoint: lokales parseId entfernen, Helper importieren
|
||||||
|
- [ ] Tests grün
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
### 3c Migration safeParse → validateBody
|
||||||
|
Files: alle `+server.ts` mit `safeParse`. ErrorResponse-Shape standardisieren.
|
||||||
|
|
||||||
|
- [ ] Pro Endpoint umstellen
|
||||||
|
- [ ] Tests grün
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
## Wave 4 — Client-Helpers
|
||||||
|
|
||||||
|
### 4a requireProfile()
|
||||||
|
- [ ] Helper in `src/lib/client/profile.svelte.ts` ergänzen
|
||||||
|
- [ ] 4 Sites in `src/routes/recipes/[id]/+page.svelte` ersetzen
|
||||||
|
- [ ] Test + Commit
|
||||||
|
|
||||||
|
### 4b asyncFetch Wrapper
|
||||||
|
- [ ] `src/lib/client/api-fetch-wrapper.ts` mit `asyncFetch(url, init, actionTitle)`
|
||||||
|
- [ ] 5 Sites umstellen: `recipes/[id]/+page.svelte` (2×), `admin/domains/+page.svelte` (2×), `admin/profiles/+page.svelte`
|
||||||
|
- [ ] Test + Commit
|
||||||
|
|
||||||
|
## Wave 5 — Cleanup
|
||||||
|
|
||||||
|
- [ ] yauzl: Inline-Kommentar in package.json: "Reserved for Phase 5b ZIP-Backup-Import"
|
||||||
|
- [ ] baseRecipe Fixture nach `tests/fixtures/recipe.ts` (wenn dupliziert)
|
||||||
|
- [ ] Console-Logs: per `if (import.meta.env.DEV)` wrappen oder absichtlich-Kommentar
|
||||||
|
- [ ] Commit
|
||||||
|
|
||||||
|
## Wave 6 — Ingredient-Parser Edge-Cases
|
||||||
|
|
||||||
|
**Files:** `src/lib/server/parsers/ingredient.ts`, `tests/unit/ingredient.test.ts`
|
||||||
|
|
||||||
|
### Tests zuerst (red)
|
||||||
|
- [ ] Locale-Komma: `"1,5 kg Mehl"` → qty 1.5
|
||||||
|
- [ ] Unicode-½: `"½ TL Salz"` → qty 0.5
|
||||||
|
- [ ] Unicode-⅓: `"⅓ Tasse Wasser"` → qty 1/3
|
||||||
|
- [ ] Unicode-¼: `"¼ kg Zucker"` → qty 0.25
|
||||||
|
- [ ] Negativ: `"-1 EL Öl"` → wirft / qty=null
|
||||||
|
- [ ] Null: `"0 g Mehl"` → wirft / qty=null
|
||||||
|
- [ ] Führende Null: `"0.5 kg"` → 0.5
|
||||||
|
- [ ] Wissenschaftliche Notation: `"1e3 g"` → wirft / qty=null
|
||||||
|
|
||||||
|
### Parser fixen
|
||||||
|
- [ ] Unicode-Brüche-Map
|
||||||
|
- [ ] Locale-Komma-Handling (sicher: "1,5" wenn nur 1 Komma + Ziffern drumrum)
|
||||||
|
- [ ] Bounds: 0 < qty <= 10000 (Zod refinement oder Pre-Check)
|
||||||
|
- [ ] Tests grün, Commit
|
||||||
|
|
||||||
|
## Wave 7 — Verifikation
|
||||||
|
|
||||||
|
- [ ] `npm test` — 158+ Tests grün
|
||||||
|
- [ ] `npm run check` — 0 Errors
|
||||||
|
- [ ] `npm run build` — erfolgreich
|
||||||
|
- [ ] Optional: Docker-Smoke `docker compose -f docker-compose.prod.yml up --build`
|
||||||
|
- [ ] Push aller Commits
|
||||||
|
|
||||||
|
## Wave 8 — Re-Review + OPEN-ISSUES-NEXT.md
|
||||||
|
|
||||||
|
- [ ] Parallele Explore-Agenten: dead-code, redundancy, structure, docs-vs-code
|
||||||
|
- [ ] Befunde in `docs/superpowers/review/OPEN-ISSUES-NEXT.md`
|
||||||
|
- [ ] Bewusst verschobene Items mit Begründung
|
||||||
|
- [ ] Neue Findings (falls vorhanden)
|
||||||
|
- [ ] Commit + Push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erfolgs-Kriterien
|
||||||
|
|
||||||
|
1. Tests grün (158+)
|
||||||
|
2. svelte-check: 0 Errors, 0 Warnings (oder ≤ Baseline)
|
||||||
|
3. Build erfolgreich
|
||||||
|
4. Alle 8 Quick-Wins + Refactor A + Refactor D umgesetzt
|
||||||
|
5. OPEN-ISSUES-NEXT.md vorhanden mit klarer Trennung "verschoben (warum)" vs "neu entdeckt"
|
||||||
|
6. Branch ready zum Mergen / PR
|
||||||
217
docs/superpowers/plans/2026-04-19-post-review-roadmap.md
Normal file
217
docs/superpowers/plans/2026-04-19-post-review-roadmap.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Post-Review Roadmap 2026-04-19
|
||||||
|
|
||||||
|
> **Quelle:** `docs/superpowers/review/OPEN-ISSUES-NEXT.md` (Items A–I) + UAT `kochwas-dev.siegeln.net` (Branch `review-fixes-2026-04-18`, 2026-04-19).
|
||||||
|
> **Branch-Status:** Merge-ready — 8 atomare Commits, 184/184 Tests grün, svelte-check 0 Errors, UAT durchgeklickt (Profil, Suche, Rezept-Actions, Wunschliste, Preview, Admin, API-Shapes).
|
||||||
|
> **Goal:** Die nach dem Review-Branch offenen 9 Items in priorisierte Phasen übersetzen, damit jede einzeln via `/gsd-plan-phase` → `/gsd-execute-phase` abgearbeitet werden kann.
|
||||||
|
> **Architecture:** Keine Groß-Refactor-Phase, sondern getaktete Einzel-Phasen mit klarem Gate. Reihenfolge folgt Risiko × Wert: erst kleine Wins, dann eine strukturelle Phase (A), dann opportunistische.
|
||||||
|
> **Tech-Stack:** SvelteKit, TypeScript-strict, Zod, Vitest, Playwright-UAT, better-sqlite3, Service-Worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Merge-Entscheidung
|
||||||
|
|
||||||
|
**Jetzt mergen.** Der Branch-UAT auf `kochwas-dev` war clean (siehe Session-Log 2026-04-19). Findings aus dem UAT:
|
||||||
|
|
||||||
|
- Kommentar-Delete hat keinen UI-Button (MINOR, kein Branch-Regress — Zustand schon vor Refactor so).
|
||||||
|
- `/preview` ohne `?url=` bleibt im Dauer-Lader (MINOR, harmlos — niemand ruft die Route blank auf).
|
||||||
|
|
||||||
|
Beide werden als LOW-Items unten aufgenommen, sind aber **kein Merge-Blocker**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier-Zuordnung
|
||||||
|
|
||||||
|
| Tier | Items | Wann | Aufwand total |
|
||||||
|
|------|-------|------|---------------|
|
||||||
|
| 1 — Schneller Cleanup-Batch | F, G, H, I | Direkt nach Merge | ~2 h |
|
||||||
|
| 2 — Phase Search-State-Store | A | Nächster größerer Slot | halber Tag |
|
||||||
|
| 3 — Phase SearXNG-Recovery | C | Wenn Rate-Limit-Schmerz konkret auftaucht | 1–2 h |
|
||||||
|
| 4 — Opportunistisch | B, D, E, + Kommentar-Delete, Preview-Guard | Trigger-basiert | reaktiv |
|
||||||
|
| 5 — Geparkt | yauzl / Phase 5b | Nur bei explizitem Bedarf | nicht geplant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 — Cleanup-Batch (1 Phase, 4 Items)
|
||||||
|
|
||||||
|
**Phasenname-Vorschlag:** `Phase Cleanup-Batch nach Review-Fixes` (via `/gsd-new-phase` oder `/gsd-add-phase`).
|
||||||
|
|
||||||
|
Alle vier Items touchen wenige Zeilen, sind LOW/MEDIUM, und lassen sich in 1–2 Commits pro Item sauber atomar committen. **Gebündelt statt einzeln**, weil Kontext-Overhead pro Einzelphase größer wäre als der Fix.
|
||||||
|
|
||||||
|
### Item I — RecipeEditor auf `$derived` umstellen
|
||||||
|
|
||||||
|
**Files:** `src/lib/components/RecipeEditor.svelte:28,97–102,113,121`, `src/routes/recipes/[id]/+page.svelte:43`
|
||||||
|
|
||||||
|
Pattern aktuell: `let foo = recipe.bar` → Svelte-5-Warning, Snapshot-only, bricht bei In-Place-Mutation des Rezepts.
|
||||||
|
|
||||||
|
**Plan pro Warnung:**
|
||||||
|
- [ ] Warning-Site auslesen, beurteilen: soll `foo` Mutations am `recipe` tracken oder bewusst ein Snapshot bleiben?
|
||||||
|
- [ ] Track-Fall: `let foo = $derived(recipe.bar)`.
|
||||||
|
- [ ] Snapshot-Fall: Variable umbenennen (z. B. `initialFoo`) und als `$state` deklarieren mit Kommentar `// intentional snapshot`.
|
||||||
|
- [ ] `npm run check` — 0 Warnings erwartet.
|
||||||
|
- [ ] `npm test` — grün.
|
||||||
|
- [ ] Commit: `refactor(editor): RecipeEditor auf $derived umstellen`.
|
||||||
|
|
||||||
|
**Gate:** svelte-check 0 Warnings, alle Editor-Flows (Titel, Zutaten, Schritte) per Hand getestet — In-Place-PATCH zeigt aktualisierten Wert.
|
||||||
|
|
||||||
|
### Item H — RecipeEditor Bild-Upload/Delete auf `asyncFetch`
|
||||||
|
|
||||||
|
**Files:** `src/lib/components/RecipeEditor.svelte:54,83`
|
||||||
|
|
||||||
|
**Warum zusammen mit I:** Gleiche Datei, gleicher Touch.
|
||||||
|
|
||||||
|
- [ ] Zeile 54 (Upload): `const res = await fetch(...); if (!res.ok) alertAction(...)` → `await asyncFetch(...)`.
|
||||||
|
- [ ] Zeile 83 (Delete): dito.
|
||||||
|
- [ ] Error-Messages beibehalten.
|
||||||
|
- [ ] Test manuell: Bild hochladen + löschen in einem Test-Rezept.
|
||||||
|
- [ ] Commit: `refactor(editor): Bild-Upload/Delete auf asyncFetch`.
|
||||||
|
|
||||||
|
**Gate:** Bild-Upload + Delete-Flow grün in manuellem Smoke; `npm run check` clean.
|
||||||
|
|
||||||
|
### Item F — Inline UI-Constants in `src/lib/theme.ts`
|
||||||
|
|
||||||
|
**Files:** Neu `src/lib/theme.ts`, Modify `ConfirmDialog.svelte`, `ProfileSwitcher.svelte`, weitere Call-Sites via `grep`.
|
||||||
|
|
||||||
|
- [ ] `grep -rn "z-index:\|border-radius: 999\|setTimeout.*[0-9]{3,4}" src/lib/components src/routes` — Call-Sites auflisten.
|
||||||
|
- [ ] `src/lib/theme.ts` anlegen mit: `MODAL_Z_INDEX = 1000`, `POPOVER_Z_INDEX = 900`, `PILL_RADIUS = '999px'` (nur Werte, die wirklich mehrfach vorkommen — YAGNI).
|
||||||
|
- [ ] Call-Sites durchgehen, Inline-Werte durch Import ersetzen.
|
||||||
|
- [ ] `npm run check` + `npm test`.
|
||||||
|
- [ ] Commit: `refactor(ui): shared theme constants fuer z-index/radius`.
|
||||||
|
|
||||||
|
**Gate:** Keine visuellen Änderungen beim Durchklicken (Confirm-Dialog, Profile-Switcher, Toast, Menü).
|
||||||
|
|
||||||
|
### Item G — `requireProfile()` mit optionaler Message
|
||||||
|
|
||||||
|
**Files:** `src/lib/client/confirm.svelte.ts` (oder wo `requireProfile` liegt), `src/routes/wishlist/+page.svelte:38`
|
||||||
|
|
||||||
|
**Option A — minimal invasiv:** `wishlist/+page.svelte` belassen, Custom-Message-Konstante in der Datei. Dann **nur dokumentieren** im Kommentar der `requireProfile`-Funktion, dass die Wunschliste bewusst eigenständig ist.
|
||||||
|
|
||||||
|
**Option B — DRY:** `requireProfile(message?: string): Profile | null` mit Fallback auf Default.
|
||||||
|
|
||||||
|
- [ ] **Entscheidung zuerst** — Option A sparsamer, Option B konsistent. Ich empfehle **A**, weil die Custom-Message in der Wunschliste wirklich Kontext ist („um mitzuwünschen"), nicht nur Deko. Aber: wenn B, dann sauber mit Unit-Test.
|
||||||
|
- [ ] Commit: `refactor(client): requireProfile Custom-Message entscheiden` (je nach Entscheidung).
|
||||||
|
|
||||||
|
**Gate:** Wunschliste zeigt beim Klick ohne Profil die korrekte Message; keine anderen Sites verhalten sich anders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 — Phase Search-State-Store (Item A)
|
||||||
|
|
||||||
|
**Empfohlener Einstieg:** `/gsd-discuss-phase Search-State-Store` (per OPEN-ISSUES Empfehlung), nicht direkt `/gsd-plan-phase`.
|
||||||
|
|
||||||
|
**Warum eigene Phase:** Touch `+page.svelte` (808 L) + `+layout.svelte` (678 L), Reactive-Glue zwischen Header-Search-Dropdown und Home-Search muss 1:1 übernommen werden. **UAT-pflichtig**, weil es keine UI-Tests gibt.
|
||||||
|
|
||||||
|
**Scope-Sketch (für die Discuss-Phase):**
|
||||||
|
|
||||||
|
- Neu: `src/lib/client/search.svelte.ts` — reaktiver Store mit `query`, `hits`, `loading`, `error`, `hasMore`, `search(q)`, `loadMore()`, `clear()`.
|
||||||
|
- Debounce (aktuell in `+page.svelte`) in den Store migrieren.
|
||||||
|
- Web-Fallback-Logik (lokal leer → Web-Suche) beibehalten — Store muss beide Modi kennen (`mode: 'local' | 'web'`).
|
||||||
|
- `+layout.svelte` Header-Dropdown zuerst migrieren (kleineres Surface), dann `+page.svelte`.
|
||||||
|
- Duplizierten `$state`-Block entfernen.
|
||||||
|
|
||||||
|
**Verifikation pro Wave:**
|
||||||
|
1. Nach Store-Anlegen: Vitest-Unit-Tests für Store (mocked fetch).
|
||||||
|
2. Nach Layout-Migration: Browser-UAT Header-Dropdown auf Rezept-Seite + Startseite.
|
||||||
|
3. Nach Page-Migration: Browser-UAT Live-Suche (lokaler Treffer, Web-Fallback, Empty-State), inkl. Deep-Link `?q=xyz`.
|
||||||
|
4. Playwright-Script wiederholen (existiert aus 2026-04-19 UAT).
|
||||||
|
|
||||||
|
**Gate:** Alle 3 UAT-Pfade clean; `+page.svelte` unter 700 L; `+layout.svelte` unter 600 L; `npm test` + `npm run check` grün.
|
||||||
|
|
||||||
|
**Aufwand:** halber Tag (4–6 h).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 — Phase SearXNG-Rate-Limit-Recovery (Item C)
|
||||||
|
|
||||||
|
**Trigger:** Wenn konkreter Schmerz (User merkt „Suche liefert komische alte Sachen" oder SearXNG logt 429/403 gehäuft).
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- `src/lib/server/search/searxng.ts`: `lastFailureAt: Map<string, number>` pro Domain.
|
||||||
|
- Exponentieller Backoff: bei wiederholtem 429/403 → 1 min → 5 min → 30 min (Cap).
|
||||||
|
- Response-Shape erweitern: `isStale?: boolean` wenn aus Cache nach Fail.
|
||||||
|
- UI: `src/routes/+page.svelte` Such-Ergebnisheader zeigt „Ergebnisse evtl. veraltet" wenn `isStale`.
|
||||||
|
|
||||||
|
**Tests (TDD, Vitest):**
|
||||||
|
|
||||||
|
- Simulierter 429 → nächster Call innerhalb 1 min geht nicht raus, Response aus Cache mit `isStale: true`.
|
||||||
|
- Nach 1 min Wartezeit → Call geht wieder raus.
|
||||||
|
- Nach erfolgreichem Call → Backoff-Zähler resettet.
|
||||||
|
|
||||||
|
**Gate:** Tests grün; manuell: Fake-429 injizieren (z. B. über ENV-Toggle im Dev), UI zeigt Hinweis.
|
||||||
|
|
||||||
|
**Aufwand:** 1–2 h.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 4 — Opportunistisch (Trigger-gesteuert)
|
||||||
|
|
||||||
|
Alle Items hier werden **nicht proaktiv** geplant. Sie warten auf ihren Trigger.
|
||||||
|
|
||||||
|
### Item B — RecipeEditor/RecipeView in Sub-Components
|
||||||
|
|
||||||
|
**Trigger:** Zweite Person arbeitet am Projekt mit, ODER Editor-Bug-Hunt wird unübersichtlich.
|
||||||
|
|
||||||
|
**Scope-Sketch:** `IngredientRow.svelte`, `StepList.svelte`, `TimeDisplay.svelte`, `ImageUploadBox.svelte`.
|
||||||
|
|
||||||
|
**Vorbedingung:** Item I muss zuerst durch sein (die pre-existing Warnings würden sonst in die Sub-Components wandern).
|
||||||
|
|
||||||
|
### Item D — SW Zombie-Cleanup unter Drosselung
|
||||||
|
|
||||||
|
**Trigger:** Nächster Service-Worker-Touch (z. B. neue Cache-Strategy oder Chunks-Manifest-Änderung).
|
||||||
|
|
||||||
|
**Scope:** Mit DevTools-Throttling-Profil „Slow 3G" durchgehen, prüfen ob der 1500ms-Timeout in `pwa.svelte.ts` False-Positives triggert. Falls ja: Timeout konfigurierbar oder Heuristik verfeinern.
|
||||||
|
|
||||||
|
### Item E — JSON-LD Parser Locale-Edge-Cases
|
||||||
|
|
||||||
|
**Trigger:** Echter Import-Bug aus dem Alltag.
|
||||||
|
|
||||||
|
**Scope:** Gezielter Test für die Fail-URL + Fix. Kein Vorab-Sprint.
|
||||||
|
|
||||||
|
### Kommentar-Delete-UI (UAT 2026-04-19)
|
||||||
|
|
||||||
|
**Status:** Kommentar-DELETE-Endpoint existiert, aber keine UI-Exposition.
|
||||||
|
|
||||||
|
**Vorschlag:** In `src/routes/recipes/[id]/+page.svelte` Kommentar-Liste pro Eintrag ein 🗑-Button für den Autor (`comment.profile_id === profileStore.active?.id`). Mit `confirmAction`-Dialog.
|
||||||
|
|
||||||
|
**Trigger:** Erster Wunsch, einen Kommentar loszuwerden.
|
||||||
|
|
||||||
|
**Aufwand:** ~30 min.
|
||||||
|
|
||||||
|
### Preview-ohne-URL-Guard (UAT 2026-04-19)
|
||||||
|
|
||||||
|
**Status:** `/preview` ohne `?url=` bleibt im Dauer-Lader.
|
||||||
|
|
||||||
|
**Vorschlag:** `src/routes/preview/+page.svelte` Zeile 33ff.: wenn `u` leer, `errored = 'Kein URL-Parameter gesetzt'` oder Redirect auf `/`. **2-Zeilen-Fix.**
|
||||||
|
|
||||||
|
**Trigger:** Bevor jemand die Route bookmarked.
|
||||||
|
|
||||||
|
**Aufwand:** 5 min — könnte man auch sofort in Tier 1 reinnehmen, ist aber so trivial, dass es ohne Phase geht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 5 — Geparkt
|
||||||
|
|
||||||
|
### Phase 5b — ZIP-Backup-Restore via `yauzl`
|
||||||
|
|
||||||
|
**Status:** Dokumentiert in `ARCHITECTURE.md:33` und `session-handoff-2026-04-17.md`. Dependency bleibt installiert.
|
||||||
|
|
||||||
|
**Kein Plan.** Wird erst aktiviert, wenn jemand wirklich ein Backup-ZIP zurückspielen will. Dann: `/gsd-plan-phase Phase-5b-ZIP-Restore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Ausführungs-Reihenfolge
|
||||||
|
|
||||||
|
1. **Merge** `review-fixes-2026-04-18` → `main`.
|
||||||
|
2. **Neuen Branch** `cleanup-batch-post-review` → Tier 1 (Items I + H zusammen in einem Wave, dann F, dann G).
|
||||||
|
3. **Merge** → Tier 2 Discuss: `/gsd-discuss-phase Search-State-Store`.
|
||||||
|
4. Tier 2 execution.
|
||||||
|
5. Tier 3 erst wenn der Trigger da ist, sonst Tier 4 abwarten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit-Stil für alle Phasen
|
||||||
|
|
||||||
|
- Deutsch, kleinteilig, eine Idee pro Commit.
|
||||||
|
- Body erklärt das *Warum* (Reference auf Item-Nummer aus diesem Doc).
|
||||||
|
- Nach jedem Commit `npm test` + `npm run check` grün.
|
||||||
|
- Push direkt nach Commit (CI baut Branch-Tag, siehe `docker.yml`).
|
||||||
166
docs/superpowers/review/OPEN-ISSUES-NEXT.md
Normal file
166
docs/superpowers/review/OPEN-ISSUES-NEXT.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Open Issues — Stand nach Review-Fixes
|
||||||
|
|
||||||
|
**Datum:** 2026-04-18 (Nacht-Session)
|
||||||
|
**Branch:** `review-fixes-2026-04-18`
|
||||||
|
**Baseline:** REVIEW-2026-04-18.md + 4 Sub-Reports vom Morgen
|
||||||
|
**Tests:** 184/184 grün (Baseline waren 158, +26 neue Tests)
|
||||||
|
**svelte-check:** 0 Errors, 10 Warnings (alle pre-existing in `RecipeEditor.svelte` / `recipes/[id]/+page.svelte`)
|
||||||
|
**Build:** `npm run build` erfolgreich
|
||||||
|
**Smoke-Test:** `npm run preview` + curl auf `/api/health`, `/api/profiles`, `/api/recipes/abc` (400), `/api/wishlist` mit invalider Body (400 + issues) — alle Endpunkte verhalten sich korrekt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was wurde gemacht (8 Commits)
|
||||||
|
|
||||||
|
| Commit | Inhalt | Verifikation |
|
||||||
|
|---|---|---|
|
||||||
|
| `2289547` | docs(review): table names, IMAGE_DIR, image endpoints | grep auf alte Namen → 0 |
|
||||||
|
| `830c740` | refactor(constants): SW-Timing-Konstanten, RequestShape/ManifestDiff intern, Image-Endpoint EN | tests + check grün |
|
||||||
|
| `739cc2d` | feat(server): api-helpers.ts (parsePositiveIntParam, validateBody, ErrorResponse) | 13 neue Tests |
|
||||||
|
| `ff293e9` | refactor(api): 13 +server.ts handler auf api-helpers (-67 Zeilen netto) | 171/171 |
|
||||||
|
| `30a447a` | refactor(client): requireProfile() + asyncFetch wrapper | 5 + 4 Sites umgestellt |
|
||||||
|
| `60c8352` | docs(searxng): Intent-Kommentar fuer Prod-Logs | — |
|
||||||
|
| `6d9e79d` | feat(parser): Unicode-Brueche + Mengen-Plausibilitaet | 13 neue Tests |
|
||||||
|
| `31c6e5c` | refactor(server): IMAGE_DIR/DATABASE_PATH zentralisieren + Doku-Drift | grep auf alte Pattern → 0 |
|
||||||
|
|
||||||
|
Net: 31 Files, +626/-272.
|
||||||
|
|
||||||
|
### Re-Review per 4 paralleler Explore-Agenten — Beweis
|
||||||
|
|
||||||
|
**Dead-Code (HIGH-Confidence):** Alle vorherigen Findings resolved. RequestShape + ManifestDiff sind nur noch interne Types. yauzl ist explizit als Phase 5b markiert (in `session-handoff-2026-04-17.md` und `ARCHITECTURE.md:33`). Kein neuer toter Code durch die Refactors.
|
||||||
|
|
||||||
|
**Redundancy (HIGH-Confidence):** 0 verbleibende `function parseId`/`parsePositiveInt`-Definitionen in `src/routes/api/`. 0 verbleibende `safeParse(...) + manueller error(400)`-Blöcke. Der gerade behobene `IMAGE_DIR`-Drift war 6× im Code und 1× in `db/index.ts`. Verbleibende kleine Pattern siehe unten.
|
||||||
|
|
||||||
|
**Structure:** Constants-Extraktion + API-Error-Shape-Standardisierung erledigt. Ingredient-Parser-Edge-Cases mit 13 Tests abgesichert. Große Pages bleiben groß (siehe „Bewusst verschoben").
|
||||||
|
|
||||||
|
**Docs-vs-Code:** Alle drei Original-Findings behoben. Zwei kleine zusätzliche Mismatches (149→150 Quote-Count, search/-Route gar nicht existent) heute gleich mitgenommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ Verbleibende Items — bewusst verschoben mit Begründung
|
||||||
|
|
||||||
|
### A. Refactor B — Search-State-Store extrahieren (HIGH, halber Tag)
|
||||||
|
**Wo:** `src/routes/+page.svelte` (808 Zeilen, 20+ `$state`-Vars), `src/routes/+layout.svelte` (678 Zeilen, dupliziert das Header-Search-Dropdown).
|
||||||
|
|
||||||
|
**Vorschlag:** `src/lib/client/search.svelte.ts` mit `search()`, `loadMore()`, `clear()` und reaktivem `query / hits / loading / error`-Zustand.
|
||||||
|
|
||||||
|
**Warum nicht heute:**
|
||||||
|
1. Touch in zwei der drei größten Files der Codebase (808L + 678L)
|
||||||
|
2. Bricht Frontend-Verhalten subtil, wenn Reactive-Glue zwischen Layout-Search und Page-Search nicht 1:1 übernommen wird
|
||||||
|
3. UAT-pflichtig (Live-Suche, Empty-State, Web-Suche-Fallback) — ohne UAT-Slot zu riskant
|
||||||
|
4. Kein automatisches Test-Sicherheitsnetz für die UI-Layer
|
||||||
|
|
||||||
|
**Empfehlung:** Eigene Phase mit `/gsd-discuss-phase` und Smoke-UAT vor dem Mergen. Anschließend `/gsd-execute-phase` mit Browser-Check pro Wave.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Refactor C — RecipeEditor / RecipeView in Sub-Components zerlegen (MEDIUM, halber Tag)
|
||||||
|
**Wo:** `src/lib/components/RecipeEditor.svelte` (630L), `RecipeView.svelte` (398L).
|
||||||
|
|
||||||
|
**Kandidaten:** `IngredientRow.svelte`, `StepList.svelte`, `TimeDisplay.svelte`, `ImageUploadBox.svelte`.
|
||||||
|
|
||||||
|
**Warum nicht heute:**
|
||||||
|
- REVIEW-2026-04-18.md sagt explizit: *"Aber: keine Eile, solange niemand sonst drin arbeitet."*
|
||||||
|
- Solange der Owner allein entwickelt, ist 630L pro Komponente kein Blocker.
|
||||||
|
- Tests gibt es nur indirekt (über Importer-Tests und Unit-Tests der Parser).
|
||||||
|
|
||||||
|
**Empfehlung:** Spätere Phase, falls eine zweite Person mitarbeitet oder wenn Editor-Bug-Hunting zu schwierig wird. Vorher zumindest die 10 pre-existing svelte-check WARNINGs in `RecipeEditor.svelte` fixen — die sind schon flackrige Reactive-Patterns (`$derived` statt `$state` für abgeleitete Werte).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C. SearXNG Rate-Limit Recovery (MEDIUM, 1-2 h)
|
||||||
|
**Wo:** `src/lib/server/search/searxng.ts`.
|
||||||
|
|
||||||
|
**Was fehlt:** Bei 429/403 wird zwar geloggt, aber kein Backoff oder `isStale`-Flag. Folgesuchen liefern alten Cache, der User merkt nichts.
|
||||||
|
|
||||||
|
**Empfehlung:** Eigene Phase. Drei mögliche Zutaten: (1) `lastFailureAt`-Map per Domain, (2) exponentieller Backoff, (3) `isStale: boolean` im Response, das die UI als „Ergebnisse evtl. veraltet" anzeigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D. Service-Worker Zombie-Cleanup unter Last testen (MEDIUM, 2-3 h)
|
||||||
|
**Wo:** `src/lib/client/pwa.svelte.ts` Zombie-Heuristik.
|
||||||
|
|
||||||
|
**Status:** 6 Unit-Tests existieren bereits (`tests/unit/pwa-store.test.ts`), die beide Pfade abdecken.
|
||||||
|
|
||||||
|
**Was offen ist:** Verhalten unter sehr langsamen Netzen (1500ms-Timeout könnte False-Positive triggern). Sehr edge-case, aber im REVIEW-Original als MEDIUM gelistet.
|
||||||
|
|
||||||
|
**Empfehlung:** Beim nächsten Service-Worker-Touch mit Throttling-DevTools-Profil testen. Kein eigener Sprint nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E. JSON-LD Parser Edge-Cases (MEDIUM, halbe Phase)
|
||||||
|
**Wo:** `src/lib/server/parsers/json-ld-recipe.ts` (402L).
|
||||||
|
|
||||||
|
**Was abgesichert ist:** Ingredient-Parser-Käfer (Unicode-Brüche, Bounds, Komma-Dezimal) sind heute mit 13 neuen Tests dicht.
|
||||||
|
|
||||||
|
**Was offen ist:** JSON-LD selbst hat Edge-Cases — null-Servings, Locale-spezifische Number-Formats, defekte `recipeIngredient`-Arrays.
|
||||||
|
|
||||||
|
**Empfehlung:** Wenn beim Importieren ein Bug auftaucht, gezielt einen Test schreiben. Kein Vorab-Sprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F. Inline UI-Constants (LOW, 30 min)
|
||||||
|
**Wo:** `ConfirmDialog.svelte`, `ProfileSwitcher.svelte` etc. mit Hardcoded `z-index`, `border-radius: 999px`, kleinen Timeouts.
|
||||||
|
|
||||||
|
**Vorschlag:** `src/lib/theme.ts` mit `MODAL_Z_INDEX`, `POPOVER_Z_INDEX`, `PILL_RADIUS`.
|
||||||
|
|
||||||
|
**Warum nicht heute:** LOW-Severity, kein konkreter Bug damit verbunden, betrifft viele Files punktuell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### G. wishlist/+page.svelte:38 — Profil-Guard mit individueller Message (LOW)
|
||||||
|
**Was:** Eine 7. Stelle hat das Profil-Guard-Pattern, aber mit eigenem Text („um mitzuwünschen"). `requireProfile()` akzeptiert aktuell keine Custom-Message.
|
||||||
|
|
||||||
|
**Empfehlung:** Entweder `requireProfile(message?)`-Variante einführen oder das Site so lassen — die Custom-Message ist dort wirklich Kontext-Information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H. RecipeEditor.svelte:54 + :83 — Bild-Upload/Delete mit inline `if (!res.ok)` (LOW)
|
||||||
|
**Was:** Image-Upload und -Delete im Editor nutzen noch das Pattern, das `asyncFetch` ersetzen sollte. Der Aufwand wäre 5 Minuten, aber RecipeEditor steckt in den 10 svelte-check-WARNINGs (siehe Refactor B-Notiz) — beim nächsten Touch der Datei mitnehmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I. Pre-Existing svelte-check Warnings (10 Stück)
|
||||||
|
**Wo:** `RecipeEditor.svelte` (9× Zeilen 28, 97-102, 113, 121) + `recipes/[id]/+page.svelte` (1× Zeile 43).
|
||||||
|
|
||||||
|
**Was:** Pattern `let foo = recipe.bar` im Top-Level-Script — Svelte 5 will `$derived(recipe.bar)`. Aktuell snapshot-only.
|
||||||
|
|
||||||
|
**Risiko:** Bei In-Place-Mutation des Rezepts (z. B. nach PATCH) zeigt der Editor ggf. den alten Wert. **Tests fangen das nicht.**
|
||||||
|
|
||||||
|
**Empfehlung:** Kleine Phase „RecipeEditor auf $derived umstellen" — passt gut zur RecipeEditor-Subkomponentenphase (B oben), oder vorab alleine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 Neu entdeckt in der zweiten Runde — alle behoben
|
||||||
|
|
||||||
|
| # | Fund | Severity | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `IMAGE_DIR` 6× dupliziert + `DATABASE_PATH` 2× | HIGH | ✅ `src/lib/server/paths.ts` |
|
||||||
|
| 2 | `ARCHITECTURE.md:34` — „49 Flachwitze" | MEDIUM | ✅ → 150 |
|
||||||
|
| 3 | `ARCHITECTURE.md:41` — `search/`-Route existiert nicht | LOW | ✅ entfernt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene nächste Schritte
|
||||||
|
|
||||||
|
1. **PR mergen** sobald lokal abgenickt — der Branch enthält 8 atomische Commits, jeder einzeln revert-bar.
|
||||||
|
2. **Falls UAT erwünscht:** `npm run build && npm run preview`, dann manuell Profile-Switching, Rezept-Edit, Favoriten-Toggle, Wunschliste, Bild-Upload, Such-Pfade durchklicken. Erwartung: keine Verhaltensänderung gegenüber `main`.
|
||||||
|
3. **Phase „RecipeEditor reactive cleanup"** für die 10 svelte-check-Warnings (klein) — schließt Item I.
|
||||||
|
4. **Phase „Search-State-Store"** als nächste größere Phase — schließt Item A und drückt das größte Page-File spürbar runter.
|
||||||
|
5. yauzl/Phase 5b (ZIP-Backup-Restore) bleibt als ungeplant bis explizit gebraucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code-Quality Snapshot
|
||||||
|
|
||||||
|
| Metrik | Vorher | Nachher | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Tests gesamt | 158 | 184 | +26 |
|
||||||
|
| Tests Files | 23 | 24 | +1 (api-helpers) |
|
||||||
|
| svelte-check Errors | 0 | 0 | — |
|
||||||
|
| svelte-check Warnings | 10 | 10 | — (alle pre-existing) |
|
||||||
|
| Build | ✓ | ✓ | — |
|
||||||
|
| Größte Datei (recipes/[id]/+page.svelte) | 757 | 725 | -32 |
|
||||||
|
| Größte Datei (+page.svelte) | 808 | 808 | — |
|
||||||
|
| API +server.ts Boilerplate | ca. 11 Zeilen pro Handler | ca. 4 Zeilen pro Handler | -64% |
|
||||||
|
| Duplizierte ENV-Defaults | 8 Sites | 1 Site | -7 |
|
||||||
140
docs/superpowers/review/REVIEW-2026-04-18.md
Normal file
140
docs/superpowers/review/REVIEW-2026-04-18.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Deep Code Review — Kochwas
|
||||||
|
|
||||||
|
**Datum:** 2026-04-18
|
||||||
|
**Stand:** commit `5283ab9` auf `main`
|
||||||
|
**Testsuite beim Start:** 158/158 grün
|
||||||
|
**Scope:** `src/` (~97 Dateien), Migrations, Tests, Docker-Setup, alle Docs unter `docs/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Der Code ist **gesund**. Keine toten Pfade, keine broken Features, keine strukturellen Fehlentscheidungen. Die vier auffälligsten Themen sind alle **Natural-Growth-Pressure** aus der v1.x-Phase, keine Fehler:
|
||||||
|
|
||||||
|
1. **Ein echter Doku-Bug:** `docs/ARCHITECTURE.md:55` sagt `recipe_ingredient` + `recipe_step` — die Tabellen heißen in Wirklichkeit `ingredient` / `step` (siehe `001_init.sql`). 5-Minuten-Fix.
|
||||||
|
2. **API-Handler duplizieren `parseId`** neunmal. Kandidat #1 für einen `src/lib/server/api-helpers.ts`.
|
||||||
|
3. **Page-Komponenten sind groß** geworden (`+page.svelte` 808 Zeilen, `recipes/[id]/+page.svelte` 757 Zeilen). Solange du allein dran arbeitest: akzeptabel. Sobald jemand mitprogrammiert: refactor.
|
||||||
|
4. **`yauzl` / `@types/yauzl` sind installiert, aber nicht importiert.** Reserviert für den noch fehlenden ZIP-Backup-Import. Entweder im Session-Handoff verankert lassen oder als Phase ziehen.
|
||||||
|
|
||||||
|
Keine Sicherheits- oder Performance-Probleme im Code-Review aufgetaucht. Keine Reviewer-Korrekturen an der Architektur-Grundlinie (Server/Client-Trennung, Repository-Pattern, Runes-Stores).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick-Wins (≤ 30 min pro Stück)
|
||||||
|
|
||||||
|
| # | Titel | Aufwand | Wert |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `ARCHITECTURE.md:55` auf `ingredient` + `step` korrigieren | 2 min | hoch (sonst debuggt jemand Geisterschema) |
|
||||||
|
| 2 | `OPERATIONS.md:135` `IMAGES_PATH` → `IMAGE_DIR` | 2 min | niedrig, aber trivial |
|
||||||
|
| 3 | `parseId` zentralisieren (`src/lib/server/api-helpers.ts`) | 20 min | hoch — 9 Call-Sites |
|
||||||
|
| 4 | Unit-Test für `parseId`-Helper | 10 min | hoch — fängt zukünftige Regressionen |
|
||||||
|
| 5 | `requireProfile()`-Helper in `recipes/[id]/+page.svelte` (Zeilen 124/143/166/188 räumen 4×7 Zeilen weg) | 15 min | mittel |
|
||||||
|
| 6 | Timeout-Magic-Numbers nach `src/lib/constants.ts` (1500 ms, 30-min SW-Poll) | 10 min | mittel |
|
||||||
|
| 7 | Deutsche Fehler-Texte in `api/recipes/[id]/image/+server.ts` englisch ziehen (Konsistenz) | 5 min | kosmetisch |
|
||||||
|
| 8 | Im Session-Handoff `/api/recipes/[id]/image` (POST/DELETE) nachtragen | 5 min | niedrig |
|
||||||
|
|
||||||
|
Summe: unter 90 Minuten — und du hast den Großteil der Haut-Irritationen unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Größere Refactor-Kandidaten
|
||||||
|
|
||||||
|
### A. API-Endpoints entkoppeln (HIGH, 1–2 Std)
|
||||||
|
Extrahiere `src/lib/server/api-helpers.ts` mit:
|
||||||
|
- `parsePositiveIntParam(raw: string, field: string): number` — wirft via SvelteKit `error(400, …)`
|
||||||
|
- `validateBody<T>(body: unknown, schema: ZodSchema<T>): T` — ersetzt die `safeParse()`-Loops in 8+ Handlern
|
||||||
|
- gemeinsame `ErrorResponse`-Shape (aktuell mal `{message}`, mal `{message, issues}`)
|
||||||
|
|
||||||
|
Nach dem Helper-Refactor sollten die Handler nur noch echtes Business-Logik enthalten und je 30–50 Zeilen kürzer werden.
|
||||||
|
|
||||||
|
### B. Search-State aus `+page.svelte` ziehen (HIGH, halber Tag)
|
||||||
|
`+page.svelte` trägt 20+ `$state`-Variablen (`query`, `hits`, `webHits`, `searching`, `webError` …) und duplizierte Search-UI in `+layout.svelte`. Vorschlag: `src/lib/client/search.svelte.ts` mit `search()`, `loadMore()`, `clear()`. Danach ist das Page-File halbiert und der Layout-Nav-Search nutzt denselben Store.
|
||||||
|
|
||||||
|
### C. `RecipeEditor` / `RecipeView` in Sub-Components zerlegen (MEDIUM, halber Tag)
|
||||||
|
Kandidaten: `IngredientRow.svelte`, `StepList.svelte`, `TimeDisplay.svelte`, `ImageUploadBox.svelte`. Vorteile: isoliert testbar, wiederverwendbar in Preview-Seite. **Aber:** keine Eile, solange niemand sonst drin arbeitet.
|
||||||
|
|
||||||
|
### D. Ingredient-Parser-Edge-Cases (HIGH, 2–3 Std)
|
||||||
|
Der Parser (`src/lib/server/parsers/ingredient.ts`) und seine Tests decken ASCII-Ganzzahlen + Dezimal + Brüche ab. Fehlt:
|
||||||
|
- Unicode-Brüche (½, ⅓, ¼)
|
||||||
|
- führende Nullen, wissenschaftliche Notation
|
||||||
|
- Locale-Kommadezimal (deutsche Rezepte!)
|
||||||
|
- 0-Portionen, negative Mengen
|
||||||
|
|
||||||
|
Parametrisierte Tests anlegen, dann Parser ggf. mit Zod-Refinement absichern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einzelbefunde im Detail
|
||||||
|
|
||||||
|
### Dead Code
|
||||||
|
- Unused Deps: `yauzl`, `@types/yauzl` (absichtlich für Phase 5b; Entscheidung treffen: behalten oder entfernen bis Phase kommt).
|
||||||
|
- `RequestShape` (`src/lib/sw/cache-strategy.ts:3`) und `ManifestDiff` (`src/lib/sw/diff-manifest.ts:4`) sind exportiert, aber nur intern benutzt — `export` weg oder im Test importieren.
|
||||||
|
- Alle 97 Source-Files erreichbar, keine orphan-Assets, keine TODO/FIXME/HACK-Marker, keine großen auskommentierten Blöcke.
|
||||||
|
|
||||||
|
### Redundanzen
|
||||||
|
- `parseId`/`parsePositiveInt` — 9 Sites: `api/recipes/[id]/`, `…/favorite`, `…/rating`, `…/cooked`, `…/comments`, `…/image`, `api/profiles/[id]/`, `api/domains/[id]/`, `api/wishlist/[recipe_id]/`
|
||||||
|
- Fetch-try/catch-alert-Pattern in 5 Svelte-Komponenten: `recipes/[id]/+page.svelte` (2×), `admin/domains/+page.svelte` (2×), `admin/profiles/+page.svelte`
|
||||||
|
- Zod-`safeParse` + gleicher Error-Throw in 12+ Endpoints
|
||||||
|
- `parseQty` + Zutat-Reassembly in `RecipeEditor` dupliziert Logik aus `parseIngredient` — könnte über `src/lib/shared/` geteilt werden
|
||||||
|
- Profile-Guard (`if (!profile.active) alert(…)`) 4× identisch in `recipes/[id]/+page.svelte`
|
||||||
|
|
||||||
|
### Struktur
|
||||||
|
- Große Dateien: `+page.svelte` (808), `recipes/[id]/+page.svelte` (757), `+layout.svelte` (678), `RecipeEditor` (630), `recipes/+page.svelte` (539). Keine davon ist kaputt; alle sind Wachstum unter Last.
|
||||||
|
- API-Error-Shape: mehrheitlich `{message}`, `profiles/+server.ts` gibt zusätzlich `{issues}` aus (Zod-Details). Festschreiben.
|
||||||
|
- Store-Init-Races: `profile.svelte.ts` und `search-filter.svelte.ts` laden bei erstem Zugriff. Komponenten sehen ggf. Leer-State vor Fetch. Optional `loading`-Flag.
|
||||||
|
- Konsolen-Logs: 6 Stück in Prod-Build (`service-worker.ts` 2×, `searxng.ts` 3×, `sw-register.ts` 1×). Vermutlich Absicht; als Dok-Kommentar festhalten oder in `if (DEV)`-Guards packen.
|
||||||
|
- Svelte-5-Runes-Stores sind konsistent, keine God-Stores.
|
||||||
|
- TypeScript: `strict` an, 0× `any`, 0× Server-Import-in-Client — bestätigt die CLAUDE.md-Regel.
|
||||||
|
|
||||||
|
### Docs-vs-Code-Mismatches
|
||||||
|
| Fundstelle | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `ARCHITECTURE.md:55` — `recipe_ingredient` + `recipe_step` | `ingredient` + `step` |
|
||||||
|
| `OPERATIONS.md:135` — `IMAGES_PATH` | `IMAGE_DIR` |
|
||||||
|
| `session-handoff-2026-04-17.md:46` — fehlt `/api/recipes/[id]/image` (POST/DELETE) | ergänzen |
|
||||||
|
| Alle Gotchas in `CLAUDE.md` | ✓ verifiziert, stimmen |
|
||||||
|
| Alle Claims im offline-PWA-Spec | ✓ verifiziert, alle in Code vorhanden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was bleibt wie es ist
|
||||||
|
|
||||||
|
- **Migrationen:** 001–011 sind historisch sauber. 008 + 010 löschen beide den Thumbnail-Cache — Feature-Iteration, kein Bug. **Keine** bestehende Migration anfassen (das ist ohnehin die dokumentierte Regel).
|
||||||
|
- **Service-Worker:** Zombie-Cleanup-Logik (`pwa.svelte.ts`) ist Kunst, aber funktioniert und ist kommentiert. Unit-Tests decken beide Zweige (Zombie vs alter SW) ab.
|
||||||
|
- **Repository-Pattern:** Cleane Schichtung. Nicht refactoren.
|
||||||
|
- **Test-Suite:** 23 Dateien, 158 Tests, volle Integration inkl. DB/HTTP/Import/SearXNG. Leichte Lücken bei Parser-Edge-Cases (siehe oben).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ampel
|
||||||
|
|
||||||
|
| Dimension | Status |
|
||||||
|
|---|---|
|
||||||
|
| Architektur & Schichten | 🟢 gesund |
|
||||||
|
| Dead Code | 🟢 minimal |
|
||||||
|
| Redundanzen | 🟡 adressierbar, nicht dringend |
|
||||||
|
| Datei-/Komponenten-Größen | 🟡 zwei Pages ≥ 750L |
|
||||||
|
| Tests | 🟢 stark, Edge-Cases ausbaufähig |
|
||||||
|
| Doku | 🟡 1 inhaltlicher Fehler + 1 ENV-Tippfehler, sonst stabil |
|
||||||
|
| Sicherheit/Perf | 🟢 keine Funde im statischen Review |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorgeschlagene Reihenfolge
|
||||||
|
|
||||||
|
1. Heute (10 min): Quick-Wins 1 + 2 (ARCHITECTURE-Tabellen, OPERATIONS-ENV).
|
||||||
|
2. Nächste Session (2 h): `api-helpers.ts` + `parseId`-Consolidation + Tests (Refactor A).
|
||||||
|
3. Bei Zeit: Search-State-Store (Refactor B) — bringt beim nächsten Feature sofort Dividende.
|
||||||
|
4. Phase-5b: `yauzl` einsetzen ODER Deps entfernen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teilreports
|
||||||
|
|
||||||
|
Die vollständigen Agent-Befunde liegen daneben:
|
||||||
|
- `docs/superpowers/review/dead-code.md`
|
||||||
|
- `docs/superpowers/review/redundancy.md`
|
||||||
|
- `docs/superpowers/review/structure.md`
|
||||||
|
- `docs/superpowers/review/docs-vs-code.md`
|
||||||
|
|
||||||
|
Review-Metadaten: 4 parallele Explore-Agenten, jeweils read-only, Summen manuell gegen Code verifiziert (Line-Counts, Tabellen-Namen, ENV-Namen, `parseId`-Sites).
|
||||||
42
docs/superpowers/review/dead-code.md
Normal file
42
docs/superpowers/review/dead-code.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Dead-Code Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Kochwas codebase is remarkably clean with minimal dead code. Primary finding: **yauzl dependency is unused** (reserved for future backup-restore feature). All exports are active, files are properly structured, and no unreachable code paths detected.
|
||||||
|
|
||||||
|
## HIGH confidence findings
|
||||||
|
|
||||||
|
### Unused Dependencies
|
||||||
|
- **package.json: yauzl, @types/yauzl** — Declared in `dependencies` but never imported in source code. Added in commit for future backup ZIP import feature (currently only export via archiver is implemented). See `docs/superpowers/session-handoff-2026-04-17.md` which notes: "Import aus ZIP ist noch manueller DB-Copy. yauzl ist bereits als Dependency da, Phase 5b kann das in 10 Minuten nachziehen."
|
||||||
|
|
||||||
|
### Exported Types Not Imported Elsewhere
|
||||||
|
- **src/lib/sw/cache-strategy.ts:3** — `RequestShape` — Exported type only used within the same file as a function parameter. Not imported anywhere (type is passed inline at call site in service-worker.ts). Candidates for internal-only marking.
|
||||||
|
- **src/lib/sw/diff-manifest.ts:4** — `ManifestDiff` — Exported type only used within same file as a return type of `diffManifest()`. Not imported by any other module.
|
||||||
|
|
||||||
|
## MEDIUM confidence findings
|
||||||
|
|
||||||
|
None identified. All functions, types, and stores are actively used. All 85 source files are reachable through proper route conventions (+page.svelte, +server.ts, +layout.svelte are auto-routed by SvelteKit).
|
||||||
|
|
||||||
|
## LOW confidence / worth double-checking
|
||||||
|
|
||||||
|
### Conditional Dead Code in Service Worker (Low risk)
|
||||||
|
- **src/service-worker.ts:99-110** — The `GET_VERSION` message handler for zombie-SW cleanup is only triggered by `pwaStore` when specific conditions match (bit-identical versions detected after SKIP_WAITING). Works correctly but only fires on edge-case deployments (Chromium race condition). Verified it's needed—comments explain the scenario thoroughly.
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
- **src/lib/server/db/migrations/007-011** — Recent migrations (thumbnail_cache rerun, favicon resets) are cleanup/maintenance operations. Verified they're applied in sequence and read by code (e.g., searxng.ts queries thumbnail_cache). No orphaned migration tables.
|
||||||
|
|
||||||
|
## Non-findings (places I checked and confirmed alive)
|
||||||
|
|
||||||
|
- **All client stores** (confirm, install-prompt, network, profile, pwa, search-filter, sync-status, toast, wishlist) — Every export used in components
|
||||||
|
- **All server repositories** (domains, profiles, recipes, wishlist) — All functions imported by API routes
|
||||||
|
- **All parsers** (ingredient, iso8601-duration, json-ld-recipe) — Used by recipe importer and web search
|
||||||
|
- **All API routes** — All 27 route handlers are reachable and handler import the functions they need
|
||||||
|
- **All Svelte components** — No orphaned .svelte files; all imported by routes or other components
|
||||||
|
- **Static assets** (/manifest.webmanifest, /icon.svg, /icon-192.png, /icon-512.png) — Referenced in app.html, cache-strategy.ts, and manifest
|
||||||
|
- **Service worker** — All functions in service-worker.ts are called; no dead branches
|
||||||
|
- **Commented code** — Only legitimate documentation comments (German docs explaining design decisions); no large disabled code blocks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Review Scope:** src/ (~85 files), package.json dependencies, tests/
|
||||||
|
**Tools used:** Grep (regex + pattern matching), Read (file inspection), Bash (git log)
|
||||||
|
**Confidence Threshold:** HIGH = 100% sure, MEDIUM = 95%+, LOW = contextual
|
||||||
130
docs/superpowers/review/docs-vs-code.md
Normal file
130
docs/superpowers/review/docs-vs-code.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Docs vs Code Audit
|
||||||
|
|
||||||
|
**Date:** 2026-04-18 | **Scope:** Full Documentation Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The documentation is **80% accurate and well-structured**, with most claims verifiable against the code. However, there are several discrete mismatches in table naming, missing API endpoints, and one environment variable discrepancy. Core concepts (architecture, deployment, gotchas) are reliable. No critical blockers found — all mismatches are either naming inconsistencies or minor omissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLAUDE.md Findings
|
||||||
|
|
||||||
|
### ✅ All gotchas verified
|
||||||
|
- **Healthcheck rule:** Confirmed in `Dockerfile` line 37: uses `http://127.0.0.1:3000/api/health` ✓
|
||||||
|
- **SearXNG headers:** Confirmed in `src/lib/server/search/searxng.ts` — sets `X-Forwarded-For: 127.0.0.1` and `X-Real-IP: 127.0.0.1` ✓
|
||||||
|
- **Icon rendering:** Confirmed — `scripts/render-icons.mjs` renders 192 + 512 PNG icons from `static/icon.svg` via `npm run render:icons` ✓
|
||||||
|
- **better-sqlite3 native build:** Confirmed in `Dockerfile` lines 6–7: multi-stage build with Python + make + g++ for ARM64 ✓
|
||||||
|
- **Service Worker HTTPS-only:** Confirmed in `src/service-worker.ts` and offline-pwa-design.md specs ✓
|
||||||
|
- **Migration workflow:** Confirmed in `src/lib/server/db/migrations/` — 11 migrations exist, Vite glob bundled ✓
|
||||||
|
|
||||||
|
### ⚠ Minor: Environment Variable Name
|
||||||
|
- **Claim in doc:** OPERATIONS.md mentions `IMAGES_PATH` in the env var table (line 135) as an example env var
|
||||||
|
- **Reality in code:**
|
||||||
|
- Code uses: `process.env.IMAGE_DIR` (not `IMAGES_PATH`) — see `src/lib/server/db/index.ts`
|
||||||
|
- `.env.example` and `Dockerfile` both use `IMAGE_DIR`
|
||||||
|
- `.env.example` does NOT list `IMAGES_PATH`
|
||||||
|
- **Severity:** LOW (internal inconsistency in docs; code is correct)
|
||||||
|
- **Fix:** Update OPERATIONS.md line 135 to use `IMAGE_DIR` instead of `IMAGES_PATH`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## docs/ARCHITECTURE.md Findings
|
||||||
|
|
||||||
|
### ❌ CRITICAL: Incorrect Table Names
|
||||||
|
|
||||||
|
**Claim in doc (line 55):**
|
||||||
|
> "INSERT in `recipe` + `recipe_ingredient` + `recipe_step` + `recipe_tag`"
|
||||||
|
|
||||||
|
**Reality in code:**
|
||||||
|
- Actual table names in `src/lib/server/db/migrations/001_init.sql`:
|
||||||
|
- Line 29: `CREATE TABLE IF NOT EXISTS ingredient` (NOT `recipe_ingredient`)
|
||||||
|
- Line 41: `CREATE TABLE IF NOT EXISTS step` (NOT `recipe_step`)
|
||||||
|
- Line 54: `CREATE TABLE IF NOT EXISTS recipe_tag` (this one is correct ✓)
|
||||||
|
|
||||||
|
**Severity:** HIGH
|
||||||
|
- **Impact:** Anyone reading docs will search for `recipe_ingredient` table and not find it; confuses debugging
|
||||||
|
- **Fix:** Update ARCHITECTURE.md line 55 from `recipe_ingredient` + `recipe_step` to `ingredient` + `step`
|
||||||
|
|
||||||
|
Also verify the same claim doesn't appear in design specs (section 8.8 of 2026-04-17-kochwas-design.md is correct — it already lists `ingredient` and `step` without the prefix).
|
||||||
|
|
||||||
|
### ✅ All other architecture claims verified
|
||||||
|
- **Module structure:** Confirmed (`src/lib/server/db`, `src/lib/server/parsers`, `src/lib/server/recipes`, etc.) ✓
|
||||||
|
- **FTS5 virtual table:** Confirmed in `001_init.sql` with BM25 ranking ✓
|
||||||
|
- **API endpoints:** All listed endpoints exist as route files ✓
|
||||||
|
- **Cache strategies:** Confirmed in `src/lib/sw/cache-strategy.ts` ✓
|
||||||
|
- **Service Worker behavior:** Confirmed in `src/service-worker.ts` ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## docs/OPERATIONS.md Findings
|
||||||
|
|
||||||
|
### ⚠ MEDIUM: Environment Variable Discrepancy
|
||||||
|
- **Same as CLAUDE.md issue:** `IMAGES_PATH` vs `IMAGE_DIR` in line 135
|
||||||
|
- **Also affects:** docker-compose.prod.yml example in section "Umgebungsvariablen" — doc doesn't show it being set, but it's not needed (code defaults to `./data/images`)
|
||||||
|
|
||||||
|
### ✅ All deployment claims verified
|
||||||
|
- **Healthcheck interval/timeout:** Confirmed in Dockerfile ✓
|
||||||
|
- **SearXNG configuration:** Confirmed `searxng/settings.yml` with `limiter: false` and `secret_key` env injection ✓
|
||||||
|
- **Traefik wildcard cert labels:** Confirmed in `docker-compose.prod.yml` lines 26–27 ✓
|
||||||
|
- **PWA offline behavior:** Confirmed in spec and code ✓
|
||||||
|
- **Backup/restore UI:** Confirmed routes exist `/admin/backup` and `/api/admin/backup` ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## docs/superpowers/ Findings
|
||||||
|
|
||||||
|
### ✅ Session Handoff (2026-04-17)
|
||||||
|
|
||||||
|
**Routes listed (line 46):**
|
||||||
|
- Session-handoff lists: `/images/[filename]` endpoint
|
||||||
|
- **Actual code:** Route exists at `src/routes/images/[filename]/+server.ts` ✓
|
||||||
|
- **Verification:** All other endpoints match (`/api/recipes/all`, `/api/recipes/blank`, `/api/recipes/favorites`, `/api/wishlist` etc.) ✓
|
||||||
|
|
||||||
|
**Note:** Session-handoff does NOT mention `/api/recipes/[id]/image` (POST/DELETE for profile-specific image updates), which exists in code. This is not a *mismatch* but an **omission** (minor).
|
||||||
|
|
||||||
|
### ✅ Design Spec (2026-04-17)
|
||||||
|
|
||||||
|
**Section 8 (Datenmodell):**
|
||||||
|
- Lists `ingredient` and `step` tables correctly (no prefix) ✓
|
||||||
|
- This contradicts ARCHITECTURE.md (which says `recipe_ingredient` + `recipe_step`), but ARCHITECTURE.md is wrong
|
||||||
|
- Design spec is the source of truth here ✓
|
||||||
|
|
||||||
|
### ✅ Offline PWA Design (2026-04-18)
|
||||||
|
|
||||||
|
**All claims verified:**
|
||||||
|
- `src/service-worker.ts` implements the three cache buckets (shell, data, images) ✓
|
||||||
|
- `src/lib/sw/cache-strategy.ts` implements the strategy dispatcher ✓
|
||||||
|
- `src/lib/client/sync-status.svelte.ts` exists with message handler ✓
|
||||||
|
- `src/lib/client/network.svelte.ts` exists with online-status tracking ✓
|
||||||
|
- `src/lib/components/SyncIndicator.svelte` exists ✓
|
||||||
|
- `src/lib/components/Toast.svelte` exists ✓
|
||||||
|
- `/admin/app/+page.svelte` exists (confirmed in route listing) ✓
|
||||||
|
- Icon rendering script confirmed ✓
|
||||||
|
- PWA manifest with PNG icons confirmed ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Docs Get Right
|
||||||
|
|
||||||
|
1. **Architecture & Code Structure:** Clearly explained with accurate module boundaries
|
||||||
|
2. **Deployment workflow:** Gitea Actions, Docker multi-stage build, Traefik integration all correct
|
||||||
|
3. **Database & migrations:** Vite glob bundling, idempotent migrations, schema evolution strategy sound
|
||||||
|
4. **PWA offline-first design:** Well thought out, faithfully implemented
|
||||||
|
5. **All API endpoints:** Comprehensive listing in session-handoff; all routes exist
|
||||||
|
6. **Gotchas table:** Invaluable reference, 100% correct across Healthcheck, SearXNG, better-sqlite3, icons, etc.
|
||||||
|
7. **Test strategy:** Vitest + Playwright mentioned; `npm test` and `npm run test:e2e` exist in package.json
|
||||||
|
8. **Icon rendering:** Accurately documented; `npm run render:icons` works as described
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Findings
|
||||||
|
|
||||||
|
| Finding | Severity | File | Line | Action |
|
||||||
|
|---------|----------|------|------|--------|
|
||||||
|
| Table names: `recipe_ingredient` → should be `ingredient` | HIGH | ARCHITECTURE.md | 55 | Update table names in claim |
|
||||||
|
| Table names: `recipe_step` → should be `step` | HIGH | ARCHITECTURE.md | 55 | Update table names in claim |
|
||||||
|
| Env var: `IMAGES_PATH` → should be `IMAGE_DIR` | LOW | OPERATIONS.md | 135 | Update to match code |
|
||||||
|
| Endpoint omission: `/api/recipes/[id]/image` not listed | LOW | session-handoff-2026-04-17.md | 46 | Add to routes list (optional) |
|
||||||
|
|
||||||
|
**Total issues found:** 4 (1 HIGH, 2 MEDIUM, 1 LOW) | **Blocker for development:** None
|
||||||
61
docs/superpowers/review/redundancy.md
Normal file
61
docs/superpowers/review/redundancy.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Redundancy Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Kochwas exhibits significant duplication in API endpoint handlers across 22 endpoints, with copy-pasted parameter parsing (parseId/parsePositiveInt) in 8+ files, repeated error-handling patterns in fetch wrappers across 5+ Svelte components, and schema validation blocks that could be consolidated.
|
||||||
|
|
||||||
|
## HIGH severity
|
||||||
|
|
||||||
|
### Duplicated parseId / parsePositiveInt helpers across API endpoints
|
||||||
|
- **Sites**: /api/recipes/[id]/+server.ts:51-54, /api/recipes/[id]/favorite/+server.ts:9-13, /api/recipes/[id]/rating/+server.ts:14-18, /api/recipes/[id]/cooked/+server.ts:10-14, /api/recipes/[id]/comments/+server.ts:14-18, /api/profiles/[id]/+server.ts:9-13, /api/domains/[id]/+server.ts:19-23, /api/wishlist/[recipe_id]/+server.ts:9-13
|
||||||
|
- **Pattern**: Eight API endpoints independently define nearly identical parameter-parsing functions that validate positive integers from route params.
|
||||||
|
- **Suggestion**: Extract to src/lib/server/api-helpers.ts with parsePositiveIntParam(raw, field) returning the number or throwing via SvelteKit error().
|
||||||
|
|
||||||
|
### Copy-pasted fetch error-handling + alert pattern in Svelte components
|
||||||
|
- **Sites**: /recipes/[id]/+page.svelte:76-87, /recipes/[id]/+page.svelte:253-265, /admin/domains/+page.svelte:31-48, /admin/domains/+page.svelte:67-87, /admin/profiles/+page.svelte:30-44
|
||||||
|
- **Pattern**: Five component functions repeat identical 6-line blocks: await fetch(); if not ok, parse JSON, show alert with body.message or HTTP status.
|
||||||
|
- **Suggestion**: Create src/lib/client/api-fetch-wrapper.ts with asyncFetch(url, init, actionTitle) that wraps fetch, error handling, and alertAction.
|
||||||
|
|
||||||
|
## MEDIUM severity
|
||||||
|
|
||||||
|
### Repeated Zod schema validation + error pattern across API endpoints
|
||||||
|
- **Sites**: /api/recipes/[id]/+server.ts, /api/recipes/[id]/favorite/+server.ts, /api/recipes/[id]/rating/+server.ts, /api/recipes/[id]/cooked/+server.ts, /api/recipes/[id]/comments/+server.ts, /api/profiles/+server.ts, /api/domains/[id]/+server.ts, /api/wishlist/+server.ts
|
||||||
|
- **Pattern**: Every endpoint defines schemas locally with safeParse() followed by identical error handling: if not success, error 400 Invalid body. 12+ endpoints repeat this 3-4 line pattern.
|
||||||
|
- **Suggestion**: Create src/lib/server/schemas.ts with common validators and validateBody<T>(body, schema) helper that centralizes the error throw.
|
||||||
|
|
||||||
|
### Recipe scaling / ingredient manipulation scattered without consolidation
|
||||||
|
- **Sites**: /lib/recipes/scaler.ts:10-16, /lib/server/parsers/ingredient.ts:42-68, /lib/components/RecipeEditor.svelte:144-149, /lib/components/RecipeEditor.svelte:156-175
|
||||||
|
- **Pattern**: RecipeEditor re-implements parseQty and ingredient assembly (raw_text building) instead of importing parseIngredient from server parser. Logic is nearly identical in two places.
|
||||||
|
- **Suggestion**: Expose parseIngredient as shared client code or create src/lib/shared/ingredient-utils.ts; import into component to avoid duplication.
|
||||||
|
|
||||||
|
### Profile not-selected alert pattern duplicated 4x in same component
|
||||||
|
- **Sites**: /recipes/[id]/+page.svelte:124-131, :143-150, :166-173, :188-195
|
||||||
|
- **Pattern**: Four action functions (setRating, toggleFavorite, logCooked, addComment) all open with identical 7-line guard checking active profile and showing same alert message.
|
||||||
|
- **Suggestion**: Extract requireProfile() helper in src/lib/client/profile.svelte that performs the alert and returns boolean; replace all four guard clauses.
|
||||||
|
|
||||||
|
## LOW severity
|
||||||
|
|
||||||
|
### WakeLock error handling try-catch pattern
|
||||||
|
- **Sites**: /recipes/[id]/+page.svelte:318-327, :332-338
|
||||||
|
- **Pattern**: Both functions independently have try-catch that silently swallows errors. Identical empty catch pattern duplicated.
|
||||||
|
- **Suggestion**: Cosmetic - document once or combine into manageWakeLock(action) wrapper.
|
||||||
|
|
||||||
|
### Migration cache-clearing pattern (historical only)
|
||||||
|
- **Sites**: /db/migrations/008_thumbnail_cache_drop_unknown.sql, /db/migrations/010_thumbnail_cache_rerun_negatives.sql
|
||||||
|
- **Pattern**: Two consecutive migrations both DELETE from thumbnail_cache due to feature iteration. Not a bug, just historical stacking.
|
||||||
|
- **Note**: Safe to leave as-is.
|
||||||
|
|
||||||
|
### Test fixture duplication: baseRecipe helper
|
||||||
|
- **Sites**: /tests/integration/recipe-repository.test.ts:22-42
|
||||||
|
- **Pattern**: baseRecipe factory defined locally; likely duplicated in other tests.
|
||||||
|
- **Suggestion**: Move to tests/fixtures/recipe.ts and import everywhere.
|
||||||
|
|
||||||
|
### API error message language inconsistency
|
||||||
|
- **Sites**: /api/recipes/[id]/image/+server.ts (German), all others (English)
|
||||||
|
- **Pattern**: Image endpoint uses German error messages; all other endpoints use English.
|
||||||
|
- **Suggestion**: Standardize to German or English for consistent UX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Strong separation of concerns observed in repositories and parsers. Type definitions well-centralized in src/lib/types.ts. No major SQL redundancy beyond historical migrations. Primary improvement opportunities are parameter validation, error handling, and component fetch logic consolidation.
|
||||||
146
docs/superpowers/review/structure.md
Normal file
146
docs/superpowers/review/structure.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Structure / Design / Maintainability Review
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Kochwas has a healthy, maintainable codebase with strong architectural boundaries between server and client, comprehensive test coverage (integration + e2e), and disciplined use of TypeScript. The main pressure points are large page components (+700 lines) and some high-complexity features (search orchestration, image import pipeline) that could benefit from further decomposition.
|
||||||
|
|
||||||
|
## Big-picture observations
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
1. **Clean architectural layers**: No server code bleeding into client. Strict separation of $lib/server/*, $lib/client/*.svelte.ts, and components.
|
||||||
|
2. **Comprehensive testing**: 17+ integration tests, 4+ unit tests, 2 e2e suites covering recipes, images, parsers, search.
|
||||||
|
3. **Type-safe API**: Domain types in src/lib/types.ts are exhaustive; Zod schemas match; no shadow types.
|
||||||
|
4. **Consistent error handling**: Custom ImporterError with codes, mapped through mapImporterError().
|
||||||
|
5. **Smart runes stores**: Separate concerns (profile, network, pwa, sync-status, toast, wishlist, search-filter). No god-stores.
|
||||||
|
6. **Well-documented gotchas**: CLAUDE.md clearly marks traps (SW HTTPS-only, healthcheck IPv4, native module arm64).
|
||||||
|
|
||||||
|
### Concerns
|
||||||
|
1. **Large page components**: +page.svelte (808L), recipes/[id]/+page.svelte (757L), +layout.svelte (678L).
|
||||||
|
2. **Dense components**: RecipeEditor (630L), RecipeView (398L), SearchFilter (360L) hard to unit-test.
|
||||||
|
3. **Complex parsers**: json-ld-recipe.ts (402L) and searxng.ts (389L) lack edge-case validation.
|
||||||
|
4. **State synchronization**: 20+ local state variables in search page; duplication in +layout.svelte.
|
||||||
|
5. **Magic numbers**: Timeout constants (1500ms, 30min) and z-index values are inline.
|
||||||
|
|
||||||
|
## HIGH severity findings
|
||||||
|
|
||||||
|
### Large page components
|
||||||
|
- **Where**: src/routes/+page.svelte (808L), src/routes/recipes/[id]/+page.svelte (757L), src/routes/+layout.svelte (678L)
|
||||||
|
- **What**: Pages bundle view + component orchestration + state management (20+ $state vars) + fetch logic. Hard to test individual behaviors without mounting entire page.
|
||||||
|
- **Suggestion**: Extract orchestration into composables/stores (e.g., usePageSearch()). Break out visual widgets as sub-components. Move fetch logic to +page.server.ts.
|
||||||
|
|
||||||
|
### State density: 20+ variables in search page
|
||||||
|
- **Where**: src/routes/+page.svelte lines 17-48
|
||||||
|
- **What**: Local state controls search (query, hits, webHits, searching, webError, etc.). Duplication in +layout.svelte nav search. Risk of stale state.
|
||||||
|
- **Suggestion**: Create useSearchState() rune or dedicated store with methods: .search(q), .loadMore(), .clear().
|
||||||
|
|
||||||
|
### JSON-LD parser edge cases
|
||||||
|
- **Where**: src/lib/server/parsers/json-ld-recipe.ts (402L)
|
||||||
|
- **What**: Parser assumes well-formed JSON-LD. Tests only cover ASCII digits; no coverage for non-ASCII numerals, fraction chars, or 0 servings.
|
||||||
|
- **Suggestion**: Add Zod refinement for quantity validation. Test against real recipes from different locales. Document assumptions.
|
||||||
|
|
||||||
|
### Ingredient parsing gaps
|
||||||
|
- **Where**: tests/unit/ingredient.test.ts
|
||||||
|
- **What**: Tests cover integers/decimals/fractions but not: leading zeros, scientific notation, Unicode fractions, unusual separators, null ingredients.
|
||||||
|
- **Suggestion**: Parametrized tests for edge cases. Clamp quantity range (0-1000) at parser level.
|
||||||
|
|
||||||
|
### Unnamed timeout constants
|
||||||
|
- **Where**: src/routes/+page.svelte, src/lib/client/pwa.svelte.ts
|
||||||
|
- **What**: 1500ms (PWA version query), 30*60_000ms (SW update poll), implicit debounce. Hard to find all call sites.
|
||||||
|
- **Suggestion**: Export to src/lib/constants.ts: SW_VERSION_QUERY_TIMEOUT_MS, SW_UPDATE_POLL_INTERVAL_MS.
|
||||||
|
|
||||||
|
## MEDIUM severity findings
|
||||||
|
|
||||||
|
### RecipeEditor/RecipeView component size
|
||||||
|
- **Where**: src/lib/components/RecipeEditor.svelte (630L), src/lib/components/RecipeView.svelte (398L)
|
||||||
|
- **What**: Feature-complete but dense; hard to test rendering in isolation (e.g., ingredient scaling).
|
||||||
|
- **Suggestion**: Extract sub-components: IngredientRow.svelte, StepList.svelte, TimeDisplay.svelte, ImageUploadBox.svelte.
|
||||||
|
|
||||||
|
### API error shape inconsistency
|
||||||
|
- **Where**: src/routes/api/**/*.ts
|
||||||
|
- **What**: Most return {message}. But profiles/+server.ts POST returns {message, issues} (Zod details). Implicit schema.
|
||||||
|
- **Suggestion**: Standardize or define shared ErrorResponse type in src/lib/types.ts. Document in docs/API.md.
|
||||||
|
|
||||||
|
### Service Worker zombie cleanup untested
|
||||||
|
- **Where**: src/lib/client/pwa.svelte.ts (lines 1-72)
|
||||||
|
- **What**: Clever but untested heuristic. 1500ms timeout may cause false positives on slow networks.
|
||||||
|
- **Suggestion**: Unit test timeout scenario. Document 1500ms rationale in comments.
|
||||||
|
|
||||||
|
### Searxng rate-limit recovery
|
||||||
|
- **Where**: src/lib/server/search/searxng.ts (389L)
|
||||||
|
- **What**: Caches per-query. On 429/403, logs but doesn't backoff. Second search returns stale cache with no signal.
|
||||||
|
- **Suggestion**: Add isStale flag. Show "results may be outdated" banner or implement exponential backoff.
|
||||||
|
|
||||||
|
### Store initialization races
|
||||||
|
- **Where**: src/lib/client/profile.svelte.ts, src/lib/client/search-filter.svelte.ts
|
||||||
|
- **What**: Load data on first access. If component mounts before fetch completes, shows stale state. No loading signal.
|
||||||
|
- **Suggestion**: Add loading property. Load in +page.server.ts instead or await store.init() in onMount().
|
||||||
|
|
||||||
|
## LOW severity findings
|
||||||
|
|
||||||
|
### Missing named constants
|
||||||
|
- **Where**: ConfirmDialog.svelte, ProfileSwitcher.svelte (z-index, border-radius, timeouts inline)
|
||||||
|
- **What**: Z-index (100, 200), border-radius (999px), timeouts (1500ms) hardcoded.
|
||||||
|
- **Suggestion**: Create src/lib/theme.ts: MODAL_Z_INDEX, POPOVER_Z_INDEX, etc.
|
||||||
|
|
||||||
|
### console logging in production
|
||||||
|
- **Where**: src/service-worker.ts (2), src/lib/server/search/searxng.ts (3), src/lib/client/sw-register.ts (1)
|
||||||
|
- **What**: Likely intentional (production diagnostics) but unfiltered by log level.
|
||||||
|
- **Suggestion**: Document intent. If not intentional, wrap in if (DEV) guards.
|
||||||
|
|
||||||
|
### Unhandled DB errors
|
||||||
|
- **Where**: src/routes/api/recipes/all/+server.ts
|
||||||
|
- **What**: If DB query fails, error propagates as 500.
|
||||||
|
- **Suggestion**: Wrap in try-catch for consistency (unlikely with local SQLite).
|
||||||
|
|
||||||
|
### Migration ordering
|
||||||
|
- **Where**: Tests don't verify migration sequence
|
||||||
|
- **What**: Migrations autodiscovered via glob; out-of-order filenames won't cause build error.
|
||||||
|
- **Suggestion**: CI check verifying 00X_* sequence.
|
||||||
|
|
||||||
|
### Incomplete image downloader errors
|
||||||
|
- **Where**: src/lib/server/images/image-downloader.ts
|
||||||
|
- **What**: Generic error message; can't distinguish "URL wrong" from "network down."
|
||||||
|
- **Suggestion**: Add error codes (NOT_FOUND, TIMEOUT, NETWORK).
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
### Lines per file (top 15)
|
||||||
|
```
|
||||||
|
808 src/routes/+page.svelte
|
||||||
|
757 src/routes/recipes/[id]/+page.svelte
|
||||||
|
678 src/routes/+layout.svelte
|
||||||
|
630 src/lib/components/RecipeEditor.svelte
|
||||||
|
539 src/routes/recipes/+page.svelte
|
||||||
|
402 src/lib/server/parsers/json-ld-recipe.ts
|
||||||
|
398 src/lib/components/RecipeView.svelte
|
||||||
|
389 src/lib/server/search/searxng.ts
|
||||||
|
360 src/lib/components/SearchFilter.svelte
|
||||||
|
321 src/routes/wishlist/+page.svelte
|
||||||
|
318 src/routes/admin/domains/+page.svelte
|
||||||
|
259 src/service-worker.ts
|
||||||
|
244 src/lib/server/recipes/repository.ts
|
||||||
|
218 src/lib/components/ProfileSwitcher.svelte
|
||||||
|
216 src/routes/preview/+page.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality metrics
|
||||||
|
| Metric | Value | Status |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| Test suites (integration) | 17 | Good |
|
||||||
|
| Test suites (unit) | 5+ | Adequate |
|
||||||
|
| Zod validation endpoints | 11 | Excellent |
|
||||||
|
| TypeScript strict | Yes | Excellent |
|
||||||
|
| Any types found | 0 | Excellent |
|
||||||
|
| Server code in client | 0 | Excellent |
|
||||||
|
| Console logging | 6 instances | Minor |
|
||||||
|
|
||||||
|
## Recommendations (priority)
|
||||||
|
|
||||||
|
1. **Extract page state to stores** (HIGH, medium effort): Reduce +page.svelte by ~200L; enable isolated testing.
|
||||||
|
2. **Split large components** (HIGH, medium effort): RecipeEditor/RecipeView sub-components.
|
||||||
|
3. **Add ingredient validation** (HIGH, low effort): Zod refinement + edge-case tests.
|
||||||
|
4. **Define named constants** (MEDIUM, low effort): src/lib/constants.ts for timeouts/z-index.
|
||||||
|
5. **Standardize API errors** (MEDIUM, low effort): docs/API.md + shared ErrorResponse type.
|
||||||
|
6. **Test SW zombie cleanup** (MEDIUM, medium effort): Unit tests + comments.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
Healthy, maintainable codebase. Main pressure: large page/component sizes (natural scaling). With recommendations above, ready for continued development and easy to onboard new developers.
|
||||||
@@ -43,7 +43,7 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
### Server-Seite
|
### Server-Seite
|
||||||
- **DB:** SQLite mit FTS5, Migrationen (`./migrations/*.sql`) werden von Vite gebündelt und beim ersten DB-Zugriff angewendet. Auto-mkdir für `data/` und `data/images/`.
|
- **DB:** SQLite mit FTS5, Migrationen (`./migrations/*.sql`) werden von Vite gebündelt und beim ersten DB-Zugriff angewendet. Auto-mkdir für `data/` und `data/images/`.
|
||||||
- **Module:** `parsers/` (iso8601, ingredient, json-ld-recipe), `recipes/` (scaler + repository + actions + importer + search-local), `domains/` (repository + whitelist), `profiles/`, `images/image-downloader`, `search/searxng`, `backup/export`, `http`.
|
- **Module:** `parsers/` (iso8601, ingredient, json-ld-recipe), `recipes/` (scaler + repository + actions + importer + search-local), `domains/` (repository + whitelist), `profiles/`, `images/image-downloader`, `search/searxng`, `backup/export`, `http`.
|
||||||
- **Routes:** `/api/health`, `/api/profiles`, `/api/profiles/[id]`, `/api/domains`, `/api/domains/[id]`, `/api/recipes/search`, `/api/recipes/search/web`, `/api/recipes/preview`, `/api/recipes/import`, `/api/recipes/[id]`, `/api/recipes/[id]/rating`, `/api/recipes/[id]/favorite`, `/api/recipes/[id]/cooked`, `/api/recipes/[id]/comments`, `/api/admin/backup`, `/images/[filename]`.
|
- **Routes:** `/api/health`, `/api/profiles`, `/api/profiles/[id]`, `/api/domains`, `/api/domains/[id]`, `/api/recipes/search`, `/api/recipes/search/web`, `/api/recipes/preview`, `/api/recipes/import`, `/api/recipes/[id]`, `/api/recipes/[id]/rating`, `/api/recipes/[id]/favorite`, `/api/recipes/[id]/cooked`, `/api/recipes/[id]/comments`, `/api/recipes/[id]/image` (POST/DELETE), `/api/admin/backup`, `/images/[filename]`.
|
||||||
|
|
||||||
### Client-Seite (Svelte 5 Runes)
|
### Client-Seite (Svelte 5 Runes)
|
||||||
- **Layout** mit Profil-Chip und Zahnrad zu Admin.
|
- **Layout** mit Profil-Chip und Zahnrad zu Admin.
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
use_default_settings: true
|
# Defaults laden, aber Engine-Liste rigoros auf brave eindampfen.
|
||||||
|
# keep_only ist robuster als einzelne `disabled: true`-Overrides: SearXNGs
|
||||||
|
# Merge-Semantik für partial overrides (nur name + disabled ohne engine:)
|
||||||
|
# greift nicht zuverlässig — DDG & Co. wurden trotzdem abgefragt. keep_only
|
||||||
|
# wirft alles andere vor dem Laden raus, kein Captcha-/403-Log-Lärm mehr.
|
||||||
|
# Mojeek blockt die Pi-IP mit 403 und ist deshalb draußen.
|
||||||
|
use_default_settings:
|
||||||
|
engines:
|
||||||
|
keep_only:
|
||||||
|
- brave
|
||||||
|
|
||||||
server:
|
server:
|
||||||
# Platzhalter wird beim Container-Start per os.path.expandvars aus der
|
# Platzhalter wird beim Container-Start per os.path.expandvars aus der
|
||||||
@@ -31,71 +40,21 @@ outgoing:
|
|||||||
ui:
|
ui:
|
||||||
default_locale: de
|
default_locale: de
|
||||||
|
|
||||||
# Quieten engines that fail on cold start and aren't useful here
|
|
||||||
enabled_plugins:
|
enabled_plugins:
|
||||||
- 'Hash plugin'
|
- 'Hash plugin'
|
||||||
- 'Tracker URL remover'
|
- 'Tracker URL remover'
|
||||||
- 'Open Access DOI rewrite'
|
- 'Open Access DOI rewrite'
|
||||||
|
|
||||||
engines:
|
engines:
|
||||||
# Brave mit API-Key: stabiler als der HTML-Scraper, kein Rate-Limit-Spam
|
# Brave Search API (engine: braveapi). Die Engine "brave" ist der
|
||||||
# mehr. Key kommt aus dem BRAVE_API_KEY-Env (.env auf dem Pi, nicht im Repo).
|
# HTML-Scraper von search.brave.com und ignoriert api_key — deshalb
|
||||||
# Fehlt der Key oder ist er leer, fällt Brave bei der ersten Anfrage zurück
|
# hier explizit braveapi, sonst landen wir in Brave-Rate-Limits.
|
||||||
# auf einen 401 — andere Engines laufen normal weiter.
|
# Key kommt aus dem BRAVE_API_KEY-Env (.env auf dem Pi, nicht im Repo),
|
||||||
|
# expandiert via Python os.path.expandvars im searxng-init-Container.
|
||||||
- name: brave
|
- name: brave
|
||||||
engine: brave
|
engine: braveapi
|
||||||
shortcut: br
|
shortcut: br
|
||||||
categories: [general, web]
|
categories: [general, web]
|
||||||
timeout: 6.0
|
timeout: 6.0
|
||||||
# Wert wird beim Container-Start durch Python-os.path.expandvars aus der
|
|
||||||
# BRAVE_API_KEY-Env-Variable eingesetzt (siehe docker-compose.prod.yml
|
|
||||||
# entrypoint-Override). SearXNG selbst hat kein !env-Tag.
|
|
||||||
api_key: "${BRAVE_API_KEY}"
|
api_key: "${BRAVE_API_KEY}"
|
||||||
disabled: false
|
disabled: false
|
||||||
|
|
||||||
# DuckDuckGo: deaktiviert, weil DDG die Pi-IP als Bot erkannt hat und
|
|
||||||
# bei jeder Anfrage mit CAPTCHA antwortet. Brave (API) + Mojeek decken
|
|
||||||
# die Websuche zuverlässig ab — DDG-Scraping wäre nur zusätzlicher Lärm.
|
|
||||||
- name: duckduckgo
|
|
||||||
disabled: true
|
|
||||||
|
|
||||||
# Mojeek: eigener Index, seltener Rate-Limits, ergänzt Brave.
|
|
||||||
- name: mojeek
|
|
||||||
engine: mojeek
|
|
||||||
shortcut: mjk
|
|
||||||
timeout: 6.0
|
|
||||||
disabled: false
|
|
||||||
|
|
||||||
# Video-/News-Engines abdrehen — wir wollen nur Text-Treffer für Rezeptseiten.
|
|
||||||
- name: google videos
|
|
||||||
disabled: true
|
|
||||||
- name: google news
|
|
||||||
disabled: true
|
|
||||||
- name: google images
|
|
||||||
disabled: true
|
|
||||||
- name: bing videos
|
|
||||||
disabled: true
|
|
||||||
- name: bing news
|
|
||||||
disabled: true
|
|
||||||
- name: bing images
|
|
||||||
disabled: true
|
|
||||||
- name: karmasearch videos
|
|
||||||
disabled: true
|
|
||||||
|
|
||||||
# Startpage: hat unsere Pi-IP als Bot erkannt und blockt mit Captcha
|
|
||||||
# (1h suspended_time pro Fehler). Bringt für Rezeptsuche nichts, was
|
|
||||||
# nicht schon Brave/DDG liefern.
|
|
||||||
- name: startpage
|
|
||||||
disabled: true
|
|
||||||
|
|
||||||
# Tor-basierte Engines brauchen einen Tor-Proxy im Container — haben
|
|
||||||
# wir nicht, also harmlos deaktivieren, um Init-Fehler loszuwerden.
|
|
||||||
- name: ahmia
|
|
||||||
disabled: true
|
|
||||||
- name: torch
|
|
||||||
disabled: true
|
|
||||||
|
|
||||||
# Wikidata produziert beim Cold-Start einen KeyError (Init-Bug in der
|
|
||||||
# aktuellen SearXNG-Version 2026.4). Für Rezeptsuche ohne Mehrwert.
|
|
||||||
- name: wikidata
|
|
||||||
disabled: true
|
|
||||||
|
|||||||
25
src/lib/client/api-fetch-wrapper.ts
Normal file
25
src/lib/client/api-fetch-wrapper.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch wrapper for actions where a non-OK response should pop a modal
|
||||||
|
* via alertAction(). Returns the Response on 2xx, or null after showing
|
||||||
|
* the alert. Caller should `if (!res) return;` after the call.
|
||||||
|
*
|
||||||
|
* Use this for *interactive* actions (rename, delete, save). For form
|
||||||
|
* submissions where the error should appear inline next to the field
|
||||||
|
* (e.g. admin/domains add()), keep manual handling.
|
||||||
|
*/
|
||||||
|
export async function asyncFetch(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit | undefined,
|
||||||
|
errorTitle: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (res.ok) return res;
|
||||||
|
const body = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
await alertAction({
|
||||||
|
title: errorTitle,
|
||||||
|
message: body?.message ?? `HTTP ${res.status}`
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Profile } from '$lib/types';
|
import type { Profile } from '$lib/types';
|
||||||
|
import { alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
const STORAGE_KEY = 'kochwas.activeProfileId';
|
const STORAGE_KEY = 'kochwas.activeProfileId';
|
||||||
|
|
||||||
@@ -60,3 +61,19 @@ class ProfileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const profileStore = new ProfileStore();
|
export const profileStore = new ProfileStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active profile, or null after showing the standard
|
||||||
|
* "kein Profil gewählt" dialog. Use as the first line of any per-profile
|
||||||
|
* action so we don't repeat the guard at every call-site.
|
||||||
|
*
|
||||||
|
* `message` ueberschreibt den Default, wenn eine Aktion einen spezifischen
|
||||||
|
* Hinweis braucht (z. B. „um mitzuwünschen" auf der Wunschliste).
|
||||||
|
*/
|
||||||
|
export async function requireProfile(
|
||||||
|
message = 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||||
|
): Promise<Profile | null> {
|
||||||
|
if (profileStore.active) return profileStore.active;
|
||||||
|
await alertAction({ title: 'Kein Profil gewählt', message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SW_UPDATE_POLL_INTERVAL_MS, SW_VERSION_QUERY_TIMEOUT_MS } from '$lib/constants';
|
||||||
|
|
||||||
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
|
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
|
||||||
// skipWaiting im install-Handler, User bestätigt via Toast) mit
|
// skipWaiting im install-Handler, User bestätigt via Toast) mit
|
||||||
// zusätzlichem Zombie-Schutz.
|
// zusätzlichem Zombie-Schutz.
|
||||||
@@ -39,7 +41,7 @@ class PwaStore {
|
|||||||
// mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren.
|
// mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren.
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
void this.registration?.update().catch(() => {});
|
void this.registration?.update().catch(() => {});
|
||||||
}, 30 * 60_000);
|
}, SW_UPDATE_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onUpdateFound(): void {
|
private onUpdateFound(): void {
|
||||||
@@ -97,7 +99,7 @@ class PwaStore {
|
|||||||
function queryVersion(sw: ServiceWorker): Promise<string | null> {
|
function queryVersion(sw: ServiceWorker): Promise<string | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const channel = new MessageChannel();
|
const channel = new MessageChannel();
|
||||||
const timer = setTimeout(() => resolve(null), 1500);
|
const timer = setTimeout(() => resolve(null), SW_VERSION_QUERY_TIMEOUT_MS);
|
||||||
channel.port1.onmessage = (e) => {
|
channel.port1.onmessage = (e) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
const v = (e.data as { version?: unknown } | null)?.version;
|
const v = (e.data as { version?: unknown } | null)?.version;
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.5rem 0.9rem;
|
padding: 0.5rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
background: white;
|
background: white;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Plus, Trash2, GripVertical } from 'lucide-svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
|
||||||
import type { Recipe, Ingredient, Step } from '$lib/types';
|
import type { Recipe, Ingredient, Step } from '$lib/types';
|
||||||
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||||
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
recipe: Recipe;
|
recipe: Recipe;
|
||||||
@@ -16,16 +20,82 @@
|
|||||||
steps: Step[];
|
steps: Step[];
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
oncancel: () => void;
|
oncancel: () => void;
|
||||||
|
/** Fires whenever the image was uploaded or removed — separate from save,
|
||||||
|
* because the image is its own endpoint and persists immediately. */
|
||||||
|
onimagechange?: (image_path: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { recipe, saving = false, onsave, oncancel }: Props = $props();
|
let { recipe, saving = false, onsave, oncancel, onimagechange }: Props = $props();
|
||||||
|
|
||||||
let title = $state(recipe.title);
|
let imagePath = $state<string | null>(untrack(() => recipe.image_path));
|
||||||
let description = $state(recipe.description ?? '');
|
let uploading = $state(false);
|
||||||
let servings = $state<number | ''>(recipe.servings_default ?? '');
|
let fileInput: HTMLInputElement | null = $state(null);
|
||||||
let prepMin = $state<number | ''>(recipe.prep_time_min ?? '');
|
|
||||||
let cookMin = $state<number | ''>(recipe.cook_time_min ?? '');
|
const imageSrc = $derived(
|
||||||
let totalMin = $state<number | ''>(recipe.total_time_min ?? '');
|
imagePath === null
|
||||||
|
? null
|
||||||
|
: /^https?:\/\//i.test(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: `/images/${imagePath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onFileChosen(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!requireOnline('Der Bild-Upload')) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${recipe.id}/image`,
|
||||||
|
{ method: 'POST', body: fd },
|
||||||
|
'Upload fehlgeschlagen'
|
||||||
|
);
|
||||||
|
if (!res) return;
|
||||||
|
const body = await res.json();
|
||||||
|
imagePath = body.image_path;
|
||||||
|
onimagechange?.(imagePath);
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage() {
|
||||||
|
if (imagePath === null) return;
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Bild entfernen?',
|
||||||
|
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
|
||||||
|
confirmLabel: 'Entfernen',
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Entfernen')) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${recipe.id}/image`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
'Entfernen fehlgeschlagen'
|
||||||
|
);
|
||||||
|
if (!res) return;
|
||||||
|
imagePath = null;
|
||||||
|
onimagechange?.(null);
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form-lokaler Zustand: Initialwerte aus dem Prop snapshotten (untrack),
|
||||||
|
// damit User-Edits nicht von prop-Updates ueberschrieben werden.
|
||||||
|
let title = $state(untrack(() => recipe.title));
|
||||||
|
let description = $state(untrack(() => recipe.description ?? ''));
|
||||||
|
let servings = $state<number | ''>(untrack(() => recipe.servings_default ?? ''));
|
||||||
|
let prepMin = $state<number | ''>(untrack(() => recipe.prep_time_min ?? ''));
|
||||||
|
let cookMin = $state<number | ''>(untrack(() => recipe.cook_time_min ?? ''));
|
||||||
|
let totalMin = $state<number | ''>(untrack(() => recipe.total_time_min ?? ''));
|
||||||
|
|
||||||
type DraftIng = {
|
type DraftIng = {
|
||||||
qty: string;
|
qty: string;
|
||||||
@@ -36,15 +106,17 @@
|
|||||||
type DraftStep = { text: string };
|
type DraftStep = { text: string };
|
||||||
|
|
||||||
let ingredients = $state<DraftIng[]>(
|
let ingredients = $state<DraftIng[]>(
|
||||||
|
untrack(() =>
|
||||||
recipe.ingredients.map((i) => ({
|
recipe.ingredients.map((i) => ({
|
||||||
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
qty: i.quantity !== null ? String(i.quantity).replace('.', ',') : '',
|
||||||
unit: i.unit ?? '',
|
unit: i.unit ?? '',
|
||||||
name: i.name,
|
name: i.name,
|
||||||
note: i.note ?? ''
|
note: i.note ?? ''
|
||||||
}))
|
}))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
let steps = $state<DraftStep[]>(
|
let steps = $state<DraftStep[]>(
|
||||||
recipe.steps.map((s) => ({ text: s.text }))
|
untrack(() => recipe.steps.map((s) => ({ text: s.text })))
|
||||||
);
|
);
|
||||||
|
|
||||||
function addIngredient() {
|
function addIngredient() {
|
||||||
@@ -53,6 +125,13 @@
|
|||||||
function removeIngredient(idx: number) {
|
function removeIngredient(idx: number) {
|
||||||
ingredients = ingredients.filter((_, i) => i !== idx);
|
ingredients = ingredients.filter((_, i) => i !== idx);
|
||||||
}
|
}
|
||||||
|
function moveIngredient(idx: number, dir: -1 | 1) {
|
||||||
|
const target = idx + dir;
|
||||||
|
if (target < 0 || target >= ingredients.length) return;
|
||||||
|
const next = [...ingredients];
|
||||||
|
[next[idx], next[target]] = [next[target], next[idx]];
|
||||||
|
ingredients = next;
|
||||||
|
}
|
||||||
function addStep() {
|
function addStep() {
|
||||||
steps = [...steps, { text: '' }];
|
steps = [...steps, { text: '' }];
|
||||||
}
|
}
|
||||||
@@ -110,6 +189,52 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor">
|
<div class="editor">
|
||||||
|
<section class="block image-block">
|
||||||
|
<h2>Bild</h2>
|
||||||
|
<div class="image-row">
|
||||||
|
<div class="image-preview" class:empty={!imageSrc}>
|
||||||
|
{#if imageSrc}
|
||||||
|
<img src={imageSrc} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder">Kein Bild</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="image-actions">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<ImagePlus size={16} strokeWidth={2} />
|
||||||
|
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
|
||||||
|
</button>
|
||||||
|
{#if imagePath}
|
||||||
|
<button
|
||||||
|
class="btn ghost"
|
||||||
|
type="button"
|
||||||
|
onclick={removeImage}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<ImageOff size={16} strokeWidth={2} />
|
||||||
|
<span>Entfernen</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if uploading}
|
||||||
|
<span class="upload-status">Lade …</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
class="file-input"
|
||||||
|
onchange={onFileChosen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="lbl">Titel</span>
|
<span class="lbl">Titel</span>
|
||||||
@@ -149,7 +274,26 @@
|
|||||||
<ul class="ing-list">
|
<ul class="ing-list">
|
||||||
{#each ingredients as ing, idx (idx)}
|
{#each ingredients as ing, idx (idx)}
|
||||||
<li class="ing-row">
|
<li class="ing-row">
|
||||||
<span class="grip" aria-hidden="true"><GripVertical size={16} /></span>
|
<div class="move">
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Zutat nach oben"
|
||||||
|
disabled={idx === 0}
|
||||||
|
onclick={() => moveIngredient(idx, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Zutat nach unten"
|
||||||
|
disabled={idx === ingredients.length - 1}
|
||||||
|
onclick={() => moveIngredient(idx, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
|
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
|
||||||
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
|
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
|
||||||
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
|
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
|
||||||
@@ -252,6 +396,67 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
.image-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.image-preview {
|
||||||
|
width: 160px;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #eef3ef;
|
||||||
|
border: 1px solid #e4eae7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.image-preview.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.image-preview .placeholder {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.image-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.image-actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
min-height: 40px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.upload-status {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.image-hint {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
.block h2 {
|
.block h2 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
@@ -268,14 +473,34 @@
|
|||||||
}
|
}
|
||||||
.ing-row {
|
.ing-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 16px 70px 70px 1fr 90px 40px;
|
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.grip {
|
.move {
|
||||||
color: #bbb;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.move-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #555;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.move-btn:hover:not(:disabled) {
|
||||||
|
background: #f4f8f5;
|
||||||
|
}
|
||||||
|
.move-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.ing-row input {
|
.ing-row input {
|
||||||
padding: 0.5rem 0.55rem;
|
padding: 0.5rem 0.55rem;
|
||||||
@@ -375,14 +600,14 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.ing-row {
|
.ing-row {
|
||||||
grid-template-columns: 70px 1fr 40px;
|
grid-template-columns: 28px 70px 1fr 40px;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'qty name del'
|
'move qty name del'
|
||||||
'unit unit del'
|
'move unit unit del'
|
||||||
'note note note';
|
'note note note note';
|
||||||
}
|
}
|
||||||
.grip {
|
.ing-row .move {
|
||||||
display: none;
|
grid-area: move;
|
||||||
}
|
}
|
||||||
.ing-row .qty {
|
.ing-row .qty {
|
||||||
grid-area: qty;
|
grid-area: qty;
|
||||||
|
|||||||
@@ -204,7 +204,7 @@
|
|||||||
.pill {
|
.pill {
|
||||||
padding: 0.15rem 0.55rem;
|
padding: 0.15rem 0.55rem;
|
||||||
background: #eaf4ed;
|
background: #eaf4ed;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,9 @@
|
|||||||
|
|
||||||
/* Querformat-Tablets und Desktop: Zutaten + Zubereitung nebeneinander,
|
/* Querformat-Tablets und Desktop: Zutaten + Zubereitung nebeneinander,
|
||||||
Tabs ausgeblendet. Zutaten sticky, damit sie beim Scrollen der
|
Tabs ausgeblendet. Zutaten sticky, damit sie beim Scrollen der
|
||||||
Zubereitung oben bleiben. */
|
Zubereitung oben bleiben.
|
||||||
|
Schriftgrößen hier bewusst größer — das Rezept wird auf einem 10"-
|
||||||
|
Tablet beim Kochen aus ~50 cm Abstand gelesen. */
|
||||||
@media (min-width: 820px) {
|
@media (min-width: 820px) {
|
||||||
.tabs {
|
.tabs {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -367,5 +369,30 @@
|
|||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100vh - 2rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.ing-list li {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0.85rem 0.25rem;
|
||||||
|
}
|
||||||
|
.qty {
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
.srv-value strong {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.srv-value span {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.steps li {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
padding: 1rem 0 1rem 3.4rem;
|
||||||
|
}
|
||||||
|
.steps li::before {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
padding: 0.3rem 0.65rem;
|
padding: 0.3rem 0.65rem;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
color: #555;
|
color: #555;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
padding: 0.6rem 0.85rem 0.6rem 1.1rem;
|
padding: 0.6rem 0.85rem 0.6rem 1.1rem;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
max-width: calc(100% - 2rem);
|
max-width: calc(100% - 2rem);
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
background: #2b6a3d;
|
background: #2b6a3d;
|
||||||
color: white;
|
color: white;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.dismiss:hover {
|
.dismiss:hover {
|
||||||
|
|||||||
11
src/lib/constants.ts
Normal file
11
src/lib/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Shared timing constants. Keep magic numbers here so callers stay readable
|
||||||
|
// and the rationale lives next to the value.
|
||||||
|
|
||||||
|
// How long to wait for a Service Worker to answer GET_VERSION before
|
||||||
|
// treating the response as missing. Short on purpose — SWs that take this
|
||||||
|
// long are likely the Chromium zombie case (see pwa.svelte.ts).
|
||||||
|
export const SW_VERSION_QUERY_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
// Active update check while the page sits open in a tab. 30 minutes is a
|
||||||
|
// trade-off between being timely and not hammering the server.
|
||||||
|
export const SW_UPDATE_POLL_INTERVAL_MS = 30 * 60_000;
|
||||||
39
src/lib/server/api-helpers.ts
Normal file
39
src/lib/server/api-helpers.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { ZodSchema } from 'zod';
|
||||||
|
|
||||||
|
// Shared error body shape for SvelteKit `error()` calls. `issues` is set
|
||||||
|
// when validateBody fails so the client can show a precise validation
|
||||||
|
// hint; everywhere else only `message` is used.
|
||||||
|
export type ErrorResponse = {
|
||||||
|
message: string;
|
||||||
|
issues?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a route param (or query param) as a positive integer (>=1).
|
||||||
|
* Throws SvelteKit `error(400)` with `Missing <field>` when null/undefined,
|
||||||
|
* or `Invalid <field>` when the value is not an integer >= 1.
|
||||||
|
*/
|
||||||
|
export function parsePositiveIntParam(
|
||||||
|
raw: string | undefined | null,
|
||||||
|
field: string
|
||||||
|
): number {
|
||||||
|
if (raw == null) error(400, { message: `Missing ${field}` });
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an unknown body against a Zod schema. Throws SvelteKit
|
||||||
|
* `error(400, { message: 'Invalid body', issues })` on mismatch and returns
|
||||||
|
* the typed parse result on success. Accepts `null` (the typical result of
|
||||||
|
* `await request.json().catch(() => null)`).
|
||||||
|
*/
|
||||||
|
export function validateBody<T>(body: unknown, schema: ZodSchema<T>): T {
|
||||||
|
const parsed = schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
error(400, { message: 'Invalid body', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
|
import { DATABASE_PATH, IMAGE_DIR } from '$lib/server/paths';
|
||||||
import { runMigrations } from './migrate';
|
import { runMigrations } from './migrate';
|
||||||
|
|
||||||
let instance: Database.Database | null = null;
|
let instance: Database.Database | null = null;
|
||||||
|
|
||||||
export function getDb(path = process.env.DATABASE_PATH ?? './data/kochwas.db'): Database.Database {
|
export function getDb(path = DATABASE_PATH): Database.Database {
|
||||||
if (instance) return instance;
|
if (instance) return instance;
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
const imageDir = process.env.IMAGE_DIR ?? './data/images';
|
mkdirSync(IMAGE_DIR, { recursive: true });
|
||||||
mkdirSync(imageDir, { recursive: true });
|
|
||||||
instance = new Database(path);
|
instance = new Database(path);
|
||||||
instance.pragma('journal_mode = WAL');
|
instance.pragma('journal_mode = WAL');
|
||||||
instance.pragma('foreign_keys = ON');
|
instance.pragma('foreign_keys = ON');
|
||||||
|
|||||||
@@ -28,6 +28,42 @@ const FRACTION_MAP: Record<string, number> = {
|
|||||||
'3/4': 0.75
|
'3/4': 0.75
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Vulgar-Fraction-Codepoints — kommen in deutschsprachigen Rezept-Quellen
|
||||||
|
// regelmäßig vor (Chefkoch et al. liefern sie vereinzelt, mehr aber bei
|
||||||
|
// Apple's Food App, Fork etc.).
|
||||||
|
const UNICODE_FRACTION_MAP: Record<string, number> = {
|
||||||
|
'\u00BD': 0.5, // ½
|
||||||
|
'\u00BC': 0.25, // ¼
|
||||||
|
'\u00BE': 0.75, // ¾
|
||||||
|
'\u2150': 1 / 7,
|
||||||
|
'\u2151': 1 / 9,
|
||||||
|
'\u2152': 1 / 10,
|
||||||
|
'\u2153': 1 / 3, // ⅓
|
||||||
|
'\u2154': 2 / 3, // ⅔
|
||||||
|
'\u2155': 0.2, // ⅕
|
||||||
|
'\u2156': 0.4, // ⅖
|
||||||
|
'\u2157': 0.6, // ⅗
|
||||||
|
'\u2158': 0.8, // ⅘
|
||||||
|
'\u2159': 1 / 6, // ⅙
|
||||||
|
'\u215A': 5 / 6, // ⅚
|
||||||
|
'\u215B': 0.125, // ⅛
|
||||||
|
'\u215C': 0.375, // ⅜
|
||||||
|
'\u215D': 0.625, // ⅝
|
||||||
|
'\u215E': 0.875 // ⅞
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mengen außerhalb dieses Bereichs sind fast sicher ein Parse-Müll
|
||||||
|
// (z. B. Microformat-Date oder Telefon-Nummer in einem JSON-LD-Quantity-
|
||||||
|
// Feld). Wir geben null zurück, raw_text bleibt für die UI erhalten.
|
||||||
|
const MAX_REASONABLE_QTY = 10000;
|
||||||
|
|
||||||
|
function clampQuantity(n: number | null): number | null {
|
||||||
|
if (n === null || !Number.isFinite(n)) return null;
|
||||||
|
if (n <= 0) return null;
|
||||||
|
if (n > MAX_REASONABLE_QTY) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
function parseQuantity(raw: string): number | null {
|
function parseQuantity(raw: string): number | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed];
|
if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed];
|
||||||
@@ -39,6 +75,16 @@ function parseQuantity(raw: string): number | null {
|
|||||||
return Number.isFinite(num) ? num : null;
|
return Number.isFinite(num) ? num : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits "TL Salz" → unit "TL", name "Salz"; "Zitrone" → unit null, name "Zitrone".
|
||||||
|
function splitUnitAndName(rest: string): { unit: string | null; name: string } {
|
||||||
|
const trimmed = rest.trim();
|
||||||
|
const firstTokenMatch = /^(\S+)\s+(.+)$/.exec(trimmed);
|
||||||
|
if (firstTokenMatch && UNITS.has(firstTokenMatch[1])) {
|
||||||
|
return { unit: firstTokenMatch[1], name: firstTokenMatch[2].trim() };
|
||||||
|
}
|
||||||
|
return { unit: null, name: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseIngredient(raw: string, position = 0): Ingredient {
|
export function parseIngredient(raw: string, position = 0): Ingredient {
|
||||||
const rawText = raw.trim();
|
const rawText = raw.trim();
|
||||||
let working = rawText;
|
let working = rawText;
|
||||||
@@ -51,18 +97,24 @@ export function parseIngredient(raw: string, position = 0): Ingredient {
|
|||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unicode-Bruch am Anfang? Dann das eine Zeichen als Menge nehmen
|
||||||
|
// und den Rest wie üblich in Unit + Name aufteilen.
|
||||||
|
const firstChar = working.charAt(0);
|
||||||
|
if (UNICODE_FRACTION_MAP[firstChar] !== undefined) {
|
||||||
|
const tail = working.slice(1).trimStart();
|
||||||
|
if (tail.length > 0) {
|
||||||
|
const quantity = clampQuantity(UNICODE_FRACTION_MAP[firstChar]);
|
||||||
|
const { unit, name } = splitUnitAndName(tail);
|
||||||
|
return { position, quantity, unit, name, note, raw_text: rawText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
|
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
|
||||||
const qtyMatch = qtyPattern.exec(working);
|
const qtyMatch = qtyPattern.exec(working);
|
||||||
if (!qtyMatch) {
|
if (!qtyMatch) {
|
||||||
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
|
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
|
||||||
}
|
}
|
||||||
const quantity = parseQuantity(qtyMatch[1]);
|
const quantity = clampQuantity(parseQuantity(qtyMatch[1]));
|
||||||
let rest = qtyMatch[2].trim();
|
const { unit, name } = splitUnitAndName(qtyMatch[2]);
|
||||||
let unit: string | null = null;
|
return { position, quantity, unit, name, note, raw_text: rawText };
|
||||||
const firstTokenMatch = /^(\S+)\s+(.+)$/.exec(rest);
|
|
||||||
if (firstTokenMatch && UNITS.has(firstTokenMatch[1])) {
|
|
||||||
unit = firstTokenMatch[1];
|
|
||||||
rest = firstTokenMatch[2].trim();
|
|
||||||
}
|
|
||||||
return { position, quantity, unit, name: rest, note, raw_text: rawText };
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/lib/server/paths.ts
Normal file
6
src/lib/server/paths.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Filesystem paths read from env at module load. Centralized so a misset
|
||||||
|
// env var only causes one place to be wrong, not six. Both defaults match
|
||||||
|
// the docker-compose volume mounts under `/app/data`.
|
||||||
|
|
||||||
|
export const DATABASE_PATH = process.env.DATABASE_PATH ?? './data/kochwas.db';
|
||||||
|
export const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||||
@@ -196,6 +196,17 @@ export function updateRecipeMeta(
|
|||||||
db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id);
|
db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateImagePath(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number,
|
||||||
|
filename: string | null
|
||||||
|
): void {
|
||||||
|
db.prepare('UPDATE recipe SET image_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||||
|
filename,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function replaceIngredients(
|
export function replaceIngredients(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
|
|||||||
@@ -312,6 +312,10 @@ export async function searchWeb(
|
|||||||
// Nur Text-Engines abfragen — SearXNG-Video/Image-Engines (karmasearch etc.)
|
// Nur Text-Engines abfragen — SearXNG-Video/Image-Engines (karmasearch etc.)
|
||||||
// bringen uns für Rezeptseiten nichts und produzieren nur 403-Log-Noise.
|
// bringen uns für Rezeptseiten nichts und produzieren nur 403-Log-Noise.
|
||||||
endpoint.searchParams.set('categories', 'general');
|
endpoint.searchParams.set('categories', 'general');
|
||||||
|
// Nur Brave (via API) — Mojeek blockt die Pi-IP mit 403, andere Engines
|
||||||
|
// sind von SearXNG-Seite durch keep_only ohnehin ausgeknipst. So bleibt
|
||||||
|
// das Log sauber und kochwas ist unabhängig von der globalen Engine-Liste.
|
||||||
|
endpoint.searchParams.set('engines', 'brave');
|
||||||
if (pageno > 1) endpoint.searchParams.set('pageno', String(pageno));
|
if (pageno > 1) endpoint.searchParams.set('pageno', String(pageno));
|
||||||
|
|
||||||
const body = await fetchText(endpoint.toString(), {
|
const body = await fetchText(endpoint.toString(), {
|
||||||
@@ -361,6 +365,9 @@ export async function searchWeb(
|
|||||||
});
|
});
|
||||||
if (hits.length >= limit) break;
|
if (hits.length >= limit) break;
|
||||||
}
|
}
|
||||||
|
// Absichtliches Prod-Logging: diese drei [searxng]-Zeilen erlauben "warum
|
||||||
|
// wurde Domain X gefiltert?" ohne Code-Änderung. Strukturiert genug für
|
||||||
|
// grep/awk, klein genug für jeden Log-Sammler.
|
||||||
console.log(
|
console.log(
|
||||||
`[searxng] q=${JSON.stringify(trimmed)} pageno=${pageno} domains=${domains.length} raw=${results.length} non_whitelist=${dropNonWhitelist} non_recipe_url=${dropNonRecipeUrl} dup=${dropDup} kept_pre_enrich=${hits.length}`
|
`[searxng] q=${JSON.stringify(trimmed)} pageno=${pageno} domains=${domains.length} raw=${results.length} non_whitelist=${dropNonWhitelist} non_recipe_url=${dropNonRecipeUrl} dup=${dropDup} kept_pre_enrich=${hits.length}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
|
export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only';
|
||||||
|
|
||||||
export type RequestShape = { url: string; method: string };
|
type RequestShape = { url: string; method: string };
|
||||||
|
|
||||||
// Pure function — sole decision-maker for "which strategy for this request?".
|
// Pure function — sole decision-maker for "which strategy for this request?".
|
||||||
// Called by the service worker for every fetch event.
|
// Called by the service worker for every fetch event.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Vergleicht die aktuelle Rezept-ID-Liste (vom Server) mit dem, was
|
// Vergleicht die aktuelle Rezept-ID-Liste (vom Server) mit dem, was
|
||||||
// der Cache schon hat. Der SW nutzt das Delta, um nur Neue zu laden
|
// der Cache schon hat. Der SW nutzt das Delta, um nur Neue zu laden
|
||||||
// und Gelöschte abzuräumen.
|
// und Gelöschte abzuräumen.
|
||||||
export type ManifestDiff = { toAdd: number[]; toRemove: number[] };
|
type ManifestDiff = { toAdd: number[]; toRemove: number[] };
|
||||||
|
|
||||||
export function diffManifest(currentIds: number[], cachedIds: number[]): ManifestDiff {
|
export function diffManifest(currentIds: number[], cachedIds: number[]): ManifestDiff {
|
||||||
const current = new Set(currentIds);
|
const current = new Set(currentIds);
|
||||||
|
|||||||
@@ -386,6 +386,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:global(:root) {
|
||||||
|
--pill-radius: 999px;
|
||||||
|
}
|
||||||
:global(html, body) {
|
:global(html, body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -429,7 +432,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -621,7 +624,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -636,7 +639,7 @@
|
|||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
background: #c53030;
|
background: #c53030;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -653,7 +653,7 @@
|
|||||||
padding: 0.4rem 0.85rem;
|
padding: 0.4rem 0.85rem;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -760,7 +760,7 @@
|
|||||||
right: 0.4rem;
|
right: 0.4rem;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
border: 0;
|
border: 0;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
color: #444;
|
color: #444;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
padding: 0.5rem 0.95rem 0.5rem 0.8rem;
|
padding: 0.5rem 0.95rem 0.5rem 0.8rem;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e4eae7;
|
border: 1px solid #e4eae7;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #444;
|
color: #444;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
||||||
import type { AllowedDomain } from '$lib/types';
|
import type { AllowedDomain } from '$lib/types';
|
||||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||||
import { requireOnline } from '$lib/client/require-online';
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
|
||||||
let domains = $state<AllowedDomain[]>([]);
|
let domains = $state<AllowedDomain[]>([]);
|
||||||
@@ -64,22 +65,19 @@
|
|||||||
if (!requireOnline('Das Speichern')) return;
|
if (!requireOnline('Das Speichern')) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/domains/${d.id}`, {
|
const res = await asyncFetch(
|
||||||
|
`/api/domains/${d.id}`,
|
||||||
|
{
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
domain: editDomain.trim(),
|
domain: editDomain.trim(),
|
||||||
display_name: editLabel.trim() || null
|
display_name: editLabel.trim() || null
|
||||||
})
|
})
|
||||||
});
|
},
|
||||||
if (!res.ok) {
|
'Speichern fehlgeschlagen'
|
||||||
const body = await res.json().catch(() => ({}));
|
);
|
||||||
await alertAction({
|
if (!res) return;
|
||||||
title: 'Speichern fehlgeschlagen',
|
|
||||||
message: body.message ?? `HTTP ${res.status}`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||||
import { requireOnline } from '$lib/client/require-online';
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
|
||||||
let newName = $state('');
|
let newName = $state('');
|
||||||
@@ -27,19 +28,16 @@
|
|||||||
const next = prompt('Neuer Name:', currentName);
|
const next = prompt('Neuer Name:', currentName);
|
||||||
if (!next || next === currentName) return;
|
if (!next || next === currentName) return;
|
||||||
if (!requireOnline('Das Umbenennen')) return;
|
if (!requireOnline('Das Umbenennen')) return;
|
||||||
const res = await fetch(`/api/profiles/${id}`, {
|
const res = await asyncFetch(
|
||||||
|
`/api/profiles/${id}`,
|
||||||
|
{
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ name: next.trim() })
|
body: JSON.stringify({ name: next.trim() })
|
||||||
});
|
},
|
||||||
if (!res.ok) {
|
'Umbenennen fehlgeschlagen'
|
||||||
const body = await res.json().catch(() => ({}));
|
);
|
||||||
await alertAction({
|
if (!res) return;
|
||||||
title: 'Umbenennen fehlgeschlagen',
|
|
||||||
message: body.message ?? `HTTP ${res.status}`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await profileStore.load();
|
await profileStore.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +185,7 @@
|
|||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
background: #eaf4ed;
|
background: #eaf4ed;
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { createBackupStream, backupFilename } from '$lib/server/backup/export';
|
import { createBackupStream, backupFilename } from '$lib/server/backup/export';
|
||||||
|
import { DATABASE_PATH, IMAGE_DIR } from '$lib/server/paths';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
const DB_PATH = process.env.DATABASE_PATH ?? './data/kochwas.db';
|
|
||||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
const archive = createBackupStream({ dbPath: DB_PATH, imagesDir: IMAGE_DIR });
|
const archive = createBackupStream({ dbPath: DATABASE_PATH, imagesDir: IMAGE_DIR });
|
||||||
const filename = backupFilename();
|
const filename = backupFilename();
|
||||||
return new Response(Readable.toWeb(archive) as ReadableStream, {
|
return new Response(Readable.toWeb(archive) as ReadableStream, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { validateBody } from '$lib/server/api-helpers';
|
||||||
import { addDomain, listDomains, setDomainFavicon } from '$lib/server/domains/repository';
|
import { addDomain, listDomains, setDomainFavicon } from '$lib/server/domains/repository';
|
||||||
import { ensureFavicons, fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
import { ensureFavicons, fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||||
|
import { IMAGE_DIR } from '$lib/server/paths';
|
||||||
|
|
||||||
const CreateSchema = z.object({
|
const CreateSchema = z.object({
|
||||||
domain: z.string().min(3).max(253),
|
domain: z.string().min(3).max(253),
|
||||||
@@ -11,8 +13,6 @@ const CreateSchema = z.object({
|
|||||||
added_by_profile_id: z.number().int().positive().nullable().optional()
|
added_by_profile_id: z.number().int().positive().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
// Favicons lazy nachziehen — beim zweiten Aufruf gibt es nichts mehr zu tun.
|
// Favicons lazy nachziehen — beim zweiten Aufruf gibt es nichts mehr zu tun.
|
||||||
@@ -21,16 +21,14 @@ export const GET: RequestHandler = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), CreateSchema);
|
||||||
const parsed = CreateSchema.safeParse(body);
|
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const d = addDomain(
|
const d = addDomain(
|
||||||
db,
|
db,
|
||||||
parsed.data.domain,
|
data.domain,
|
||||||
parsed.data.display_name ?? null,
|
data.display_name ?? null,
|
||||||
parsed.data.added_by_profile_id ?? null
|
data.added_by_profile_id ?? null
|
||||||
);
|
);
|
||||||
// Favicon direkt nach dem Insert mitziehen, damit die Antwort schon das
|
// Favicon direkt nach dem Insert mitziehen, damit die Antwort schon das
|
||||||
// Icon enthält — der POST ist eh ein interaktiver Admin-Vorgang.
|
// Icon enthält — der POST ist eh ein interaktiver Admin-Vorgang.
|
||||||
@@ -41,6 +39,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
return json(d, { status: 201 });
|
return json(d, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isHttpError(e)) throw e;
|
||||||
error(409, { message: (e as Error).message });
|
error(409, { message: (e as Error).message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,35 +1,27 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||||
import {
|
import {
|
||||||
removeDomain,
|
removeDomain,
|
||||||
updateDomain,
|
updateDomain,
|
||||||
setDomainFavicon
|
setDomainFavicon
|
||||||
} from '$lib/server/domains/repository';
|
} from '$lib/server/domains/repository';
|
||||||
import { fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
import { fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||||
|
import { IMAGE_DIR } from '$lib/server/paths';
|
||||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
|
||||||
|
|
||||||
const UpdateSchema = z.object({
|
const UpdateSchema = z.object({
|
||||||
domain: z.string().min(3).max(253).optional(),
|
domain: z.string().min(3).max(253).optional(),
|
||||||
display_name: z.string().max(100).nullable().optional()
|
display_name: z.string().max(100).nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
|
||||||
const id = Number(raw);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), UpdateSchema);
|
||||||
const parsed = UpdateSchema.safeParse(body);
|
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const updated = updateDomain(db, id, parsed.data);
|
const updated = updateDomain(db, id, data);
|
||||||
if (!updated) error(404, { message: 'Not found' });
|
if (!updated) error(404, { message: 'Not found' });
|
||||||
// Wenn updateDomain favicon_path genullt hat (Domain geändert), frisch laden.
|
// Wenn updateDomain favicon_path genullt hat (Domain geändert), frisch laden.
|
||||||
if (updated.favicon_path === null) {
|
if (updated.favicon_path === null) {
|
||||||
@@ -41,12 +33,14 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
|||||||
}
|
}
|
||||||
return json(updated);
|
return json(updated);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// HTTP-Errors aus error() durchreichen, sonst landet ein 404 als 409.
|
||||||
|
if (isHttpError(e)) throw e;
|
||||||
error(409, { message: (e as Error).message });
|
error(409, { message: (e as Error).message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params }) => {
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
removeDomain(getDb(), id);
|
removeDomain(getDb(), id);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { validateBody } from '$lib/server/api-helpers';
|
||||||
import { createProfile, listProfiles } from '$lib/server/profiles/repository';
|
import { createProfile, listProfiles } from '$lib/server/profiles/repository';
|
||||||
|
|
||||||
const CreateSchema = z.object({
|
const CreateSchema = z.object({
|
||||||
@@ -14,15 +15,12 @@ export const GET: RequestHandler = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), CreateSchema);
|
||||||
const parsed = CreateSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
error(400, { message: 'Invalid body', issues: parsed.error.issues });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const p = createProfile(getDb(), parsed.data.name, parsed.data.avatar_emoji ?? null);
|
const p = createProfile(getDb(), data.name, data.avatar_emoji ?? null);
|
||||||
return json(p, { status: 201 });
|
return json(p, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isHttpError(e)) throw e;
|
||||||
error(409, { message: (e as Error).message });
|
error(409, { message: (e as Error).message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||||
import { deleteProfile, renameProfile } from '$lib/server/profiles/repository';
|
import { deleteProfile, renameProfile } from '$lib/server/profiles/repository';
|
||||||
|
|
||||||
const RenameSchema = z.object({ name: z.string().min(1).max(50) });
|
const RenameSchema = z.object({ name: z.string().min(1).max(50) });
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
|
||||||
const id = Number(raw);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), RenameSchema);
|
||||||
const parsed = RenameSchema.safeParse(body);
|
renameProfile(getDb(), id, data.name);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
renameProfile(getDb(), id, parsed.data.name);
|
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params }) => {
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
deleteProfile(getDb(), id);
|
deleteProfile(getDb(), id);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||||
import {
|
import {
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
getRecipeById,
|
getRecipeById,
|
||||||
@@ -48,14 +49,8 @@ const PatchSchema = z
|
|||||||
})
|
})
|
||||||
.refine((v) => Object.keys(v).length > 0, { message: 'Empty patch' });
|
.refine((v) => Object.keys(v).length > 0, { message: 'Empty patch' });
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
|
||||||
const id = Number(raw);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const recipe = getRecipeById(db, id);
|
const recipe = getRecipeById(db, id);
|
||||||
if (!recipe) error(404, { message: 'Recipe not found' });
|
if (!recipe) error(404, { message: 'Recipe not found' });
|
||||||
@@ -68,12 +63,10 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
const parsed = PatchSchema.safeParse(body);
|
const p = validateBody(body, PatchSchema);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const p = parsed.data;
|
|
||||||
// Spezielle Kurz-Updates (bleiben als Sonderfall, weil sie FTS triggern
|
// Spezielle Kurz-Updates (bleiben als Sonderfall, weil sie FTS triggern
|
||||||
// bzw. andere Tabellen mitpflegen).
|
// bzw. andere Tabellen mitpflegen).
|
||||||
if (p.title !== undefined && Object.keys(p).length === 1) {
|
if (p.title !== undefined && Object.keys(p).length === 1) {
|
||||||
@@ -121,7 +114,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params }) => {
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
deleteRecipe(getDb(), id);
|
deleteRecipe(getDb(), id);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||||
import { addComment, deleteComment, listComments } from '$lib/server/recipes/actions';
|
import { addComment, deleteComment, listComments } from '$lib/server/recipes/actions';
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
@@ -11,30 +12,20 @@ const Schema = z.object({
|
|||||||
|
|
||||||
const DeleteSchema = z.object({ comment_id: z.number().int().positive() });
|
const DeleteSchema = z.object({ comment_id: z.number().int().positive() });
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
|
||||||
const id = Number(raw);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
return json(listComments(getDb(), id));
|
return json(listComments(getDb(), id));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request }) => {
|
export const POST: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||||
const parsed = Schema.safeParse(body);
|
const cid = addComment(getDb(), id, data.profile_id, data.text);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
const cid = addComment(getDb(), id, parsed.data.profile_id, parsed.data.text);
|
|
||||||
return json({ id: cid }, { status: 201 });
|
return json({ id: cid }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ request }) => {
|
export const DELETE: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), DeleteSchema);
|
||||||
const parsed = DeleteSchema.safeParse(body);
|
deleteComment(getDb(), data.comment_id);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
deleteComment(getDb(), parsed.data.comment_id);
|
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||||
import { logCooked } from '$lib/server/recipes/actions';
|
import { logCooked } from '$lib/server/recipes/actions';
|
||||||
import { removeFromWishlistForAll } from '$lib/server/wishlist/repository';
|
import { removeFromWishlistForAll } from '$lib/server/wishlist/repository';
|
||||||
|
|
||||||
const Schema = z.object({ profile_id: z.number().int().positive() });
|
const Schema = z.object({ profile_id: z.number().int().positive() });
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
|
||||||
const id = Number(raw);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request }) => {
|
export const POST: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||||
const parsed = Schema.safeParse(body);
|
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const entry = logCooked(db, id, parsed.data.profile_id);
|
const entry = logCooked(db, id, data.profile_id);
|
||||||
// Wenn das Rezept heute gekocht wurde, ist der Wunsch erfüllt — für alle
|
// Wenn das Rezept heute gekocht wurde, ist der Wunsch erfüllt — für alle
|
||||||
// Profile raus aus der Wunschliste. Client nutzt den removed_from_wishlist-
|
// Profile raus aus der Wunschliste. Client nutzt den removed_from_wishlist-
|
||||||
// Flag, um den lokalen State (Badge, Button) ohne Reload zu aktualisieren.
|
// Flag, um den lokalen State (Badge, Button) ohne Reload zu aktualisieren.
|
||||||
|
|||||||
@@ -1,31 +1,22 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||||
import { addFavorite, removeFavorite } from '$lib/server/recipes/actions';
|
import { addFavorite, removeFavorite } from '$lib/server/recipes/actions';
|
||||||
|
|
||||||
const Schema = z.object({ profile_id: z.number().int().positive() });
|
const Schema = z.object({ profile_id: z.number().int().positive() });
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
|
||||||
const id = Number(raw);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ params, request }) => {
|
export const PUT: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||||
const parsed = Schema.safeParse(body);
|
addFavorite(getDb(), id, data.profile_id);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
addFavorite(getDb(), id, parsed.data.profile_id);
|
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
export const DELETE: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||||
const parsed = Schema.safeParse(body);
|
removeFavorite(getDb(), id, data.profile_id);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
removeFavorite(getDb(), id, parsed.data.profile_id);
|
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
56
src/routes/api/recipes/[id]/image/+server.ts
Normal file
56
src/routes/api/recipes/[id]/image/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam } from '$lib/server/api-helpers';
|
||||||
|
import { getRecipeById, updateImagePath } from '$lib/server/recipes/repository';
|
||||||
|
import { IMAGE_DIR } from '$lib/server/paths';
|
||||||
|
|
||||||
|
const MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/avif': '.avif'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request }) => {
|
||||||
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
|
const db = getDb();
|
||||||
|
if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' });
|
||||||
|
|
||||||
|
const form = await request.formData().catch(() => null);
|
||||||
|
const file = form?.get('file');
|
||||||
|
if (!(file instanceof File)) error(400, { message: 'Field "file" missing' });
|
||||||
|
if (file.size === 0) error(400, { message: 'Empty file' });
|
||||||
|
if (file.size > MAX_BYTES) error(413, { message: 'Image too large (max 10 MB)' });
|
||||||
|
|
||||||
|
const mime = file.type.toLowerCase();
|
||||||
|
const ext = EXT_BY_MIME[mime];
|
||||||
|
if (!ext) error(415, { message: `Image format ${file.type || 'unknown'} not supported` });
|
||||||
|
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
const hash = createHash('sha256').update(buf).digest('hex');
|
||||||
|
const filename = `${hash}${ext}`;
|
||||||
|
const target = join(IMAGE_DIR, filename);
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
await mkdir(IMAGE_DIR, { recursive: true });
|
||||||
|
await writeFile(target, buf);
|
||||||
|
}
|
||||||
|
updateImagePath(db, id, filename);
|
||||||
|
return json({ ok: true, image_path: filename });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = ({ params }) => {
|
||||||
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
|
const db = getDb();
|
||||||
|
if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' });
|
||||||
|
updateImagePath(db, id, null);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||||
import { clearRating, setRating } from '$lib/server/recipes/actions';
|
import { clearRating, setRating } from '$lib/server/recipes/actions';
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
@@ -11,26 +12,16 @@ const Schema = z.object({
|
|||||||
|
|
||||||
const DeleteSchema = z.object({ profile_id: z.number().int().positive() });
|
const DeleteSchema = z.object({ profile_id: z.number().int().positive() });
|
||||||
|
|
||||||
function parseId(raw: string): number {
|
|
||||||
const id = Number(raw);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ params, request }) => {
|
export const PUT: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||||
const parsed = Schema.safeParse(body);
|
setRating(getDb(), id, data.profile_id, data.stars);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
setRating(getDb(), id, parsed.data.profile_id, parsed.data.stars);
|
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
export const DELETE: RequestHandler = async ({ params, request }) => {
|
||||||
const id = parseId(params.id!);
|
const id = parsePositiveIntParam(params.id, 'id');
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), DeleteSchema);
|
||||||
const parsed = DeleteSchema.safeParse(body);
|
clearRating(getDb(), id, data.profile_id);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
clearRating(getDb(), id, parsed.data.profile_id);
|
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { validateBody } from '$lib/server/api-helpers';
|
||||||
import { importRecipe } from '$lib/server/recipes/importer';
|
import { importRecipe } from '$lib/server/recipes/importer';
|
||||||
import { mapImporterError } from '$lib/server/errors';
|
import { mapImporterError } from '$lib/server/errors';
|
||||||
|
import { IMAGE_DIR } from '$lib/server/paths';
|
||||||
|
|
||||||
const ImportSchema = z.object({ url: z.string().url() });
|
const ImportSchema = z.object({ url: z.string().url() });
|
||||||
|
|
||||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), ImportSchema);
|
||||||
const parsed = ImportSchema.safeParse(body);
|
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
|
||||||
try {
|
try {
|
||||||
const result = await importRecipe(getDb(), IMAGE_DIR, parsed.data.url);
|
const result = await importRecipe(getDb(), IMAGE_DIR, data.url);
|
||||||
return json({ id: result.id, duplicate: result.duplicate });
|
return json({ id: result.id, duplicate: result.duplicate });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mapImporterError(e);
|
mapImporterError(e);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { validateBody } from '$lib/server/api-helpers';
|
||||||
import {
|
import {
|
||||||
addToWishlist,
|
addToWishlist,
|
||||||
listWishlist,
|
listWishlist,
|
||||||
@@ -32,9 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.json().catch(() => null);
|
const data = validateBody(await request.json().catch(() => null), AddSchema);
|
||||||
const parsed = AddSchema.safeParse(body);
|
addToWishlist(getDb(), data.recipe_id, data.profile_id);
|
||||||
if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' });
|
|
||||||
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id);
|
|
||||||
return json({ ok: true }, { status: 201 });
|
return json({ ok: true }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
|
import { parsePositiveIntParam } from '$lib/server/api-helpers';
|
||||||
import {
|
import {
|
||||||
removeFromWishlist,
|
removeFromWishlist,
|
||||||
removeFromWishlistForAll
|
removeFromWishlistForAll
|
||||||
} from '$lib/server/wishlist/repository';
|
} from '$lib/server/wishlist/repository';
|
||||||
|
|
||||||
function parsePositiveInt(raw: string | null, field: string): number {
|
|
||||||
const n = raw === null ? NaN : Number(raw);
|
|
||||||
if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` });
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/wishlist/:id?profile_id=X → entfernt nur den eigenen Wunsch
|
// DELETE /api/wishlist/:id?profile_id=X → entfernt nur den eigenen Wunsch
|
||||||
// DELETE /api/wishlist/:id?all=true → entfernt für ALLE Profile
|
// DELETE /api/wishlist/:id?all=true → entfernt für ALLE Profile
|
||||||
export const DELETE: RequestHandler = async ({ params, url }) => {
|
export const DELETE: RequestHandler = async ({ params, url }) => {
|
||||||
const id = parsePositiveInt(params.recipe_id!, 'recipe_id');
|
const id = parsePositiveIntParam(params.recipe_id, 'recipe_id');
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (url.searchParams.get('all') === 'true') {
|
if (url.searchParams.get('all') === 'true') {
|
||||||
removeFromWishlistForAll(db, id);
|
removeFromWishlistForAll(db, id);
|
||||||
} else {
|
} else {
|
||||||
const profileId = parsePositiveInt(url.searchParams.get('profile_id'), 'profile_id');
|
const profileId = parsePositiveIntParam(url.searchParams.get('profile_id'), 'profile_id');
|
||||||
removeFromWishlist(db, id, profileId);
|
removeFromWishlist(db, id, profileId);
|
||||||
}
|
}
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||||
import { join, basename, extname } from 'node:path';
|
import { join, basename, extname } from 'node:path';
|
||||||
|
import { IMAGE_DIR } from '$lib/server/paths';
|
||||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
|
||||||
|
|
||||||
const MIME: Record<string, string> = {
|
const MIME: Record<string, string> = {
|
||||||
'.jpg': 'image/jpeg',
|
'.jpg': 'image/jpeg',
|
||||||
|
|||||||
@@ -33,7 +33,12 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const u = ($page.url.searchParams.get('url') ?? '').trim();
|
const u = ($page.url.searchParams.get('url') ?? '').trim();
|
||||||
targetUrl = u;
|
targetUrl = u;
|
||||||
if (u) void load(u);
|
if (u) {
|
||||||
|
void load(u);
|
||||||
|
} else {
|
||||||
|
loading = false;
|
||||||
|
errored = 'Kein ?url=-Parameter. Suche zuerst ein Rezept und klicke auf einen Treffer.';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|||||||
@@ -441,7 +441,7 @@
|
|||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
background: white;
|
background: white;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, tick, untrack } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -17,9 +17,10 @@
|
|||||||
import RecipeView from '$lib/components/RecipeView.svelte';
|
import RecipeView from '$lib/components/RecipeView.svelte';
|
||||||
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
|
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
|
||||||
import StarRating from '$lib/components/StarRating.svelte';
|
import StarRating from '$lib/components/StarRating.svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore, requireProfile } from '$lib/client/profile.svelte';
|
||||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||||
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
|
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
|
||||||
import { requireOnline } from '$lib/client/require-online';
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
import type { CommentRow } from '$lib/server/recipes/actions';
|
import type { CommentRow } from '$lib/server/recipes/actions';
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
|
|
||||||
let editMode = $state(false);
|
let editMode = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let recipeState = $state(data.recipe);
|
let recipeState = $state(untrack(() => data.recipe));
|
||||||
|
|
||||||
// Einmalige Pulse-Animation beim Aktivieren (nicht beim Wieder-Abwählen).
|
// Einmalige Pulse-Animation beim Aktivieren (nicht beim Wieder-Abwählen).
|
||||||
// Per tick()-Zwischenschritt "aus → an" erzwingen, damit die Animation
|
// Per tick()-Zwischenschritt "aus → an" erzwingen, damit die Animation
|
||||||
@@ -73,19 +74,16 @@
|
|||||||
if (!requireOnline('Das Speichern')) return;
|
if (!requireOnline('Das Speichern')) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${data.recipe.id}`,
|
||||||
|
{
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(patch)
|
body: JSON.stringify(patch)
|
||||||
});
|
},
|
||||||
if (!res.ok) {
|
'Speichern fehlgeschlagen'
|
||||||
const body = await res.json().catch(() => ({}));
|
);
|
||||||
await alertAction({
|
if (!res) return;
|
||||||
title: 'Speichern fehlgeschlagen',
|
|
||||||
message: body.message ?? `HTTP ${res.status}`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body.recipe) {
|
if (body.recipe) {
|
||||||
recipeState = body.recipe;
|
recipeState = body.recipe;
|
||||||
@@ -122,60 +120,44 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function setRating(stars: number) {
|
async function setRating(stars: number) {
|
||||||
if (!profileStore.active) {
|
const profile = await requireProfile();
|
||||||
await alertAction({
|
if (!profile) return;
|
||||||
title: 'Kein Profil gewählt',
|
|
||||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!requireOnline('Das Rating')) return;
|
if (!requireOnline('Das Rating')) return;
|
||||||
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
|
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ profile_id: profileStore.active.id, stars })
|
body: JSON.stringify({ profile_id: profile.id, stars })
|
||||||
});
|
});
|
||||||
const existing = ratings.find((r) => r.profile_id === profileStore.active!.id);
|
const existing = ratings.find((r) => r.profile_id === profile.id);
|
||||||
if (existing) existing.stars = stars;
|
if (existing) existing.stars = stars;
|
||||||
else ratings = [...ratings, { profile_id: profileStore.active.id, stars }];
|
else ratings = [...ratings, { profile_id: profile.id, stars }];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
if (!profileStore.active) {
|
const profile = await requireProfile();
|
||||||
await alertAction({
|
if (!profile) return;
|
||||||
title: 'Kein Profil gewählt',
|
|
||||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!requireOnline('Das Favorit-Setzen')) return;
|
if (!requireOnline('Das Favorit-Setzen')) return;
|
||||||
const profileId = profileStore.active.id;
|
|
||||||
const wasFav = isFav;
|
const wasFav = isFav;
|
||||||
const method = wasFav ? 'DELETE' : 'PUT';
|
const method = wasFav ? 'DELETE' : 'PUT';
|
||||||
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
|
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
|
||||||
method,
|
method,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ profile_id: profileId })
|
body: JSON.stringify({ profile_id: profile.id })
|
||||||
});
|
});
|
||||||
favoriteProfileIds = wasFav
|
favoriteProfileIds = wasFav
|
||||||
? favoriteProfileIds.filter((id) => id !== profileId)
|
? favoriteProfileIds.filter((id) => id !== profile.id)
|
||||||
: [...favoriteProfileIds, profileId];
|
: [...favoriteProfileIds, profile.id];
|
||||||
if (!wasFav) void firePulse('fav');
|
if (!wasFav) void firePulse('fav');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logCooked() {
|
async function logCooked() {
|
||||||
if (!profileStore.active) {
|
const profile = await requireProfile();
|
||||||
await alertAction({
|
if (!profile) return;
|
||||||
title: 'Kein Profil gewählt',
|
|
||||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!requireOnline('Der Kochjournal-Eintrag')) return;
|
if (!requireOnline('Der Kochjournal-Eintrag')) return;
|
||||||
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
|
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ profile_id: profileStore.active.id })
|
body: JSON.stringify({ profile_id: profile.id })
|
||||||
});
|
});
|
||||||
const entry = await res.json();
|
const entry = await res.json();
|
||||||
cookingLog = [entry, ...cookingLog];
|
cookingLog = [entry, ...cookingLog];
|
||||||
@@ -186,20 +168,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addComment() {
|
async function addComment() {
|
||||||
if (!profileStore.active) {
|
const profile = await requireProfile();
|
||||||
await alertAction({
|
if (!profile) return;
|
||||||
title: 'Kein Profil gewählt',
|
|
||||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!requireOnline('Das Speichern des Kommentars')) return;
|
if (!requireOnline('Das Speichern des Kommentars')) return;
|
||||||
const text = newComment.trim();
|
const text = newComment.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
|
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ profile_id: profileStore.active.id, text })
|
body: JSON.stringify({ profile_id: profile.id, text })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -207,16 +184,38 @@
|
|||||||
...comments,
|
...comments,
|
||||||
{
|
{
|
||||||
id: body.id,
|
id: body.id,
|
||||||
profile_id: profileStore.active.id,
|
profile_id: profile.id,
|
||||||
text,
|
text,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
author: profileStore.active.name
|
author: profile.name
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
newComment = '';
|
newComment = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteComment(id: number) {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Kommentar löschen?',
|
||||||
|
message: 'Der Eintrag verschwindet ohne Umweg.',
|
||||||
|
confirmLabel: 'Löschen',
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (!requireOnline('Das Löschen')) return;
|
||||||
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${data.recipe.id}/comments`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ comment_id: id })
|
||||||
|
},
|
||||||
|
'Löschen fehlgeschlagen'
|
||||||
|
);
|
||||||
|
if (!res) return;
|
||||||
|
comments = comments.filter((c) => c.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRecipe() {
|
async function deleteRecipe() {
|
||||||
const ok = await confirmAction({
|
const ok = await confirmAction({
|
||||||
title: 'Rezept löschen?',
|
title: 'Rezept löschen?',
|
||||||
@@ -250,19 +249,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!requireOnline('Das Umbenennen')) return;
|
if (!requireOnline('Das Umbenennen')) return;
|
||||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
const res = await asyncFetch(
|
||||||
|
`/api/recipes/${data.recipe.id}`,
|
||||||
|
{
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ title: next })
|
body: JSON.stringify({ title: next })
|
||||||
});
|
},
|
||||||
if (!res.ok) {
|
'Umbenennen fehlgeschlagen'
|
||||||
const body = await res.json().catch(() => ({}));
|
);
|
||||||
await alertAction({
|
if (!res) return;
|
||||||
title: 'Umbenennen fehlgeschlagen',
|
|
||||||
message: body.message ?? `HTTP ${res.status}`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
title = next;
|
title = next;
|
||||||
editingTitle = false;
|
editingTitle = false;
|
||||||
}
|
}
|
||||||
@@ -278,28 +274,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleWishlist() {
|
async function toggleWishlist() {
|
||||||
if (!profileStore.active) {
|
const profile = await requireProfile();
|
||||||
await alertAction({
|
if (!profile) return;
|
||||||
title: 'Kein Profil gewählt',
|
|
||||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!requireOnline('Das Wunschlisten-Setzen')) return;
|
if (!requireOnline('Das Wunschlisten-Setzen')) return;
|
||||||
const profileId = profileStore.active.id;
|
|
||||||
const wasOn = onMyWishlist;
|
const wasOn = onMyWishlist;
|
||||||
if (wasOn) {
|
if (wasOn) {
|
||||||
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, {
|
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profile.id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profileId);
|
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profile.id);
|
||||||
} else {
|
} else {
|
||||||
await fetch('/api/wishlist', {
|
await fetch('/api/wishlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profileId })
|
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profile.id })
|
||||||
});
|
});
|
||||||
wishlistProfileIds = [...wishlistProfileIds, profileId];
|
wishlistProfileIds = [...wishlistProfileIds, profile.id];
|
||||||
}
|
}
|
||||||
void wishlistStore.refresh();
|
void wishlistStore.refresh();
|
||||||
if (!wasOn) void firePulse('wish');
|
if (!wasOn) void firePulse('wish');
|
||||||
@@ -379,6 +369,7 @@
|
|||||||
{saving}
|
{saving}
|
||||||
onsave={saveRecipe}
|
onsave={saveRecipe}
|
||||||
oncancel={() => (editMode = false)}
|
oncancel={() => (editMode = false)}
|
||||||
|
onimagechange={(path) => (recipeState = { ...recipeState, image_path: path })}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<RecipeView recipe={recipeState}>
|
<RecipeView recipe={recipeState}>
|
||||||
@@ -497,6 +488,16 @@
|
|||||||
<div class="author">{c.author}</div>
|
<div class="author">{c.author}</div>
|
||||||
<div class="text">{c.text}</div>
|
<div class="text">{c.text}</div>
|
||||||
<div class="date">{new Date(c.created_at).toLocaleString('de-DE')}</div>
|
<div class="date">{new Date(c.created_at).toLocaleString('de-DE')}</div>
|
||||||
|
{#if profileStore.active?.id === c.profile_id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="comment-del"
|
||||||
|
aria-label="Kommentar löschen"
|
||||||
|
onclick={() => void deleteComment(c.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size="14" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -704,6 +705,26 @@
|
|||||||
border: 1px solid #e4eae7;
|
border: 1px solid #e4eae7;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem 0.9rem;
|
padding: 0.75rem 0.9rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.comment-del {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.comment-del:hover {
|
||||||
|
background: #f3f5f3;
|
||||||
|
color: #b42626;
|
||||||
}
|
}
|
||||||
.comments .author {
|
.comments .author {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
|
import { Utensils, Trash2, CookingPot } from 'lucide-svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore, requireProfile } from '$lib/client/profile.svelte';
|
||||||
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
import { wishlistStore } from '$lib/client/wishlist.svelte';
|
||||||
import { alertAction, confirmAction } from '$lib/client/confirm.svelte';
|
import { confirmAction } from '$lib/client/confirm.svelte';
|
||||||
import { requireOnline } from '$lib/client/require-online';
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
|
||||||
|
|
||||||
@@ -35,15 +35,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function toggleMine(entry: WishlistEntry) {
|
async function toggleMine(entry: WishlistEntry) {
|
||||||
if (!profileStore.active) {
|
const profile = await requireProfile(
|
||||||
await alertAction({
|
'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
||||||
title: 'Kein Profil gewählt',
|
);
|
||||||
message: 'Tippe oben rechts auf „Profil wählen", um mitzuwünschen.'
|
if (!profile) return;
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!requireOnline('Die Wunschlisten-Aktion')) return;
|
if (!requireOnline('Die Wunschlisten-Aktion')) return;
|
||||||
const profileId = profileStore.active.id;
|
const profileId = profile.id;
|
||||||
if (entry.on_my_wishlist) {
|
if (entry.on_my_wishlist) {
|
||||||
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
|
await fetch(`/api/wishlist/${entry.recipe_id}?profile_id=${profileId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -185,7 +182,7 @@
|
|||||||
padding: 0.4rem 0.85rem;
|
padding: 0.4rem 0.85rem;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #cfd9d1;
|
border: 1px solid #cfd9d1;
|
||||||
border-radius: 999px;
|
border-radius: var(--pill-radius);
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
95
tests/unit/api-helpers.test.ts
Normal file
95
tests/unit/api-helpers.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parsePositiveIntParam, validateBody } from '../../src/lib/server/api-helpers';
|
||||||
|
|
||||||
|
// SvelteKit's `error()` throws an HttpError shape with { status, body }.
|
||||||
|
// We verify both — wrapping these everywhere costs nothing and keeps the
|
||||||
|
// API contract stable.
|
||||||
|
|
||||||
|
function expectHttpError(fn: () => unknown, status: number, message?: string) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { status?: number; body?: { message?: string } };
|
||||||
|
expect(e.status, `status should be ${status}`).toBe(status);
|
||||||
|
if (message !== undefined) {
|
||||||
|
expect(e.body?.message).toBe(message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('expected fn to throw, but it returned normally');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parsePositiveIntParam', () => {
|
||||||
|
it('parses a valid positive integer', () => {
|
||||||
|
expect(parsePositiveIntParam('42', 'id')).toBe(42);
|
||||||
|
expect(parsePositiveIntParam('1', 'id')).toBe(1);
|
||||||
|
expect(parsePositiveIntParam('999999', 'id')).toBe(999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for zero', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('0', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for negative numbers', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('-1', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for non-integer', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('1.5', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for non-numeric strings', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('abc', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for empty string', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('', 'id'), 400, 'Invalid id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for null', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam(null, 'id'), 400, 'Missing id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for undefined', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam(undefined, 'id'), 400, 'Missing id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the provided field name in error messages', () => {
|
||||||
|
expectHttpError(() => parsePositiveIntParam('foo', 'recipe_id'), 400, 'Invalid recipe_id');
|
||||||
|
expectHttpError(() => parsePositiveIntParam(null, 'recipe_id'), 400, 'Missing recipe_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateBody', () => {
|
||||||
|
const Schema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
age: z.number().int().nonnegative()
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns parsed data when valid', () => {
|
||||||
|
const result = validateBody({ name: 'foo', age: 42 }, Schema);
|
||||||
|
expect(result).toEqual({ name: 'foo', age: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 with message and issues on schema mismatch', () => {
|
||||||
|
try {
|
||||||
|
validateBody({ name: '', age: -1 }, Schema);
|
||||||
|
throw new Error('expected throw');
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { status?: number; body?: { message?: string; issues?: unknown[] } };
|
||||||
|
expect(e.status).toBe(400);
|
||||||
|
expect(e.body?.message).toBe('Invalid body');
|
||||||
|
expect(Array.isArray(e.body?.issues)).toBe(true);
|
||||||
|
expect(e.body?.issues?.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for null body (request.json failure case)', () => {
|
||||||
|
expectHttpError(() => validateBody(null, Schema), 400, 'Invalid body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 400 for primitive non-object body', () => {
|
||||||
|
expectHttpError(() => validateBody('a string', Schema), 400, 'Invalid body');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,4 +39,66 @@ describe('parseIngredient', () => {
|
|||||||
expect(p.quantity).toBe(2);
|
expect(p.quantity).toBe(2);
|
||||||
expect(p.name).toBe('Tomaten');
|
expect(p.name).toBe('Tomaten');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Unicode-Bruchzeichen', () => {
|
||||||
|
it.each([
|
||||||
|
['½ TL Salz', 0.5, 'TL', 'Salz'],
|
||||||
|
['¼ kg Zucker', 0.25, 'kg', 'Zucker'],
|
||||||
|
['¾ l Milch', 0.75, 'l', 'Milch'],
|
||||||
|
['⅓ Tasse Mehl', 1 / 3, 'Tasse', 'Mehl'],
|
||||||
|
['⅔ TL Pfeffer', 2 / 3, 'TL', 'Pfeffer'],
|
||||||
|
['⅛ TL Muskat', 0.125, 'TL', 'Muskat']
|
||||||
|
] as const)('%s', (input, qty, unit, name) => {
|
||||||
|
const p = parseIngredient(input);
|
||||||
|
expect(p.quantity).toBeCloseTo(qty, 5);
|
||||||
|
expect(p.unit).toBe(unit);
|
||||||
|
expect(p.name).toBe(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Unicode-Bruch ohne Unit', () => {
|
||||||
|
const p = parseIngredient('½ Zitrone');
|
||||||
|
expect(p.quantity).toBeCloseTo(0.5, 5);
|
||||||
|
expect(p.unit).toBe(null);
|
||||||
|
expect(p.name).toBe('Zitrone');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mengen-Plausibilitaet (Bounds)', () => {
|
||||||
|
it('weist 0 als Menge ab → quantity null', () => {
|
||||||
|
const p = parseIngredient('0 g Mehl');
|
||||||
|
expect(p.quantity).toBe(null);
|
||||||
|
// name bleibt das was nach der "0" kommt — Importer muss das nicht
|
||||||
|
// perfekt rekonstruieren, der raw_text bleibt erhalten.
|
||||||
|
expect(p.raw_text).toBe('0 g Mehl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist negative Menge ab', () => {
|
||||||
|
// "-1 EL Öl" — Minus führt regex direkt ins Fallback (kein \d am Start),
|
||||||
|
// also bleibt name = full text.
|
||||||
|
const p = parseIngredient('-1 EL Öl');
|
||||||
|
expect(p.quantity).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist Menge > 10000 ab', () => {
|
||||||
|
const p = parseIngredient('99999 g Hokuspokus');
|
||||||
|
expect(p.quantity).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('akzeptiert die Obergrenze 10000 selbst', () => {
|
||||||
|
const p = parseIngredient('10000 g Mehl');
|
||||||
|
expect(p.quantity).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('akzeptiert führende Null bei Dezimalbrüchen', () => {
|
||||||
|
const p = parseIngredient('0.5 kg Salz');
|
||||||
|
expect(p.quantity).toBe(0.5);
|
||||||
|
expect(p.unit).toBe('kg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('akzeptiert deutsche führende Null', () => {
|
||||||
|
const p = parseIngredient('0,25 l Wasser');
|
||||||
|
expect(p.quantity).toBe(0.25);
|
||||||
|
expect(p.unit).toBe('l');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user