diff --git a/backend/src/common/utils/recipe-parser.ts b/backend/src/common/utils/recipe-parser.ts new file mode 100644 index 00000000..1aa45ac8 --- /dev/null +++ b/backend/src/common/utils/recipe-parser.ts @@ -0,0 +1,199 @@ +/** + * Markdown-parser för recept + * Extraherar namn, beskrivning, instruktioner och ingredienser från Markdown. + */ + +// ============================================================================ +// Local Type Definitions +// ============================================================================ + +interface ParsedIngredient { + rawName: string; + quantity: number; + unit: string; + note: string | null; +} + +interface ParsedRecipe { + name: string; + description: string; + instructions: string; + ingredients: ParsedIngredient[]; +} + +// ============================================================================ +// Parser Functions +// ============================================================================ + +/** + * Parsar ett recept i Markdown-format och extraherar namn, beskrivning, + * instruktioner och ingredienser. + * + * Förväntat format: + * # Receptnamn + * Beskrivning (valfritt stycke efter titeln) + * + * ## Ingredienser + * - 400 g kycklingfilé + * - 2 dl grädde (eller crème fraiche) + * + * ## Instruktioner + * 1. Stek kycklingen … + */ +export function parseRecipeMarkdown(markdown: string): ParsedRecipe { + const lines = markdown.split('\n'); + + let name = ''; + let description = ''; + let instructions = ''; + const ingredients: ParsedIngredient[] = []; + + let currentSection: 'none' | 'description' | 'ingredients' | 'instructions' = 'none'; + const descriptionLines: string[] = []; + const instructionLines: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // H1 — receptnamn + if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) { + name = trimmed.replace(/^#\s+/, '').trim(); + currentSection = 'description'; + continue; + } + + // H2 — sektionsrubriker + if (/^##\s+/.test(trimmed)) { + const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase(); + if (/ingrediens/.test(heading)) { + currentSection = 'ingredients'; + } else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) { + currentSection = 'instructions'; + } else { + currentSection = 'none'; + } + continue; + } + + // Samla rader beroende på sektion + switch (currentSection) { + case 'description': + if (trimmed.length > 0) { + descriptionLines.push(trimmed); + } + break; + + case 'ingredients': + if (/^[-*]\s+/.test(trimmed)) { + const ingredientText = trimmed.replace(/^[-*]\s+/, ''); + ingredients.push(parseIngredientLine(ingredientText)); + } + break; + + case 'instructions': + if (trimmed.length > 0) { + instructionLines.push(trimmed); + } + break; + } + } + + description = descriptionLines.join('\n'); + instructions = instructionLines.join('\n'); + + return { name, description, instructions, ingredients }; +} + +/** + * Parsar en ingrediensrad, t.ex.: + * "400 g kycklingfilé" + * "2 dl grädde (eller crème fraiche)" + * "1 1/2 dl crème fraiche" + * "1 polka- eller gulbeta" + * "1 kruka basilika" + * "salt" + */ +function parseIngredientLine(text: string): ParsedIngredient { + const trimmed = text.trim(); + + // Kända enheter + const knownUnits = [ + 'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl', + 'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled', + 'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta', + ]; + + // Extrahera eventuell parentes-not i slutet + let note: string | null = null; + let main = trimmed; + const parenMatch = trimmed.match(/\(([^)]+)\)\s*$/); + if (parenMatch) { + note = parenMatch[1].trim(); + main = trimmed.slice(0, parenMatch.index).trim(); + } + + // Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl" + const fractionMatch = main.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.*)$/); + if (fractionMatch) { + let quantity = 0; + if (fractionMatch[1]) { + quantity = parseFloat(fractionMatch[1]) + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); + } else { + quantity = parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); + } + const candidateUnit = fractionMatch[4].toLowerCase(); + if (knownUnits.includes(candidateUnit)) { + return { + quantity, + unit: candidateUnit, + rawName: fractionMatch[5].trim(), + note, + }; + } + } + + // Försök matcha "kvantitet enhet namn" — t.ex. "400 g kycklingfilé" eller "2.5 dl grädde" + const fullMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/); + if (fullMatch) { + const candidateUnit = fullMatch[2].toLowerCase(); + // Validera att det andra ordet är en känd enhet + if (knownUnits.includes(candidateUnit)) { + return { + quantity: parseNumber(fullMatch[1]), + unit: candidateUnit, + rawName: fullMatch[3].trim(), + note, + }; + } + // Om inte känd enhet, behandla som "kvantitet namn" utan enhet + return { + quantity: parseNumber(fullMatch[1]), + unit: 'st', + rawName: fullMatch[2] + ' ' + fullMatch[3], + note, + }; + } + + // Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg" + const noUnitMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(.+)$/); + if (noUnitMatch) { + return { + quantity: parseNumber(noUnitMatch[1]), + unit: 'st', + rawName: noUnitMatch[2].trim(), + note, + }; + } + + // Bara ett namn, ingen kvantitet — t.ex. "salt" + return { + quantity: 0, + unit: '', + rawName: main, + note, + }; +} + +function parseNumber(s: string): number { + return parseFloat(s.replace(',', '.')); +} diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 70bee5b7..fd2249d2 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -6,25 +6,11 @@ import { PrismaService } from '../prisma/prisma.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { downloadAndOptimizeImage } from '../common/utils/download-image'; +import { parseRecipeMarkdown } from '../common/utils/recipe-parser'; import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/utils/units'; const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images'; -// Lokala typdefiniitioner (tidigare från recipe-document-converter) -interface ParsedIngredient { - rawName: string; - quantity: number; - unit: string; - note: string | null; -} - -interface ParsedRecipe { - name: string; - description: string; - instructions: string; - ingredients: ParsedIngredient[]; -} - @Injectable() export class RecipesService { private readonly logger = new Logger(RecipesService.name); @@ -539,181 +525,4 @@ export class RecipesService { ingredients: ingredientsWithSuggestions, }; } -} - -// ============================================================================ -// Parser Functions (previously from recipe-document-converter library) -// ============================================================================ - -/** - * Parsar ett recept i Markdown-format och extraherar namn, beskrivning, - * instruktioner och ingredienser. - * - * Förväntat format: - * # Receptnamn - * Beskrivning (valfritt stycke efter titeln) - * - * ## Ingredienser - * - 400 g kycklingfilé - * - 2 dl grädde (eller crème fraiche) - * - * ## Instruktioner - * 1. Stek kycklingen … - */ -function parseRecipeMarkdown(markdown: string): ParsedRecipe { - const lines = markdown.split('\n'); - - let name = ''; - let description = ''; - let instructions = ''; - const ingredients: ParsedIngredient[] = []; - - let currentSection: 'none' | 'description' | 'ingredients' | 'instructions' = 'none'; - const descriptionLines: string[] = []; - const instructionLines: string[] = []; - - for (const line of lines) { - const trimmed = line.trim(); - - // H1 — receptnamn - if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) { - name = trimmed.replace(/^#\s+/, '').trim(); - currentSection = 'description'; - continue; - } - - // H2 — sektionsrubriker - if (/^##\s+/.test(trimmed)) { - const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase(); - if (/ingrediens/.test(heading)) { - currentSection = 'ingredients'; - } else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) { - currentSection = 'instructions'; - } else { - currentSection = 'none'; - } - continue; - } - - // Samla rader beroende på sektion - switch (currentSection) { - case 'description': - if (trimmed.length > 0) { - descriptionLines.push(trimmed); - } - break; - - case 'ingredients': - if (/^[-*]\s+/.test(trimmed)) { - const ingredientText = trimmed.replace(/^[-*]\s+/, ''); - ingredients.push(parseIngredientLine(ingredientText)); - } - break; - - case 'instructions': - if (trimmed.length > 0) { - instructionLines.push(trimmed); - } - break; - } - } - - description = descriptionLines.join('\n'); - instructions = instructionLines.join('\n'); - - return { name, description, instructions, ingredients }; -} - -/** - * Parsar en ingrediensrad, t.ex.: - * "400 g kycklingfilé" - * "2 dl grädde (eller crème fraiche)" - * "1 1/2 dl crème fraiche" - * "1 polka- eller gulbeta" - * "1 kruka basilika" - * "salt" - */ -function parseIngredientLine(text: string): ParsedIngredient { - const trimmed = text.trim(); - - // Kända enheter - const knownUnits = [ - 'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl', - 'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled', - 'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta', - ]; - - // Extrahera eventuell parentes-not i slutet - let note: string | null = null; - let main = trimmed; - const parenMatch = trimmed.match(/\(([^)]+)\)\s*$/); - if (parenMatch) { - note = parenMatch[1].trim(); - main = trimmed.slice(0, parenMatch.index).trim(); - } - - // Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl" - const fractionMatch = main.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.*)$/); - if (fractionMatch) { - let quantity = 0; - if (fractionMatch[1]) { - quantity = parseFloat(fractionMatch[1]) + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); - } else { - quantity = parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); - } - const candidateUnit = fractionMatch[4].toLowerCase(); - if (knownUnits.includes(candidateUnit)) { - return { - quantity, - unit: candidateUnit, - rawName: fractionMatch[5].trim(), - note, - }; - } - } - - // Försök matcha "kvantitet enhet namn" — t.ex. "400 g kycklingfilé" eller "2.5 dl grädde" - const fullMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/); - if (fullMatch) { - const candidateUnit = fullMatch[2].toLowerCase(); - // Validera att det andra ordet är en känd enhet - if (knownUnits.includes(candidateUnit)) { - return { - quantity: parseNumber(fullMatch[1]), - unit: candidateUnit, - rawName: fullMatch[3].trim(), - note, - }; - } - // Om inte känd enhet, behandla som "kvantitet namn" utan enhet - return { - quantity: parseNumber(fullMatch[1]), - unit: 'st', - rawName: fullMatch[2] + ' ' + fullMatch[3], - note, - }; - } - - // Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg" - const noUnitMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(.+)$/); - if (noUnitMatch) { - return { - quantity: parseNumber(noUnitMatch[1]), - unit: 'st', - rawName: noUnitMatch[2].trim(), - note, - }; - } - - // Bara ett namn, ingen kvantitet — t.ex. "salt" - return { - quantity: 0, - unit: '', - rawName: main, - note, - }; -} - -function parseNumber(s: string): number { - return parseFloat(s.replace(',', '.')); } \ No newline at end of file