diff --git a/src/lib/server/parsers/ingredient.ts b/src/lib/server/parsers/ingredient.ts new file mode 100644 index 0000000..1e2ae43 --- /dev/null +++ b/src/lib/server/parsers/ingredient.ts @@ -0,0 +1,68 @@ +import type { Ingredient } from '$lib/types'; + +const UNITS = new Set([ + 'g', + 'kg', + 'ml', + 'l', + 'cl', + 'dl', + 'TL', + 'EL', + 'Prise', + 'Pck.', + 'Pkg', + 'Becher', + 'Stk', + 'Stück', + 'Bund', + 'Tasse', + 'Dose' +]); + +const FRACTION_MAP: Record = { + '1/2': 0.5, + '1/3': 1 / 3, + '2/3': 2 / 3, + '1/4': 0.25, + '3/4': 0.75 +}; + +function parseQuantity(raw: string): number | null { + const trimmed = raw.trim(); + if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed]; + const rangeMatch = /^(\d+[.,]?\d*)\s*[-–]\s*\d+[.,]?\d*$/.exec(trimmed); + if (rangeMatch) { + return parseFloat(rangeMatch[1].replace(',', '.')); + } + const num = parseFloat(trimmed.replace(',', '.')); + return Number.isFinite(num) ? num : null; +} + +export function parseIngredient(raw: string, position = 0): Ingredient { + const rawText = raw.trim(); + let working = rawText; + let note: string | null = null; + const noteMatch = /\(([^)]+)\)/.exec(working); + if (noteMatch) { + note = noteMatch[1].trim(); + working = ( + working.slice(0, noteMatch.index) + working.slice(noteMatch.index + noteMatch[0].length) + ).trim(); + } + + 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 }; +} diff --git a/tests/unit/ingredient.test.ts b/tests/unit/ingredient.test.ts new file mode 100644 index 0000000..4fbc73a --- /dev/null +++ b/tests/unit/ingredient.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { parseIngredient } from '../../src/lib/server/parsers/ingredient'; + +describe('parseIngredient', () => { + it.each([ + ['200 g Mehl', { quantity: 200, unit: 'g', name: 'Mehl' }], + ['1 kg Kartoffeln', { quantity: 1, unit: 'kg', name: 'Kartoffeln' }], + ['500 ml Milch', { quantity: 500, unit: 'ml', name: 'Milch' }], + ['1 TL Salz', { quantity: 1, unit: 'TL', name: 'Salz' }], + ['2 EL Olivenöl', { quantity: 2, unit: 'EL', name: 'Olivenöl' }], + ['3 Eier', { quantity: 3, unit: null, name: 'Eier' }], + ['1/2 Zitrone', { quantity: 0.5, unit: null, name: 'Zitrone' }], + ['1,5 l Wasser', { quantity: 1.5, unit: 'l', name: 'Wasser' }] + ] as const)('parses %s', (input, expected) => { + const parsed = parseIngredient(input); + expect(parsed.quantity).toBe(expected.quantity); + expect(parsed.unit).toBe(expected.unit); + expect(parsed.name).toBe(expected.name); + expect(parsed.raw_text).toBe(input); + }); + + it('handles notes', () => { + const p = parseIngredient('200 g Mehl (Type 550)'); + expect(p.quantity).toBe(200); + expect(p.name).toBe('Mehl'); + expect(p.note).toBe('Type 550'); + }); + + it('falls back to raw_text when unparsable', () => { + const p = parseIngredient('etwas frischer Pfeffer'); + expect(p.quantity).toBeNull(); + expect(p.unit).toBeNull(); + expect(p.name).toBe('etwas frischer Pfeffer'); + expect(p.raw_text).toBe('etwas frischer Pfeffer'); + }); + + it('handles ranges by taking the lower bound', () => { + const p = parseIngredient('2-3 Tomaten'); + expect(p.quantity).toBe(2); + expect(p.name).toBe('Tomaten'); + }); +});