feat(parsers): add intelligent quick-create parsers for 6 apps with multilingual support

- Base parser: multilingual (DE/EN/FR/ES/IT) date, time, weekday, month parsing
- Base parser: fuzzy/typo tolerance (Levenshtein), recurrence (RRULE), relative time
- Base parser: timezone extraction, date ranges, ordinal dates, confidence scoring
- Base parser: past dates (gestern/yesterday), this/next week distinction
- Base parser: compose helper (createAppParser), multiple @references
- Calendar: event-parser with duration, time ranges, location, all-day, calendar ref
- Calendar: wire up UnifiedBar with onCreate/onParseCreate for quick event creation
- Todo: task-parser multilingual priority keywords (urgent/important/normal/later)
- Planta: plant-parser with acquisition keywords (gekauft/bought/acheté)
- Mukke: song-parser with Artist-Title format, BPM, genre, playlist/project creation
- NutriPhi: meal-parser with meal type detection, add QuickInputBar to layout
- All parsers: 210 tests across 7 test suites, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 22:18:05 +01:00
parent 5c2a8d07e3
commit 5286404129
16 changed files with 3291 additions and 94 deletions

View file

@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { parseMealInput, formatParsedMealPreview } from './meal-parser';
describe('parseMealInput', () => {
it('should parse food description', () => {
const result = parseMealInput('Spaghetti Bolognese');
expect(result.description).toBe('Spaghetti Bolognese');
expect(result.mealTypeExplicit).toBe(false);
});
it('should extract frühstück', () => {
const result = parseMealInput('2 Eier Toast Frühstück');
expect(result.description).toBe('2 Eier Toast');
expect(result.mealType).toBe('breakfast');
expect(result.mealTypeExplicit).toBe(true);
});
it('should extract mittagessen', () => {
const result = parseMealInput('Spaghetti Bolognese Mittagessen');
expect(result.description).toBe('Spaghetti Bolognese');
expect(result.mealType).toBe('lunch');
expect(result.mealTypeExplicit).toBe(true);
});
it('should extract abendessen', () => {
const result = parseMealInput('Pizza abendessen');
expect(result.description).toBe('Pizza');
expect(result.mealType).toBe('dinner');
expect(result.mealTypeExplicit).toBe(true);
});
it('should extract snack', () => {
const result = parseMealInput('Apfel snack');
expect(result.description).toBe('Apfel');
expect(result.mealType).toBe('snack');
expect(result.mealTypeExplicit).toBe(true);
});
it('should extract morgens/mittags/abends', () => {
expect(parseMealInput('Müsli morgens').mealType).toBe('breakfast');
expect(parseMealInput('Salat mittags').mealType).toBe('lunch');
expect(parseMealInput('Suppe abends').mealType).toBe('dinner');
});
it('should auto-detect meal type when not specified', () => {
const result = parseMealInput('Käsebrot');
expect(result.description).toBe('Käsebrot');
expect(result.mealTypeExplicit).toBe(false);
// mealType is auto-detected based on time of day
expect(['breakfast', 'lunch', 'dinner', 'snack']).toContain(result.mealType);
});
it('should handle empty input', () => {
const result = parseMealInput('');
expect(result.description).toBe('');
});
it('should handle comma-separated foods', () => {
const result = parseMealInput('Reis, Hähnchen, Brokkoli Mittagessen');
expect(result.description).toBe('Reis, Hähnchen, Brokkoli');
expect(result.mealType).toBe('lunch');
});
});
describe('formatParsedMealPreview', () => {
it('should show meal type', () => {
const parsed = parseMealInput('Toast Frühstück');
const preview = formatParsedMealPreview(parsed);
expect(preview).toContain('Frühstück');
});
it('should show auto-detection hint', () => {
const parsed = parseMealInput('Apfel');
const preview = formatParsedMealPreview(parsed);
expect(preview).toContain('automatisch');
});
it('should not show auto hint when explicit', () => {
const parsed = parseMealInput('Apfel snack');
const preview = formatParsedMealPreview(parsed);
expect(preview).not.toContain('automatisch');
});
});

View file

