diff --git a/README.md b/README.md index e69de29b..a4212272 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,86 @@ +# Recipe App + +En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på vad du har hemma, spara recept och se direkt om du har allt du behöver för att laga en rätt. + +> För teknisk detaljinformation, se [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md). + +--- + +## Funktioner + +- **Hemmavaror** — lägg till, redigera och konsumera varor. Filtrera på plats och bäst före-datum. +- **Recept** — skapa och redigera recept med ingredienser och tillagningsinstruktioner (Markdown-stöd). +- **Receptjämförelse** — se direkt vilka ingredienser du har hemma och vad som saknas. +- **Importera recept från Markdown** — klistra in ett recept i ett enkelt textformat, granska matchade produkter och spara med ett klick. +- **Admin: Produkter** — hantera produktnamn och slå ihop dubbletter. + +--- + +## Kom igång + +### Förutsättningar + +- Docker och Docker Compose +- En `proxy`-nätverk i Docker (extern, hanteras av Caddy eller liknande) + +### Starta applikationen + +```bash +# Bygg och starta alla tjänster +docker compose build +docker compose up -d +``` + +### Bygg om enbart backend (t.ex. efter kodändringar) + +```bash +docker compose build recipe-api +docker compose up -d recipe-api +``` + +--- + +## Importera recept från Markdown + +Gå till **Recept → Lägg till nytt recept → Importera från Markdown** och klistra in ett recept i följande format: + +```markdown +# Köttfärssås + +En klassisk köttfärssås med massa smak. + +## Ingredienser +- 500 g köttfärs +- 1 st lök +- 2 msk tomatpuré +- 1 dl grädde (vispgrädde) + +## Tillvägagångssätt +Hacka löken och stek den mjuk i lite olja. Tillsätt köttfärsen... +``` + +Systemet tolkar texten, föreslår matchande produkter från databasen och låter dig granska och justera innan receptet sparas. + +--- + +## Projektstruktur + +``` +recipe-app/ +├── frontend/ # Next.js (App Router) +├── backend/ # NestJS REST API +├── recipe-document-converter/ # Markdown-parserbibliotek +├── db/init/ # SQL-initialiseringsskript +├── compose.yml # Docker Compose +└── backup_recipe_app.sh # Backupskript +``` + +--- + +## Backup + +```bash +bash backup_recipe_app.sh +``` + +Säkerhetskopierar källkod och Docker-images till konfigurerad backupmapp. diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index a4d58070..fce000d2 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -1,5 +1,7 @@ # Teknisk beskrivning av Recipe App +> Se [README.md](README.md) för användarinformation och kom-igång-guide. + ## Översikt Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och inköpsplanering. Systemet är byggt med Next.js (frontend), NestJS (backend), Prisma ORM och MariaDB. Applikationen är containeriserad med Docker och använder Caddy som reverse proxy. @@ -114,6 +116,7 @@ Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och - Skapa, redigera, ta bort recept - Jämför mot hemmavaror (räcker/saknas/enhetskonflikt) - Visar instruktioner och saknade ingredienser +- **Importera recept från Markdown** (se nedan) ### Produkter (Admin) - Sök, sortera, redigera canonical name @@ -135,12 +138,180 @@ Recipe App är en fullstack-applikation för hantering av hemmavaror, recept och - `POST /api/recipes` – Skapa recept - `PATCH /api/recipes/:id` – Uppdatera recept - `DELETE /api/recipes/:id` – Ta bort recept +- `POST /api/recipes/parse-markdown` – Tolka Markdown-recept (se nedan) - `GET /api/products` – Lista produkter - `PATCH /api/products/:id` – Uppdatera produkt - `GET /health` – Hälsokontroll --- +--- + +## Receptimport via Markdown + +### Syfte + +Användaren kan importera ett recept skrivet i ett enkelt Markdown-format istället för att fylla i formularet manuellt. Systemet tolkar texten, föreslår matchande produkter från databasen och låter användaren granska och bekräfta innan receptet sparas. + +### Markdown-format + +```markdown +# Receptnamn + +Valfri beskrivning av receptet. + +## Ingredienser +- 500 g köttfärs +- 1 st lök +- 2 msk tomatpuré +- 1 dl grädde (vispgrädde) + +## Tillvägagångssätt +Stek löken i lite smör. Tillsats köttfärsen... +``` + +Regler: +- Rad med `#` tolkas som receptnamn +- Text mellan `#`-rubriken och `## Ingredienser` tolkas som beskrivning +- Rader under `## Ingredienser` med mönstret `- ANTAL ENHET NAMN` tolkas som ingredienser +- Text i parentes efter ingrediensnamnet (`(vispgrädde)`) sparas som anteckning +- Text under `## Tillvägagångssätt` (eller `## Instruktioner`) tolkas som instruktioner + +### Arkitektur + +``` +Användaren Frontend Backend Bibliotek +(klistrar in MD) → /recipes/import → POST /api/ → recipe-document- + ImportRecipePage recipes/ converter/ + parse-markdown parseRecipeMarkdown() + ↑ + Granskar förslag + Väljer produkter + ↓ + POST /api/recipes (befintlig endpoint) +``` + +### Komponenterna + +#### `recipe-document-converter/` (fristående TypeScript-bibliotek) + +Ett eget npm-paket som inte har externa beroenden. Det enda som exporteras är: + +```typescript +parseRecipeMarkdown(markdown: string): ParsedRecipe +``` + +Returnerar: +```typescript +type ParsedRecipe = { + name: string; + description?: string; + instructions?: string; + ingredients: Array<{ + rawName: string; // fråntext, t.ex. "köttfärs" + quantity: number; // t.ex. 500 + unit: string; // t.ex. "g" + note?: string; // text i parentes, t.ex. "nötfärs" + }>; +}; +``` + +Biblioteket kompileras i ett separat Docker-byggsteg och länkas till backend via `"recipe-document-converter": "file:../recipe-document-converter"` i `backend/package.json`. + +#### Backend — `POST /api/recipes/parse-markdown` + +Endpoint som tar emot `{ markdown: string }` och returnerar det tolkade receptet åtsamman med produktmatchförslag för varje ingrediens. + +Matchningslogik: +1. Anropar `parseRecipeMarkdown()` från biblioteket +2. Hämtar alla aktiva produkter ur databasen +3. Jämför varje ingrediensnamn mot `product.canonicalName` / `product.normalizedName` med tre metoder i ordning: + - **Exakt match** (efter normalisering) → 100 poäng + - **Delsträngsmatch** → 70 poäng + - **Levenshtein-likhet** → 0–100 poäng (filtreras under 40) +4. Returnerar upp till 5 förslag per ingrediens, sorterade efter poäng + +Svar: +```json +{ + "name": "Köttfärssås", + "description": "En klassisk...", + "instructions": "Stek löken...", + "ingredients": [ + { + "rawName": "köttfärs", + "quantity": 500, + "unit": "g", + "suggestions": [ + { "productId": 12, "productName": "Köttfärs", "score": 100 }, + { "productId": 34, "productName": "Blandfärs", "score": 55 } + ] + } + ] +} +``` + +#### Frontend — `/recipes/import` + +En 3-stegsvy (client component): + +| Steg | Innehåll | +|------|----------| +| 1. Klistra in | Textarea för Markdown + "Tolka recept"-knapp | +| 2. Granska | Redigerbara fält för namn/beskrivning/instruktioner; varje ingrediens har en dropdown med föreslagna produkter överst, sedan alla produkter | +| 3. Spara | Knapp som POSTar till befintlig `POST /api/recipes` | + +Ingrediensräder med ingen matchning markeras visuellt (gul ram) så att användaren ser att de behöver väljas manuellt. Receptet sparas inte förrän minst en ingrediens har en vald produkt. + +Flöde i Next.js: +``` +/recipes/import + └─ ImportRecipePage.tsx (client component, 3-stegsflödet) + +/api/parse-markdown-proxy + └─ route.ts (POST-proxy till backend, omgår CORS) +``` + +### Docker-bygget + +Backend-Dockerfilen använder nu projektets rot (`.`) som byggkontext. Converter-biblioteket kompileras i en separat stage: + +```dockerfile +# Stage 1: Bygg converter-biblioteket +FROM node:22-alpine AS converter-build +WORKDIR /converter +COPY recipe-document-converter/package.json ./ +RUN npm install +COPY recipe-document-converter/src ./src +COPY recipe-document-converter/tsconfig.json ./ +RUN npm run build + +# Stage 2: Installera backend-beroenden (converter kopieras in) +FROM node:22-alpine AS deps +WORKDIR /app +COPY --from=converter-build /converter /recipe-document-converter +... +``` + +Bygga om backend efter ändringar: +```bash +docker compose build recipe-api +``` + +### Relevanta filer + +| Fil | Syfte | +|-----|-------| +| `recipe-document-converter/src/parser.ts` | Markdown-parser | +| `recipe-document-converter/src/index.ts` | Biblioteksexport | +| `backend/src/recipes/dto/parse-markdown.dto.ts` | Inkommande DTO | +| `backend/src/recipes/recipes.controller.ts` | Nytt endpoint | +| `backend/src/recipes/recipes.service.ts` | Matchningslogik | +| `frontend/app/recipes/import/ImportRecipePage.tsx` | 3-stegsvy | +| `frontend/app/api/parse-markdown-proxy/route.ts` | Proxy-route | + +--- + ## Säkerhet - Ingen auth i grundutförande (kan enkelt byggas på) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0e1b5cb9..c9c553e2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,19 +1,32 @@ -# Stage 1: Installera beroenden +# Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-api:local . + +# Stage 1: Bygg recipe-document-converter +FROM node:22-alpine AS converter-build +WORKDIR /converter +COPY recipe-document-converter/package.json ./ +RUN npm install +COPY recipe-document-converter/src ./src +COPY recipe-document-converter/tsconfig.json ./ +RUN npm run build + +# Stage 2: Installera backend-beroenden FROM node:22-alpine AS deps WORKDIR /app -COPY package.json ./ -COPY prisma ./prisma +# Gör converter tillgänglig för npm:s file:-referens (../recipe-document-converter från /app) +COPY --from=converter-build /converter /recipe-document-converter +COPY backend/package.json ./ +COPY backend/prisma ./prisma RUN npm install -# Stage 2: Bygg applikationen +# Stage 3: Bygg applikationen FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules -COPY . . +COPY backend/ . RUN npx prisma generate RUN npm run build -# Stage 3: Kör applikationen +# Stage 4: Kör applikationen FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production diff --git a/backend/package.json b/backend/package.json index b7ac7a32..651b87f5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "prisma:deploy": "prisma migrate deploy" }, "dependencies": { + "recipe-document-converter": "file:../recipe-document-converter", "@nestjs/common": "^10.3.0", "@nestjs/core": "^10.3.0", "@nestjs/platform-express": "^10.3.0", diff --git a/backend/src/recipes/dto/parse-markdown.dto.ts b/backend/src/recipes/dto/parse-markdown.dto.ts new file mode 100644 index 00000000..aaa80244 --- /dev/null +++ b/backend/src/recipes/dto/parse-markdown.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class ParseMarkdownDto { + @IsString() + @MinLength(1) + markdown!: string; +} diff --git a/backend/src/recipes/recipes.controller.ts b/backend/src/recipes/recipes.controller.ts index d97fe365..43774040 100644 --- a/backend/src/recipes/recipes.controller.ts +++ b/backend/src/recipes/recipes.controller.ts @@ -1,11 +1,17 @@ import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common'; import { RecipesService } from './recipes.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; +import { ParseMarkdownDto } from './dto/parse-markdown.dto'; @Controller('recipes') export class RecipesController { constructor(private readonly recipesService: RecipesService) {} + @Post('parse-markdown') + parseMarkdown(@Body() dto: ParseMarkdownDto) { + return this.recipesService.parseMarkdown(dto); + } + @Get() findAll() { return this.recipesService.findAll(); diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 0b1848cd..35ab4e1e 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; +import { ParseMarkdownDto } from './dto/parse-markdown.dto'; +import { parseRecipeMarkdown } from 'recipe-document-converter'; @Injectable() export class RecipesService { @@ -384,4 +386,85 @@ export class RecipesService { return recipe; } + + async parseMarkdown(dto: ParseMarkdownDto) { + const parsed = parseRecipeMarkdown(dto.markdown); + + const allProducts = await this.prisma.product.findMany({ + where: { isActive: true }, + select: { id: true, name: true, canonicalName: true, normalizedName: true }, + }); + + // Normalisera en sträng för jämförelse (lowercase, trim, ta bort skiljetecken) + const normalize = (s: string) => + s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' '); + + // Enkel Levenshtein-distans + const levenshtein = (a: string, b: string): number => { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => + Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), + ); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; + }; + + const ingredientsWithSuggestions = parsed.ingredients.map((ingredient) => { + const query = normalize(ingredient.rawName); + + const scored = allProducts + .map((product) => { + const targetName = normalize(product.canonicalName || product.name); + const targetNormalized = normalize(product.normalizedName); + + // Exakt träff på normalizedName prioriteras + if (targetNormalized === query || targetName === query) { + return { product, score: 100 }; + } + + // Delsträng-match + if (targetName.includes(query) || query.includes(targetName)) { + return { product, score: 70 }; + } + + // Levenshtein-baserad likhet + const dist = levenshtein(query, targetName); + const maxLen = Math.max(query.length, targetName.length); + const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100); + + return { product, score: similarity }; + }) + .filter((s) => s.score >= 40) + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .map((s) => ({ + productId: s.product.id, + productName: s.product.canonicalName || s.product.name, + score: s.score, + })); + + return { + rawName: ingredient.rawName, + quantity: ingredient.quantity, + unit: ingredient.unit, + note: ingredient.note, + suggestions: scored, + }; + }); + + return { + name: parsed.name, + description: parsed.description, + instructions: parsed.instructions, + ingredients: ingredientsWithSuggestions, + }; + } } \ No newline at end of file diff --git a/compose.yml b/compose.yml index 72f9de33..b737413a 100644 --- a/compose.yml +++ b/compose.yml @@ -12,6 +12,9 @@ services: - recipe-internal recipe-api: + build: + context: . + dockerfile: backend/Dockerfile image: recipe-api:local container_name: recipe-api restart: unless-stopped diff --git a/frontend/app/api/parse-markdown-proxy/route.ts b/frontend/app/api/parse-markdown-proxy/route.ts new file mode 100644 index 00000000..2dd24d2a --- /dev/null +++ b/frontend/app/api/parse-markdown-proxy/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function POST(request: NextRequest) { + const body = await request.text(); + + const res = await fetch(`${API_BASE}/api/recipes/parse-markdown`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + cache: 'no-store', + }); + + const text = await res.text(); + + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/frontend/app/recipes/create/CreateRecipePage.tsx b/frontend/app/recipes/create/CreateRecipePage.tsx index 549e28bb..01318b74 100644 --- a/frontend/app/recipes/create/CreateRecipePage.tsx +++ b/frontend/app/recipes/create/CreateRecipePage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { fetchJson } from '../../../lib/api'; import { parseErrorResponse } from '../../../lib/error-handler'; import type { Product } from '../../../features/inventory/types'; @@ -138,7 +139,24 @@ export default function CreateRecipePage() { return (
-

