diff --git a/backend/src/common/utils/recipe-parser.ts b/backend/src/common/utils/recipe-parser.ts index e721e290..fbe9c30c 100644 --- a/backend/src/common/utils/recipe-parser.ts +++ b/backend/src/common/utils/recipe-parser.ts @@ -21,6 +21,26 @@ export interface ParsedRecipe { ingredients: ParsedIngredient[]; } +// ============================================================================ +// Module-level constants (hoisted for performance) +// ============================================================================ + +const KNOWN_UNITS = new Set([ + 'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl', + 'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled', + 'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta', +]); + +const H1_RE = /^#\s+/; +const H2_RE = /^##\s+/; +const INGREDIENT_HEADING_RE = /ingrediens/; +const INSTRUCTION_HEADING_RE = /instruktion|tillagning|gör så här|steg|tillväg|metod/; +const BULLET_RE = /^[-*]\s+/; +const PAREN_NOTE_RE = /\(([^)]+)\)\s*$/; +const FRACTION_RE = /^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.+)$/; +const QTY_UNIT_NAME_RE = /^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/; +const QTY_NAME_RE = /^(\d+(?:[.,]\d+)?)\s+(.+)$/; + // ============================================================================ // Parser Functions // ============================================================================ @@ -56,18 +76,18 @@ export function parseRecipeMarkdown(markdown: string): ParsedRecipe { const trimmed = line.trim(); // H1 — receptnamn - if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) { - name = trimmed.replace(/^#\s+/, '').trim(); + if (H1_RE.test(trimmed) && !trimmed.startsWith('##')) { + name = trimmed.replace(H1_RE, '').trim(); currentSection = 'description'; continue; } // H2 — sektionsrubriker - if (/^##\s+/.test(trimmed)) { - const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase(); - if (/ingrediens/.test(heading)) { + if (H2_RE.test(trimmed)) { + const heading = trimmed.replace(H2_RE, '').trim().toLowerCase(); + if (INGREDIENT_HEADING_RE.test(heading)) { currentSection = 'ingredients'; - } else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) { + } else if (INSTRUCTION_HEADING_RE.test(heading)) { currentSection = 'instructions'; } else { currentSection = 'none'; @@ -84,8 +104,8 @@ export function parseRecipeMarkdown(markdown: string): ParsedRecipe { break; case 'ingredients': - if (/^[-*]\s+/.test(trimmed)) { - const ingredientText = trimmed.replace(/^[-*]\s+/, ''); + if (BULLET_RE.test(trimmed)) { + const ingredientText = trimmed.replace(BULLET_RE, ''); ingredients.push(parseIngredientLine(ingredientText)); } break; @@ -116,82 +136,45 @@ export function parseRecipeMarkdown(markdown: string): ParsedRecipe { 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*$/); + const parenMatch = trimmed.match(PAREN_NOTE_RE); 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+(.*)$/); + // Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl mjölk" + const fractionMatch = main.match(FRACTION_RE); 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 whole = fractionMatch[1] ? parseFloat(fractionMatch[1]) : 0; + const quantity = whole + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); const candidateUnit = fractionMatch[4].toLowerCase(); - if (knownUnits.includes(candidateUnit)) { - return { - quantity, - unit: candidateUnit, - rawName: fractionMatch[5].trim(), - note, - }; + if (KNOWN_UNITS.has(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+(.+)$/); + const fullMatch = main.match(QTY_UNIT_NAME_RE); 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, - }; + if (KNOWN_UNITS.has(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, - }; + // Inte känd enhet — behandla "kvantitet ord1 ord2..." 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+(.+)$/); + const noUnitMatch = main.match(QTY_NAME_RE); if (noUnitMatch) { - return { - quantity: parseNumber(noUnitMatch[1]), - unit: 'st', - rawName: noUnitMatch[2].trim(), - note, - }; + 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, - }; + return { quantity: 0, unit: '', rawName: main, note }; } function parseNumber(s: string): number {