@ -0,0 +1,165 @@
/**
* Meal Parser for NutriPhi App
*
* Parses natural language input for quick meal logging.
* Extracts meal type and food description for AI analysis.
*
* Examples:
* - "Spaghetti Bolognese mittagessen"
* - "2 Eier, Toast, Orangensaft frühstück"
* - "Apfel snack"
* - "Hähnchenbrust mit Reis und Salat"
*/
import type { MealType } from '@nutriphi/shared';
import { suggestMealType } from '@nutriphi/shared';
import type { ParserLocale } from '@manacore/shared-utils';
export interface ParsedMeal {
description: string;
mealType: MealType;
mealTypeExplicit: boolean; // Was meal type explicitly mentioned?
}
// Meal type patterns per locale
const MEAL_TYPE_PATTERNS_BY_LOCALE: Record<ParserLocale, { pattern: RegExp; type: MealType }[]> = {
de: [
{ pattern: /\bfrühstück\b/i, type: 'breakfast' },
{ pattern: /\bmittagessen\b/i, type: 'lunch' },
{ pattern: /\babendessen\b/i, type: 'dinner' },
{ pattern: /\bsnack\b/i, type: 'snack' },
{ pattern: /\bmorgens\b/i, type: 'breakfast' },
{ pattern: /\bmittags\b/i, type: 'lunch' },
{ pattern: /\babends\b/i, type: 'dinner' },
{ pattern: /\bnachtisch\b/i, type: 'snack' },
{ pattern: /\bzwischenmahlzeit\b/i, type: 'snack' },
],
en: [
{ pattern: /\bbreakfast\b/i, type: 'breakfast' },
{ pattern: /\blunch\b/i, type: 'lunch' },
{ pattern: /\bdinner\b/i, type: 'dinner' },
{ pattern: /\bsnack\b/i, type: 'snack' },
{ pattern: /\bmorning\b/i, type: 'breakfast' },
{ pattern: /\bnoon\b/i, type: 'lunch' },
{ pattern: /\bevening\b/i, type: 'dinner' },
],
fr: [
{ pattern: /\bpetit[- ]d[ée]jeuner\b/i, type: 'breakfast' },
{ pattern: /\bd[ée]jeuner\b/i, type: 'lunch' },
{ pattern: /\bd[îi]ner\b/i, type: 'dinner' },
{ pattern: /\bgo[ûu]ter\b/i, type: 'snack' },
{ pattern: /\bmatin\b/i, type: 'breakfast' },
{ pattern: /\bmidi\b/i, type: 'lunch' },
{ pattern: /\bsoir\b/i, type: 'dinner' },
],
es: [
{ pattern: /\bdesayuno\b/i, type: 'breakfast' },
{ pattern: /\balmuerzo\b/i, type: 'lunch' },
{ pattern: /\bcena\b/i, type: 'dinner' },
{ pattern: /\bmerienda\b/i, type: 'snack' },
],
it: [
{ pattern: /\bcolazione\b/i, type: 'breakfast' },
{ pattern: /\bpranzo\b/i, type: 'lunch' },
{ pattern: /\bcena\b/i, type: 'dinner' },
{ pattern: /\bspuntino\b/i, type: 'snack' },
],
};
// Meal type labels per locale
const MEAL_TYPE_LABELS_BY_LOCALE: Record<ParserLocale, Record<MealType, string>> = {
de: {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
},
en: {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
},
fr: {
breakfast: 'Petit-déjeuner',
lunch: 'Déjeuner',
dinner: 'Dîner',
snack: 'Goûter',
},
es: {
breakfast: 'Desayuno',
lunch: 'Almuerzo',
dinner: 'Cena',
snack: 'Merienda',
},
it: {
breakfast: 'Colazione',
lunch: 'Pranzo',
dinner: 'Cena',
snack: 'Spuntino',
},
};
// Auto-detection hint per locale
const AUTO_HINT_BY_LOCALE: Record<ParserLocale, string> = {
de: 'automatisch',
en: 'auto-detected',
fr: 'automatique',
es: 'automático',
it: 'automatico',
};
function extractMealType(
text: string,
locale: ParserLocale = 'de'
): { mealType?: MealType; remaining: string } {
const patterns = MEAL_TYPE_PATTERNS_BY_LOCALE[locale];
for (const { pattern, type } of patterns) {
if (pattern.test(text)) {
return {
mealType: type,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { mealType: undefined, remaining: text };
}
/**
* Parse natural language meal input
*/
export function parseMealInput(input: string, locale: ParserLocale = 'de'): ParsedMeal {
let text = input.trim();
// Extract explicit meal type
const mealTypeResult = extractMealType(text, locale);
text = mealTypeResult.remaining;
// Clean up description
const description = text.replace(/\s+/g, ' ').trim();
// Use explicit meal type or auto-detect based on time of day
const mealType = mealTypeResult.mealType || suggestMealType();
return {
description,
mealType,
mealTypeExplicit: !!mealTypeResult.mealType,
};
}
/**
* Format parsed meal for preview display
*/
export function formatParsedMealPreview(parsed: ParsedMeal, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
const labels = MEAL_TYPE_LABELS_BY_LOCALE[locale];
parts.push(`🍽️ ${labels[parsed.mealType]}`);
if (!parsed.mealTypeExplicit) {
parts.push(`(${AUTO_HINT_BY_LOCALE[locale]})`);
}
return parts.join(' ');
}

View file

@ -2,7 +2,13 @@
import '../app.css';
import '$lib/i18n';
import { isLoading as i18nLoading, _ as t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { QuickInputBar } from '@manacore/shared-ui';
import type { QuickInputItem, CreatePreview } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { mealsStore } from '$lib/stores/meals.svelte';
import { parseMealInput, formatParsedMealPreview } from '$lib/utils/meal-parser';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { onMount } from 'svelte';
let { children } = $props();
@ -10,6 +16,46 @@
let loading = $state(true);
let appReady = $derived(!loading && !$i18nLoading);
// QuickInputBar handlers - search recent meals
async function handleSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
return mealsStore.meals
.filter((m) => m.description?.toLowerCase().includes(q))
.slice(0, 10)
.map((meal) => ({
id: meal.id,
title: meal.description || 'Mahlzeit',
subtitle: meal.mealType,
}));
}
function handleSelect(item: QuickInputItem) {
// No detail page for meals - just scroll to it
}
function handleParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseMealInput(query);
if (!parsed.description) return null;
return {
title: `"${parsed.description}" analysieren`,
subtitle: formatParsedMealPreview(parsed),
};
}
async function handleCreate(query: string): Promise<void> {
if (!query.trim()) return;
const parsed = parseMealInput(query);
if (!parsed.description) return;
// Navigate to add page with pre-filled description and meal type
const params = new URLSearchParams({
type: 'text',
description: parsed.description,
mealType: parsed.mealType,
});
goto(`/add?${params.toString()}`);
}
onMount(() => {
authStore.initialize().then(() => {
loading = false;
@ -33,4 +79,22 @@
</div>
{:else}
{@render children()}
{#if authStore.isAuthenticated}
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onParseCreate={handleParseCreate}
onCreate={handleCreate}
placeholder="Mahlzeit eingeben..."
emptyText="Keine Mahlzeiten gefunden"
searchingText="Suche..."
createText="Analysieren"
deferSearch={true}
locale="de"
appIcon="search"
bottomOffset="70px"
/>
{/if}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}