feat(parser): add ingredient parser
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
68
src/lib/server/parsers/ingredient.ts
Normal file
68
src/lib/server/parsers/ingredient.ts
Normal file
@@ -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<string, number> = {
|
||||
'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 };
|
||||
}
|
||||
42
tests/unit/ingredient.test.ts
Normal file
42
tests/unit/ingredient.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user