diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index f31c354..65fd848 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -2,7 +2,7 @@ name: Build & Publish Docker Image on: push: - branches: [main] + branches: ['**'] tags: ['v*'] workflow_dispatch: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0752fe4..ebfd775 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0adffba..e856c5c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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. diff --git a/docs/superpowers/plans/2026-04-18-review-fixes.md b/docs/superpowers/plans/2026-04-18-review-fixes.md new file mode 100644 index 0000000..cfc9660 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-review-fixes.md @@ -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 diff --git a/docs/superpowers/plans/2026-04-19-post-review-roadmap.md b/docs/superpowers/plans/2026-04-19-post-review-roadmap.md new file mode 100644 index 0000000..31f5760 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-post-review-roadmap.md @@ -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` 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`). diff --git a/docs/superpowers/review/OPEN-ISSUES-NEXT.md b/docs/superpowers/review/OPEN-ISSUES-NEXT.md new file mode 100644 index 0000000..b446688 --- /dev/null +++ b/docs/superpowers/review/OPEN-ISSUES-NEXT.md @@ -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 | diff --git a/docs/superpowers/session-handoff-2026-04-17.md b/docs/superpowers/session-handoff-2026-04-17.md index c23c51e..6bb9475 100644 --- a/docs/superpowers/session-handoff-2026-04-17.md +++ b/docs/superpowers/session-handoff-2026-04-17.md @@ -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. diff --git a/src/lib/client/api-fetch-wrapper.ts b/src/lib/client/api-fetch-wrapper.ts new file mode 100644 index 0000000..3604f7b --- /dev/null +++ b/src/lib/client/api-fetch-wrapper.ts @@ -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 { + 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; +} diff --git a/src/lib/client/profile.svelte.ts b/src/lib/client/profile.svelte.ts index d0b8e32..d8ba289 100644 --- a/src/lib/client/profile.svelte.ts +++ b/src/lib/client/profile.svelte.ts @@ -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 { + 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; +} diff --git a/src/lib/client/pwa.svelte.ts b/src/lib/client/pwa.svelte.ts index a5eb10e..d61bed9 100644 --- a/src/lib/client/pwa.svelte.ts +++ b/src/lib/client/pwa.svelte.ts @@ -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 { 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; diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..ba30530 --- /dev/null +++ b/src/lib/constants.ts @@ -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; diff --git a/src/lib/server/api-helpers.ts b/src/lib/server/api-helpers.ts new file mode 100644 index 0000000..5e9a27d --- /dev/null +++ b/src/lib/server/api-helpers.ts @@ -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 ` when null/undefined, + * or `Invalid ` 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(body: unknown, schema: ZodSchema): T { + const parsed = schema.safeParse(body); + if (!parsed.success) { + error(400, { message: 'Invalid body', issues: parsed.error.issues }); + } + return parsed.data; +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index a1e4a92..e5d516c 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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'); diff --git a/src/lib/server/parsers/ingredient.ts b/src/lib/server/parsers/ingredient.ts index 1e2ae43..51230fc 100644 --- a/src/lib/server/parsers/ingredient.ts +++ b/src/lib/server/parsers/ingredient.ts @@ -28,6 +28,42 @@ const FRACTION_MAP: Record = { '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 = { + '\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 }; } diff --git a/src/lib/server/paths.ts b/src/lib/server/paths.ts new file mode 100644 index 0000000..1629880 --- /dev/null +++ b/src/lib/server/paths.ts @@ -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'; diff --git a/src/lib/server/search/searxng.ts b/src/lib/server/search/searxng.ts index 7926bef..55ddea4 100644 --- a/src/lib/server/search/searxng.ts +++ b/src/lib/server/search/searxng.ts @@ -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}` ); diff --git a/src/lib/sw/cache-strategy.ts b/src/lib/sw/cache-strategy.ts index a1de20a..6454ae9 100644 --- a/src/lib/sw/cache-strategy.ts +++ b/src/lib/sw/cache-strategy.ts @@ -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. diff --git a/src/lib/sw/diff-manifest.ts b/src/lib/sw/diff-manifest.ts index 28a53b2..8f77d20 100644 --- a/src/lib/sw/diff-manifest.ts +++ b/src/lib/sw/diff-manifest.ts @@ -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); diff --git a/src/routes/admin/domains/+page.svelte b/src/routes/admin/domains/+page.svelte index 77ad96e..da2a010 100644 --- a/src/routes/admin/domains/+page.svelte +++ b/src/routes/admin/domains/+page.svelte @@ -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([]); @@ -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 { diff --git a/src/routes/admin/profiles/+page.svelte b/src/routes/admin/profiles/+page.svelte index 52010aa..b398589 100644 --- a/src/routes/admin/profiles/+page.svelte +++ b/src/routes/admin/profiles/+page.svelte @@ -1,6 +1,7 @@