Merge review-fixes-2026-04-18 — API-Helper + Cleanup + Roadmap
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 33s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 33s
Bundelt 10 atomare Refactor/Feature-Commits aus dem Review-Branch: api-helpers (parsePositiveIntParam, validateBody), alle 13 Handler migriert, requireProfile()+asyncFetch Wrapper, Unicode-Brueche im Ingredient-Parser, IMAGE_DIR/DATABASE_PATH zentralisiert, Doku- Drift behoben, SW-Timing-Konstanten. Plus CI-Trigger fuer alle Branches und Post-Review-Roadmap fuer die verschobenen Items A-I. 184/184 Tests gruen, svelte-check 0 Errors, UAT auf kochwas-dev clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -31,14 +31,13 @@ src/
|
||||
│ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache)
|
||||
│ │ ├── wishlist/ # Repo
|
||||
│ │ └── 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
|
||||
└── routes/
|
||||
├── +layout.svelte # Header, Confirm-Dialog-Mount, Header-Search-Dropdown
|
||||
├── +page.svelte # Home: Hero + Live-Search + Zuletzt-hinzugefügt
|
||||
├── recipes/[id]/ # Rezept-Detail
|
||||
├── preview/ # Vorschau vor dem Speichern
|
||||
├── search/ # /search (lokal), /search/web (Internet)
|
||||
├── wishlist/
|
||||
├── admin/ # Whitelist, Profile, Backup/Restore
|
||||
├── images/[filename] # Statische Auslieferung lokaler Bilder
|
||||
@@ -52,7 +51,7 @@ src/
|
||||
1. User klickt auf Web-Hit → `/preview?url=...`
|
||||
2. `/api/recipes/preview` → `importer.ts` lädt HTML, `parseHTML` von linkedom, `json-ld-recipe.ts` extrahiert `Recipe`-Objekt mit **externer** Bild-URL
|
||||
3. Preview-Seite rendert das `Recipe` via `RecipeView.svelte` (erkennt externe URL und lädt direkt vom Original-CDN)
|
||||
4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `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]`
|
||||
|
||||
### 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` |
|
||||
| `KOCHWAS_THUMB_TTL_DAYS` | `30` | TTL für Thumbnail-Cache in der SQLite |
|
||||
| `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) |
|
||||
|
||||
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 |
|
||||
@@ -43,7 +43,7 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
### 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/`.
|
||||
- **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)
|
||||
- **Layout** mit Profil-Chip und Zahnrad zu Admin.
|
||||
|
||||
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 { alertAction } from '$lib/client/confirm.svelte';
|
||||
|
||||
const STORAGE_KEY = 'kochwas.activeProfileId';
|
||||
|
||||
@@ -60,3 +61,17 @@ class 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.
|
||||
*/
|
||||
export async function requireProfile(): Promise<Profile | null> {
|
||||
if (profileStore.active) return profileStore.active;
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
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
|
||||
// skipWaiting im install-Handler, User bestätigt via Toast) mit
|
||||
// zusätzlichem Zombie-Schutz.
|
||||
@@ -39,7 +41,7 @@ class PwaStore {
|
||||
// mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren.
|
||||
this.pollTimer = setInterval(() => {
|
||||
void this.registration?.update().catch(() => {});
|
||||
}, 30 * 60_000);
|
||||
}, SW_UPDATE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private onUpdateFound(): void {
|
||||
@@ -97,7 +99,7 @@ class PwaStore {
|
||||
function queryVersion(sw: ServiceWorker): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(() => resolve(null), 1500);
|
||||
const timer = setTimeout(() => resolve(null), SW_VERSION_QUERY_TIMEOUT_MS);
|
||||
channel.port1.onmessage = (e) => {
|
||||
clearTimeout(timer);
|
||||
const v = (e.data as { version?: unknown } | null)?.version;
|
||||
|
||||
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 { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { DATABASE_PATH, IMAGE_DIR } from '$lib/server/paths';
|
||||
import { runMigrations } from './migrate';
|
||||
|
||||
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;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const imageDir = process.env.IMAGE_DIR ?? './data/images';
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
mkdirSync(IMAGE_DIR, { recursive: true });
|
||||
instance = new Database(path);
|
||||
instance.pragma('journal_mode = WAL');
|
||||
instance.pragma('foreign_keys = ON');
|
||||
|
||||
@@ -28,6 +28,42 @@ const FRACTION_MAP: Record<string, number> = {
|
||||
'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 {
|
||||
const trimmed = raw.trim();
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const rawText = raw.trim();
|
||||
let working = rawText;
|
||||
@@ -51,18 +97,24 @@ export function parseIngredient(raw: string, position = 0): Ingredient {
|
||||
).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 qtyMatch = qtyPattern.exec(working);
|
||||
if (!qtyMatch) {
|
||||
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
|
||||
}
|
||||
const quantity = parseQuantity(qtyMatch[1]);
|
||||
let rest = qtyMatch[2].trim();
|
||||
let unit: string | null = null;
|
||||
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 };
|
||||
const quantity = clampQuantity(parseQuantity(qtyMatch[1]));
|
||||
const { unit, name } = splitUnitAndName(qtyMatch[2]);
|
||||
return { position, quantity, unit, name, 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';
|
||||
@@ -365,6 +365,9 @@ export async function searchWeb(
|
||||
});
|
||||
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(
|
||||
`[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 RequestShape = { url: string; method: string };
|
||||
type RequestShape = { url: string; method: string };
|
||||
|
||||
// Pure function — sole decision-maker for "which strategy for this request?".
|
||||
// Called by the service worker for every fetch event.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// 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 {
|
||||
const current = new Set(currentIds);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
||||
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';
|
||||
|
||||
let domains = $state<AllowedDomain[]>([]);
|
||||
@@ -64,22 +65,19 @@
|
||||
if (!requireOnline('Das Speichern')) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/domains/${d.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: editDomain.trim(),
|
||||
display_name: editLabel.trim() || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/domains/${d.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: editDomain.trim(),
|
||||
display_name: editLabel.trim() || null
|
||||
})
|
||||
},
|
||||
'Speichern fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
cancelEdit();
|
||||
await load();
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
let newName = $state('');
|
||||
@@ -27,19 +28,16 @@
|
||||
const next = prompt('Neuer Name:', currentName);
|
||||
if (!next || next === currentName) return;
|
||||
if (!requireOnline('Das Umbenennen')) return;
|
||||
const res = await fetch(`/api/profiles/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: next.trim() })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Umbenennen fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/profiles/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: next.trim() })
|
||||
},
|
||||
'Umbenennen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
await profileStore.load();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createBackupStream, backupFilename } from '$lib/server/backup/export';
|
||||
import { DATABASE_PATH, IMAGE_DIR } from '$lib/server/paths';
|
||||
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 () => {
|
||||
const archive = createBackupStream({ dbPath: DB_PATH, imagesDir: IMAGE_DIR });
|
||||
const archive = createBackupStream({ dbPath: DATABASE_PATH, imagesDir: IMAGE_DIR });
|
||||
const filename = backupFilename();
|
||||
return new Response(Readable.toWeb(archive) as ReadableStream, {
|
||||
status: 200,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import { addDomain, listDomains, setDomainFavicon } from '$lib/server/domains/repository';
|
||||
import { ensureFavicons, fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
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()
|
||||
});
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const db = getDb();
|
||||
// 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 }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = CreateSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const data = validateBody(await request.json().catch(() => null), CreateSchema);
|
||||
try {
|
||||
const db = getDb();
|
||||
const d = addDomain(
|
||||
db,
|
||||
parsed.data.domain,
|
||||
parsed.data.display_name ?? null,
|
||||
parsed.data.added_by_profile_id ?? null
|
||||
data.domain,
|
||||
data.display_name ?? null,
|
||||
data.added_by_profile_id ?? null
|
||||
);
|
||||
// Favicon direkt nach dem Insert mitziehen, damit die Antwort schon das
|
||||
// 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 });
|
||||
} catch (e) {
|
||||
if (isHttpError(e)) throw e;
|
||||
error(409, { message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import {
|
||||
removeDomain,
|
||||
updateDomain,
|
||||
setDomainFavicon
|
||||
} from '$lib/server/domains/repository';
|
||||
import { fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
domain: z.string().min(3).max(253).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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = UpdateSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), UpdateSchema);
|
||||
try {
|
||||
const db = getDb();
|
||||
const updated = updateDomain(db, id, parsed.data);
|
||||
const updated = updateDomain(db, id, data);
|
||||
if (!updated) error(404, { message: 'Not found' });
|
||||
// Wenn updateDomain favicon_path genullt hat (Domain geändert), frisch laden.
|
||||
if (updated.favicon_path === null) {
|
||||
@@ -41,12 +33,14 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
}
|
||||
return json(updated);
|
||||
} 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 });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
removeDomain(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json, error, isHttpError } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import { createProfile, listProfiles } from '$lib/server/profiles/repository';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
@@ -14,15 +15,12 @@ export const GET: RequestHandler = async () => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = CreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
error(400, { message: 'Invalid body', issues: parsed.error.issues });
|
||||
}
|
||||
const data = validateBody(await request.json().catch(() => null), CreateSchema);
|
||||
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 });
|
||||
} catch (e) {
|
||||
if (isHttpError(e)) throw e;
|
||||
error(409, { message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { deleteProfile, renameProfile } from '$lib/server/profiles/repository';
|
||||
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = RenameSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
renameProfile(getDb(), id, parsed.data.name);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), RenameSchema);
|
||||
renameProfile(getDb(), id, data.name);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
deleteProfile(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import {
|
||||
deleteRecipe,
|
||||
getRecipeById,
|
||||
@@ -48,14 +49,8 @@ const PatchSchema = z
|
||||
})
|
||||
.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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const db = getDb();
|
||||
const recipe = getRecipeById(db, id);
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = PatchSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const p = validateBody(body, PatchSchema);
|
||||
const db = getDb();
|
||||
const p = parsed.data;
|
||||
// Spezielle Kurz-Updates (bleiben als Sonderfall, weil sie FTS triggern
|
||||
// bzw. andere Tabellen mitpflegen).
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
deleteRecipe(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { addComment, deleteComment, listComments } from '$lib/server/recipes/actions';
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -11,30 +12,20 @@ const Schema = z.object({
|
||||
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
return json(listComments(getDb(), id));
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const cid = addComment(getDb(), id, parsed.data.profile_id, parsed.data.text);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
const cid = addComment(getDb(), id, data.profile_id, data.text);
|
||||
return json({ id: cid }, { status: 201 });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = DeleteSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
deleteComment(getDb(), parsed.data.comment_id);
|
||||
const data = validateBody(await request.json().catch(() => null), DeleteSchema);
|
||||
deleteComment(getDb(), data.comment_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { logCooked } from '$lib/server/recipes/actions';
|
||||
import { removeFromWishlistForAll } from '$lib/server/wishlist/repository';
|
||||
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
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
|
||||
// Profile raus aus der Wunschliste. Client nutzt den removed_from_wishlist-
|
||||
// Flag, um den lokalen State (Badge, Button) ohne Reload zu aktualisieren.
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { addFavorite, removeFavorite } from '$lib/server/recipes/actions';
|
||||
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
addFavorite(getDb(), id, parsed.data.profile_id);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
addFavorite(getDb(), id, data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
removeFavorite(getDb(), id, parsed.data.profile_id);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
removeFavorite(getDb(), id, data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@ 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 IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
@@ -19,26 +20,20 @@ const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/avif': '.avif'
|
||||
};
|
||||
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
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: 'Feld "file" fehlt' });
|
||||
if (file.size === 0) error(400, { message: 'Leere Datei' });
|
||||
if (file.size > MAX_BYTES) error(413, { message: 'Bild zu groß (max. 10 MB)' });
|
||||
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: `Bildformat ${file.type || 'unbekannt'} nicht unterstützt` });
|
||||
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');
|
||||
@@ -53,7 +48,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = ({ params }) => {
|
||||
const id = parseId(params.id!);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const db = getDb();
|
||||
if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' });
|
||||
updateImagePath(db, id, null);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
|
||||
import { clearRating, setRating } from '$lib/server/recipes/actions';
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -11,26 +12,16 @@ const Schema = z.object({
|
||||
|
||||
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 }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
setRating(getDb(), id, parsed.data.profile_id, parsed.data.stars);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), Schema);
|
||||
setRating(getDb(), id, data.profile_id, data.stars);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, request }) => {
|
||||
const id = parseId(params.id!);
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = DeleteSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
clearRating(getDb(), id, parsed.data.profile_id);
|
||||
const id = parsePositiveIntParam(params.id, 'id');
|
||||
const data = validateBody(await request.json().catch(() => null), DeleteSchema);
|
||||
clearRating(getDb(), id, data.profile_id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import { importRecipe } from '$lib/server/recipes/importer';
|
||||
import { mapImporterError } from '$lib/server/errors';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const ImportSchema = z.object({ url: z.string().url() });
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = ImportSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||
const data = validateBody(await request.json().catch(() => null), ImportSchema);
|
||||
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 });
|
||||
} catch (e) {
|
||||
mapImporterError(e);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import {
|
||||
addToWishlist,
|
||||
listWishlist,
|
||||
@@ -32,9 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = AddSchema.safeParse(body);
|
||||
if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' });
|
||||
addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id);
|
||||
const data = validateBody(await request.json().catch(() => null), AddSchema);
|
||||
addToWishlist(getDb(), data.recipe_id, data.profile_id);
|
||||
return json({ ok: true }, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { parsePositiveIntParam } from '$lib/server/api-helpers';
|
||||
import {
|
||||
removeFromWishlist,
|
||||
removeFromWishlistForAll
|
||||
} 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?all=true → entfernt für ALLE Profile
|
||||
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();
|
||||
if (url.searchParams.get('all') === 'true') {
|
||||
removeFromWishlistForAll(db, id);
|
||||
} 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);
|
||||
}
|
||||
return json({ ok: true });
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { join, basename, extname } from 'node:path';
|
||||
|
||||
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||
import { IMAGE_DIR } from '$lib/server/paths';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
import RecipeView from '$lib/components/RecipeView.svelte';
|
||||
import RecipeEditor from '$lib/components/RecipeEditor.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 { 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 type { CommentRow } from '$lib/server/recipes/actions';
|
||||
|
||||
@@ -73,19 +74,16 @@
|
||||
if (!requireOnline('Das Speichern')) return;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
},
|
||||
'Speichern fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
const body = await res.json();
|
||||
if (body.recipe) {
|
||||
recipeState = body.recipe;
|
||||
@@ -122,60 +120,44 @@
|
||||
);
|
||||
|
||||
async function setRating(stars: number) {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Rating')) return;
|
||||
await fetch(`/api/recipes/${data.recipe.id}/rating`, {
|
||||
method: 'PUT',
|
||||
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;
|
||||
else ratings = [...ratings, { profile_id: profileStore.active.id, stars }];
|
||||
else ratings = [...ratings, { profile_id: profile.id, stars }];
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Favorit-Setzen')) return;
|
||||
const profileId = profileStore.active.id;
|
||||
const wasFav = isFav;
|
||||
const method = wasFav ? 'DELETE' : 'PUT';
|
||||
await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ profile_id: profileId })
|
||||
body: JSON.stringify({ profile_id: profile.id })
|
||||
});
|
||||
favoriteProfileIds = wasFav
|
||||
? favoriteProfileIds.filter((id) => id !== profileId)
|
||||
: [...favoriteProfileIds, profileId];
|
||||
? favoriteProfileIds.filter((id) => id !== profile.id)
|
||||
: [...favoriteProfileIds, profile.id];
|
||||
if (!wasFav) void firePulse('fav');
|
||||
}
|
||||
|
||||
async function logCooked() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Der Kochjournal-Eintrag')) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
|
||||
method: 'POST',
|
||||
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();
|
||||
cookingLog = [entry, ...cookingLog];
|
||||
@@ -186,20 +168,15 @@
|
||||
}
|
||||
|
||||
async function addComment() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Speichern des Kommentars')) return;
|
||||
const text = newComment.trim();
|
||||
if (!text) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
|
||||
method: 'POST',
|
||||
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) {
|
||||
const body = await res.json();
|
||||
@@ -207,10 +184,10 @@
|
||||
...comments,
|
||||
{
|
||||
id: body.id,
|
||||
profile_id: profileStore.active.id,
|
||||
profile_id: profile.id,
|
||||
text,
|
||||
created_at: new Date().toISOString(),
|
||||
author: profileStore.active.name
|
||||
author: profile.name
|
||||
}
|
||||
];
|
||||
newComment = '';
|
||||
@@ -250,19 +227,16 @@
|
||||
return;
|
||||
}
|
||||
if (!requireOnline('Das Umbenennen')) return;
|
||||
const res = await fetch(`/api/recipes/${data.recipe.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: next })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
await alertAction({
|
||||
title: 'Umbenennen fehlgeschlagen',
|
||||
message: body.message ?? `HTTP ${res.status}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: next })
|
||||
},
|
||||
'Umbenennen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
title = next;
|
||||
editingTitle = false;
|
||||
}
|
||||
@@ -278,28 +252,22 @@
|
||||
}
|
||||
|
||||
async function toggleWishlist() {
|
||||
if (!profileStore.active) {
|
||||
await alertAction({
|
||||
title: 'Kein Profil gewählt',
|
||||
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await requireProfile();
|
||||
if (!profile) return;
|
||||
if (!requireOnline('Das Wunschlisten-Setzen')) return;
|
||||
const profileId = profileStore.active.id;
|
||||
const wasOn = onMyWishlist;
|
||||
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'
|
||||
});
|
||||
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profileId);
|
||||
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profile.id);
|
||||
} else {
|
||||
await fetch('/api/wishlist', {
|
||||
method: 'POST',
|
||||
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();
|
||||
if (!wasOn) void firePulse('wish');
|
||||
|
||||
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.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