Lägg till nytt recept

+
+

Lägg till nytt recept

+ + Importera från Markdown + +
{error &&

{error}

} diff --git a/frontend/app/recipes/import/ImportRecipePage.tsx b/frontend/app/recipes/import/ImportRecipePage.tsx new file mode 100644 index 00000000..f5a7c4de --- /dev/null +++ b/frontend/app/recipes/import/ImportRecipePage.tsx @@ -0,0 +1,447 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { fetchJson } from '../../../lib/api'; +import { parseErrorResponse } from '../../../lib/error-handler'; +import type { Product } from '../../../features/inventory/types'; +import Navigation from '../../Navigation'; + +type ProductSuggestion = { + productId: number; + productName: string; + score: number; +}; + +type ParsedIngredientRow = { + rawName: string; + quantity: number; + unit: string; + note?: string; + suggestions: ProductSuggestion[]; + // Valda värden (redigerbara i steg 2) + selectedProductId: number; + editedQuantity: string; + editedUnit: string; + editedNote: string; +}; + +type ParseResult = { + name: string; + description?: string; + instructions?: string; + ingredients: ParsedIngredientRow[]; +}; + +const UNIT_OPTIONS = [ + { value: '', label: 'Välj enhet' }, + { value: 'g', label: 'g (gram)' }, + { value: 'kg', label: 'kg (kilogram)' }, + { value: 'hg', label: 'hg (hektogram)' }, + { value: 'ml', label: 'ml (milliliter)' }, + { value: 'dl', label: 'dl (deciliter)' }, + { value: 'l', label: 'l (liter)' }, + { value: 'st', label: 'st (styck)' }, + { value: 'tsk', label: 'tsk (tesked)' }, + { value: 'msk', label: 'msk (matsked)' }, +]; + +type Step = 'input' | 'review' | 'saving'; + +export default function ImportRecipePage() { + const router = useRouter(); + const [step, setStep] = useState('input'); + const [markdown, setMarkdown] = useState(''); + const [parsed, setParsed] = useState(null); + const [editedName, setEditedName] = useState(''); + const [editedDescription, setEditedDescription] = useState(''); + const [editedInstructions, setEditedInstructions] = useState(''); + const [ingredients, setIngredients] = useState([]); + const [allProducts, setAllProducts] = useState([]); + const [isParsing, setIsParsing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + fetchJson('/api/products') + .then(setAllProducts) + .catch(console.error); + }, []); + + const handleParse = async () => { + if (!markdown.trim()) return; + setIsParsing(true); + setError(null); + + try { + const res = await fetch('/api/parse-markdown-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ markdown }), + }); + + if (!res.ok) { + const errorMessage = await parseErrorResponse(res); + throw new Error(errorMessage); + } + + const data = await res.json(); + + const rows: ParsedIngredientRow[] = data.ingredients.map( + (ing: Omit) => ({ + ...ing, + selectedProductId: ing.suggestions[0]?.productId ?? 0, + editedQuantity: String(ing.quantity), + editedUnit: ing.unit, + editedNote: ing.note ?? '', + }), + ); + + setParsed(data); + setEditedName(data.name); + setEditedDescription(data.description ?? ''); + setEditedInstructions(data.instructions ?? ''); + setIngredients(rows); + setStep('review'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Något gick fel vid tolkning.'; + setError(message); + } finally { + setIsParsing(false); + } + }; + + const updateIngredient = (index: number, field: keyof ParsedIngredientRow, value: string | number) => { + setIngredients((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], [field]: value }; + return updated; + }); + }; + + const removeIngredient = (index: number) => { + setIngredients((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + setIsSaving(true); + setError(null); + + const validIngredients = ingredients.filter((ing) => ing.selectedProductId > 0); + if (validIngredients.length === 0) { + setError('Minst en ingrediens med vald produkt krävs.'); + setIsSaving(false); + return; + } + + const body = { + name: editedName, + description: editedDescription || undefined, + instructions: editedInstructions || undefined, + ingredients: validIngredients.map((ing) => ({ + productId: ing.selectedProductId, + quantity: Number(ing.editedQuantity), + unit: ing.editedUnit, + note: ing.editedNote || undefined, + })), + }; + + try { + const res = await fetch('/api/recipes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorMessage = await parseErrorResponse(res); + throw new Error(errorMessage); + } + + router.push('/recipes'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Något gick fel vid sparning.'; + setError(message); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ +

Importera recept från Markdown

+ + {/* Steg-indikator */} +
+ + 1. Klistra in + + + + 2. Granska + + + 3. Spara +
+ + {error && ( +

+ {error} +

+ )} + + {/* STEG 1: Markdown-inmatning */} + {step === 'input' && ( +
+
+ Förväntat format: +
{`# Receptnamn
+
+Valfri beskrivning av receptet.
+
+## Ingredienser
+- 500 g köttfärs
+- 1 st lök
+- 2 msk tomatpuré
+- 1 dl grädde (vispgrädde)
+
+## Tillvägagångssätt
+Stek löken i lite smör. Tillsätt köttfärsen...`}
+
+ +
+ +