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

@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { QuickInputBar } from '@manacore/shared-ui';
import type { QuickInputItem } from '@manacore/shared-ui';
import type { QuickInputItem, CreatePreview } from '@manacore/shared-ui';
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
import DateStrip from './DateStrip.svelte';
import TagStrip from './TagStrip.svelte';
@ -14,6 +14,8 @@
interface Props {
onSearch: (query: string) => Promise<QuickInputItem[]>;
onSelect: (item: QuickInputItem) => void;
onParseCreate?: (query: string) => CreatePreview | null;
onCreate?: (query: string) => Promise<void>;
onSearchChange?: (query: string, results: QuickInputItem[]) => void;
placeholder?: string;
emptyText?: string;
@ -39,6 +41,8 @@
let {
onSearch,
onSelect,
onParseCreate,
onCreate,
onSearchChange,
placeholder = 'Neuer Termin oder suchen...',
emptyText = 'Keine Termine gefunden',
@ -138,6 +142,8 @@
<QuickInputBar
{onSearch}
{onSelect}
{onParseCreate}
{onCreate}
{onSearchChange}
{placeholder}
{emptyText}
@ -145,6 +151,7 @@
{createText}
{appIcon}
{locale}
deferSearch={true}
bottomOffset="0px"
hasFabRight={showCalendarLayers}
{defaultOptions}

View file

@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest';
import { parseEventInput, resolveEventIds, formatParsedEventPreview } from './event-parser';
describe('parseEventInput', () => {
it('should parse a simple title', () => {
const result = parseEventInput('Meeting');
expect(result.title).toBe('Meeting');
expect(result.startDate).toBeUndefined();
expect(result.duration).toBeUndefined();
expect(result.tagNames).toEqual([]);
});
it('should parse date and time', () => {
const result = parseEventInput('Meeting morgen 14 Uhr');
expect(result.title).toBe('Meeting');
expect(result.startDate).toBeDefined();
expect(result.startDate!.getHours()).toBe(14);
expect(result.startDate!.getMinutes()).toBe(0);
});
it('should parse duration in hours', () => {
const result = parseEventInput('Meeting 2h');
expect(result.duration).toBe(120);
expect(result.title).toBe('Meeting');
});
it('should parse duration in minutes', () => {
const result = parseEventInput('Standup 30min');
expect(result.duration).toBe(30);
});
it('should parse combined duration 2h30m', () => {
const result = parseEventInput('Workshop 2h30m');
expect(result.duration).toBe(150);
});
it('should parse duration in Stunden', () => {
const result = parseEventInput('Konferenz 3 Stunden');
expect(result.duration).toBe(180);
});
it('should calculate endDate from startDate + duration', () => {
const result = parseEventInput('Meeting morgen 10 Uhr 2h');
expect(result.startDate).toBeDefined();
expect(result.endDate).toBeDefined();
const diffMs = result.endDate!.getTime() - result.startDate!.getTime();
expect(diffMs).toBe(120 * 60_000); // 2 hours
});
it('should default to 1h duration when no duration specified', () => {
const result = parseEventInput('Meeting morgen 10 Uhr');
expect(result.startDate).toBeDefined();
expect(result.endDate).toBeDefined();
const diffMs = result.endDate!.getTime() - result.startDate!.getTime();
expect(diffMs).toBe(60 * 60_000); // 1 hour default
});
it('should parse all-day events', () => {
const result = parseEventInput('Ganztägig Urlaub morgen');
expect(result.isAllDay).toBe(true);
expect(result.title).toBe('Urlaub');
expect(result.startDate).toBeDefined();
});
it('should parse @calendar reference', () => {
const result = parseEventInput('Meeting @Arbeit');
expect(result.calendarName).toBe('Arbeit');
expect(result.title).not.toContain('@Arbeit');
});
it('should parse #tags', () => {
const result = parseEventInput('Meeting #wichtig #team');
expect(result.tagNames).toEqual(['wichtig', 'team']);
expect(result.title).not.toContain('#');
});
it('should parse complex input with all fields', () => {
const result = parseEventInput('Teammeeting morgen 14 Uhr 1h @Arbeit #wichtig');
expect(result.title).toBe('Teammeeting');
expect(result.startDate).toBeDefined();
expect(result.startDate!.getHours()).toBe(14);
expect(result.duration).toBe(60);
expect(result.calendarName).toBe('Arbeit');
expect(result.tagNames).toEqual(['wichtig']);
});
it('should parse time range "14-16 Uhr"', () => {
const result = parseEventInput('Meeting morgen 14-16 Uhr');
expect(result.title).toBe('Meeting');
expect(result.startDate).toBeDefined();
expect(result.startDate!.getHours()).toBe(14);
expect(result.endDate).toBeDefined();
expect(result.endDate!.getHours()).toBe(16);
});
it('should parse time range "10:00-11:30"', () => {
const result = parseEventInput('Standup 10:00-11:30');
expect(result.startDate).toBeDefined();
expect(result.startDate!.getHours()).toBe(10);
expect(result.startDate!.getMinutes()).toBe(0);
expect(result.endDate).toBeDefined();
expect(result.endDate!.getHours()).toBe(11);
expect(result.endDate!.getMinutes()).toBe(30);
});
it('should parse time range with en-dash "917 Uhr"', () => {
const result = parseEventInput('Arbeitstag 917 Uhr');
expect(result.startDate!.getHours()).toBe(9);
expect(result.endDate!.getHours()).toBe(17);
});
it('should handle empty input', () => {
const result = parseEventInput('');
expect(result.title).toBe('');
expect(result.tagNames).toEqual([]);
});
it('should parse time-only input (defaults to today)', () => {
const result = parseEventInput('Lunch 12 Uhr');
expect(result.startDate).toBeDefined();
expect(result.startDate!.getHours()).toBe(12);
});
});
describe('resolveEventIds', () => {
const calendars = [
{ id: 'cal-1', name: 'Arbeit' },
{ id: 'cal-2', name: 'Privat' },
];
const tags = [
{ id: 'tag-1', name: 'Wichtig' },
{ id: 'tag-2', name: 'Team' },
];
it('should resolve calendar name to ID (case-insensitive)', () => {
const parsed = parseEventInput('Meeting @arbeit');
const resolved = resolveEventIds(parsed, calendars, tags);
expect(resolved.calendarId).toBe('cal-1');
});
it('should resolve tag names to IDs (case-insensitive)', () => {
const parsed = parseEventInput('Meeting #team');
const resolved = resolveEventIds(parsed, calendars, tags);
expect(resolved.tagIds).toEqual(['tag-2']);
});
it('should use default calendar when no calendar specified', () => {
const parsed = parseEventInput('Meeting morgen');
const resolved = resolveEventIds(parsed, calendars, tags, 'cal-1');
expect(resolved.calendarId).toBe('cal-1');
});
it('should skip unknown calendar', () => {
const parsed = parseEventInput('Meeting @Unbekannt');
const resolved = resolveEventIds(parsed, calendars, tags);
expect(resolved.calendarId).toBeUndefined();
});
it('should produce ISO date strings', () => {
const parsed = parseEventInput('Meeting morgen 14 Uhr');
const resolved = resolveEventIds(parsed, calendars, tags);
expect(resolved.startTime).toBeDefined();
expect(resolved.endTime).toBeDefined();
// Verify it's a valid ISO string
expect(new Date(resolved.startTime!).toISOString()).toBe(resolved.startTime);
});
});
describe('formatParsedEventPreview', () => {
it('should format duration', () => {
const parsed = parseEventInput('Meeting 2h');
const preview = formatParsedEventPreview(parsed);
expect(preview).toContain('2h');
});
it('should format calendar', () => {
const parsed = parseEventInput('Meeting @Arbeit');
const preview = formatParsedEventPreview(parsed);
expect(preview).toContain('Arbeit');
});
it('should format tags', () => {
const parsed = parseEventInput('Meeting #team');
const preview = formatParsedEventPreview(parsed);
expect(preview).toContain('team');
});
it('should format all-day events', () => {
const parsed = parseEventInput('Ganztägig Urlaub morgen');
const preview = formatParsedEventPreview(parsed);
expect(preview).toContain('ganztägig');
});
it('should return empty string for title-only input', () => {
const parsed = parseEventInput('Einfaches Meeting');
expect(formatParsedEventPreview(parsed)).toBe('');
});
it('should join parts with separator', () => {
const parsed = parseEventInput('Meeting morgen 14 Uhr 1h @Arbeit');
const preview = formatParsedEventPreview(parsed);
expect(preview).toContain(' · ');
});
});

View file

@ -0,0 +1,404 @@
/**
* Event Parser for Calendar App
*
* Extends the base parser with event-specific patterns:
* - Duration: 1h, 30min, 2h30m, 1 Stunde
* - Location: in Berlin, im Büro
* - Calendar: @CalendarName
*
* Examples:
* - "Meeting morgen 14 Uhr 1h @Arbeit #wichtig"
* - "Arzttermin 15.12. 10:00 30min in Praxis Dr. Müller"
* - "Mittagessen heute 12 Uhr"
* - "Ganztägig Urlaub nächste Woche"
*/
import {
parseBaseInput,
extractAtReference,
combineDateAndTime,
formatDatePreview,
formatTimePreview,
type ParserLocale,
} from '@manacore/shared-utils';
import { addHours } from 'date-fns';
export interface ParsedEvent {
title: string;
startDate?: Date;
endDate?: Date;
duration?: number; // in minutes
isAllDay?: boolean;
calendarName?: string;
location?: string;
tagNames: string[];
}
interface Calendar {
id: string;
name: string;
}
interface Tag {
id: string;
name: string;
}
export interface ParsedEventWithIds {
title: string;
startTime?: string;
endTime?: string;
isAllDay?: boolean;
calendarId?: string;
location?: string;
tagIds: string[];
}
// ============================================================================
// Time Range Extraction (14-16 Uhr, 10:00-11:30)
// ============================================================================
// "14-16 Uhr", "14:00-16:00", "10-11:30"
const TIME_RANGE_PATTERN =
/\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*[-]\s*(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
function extractTimeRange(text: string): {
startTime?: { hours: number; minutes: number };
endTime?: { hours: number; minutes: number };
remaining: string;
} {
const match = text.match(TIME_RANGE_PATTERN);
if (match) {
const startHours = parseInt(match[1]);
const startMinutes = match[2] ? parseInt(match[2]) : 0;
const endHours = parseInt(match[3]);
const endMinutes = match[4] ? parseInt(match[4]) : 0;
if (
startHours >= 0 &&
startHours <= 23 &&
endHours >= 0 &&
endHours <= 23 &&
startMinutes >= 0 &&
startMinutes <= 59 &&
endMinutes >= 0 &&
endMinutes <= 59
) {
return {
startTime: { hours: startHours, minutes: startMinutes },
endTime: { hours: endHours, minutes: endMinutes },
remaining: text.replace(TIME_RANGE_PATTERN, '').trim(),
};
}
}
return { remaining: text };
}
// ============================================================================
// Duration Extraction
// ============================================================================
// Locale-specific "hours" words (Stunden, hours, heures, horas, ore)
const HOURS_WORDS: Record<ParserLocale, string> = {
de: 'stunde[n]?',
en: 'hours?',
fr: 'heures?',
es: 'horas?',
it: 'ore',
};
function getDurationPatterns(
locale: ParserLocale
): { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] {
const hoursWord = HOURS_WORDS[locale];
return [
// 2h30m, 2h 30m, 1h30min
{
pattern: /\b(\d+)\s*h\s*(\d+)\s*(?:m(?:in)?)\b/i,
getMinutes: (m) => parseInt(m[1]) * 60 + parseInt(m[2]),
},
// 1h, 2h (hours only)
{ pattern: /\b(\d+)\s*h\b/i, getMinutes: (m) => parseInt(m[1]) * 60 },
// 30min, 45 min, 90 Minuten/minutes/etc.
{
pattern: /\b(\d+)\s*(?:min(?:uten?|utes?)?)\b/i,
getMinutes: (m) => parseInt(m[1]),
},
// Locale-specific full word: 1 Stunde, 2 hours, 3 heures, etc.
{
pattern: new RegExp(`\\b(\\d+)\\s*${hoursWord}\\b`, 'i'),
getMinutes: (m) => parseInt(m[1]) * 60,
},
];
}
function extractDuration(
text: string,
locale: ParserLocale = 'de'
): { duration?: number; remaining: string } {
for (const { pattern, getMinutes } of getDurationPatterns(locale)) {
const match = text.match(pattern);
if (match) {
return {
duration: getMinutes(match),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { duration: undefined, remaining: text };
}
// ============================================================================
// Location Extraction
// ============================================================================
// Location extraction - runs on the title AFTER date/time extraction has already
// removed date keywords like "in 3 Tagen", "in einer halben Stunde" etc.
// "in Berlin", "im Büro", "bei Dr. Müller", "am Bahnhof"
const LOCATION_PATTERN = /\b(?:in|im|bei|am)\s+(.+?)(?=\s+(?:@|#)|$)/i;
// Patterns that look like dates/times but not locations (multilingual)
const NOT_LOCATION_PATTERN =
/^\d+\s*(tage?n?|wochen?|stunde[n]?|minute[n]?|hours?|minutes?|heures?|horas?|ore|h|min)$/i;
function extractLocation(text: string): { location?: string; remaining: string } {
const match = text.match(LOCATION_PATTERN);
if (match) {
const location = match[1].trim();
// Skip if it looks like a leftover time/date expression
if (NOT_LOCATION_PATTERN.test(location)) {
return { location: undefined, remaining: text };
}
// Skip if starts with a number (likely a leftover numeric expression)
if (/^\d+\s/.test(location) && location.length < 5) {
return { location: undefined, remaining: text };
}
if (location.length >= 2) {
return {
location,
remaining: text.replace(LOCATION_PATTERN, '').trim(),
};
}
}
return { location: undefined, remaining: text };
}
// ============================================================================
// All-Day Detection
// ============================================================================
const ALL_DAY_PATTERNS: Record<ParserLocale, RegExp[]> = {
de: [/\bganzt[aä]gig\b/i, /\bganzer\s+tag\b/i],
en: [/\ball[- ]?day\b/i, /\bwhole\s+day\b/i],
fr: [/\btoute\s+la\s+journ[eé]e\b/i, /\bjour\s+entier\b/i],
es: [/\btodo\s+el\s+d[ií]a\b/i, /\bd[ií]a\s+completo\b/i],
it: [/\btutto\s+il\s+giorno\b/i, /\bgiornata\s+intera\b/i],
};
function getAllDayPatterns(locale: ParserLocale): RegExp[] {
return ALL_DAY_PATTERNS[locale];
}
function extractAllDay(
text: string,
locale: ParserLocale = 'de'
): { isAllDay: boolean; remaining: string } {
for (const pattern of getAllDayPatterns(locale)) {
if (pattern.test(text)) {
return {
isAllDay: true,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { isAllDay: false, remaining: text };
}
// ============================================================================
// Main Parser
// ============================================================================
/**
* Parse natural language event input
*
* Examples:
* - "Meeting morgen 14 Uhr 1h @Arbeit #wichtig"
* - "Arzttermin 15.12. 10:00 30min"
* - "Ganztägig Urlaub morgen"
*/
export function parseEventInput(input: string, locale: ParserLocale = 'de'): ParsedEvent {
let text = input.trim();
// Extract all-day flag
const allDayResult = extractAllDay(text, locale);
text = allDayResult.remaining;
const isAllDay = allDayResult.isAllDay;
// Extract time range first (14-16 Uhr, 10:00-11:30)
const timeRangeResult = extractTimeRange(text);
text = timeRangeResult.remaining;
// Extract duration (before base parser, since "30min" could conflict with time)
const durationResult = extractDuration(text, locale);
text = durationResult.remaining;
const duration = durationResult.duration;
// Extract calendar (@CalendarName)
const calendarResult = extractAtReference(text);
text = calendarResult.remaining;
const calendarName = calendarResult.value;
// Use base parser for common patterns (date, time, tags)
const base = parseBaseInput(text, locale);
// Try to extract location from the remaining title
const locationResult = extractLocation(base.title);
const title = locationResult.location ? locationResult.remaining : base.title;
const location = locationResult.location;
// Build start/end dates
let startDate: Date | undefined;
let endDate: Date | undefined;
if (timeRangeResult.startTime && timeRangeResult.endTime) {
// Time range provided: use it directly
const dateForRange = base.date || new Date();
startDate = combineDateAndTime(dateForRange, timeRangeResult.startTime);
endDate = combineDateAndTime(dateForRange, timeRangeResult.endTime);
} else {
// Single time or no time
startDate = combineDateAndTime(base.date, isAllDay ? undefined : base.time);
if (startDate) {
if (isAllDay) {
endDate = new Date(startDate);
endDate.setHours(23, 59, 59);
} else if (duration) {
endDate = new Date(startDate.getTime() + duration * 60_000);
} else {
// Default: 1 hour
endDate = addHours(startDate, 1);
}
}
}
return {
title,
startDate,
endDate,
duration,
isAllDay: isAllDay || undefined,
calendarName,
location,
tagNames: base.tagNames,
};
}
// ============================================================================
// ID Resolution
// ============================================================================
/**
* Resolve calendar and tag names to IDs
*/
export function resolveEventIds(
parsed: ParsedEvent,
calendars: Calendar[],
tags: Tag[],
defaultCalendarId?: string
): ParsedEventWithIds {
let calendarId: string | undefined;
const tagIds: string[] = [];
// Find calendar by name (case-insensitive)
if (parsed.calendarName) {
const calendar = calendars.find(
(c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase()
);
if (calendar) {
calendarId = calendar.id;
}
}
// Fallback to default calendar
if (!calendarId && defaultCalendarId) {
calendarId = defaultCalendarId;
}
// Find tags by name (case-insensitive)
for (const tagName of parsed.tagNames) {
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) {
tagIds.push(tag.id);
}
}
return {
title: parsed.title,
startTime: parsed.startDate?.toISOString(),
endTime: parsed.endDate?.toISOString(),
isAllDay: parsed.isAllDay,
calendarId,
location: parsed.location,
tagIds,
};
}
// ============================================================================
// Preview Formatting
// ============================================================================
// Locale-specific "all-day" label for preview display
const ALL_DAY_LABEL: Record<ParserLocale, string> = {
de: 'ganztägig',
en: 'all-day',
fr: 'toute la journée',
es: 'todo el día',
it: 'tutto il giorno',
};
/**
* Format parsed event for preview display
*/
export function formatParsedEventPreview(parsed: ParsedEvent, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
if (parsed.isAllDay && parsed.startDate) {
parts.push(`📅 ${formatDatePreview(parsed.startDate, locale)} (${ALL_DAY_LABEL[locale]})`);
} else if (parsed.startDate) {
let dateStr = `📅 ${formatDatePreview(parsed.startDate, locale)}`;
if (parsed.startDate.getHours() !== 0 || parsed.startDate.getMinutes() !== 0) {
dateStr += ` ${formatTimePreview({
hours: parsed.startDate.getHours(),
minutes: parsed.startDate.getMinutes(),
})}`;
}
parts.push(dateStr);
}
if (parsed.duration) {
const hours = Math.floor(parsed.duration / 60);
const mins = parsed.duration % 60;
let durationStr = '';
if (hours > 0) durationStr += `${hours}h`;
if (mins > 0) durationStr += `${mins}min`;
parts.push(`⏱️ ${durationStr}`);
}
if (parsed.location) {
parts.push(`📍 ${parsed.location}`);
}
if (parsed.calendarName) {
parts.push(`📆 ${parsed.calendarName}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { parseSongInput, formatParsedSongPreview } from './song-parser';
describe('parseSongInput', () => {
it('should parse a simple title', () => {
const result = parseSongInput('My Song');
expect(result.title).toBe('My Song');
expect(result.artist).toBeUndefined();
expect(result.tagNames).toEqual([]);
});
it('should parse "Artist - Title" format', () => {
const result = parseSongInput('Queen - Bohemian Rhapsody');
expect(result.artist).toBe('Queen');
expect(result.title).toBe('Bohemian Rhapsody');
});
it('should parse with en-dash separator', () => {
const result = parseSongInput('Beatles Hey Jude');
expect(result.artist).toBe('Beatles');
expect(result.title).toBe('Hey Jude');
});
it('should parse genre tags', () => {
const result = parseSongInput('Song #rock #classic');
expect(result.genre).toBe('rock');
expect(result.tagNames).toEqual(['rock', 'classic']);
});
it('should parse BPM', () => {
const result = parseSongInput('Beat 120bpm');
expect(result.bpm).toBe(120);
expect(result.title).toBe('Beat');
});
it('should parse year', () => {
const result = parseSongInput('Song 1975');
expect(result.year).toBe(1975);
});
it('should detect playlist creation', () => {
const result = parseSongInput('Neue Playlist Workout #electronic');
expect(result.isPlaylist).toBe(true);
expect(result.title).toBe('Workout');
expect(result.genre).toBe('electronic');
});
it('should detect project creation', () => {
const result = parseSongInput('Neues Projekt Demo 90bpm');
expect(result.isProject).toBe(true);
expect(result.title).toBe('Demo');
expect(result.bpm).toBe(90);
});
it('should parse complex input', () => {
const result = parseSongInput('Daft Punk - Get Lucky 2013 #electronic #disco');
expect(result.artist).toBe('Daft Punk');
expect(result.title).toBe('Get Lucky');
expect(result.year).toBe(2013);
expect(result.genre).toBe('electronic');
});
it('should handle empty input', () => {
const result = parseSongInput('');
expect(result.title).toBe('');
expect(result.tagNames).toEqual([]);
});
it('should ignore invalid BPM', () => {
const result = parseSongInput('Track 5bpm'); // too low
expect(result.bpm).toBeUndefined();
});
});
describe('formatParsedSongPreview', () => {
it('should format artist', () => {
const parsed = parseSongInput('Queen - Song');
const preview = formatParsedSongPreview(parsed);
expect(preview).toContain('Queen');
});
it('should format genre', () => {
const parsed = parseSongInput('Song #rock');
const preview = formatParsedSongPreview(parsed);
expect(preview).toContain('rock');
});
it('should format BPM', () => {
const parsed = parseSongInput('Beat 120bpm');
const preview = formatParsedSongPreview(parsed);
expect(preview).toContain('120 BPM');
});
it('should format playlist type', () => {
const parsed = parseSongInput('Neue Playlist Workout');
const preview = formatParsedSongPreview(parsed);
expect(preview).toContain('Neue Playlist');
});
it('should return empty for simple title', () => {
const parsed = parseSongInput('Simple Song');
expect(formatParsedSongPreview(parsed)).toBe('');
});
});

View file

@ -0,0 +1,193 @@
/**
* Song/Project Parser for Mukke App
*
* Parses natural language input into song metadata or project creation.
*
* Patterns:
* - "Artist - Title" format for songs
* - #genre tags
* - BPM number (e.g., 120bpm)
* - Year (e.g., 2024)
*
* Examples:
* - "Queen - Bohemian Rhapsody #rock"
* - "Neue Playlist Workout #electronic #techno"
* - "Projekt Demo Song 120bpm"
*/
import { extractTags, type ParserLocale } from '@manacore/shared-utils';
export interface ParsedSong {
title: string;
artist?: string;
album?: string;
genre?: string;
bpm?: number;
year?: number;
tagNames: string[];
isPlaylist?: boolean;
isProject?: boolean;
}
// BPM pattern: 120bpm, 120 BPM
const BPM_PATTERN = /\b(\d{2,3})\s*bpm\b/i;
// Year pattern: standalone 4-digit year (1900-2099)
const YEAR_PATTERN = /\b((?:19|20)\d{2})\b/;
// Playlist creation keywords per locale
const PLAYLIST_PATTERNS_BY_LOCALE: Record<ParserLocale, RegExp[]> = {
de: [/\bneue?\s*playlist\b/i, /\bplaylist\b/i],
en: [/\bnew\s+playlist\b/i, /\bplaylist\b/i],
fr: [/\bnouvelle\s+playlist\b/i, /\bplaylist\b/i],
es: [/\bnueva\s+playlist\b/i, /\bplaylist\b/i],
it: [/\bnuova\s+playlist\b/i, /\bplaylist\b/i],
};
// Project creation keywords per locale
const PROJECT_PATTERNS_BY_LOCALE: Record<ParserLocale, RegExp[]> = {
de: [/\bneue?s?\s*projekt\b/i, /\bprojekt\b/i],
en: [/\bnew\s+project\b/i, /\bproject\b/i],
fr: [/\bnouveau\s+projet\b/i, /\bprojet\b/i],
es: [/\bnuevo\s+proyecto\b/i, /\bproyecto\b/i],
it: [/\bnuovo\s+progetto\b/i, /\bprogetto\b/i],
};
// "Artist - Title" separator
const ARTIST_TITLE_SEPARATOR = /\s+[-–—]\s+/;
function extractBpm(text: string): { bpm?: number; remaining: string } {
const match = text.match(BPM_PATTERN);
if (match) {
const bpm = parseInt(match[1]);
if (bpm >= 20 && bpm <= 300) {
return { bpm, remaining: text.replace(BPM_PATTERN, '').trim() };
}
}
return { bpm: undefined, remaining: text };
}
function extractYear(text: string): { year?: number; remaining: string } {
const match = text.match(YEAR_PATTERN);
if (match) {
return {
year: parseInt(match[1]),
remaining: text.replace(YEAR_PATTERN, '').trim(),
};
}
return { year: undefined, remaining: text };
}
// Preview labels per locale
const TYPE_LABELS_BY_LOCALE: Record<ParserLocale, { playlist: string; project: string }> = {
de: { playlist: 'Neue Playlist', project: 'Neues Projekt' },
en: { playlist: 'New Playlist', project: 'New Project' },
fr: { playlist: 'Nouvelle Playlist', project: 'Nouveau Projet' },
es: { playlist: 'Nueva Playlist', project: 'Nuevo Proyecto' },
it: { playlist: 'Nuova Playlist', project: 'Nuovo Progetto' },
};
function extractTypeKeyword(
text: string,
locale: ParserLocale = 'de'
): { type?: 'playlist' | 'project'; remaining: string } {
const playlistPatterns = PLAYLIST_PATTERNS_BY_LOCALE[locale];
for (const pattern of playlistPatterns) {
if (pattern.test(text)) {
return { type: 'playlist', remaining: text.replace(pattern, '').trim() };
}
}
const projectPatterns = PROJECT_PATTERNS_BY_LOCALE[locale];
for (const pattern of projectPatterns) {
if (pattern.test(text)) {
return { type: 'project', remaining: text.replace(pattern, '').trim() };
}
}
return { type: undefined, remaining: text };
}
/**
* Parse natural language song/project input
*/
export function parseSongInput(input: string, locale: ParserLocale = 'de'): ParsedSong {
let text = input.trim();
// Extract tags first
const tagsResult = extractTags(text);
text = tagsResult.remaining;
const tagNames = tagsResult.value || [];
// Use first tag as genre hint
const genre = tagNames.length > 0 ? tagNames[0] : undefined;
// Extract type keyword (playlist/project)
const typeResult = extractTypeKeyword(text, locale);
text = typeResult.remaining;
// Extract BPM
const bpmResult = extractBpm(text);
text = bpmResult.remaining;
// Extract year
const yearResult = extractYear(text);
text = yearResult.remaining;
// Try "Artist - Title" format
let artist: string | undefined;
let title: string;
if (ARTIST_TITLE_SEPARATOR.test(text)) {
const parts = text.split(ARTIST_TITLE_SEPARATOR, 2);
artist = parts[0].trim();
title = parts[1].trim();
} else {
title = text.replace(/\s+/g, ' ').trim();
}
return {
title,
artist,
genre,
bpm: bpmResult.bpm,
year: yearResult.year,
tagNames,
isPlaylist: typeResult.type === 'playlist',
isProject: typeResult.type === 'project',
};
}
/**
* Format parsed song for preview display
*/
export function formatParsedSongPreview(parsed: ParsedSong, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
const typeLabels = TYPE_LABELS_BY_LOCALE[locale];
if (parsed.isPlaylist) {
parts.push(`📋 ${typeLabels.playlist}`);
} else if (parsed.isProject) {
parts.push(`🎛️ ${typeLabels.project}`);
}
if (parsed.artist) {
parts.push(`🎤 ${parsed.artist}`);
}
if (parsed.genre) {
parts.push(`🎵 ${parsed.genre}`);
}
if (parsed.bpm) {
parts.push(`⏱️ ${parsed.bpm} BPM`);
}
if (parsed.year) {
parts.push(`📅 ${parsed.year}`);
}
if (parsed.tagNames.length > 1) {
parts.push(`🏷️ ${parsed.tagNames.slice(1).join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -3,7 +3,12 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { PillNavigation, QuickInputBar, DevBuildBadge } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
QuickInputItem,
CreatePreview,
} from '@manacore/shared-ui';
import {
SplitPaneContainer,
setSplitPanelContext,
@ -19,6 +24,10 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { libraryStore } from '$lib/stores/library.svelte';
import { playlistStore } from '$lib/stores/playlist.svelte';
import { projectStore } from '$lib/stores/project.svelte';
import { parseSongInput, formatParsedSongPreview } from '$lib/utils/song-parser';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import MiniPlayer from '$lib/components/MiniPlayer.svelte';
import FullPlayer from '$lib/components/FullPlayer.svelte';
import QueuePanel from '$lib/components/QueuePanel.svelte';
@ -118,6 +127,46 @@
goto(`/library?song=${item.id}`);
}
// Quick-Create handlers
function handleParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseSongInput(query);
if (!parsed.title) return null;
const preview = formatParsedSongPreview(parsed);
if (parsed.isPlaylist) {
return {
title: `Playlist "${parsed.title}" erstellen`,
subtitle: preview || 'Neue Playlist',
};
}
if (parsed.isProject) {
return {
title: `Projekt "${parsed.title}" erstellen`,
subtitle: preview || 'Neues Projekt',
};
}
return {
title: `Projekt "${parsed.title}" erstellen`,
subtitle: preview || 'Neues Projekt',
};
}
async function handleCreate(query: string): Promise<void> {
if (!query.trim()) return;
const parsed = parseSongInput(query);
if (!parsed.title) return;
if (parsed.isPlaylist) {
await playlistStore.createPlaylist(parsed.title);
goto('/playlists');
return;
}
// Default: create project
await projectStore.createProject(parsed.title);
goto('/projects');
}
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
@ -175,9 +224,13 @@
<QuickInputBar
onSearch={handleInputSearch}
onSelect={handleInputSelect}
placeholder="Song suchen..."
onParseCreate={handleParseCreate}
onCreate={handleCreate}
placeholder="Song suchen oder Projekt erstellen..."
emptyText="Keine Songs gefunden"
searchingText="Suche..."
createText="Erstellen"
deferSearch={true}
locale="de"
appIcon="search"
bottomOffset="140px"
@ -197,6 +250,7 @@
<DevBuildBadge commitHash={__BUILD_HASH__} buildTime={__BUILD_TIME__} />
</div>
</SplitPaneContainer>
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
<style>

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}

View file

@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { parsePlantInput, resolvePlantData, formatParsedPlantPreview } from './plant-parser';
describe('parsePlantInput', () => {
it('should parse a simple name', () => {
const result = parsePlantInput('Monstera');
expect(result.name).toBe('Monstera');
expect(result.acquiredAt).toBeUndefined();
expect(result.tagNames).toEqual([]);
});
it('should parse name with tags', () => {
const result = parsePlantInput('Basilikum #kräuter #küche');
expect(result.name).toBe('Basilikum');
expect(result.tagNames).toEqual(['kräuter', 'küche']);
});
it('should parse acquisition date', () => {
const result = parsePlantInput('Ficus morgen gekauft');
expect(result.name).toBe('Ficus');
expect(result.acquiredAt).toBeDefined();
});
it('should default to today when "gekauft" without date', () => {
const result = parsePlantInput('Orchidee gekauft');
expect(result.name).toBe('Orchidee');
expect(result.acquiredAt).toBeDefined();
expect(result.acquiredAt!.toDateString()).toBe(new Date().toDateString());
});
it('should parse "gepflanzt" as acquisition', () => {
const result = parsePlantInput('Tomate heute gepflanzt');
expect(result.name).toBe('Tomate');
expect(result.acquiredAt).toBeDefined();
});
it('should parse multi-word name', () => {
const result = parsePlantInput('Monstera deliciosa');
expect(result.name).toBe('Monstera deliciosa');
});
it('should handle empty input', () => {
const result = parsePlantInput('');
expect(result.name).toBe('');
expect(result.tagNames).toEqual([]);
});
it('should parse complex input', () => {
const result = parsePlantInput('Aloe Vera heute gekauft #sukkulente #badezimmer');
expect(result.name).toBe('Aloe Vera');
expect(result.acquiredAt).toBeDefined();
expect(result.tagNames).toEqual(['sukkulente', 'badezimmer']);
});
});
describe('resolvePlantData', () => {
it('should produce ISO date string', () => {
const parsed = parsePlantInput('Ficus heute gekauft');
const resolved = resolvePlantData(parsed);
expect(resolved.name).toBe('Ficus');
expect(resolved.acquiredAt).toBeDefined();
expect(new Date(resolved.acquiredAt!).toISOString()).toBe(resolved.acquiredAt);
});
it('should handle no date', () => {
const parsed = parsePlantInput('Monstera');
const resolved = resolvePlantData(parsed);
expect(resolved.acquiredAt).toBeUndefined();
});
});
describe('formatParsedPlantPreview', () => {
it('should format date', () => {
const parsed = parsePlantInput('Ficus heute gekauft');
const preview = formatParsedPlantPreview(parsed);
expect(preview).toContain('Heute');
});
it('should format tags', () => {
const parsed = parsePlantInput('Monstera #tropisch');
const preview = formatParsedPlantPreview(parsed);
expect(preview).toContain('tropisch');
});
it('should return empty for name-only', () => {
const parsed = parsePlantInput('Monstera');
expect(formatParsedPlantPreview(parsed)).toBe('');
});
});

View file

@ -0,0 +1,117 @@
/**
* Plant Parser for Planta App
*
* Extends the base parser with plant-specific patterns:
* - Scientific names (italic Latin names)
* - Acquisition date
* - Tags for categories
*
* Examples:
* - "Monstera deliciosa #tropisch"
* - "Basilikum heute gekauft #kräuter"
* - "Ficus benjamina morgen #zimmerpflanze"
*/
import {
parseBaseInput,
extractTags,
combineDateAndTime,
formatDatePreview,
type ParserLocale,
} from '@manacore/shared-utils';
export interface ParsedPlant {
name: string;
acquiredAt?: Date;
tagNames: string[];
}
export interface ParsedPlantWithIds {
name: string;
acquiredAt?: string;
}
// Acquisition keywords per locale
const ACQUIRED_PATTERNS_BY_LOCALE: Record<ParserLocale, RegExp[]> = {
de: [/\bgekauft\b/i, /\bbekommen\b/i, /\berhalten\b/i, /\bgepflanzt\b/i],
en: [/\bbought\b/i, /\breceived\b/i, /\bgot\b/i, /\bplanted\b/i],
fr: [/\bacheté\b/i, /\breçu\b/i, /\bplanté\b/i],
es: [/\bcomprado\b/i, /\brecibido\b/i, /\bplantado\b/i],
it: [/\bcomprato\b/i, /\bricevuto\b/i, /\bpiantato\b/i],
};
function extractAcquiredKeyword(
text: string,
locale: ParserLocale = 'de'
): { found: boolean; remaining: string } {
const patterns = ACQUIRED_PATTERNS_BY_LOCALE[locale];
for (const pattern of patterns) {
if (pattern.test(text)) {
return {
found: true,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { found: false, remaining: text };
}
/**
* Parse natural language plant input
*
* Examples:
* - "Monstera #tropisch"
* - "Basilikum heute gekauft #kräuter"
* - "Ficus benjamina"
*/
export function parsePlantInput(input: string, locale: ParserLocale = 'de'): ParsedPlant {
let text = input.trim();
// Check for acquisition keywords
const acquiredResult = extractAcquiredKeyword(text, locale);
text = acquiredResult.remaining;
// Use base parser for date, time, tags
const base = parseBaseInput(text, locale);
// If we found a date (or acquisition keyword implies today)
let acquiredAt: Date | undefined;
if (base.date) {
acquiredAt = combineDateAndTime(base.date, base.time);
} else if (acquiredResult.found) {
acquiredAt = new Date(); // "gekauft" without date = today
}
return {
name: base.title,
acquiredAt,
tagNames: base.tagNames,
};
}
/**
* Resolve to API-ready format
*/
export function resolvePlantData(parsed: ParsedPlant): ParsedPlantWithIds {
return {
name: parsed.name,
acquiredAt: parsed.acquiredAt?.toISOString(),
};
}
/**
* Format parsed plant for preview display
*/
export function formatParsedPlantPreview(parsed: ParsedPlant, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
if (parsed.acquiredAt) {
parts.push(`📅 ${formatDatePreview(parsed.acquiredAt, locale)}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -3,10 +3,16 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
import type { PillNavItem, QuickInputItem } from '@manacore/shared-ui';
import type { PillNavItem, QuickInputItem, CreatePreview } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { plantsApi } from '$lib/api/plants';
import {
parsePlantInput,
resolvePlantData,
formatParsedPlantPreview,
} from '$lib/utils/plant-parser';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
let { children } = $props();
@ -54,6 +60,32 @@
goto(`/plant/${item.id}`);
}
// Quick-Create handlers
function handleParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parsePlantInput(query);
if (!parsed.name) return null;
const preview = formatParsedPlantPreview(parsed);
return {
title: `"${parsed.name}" erstellen`,
subtitle: preview || 'Neue Pflanze',
};
}
async function handleCreate(query: string): Promise<void> {
if (!query.trim()) return;
const parsed = parsePlantInput(query);
if (!parsed.name) return;
const resolved = resolvePlantData(parsed);
const plant = await plantsApi.create({
name: resolved.name,
acquiredAt: resolved.acquiredAt,
});
if (plant?.id) {
goto(`/plant/${plant.id}`);
}
}
onMount(async () => {
// Initialize auth state from stored tokens
await authStore.initialize();
@ -86,9 +118,13 @@
<QuickInputBar
onSearch={handleInputSearch}
onSelect={handleInputSelect}
placeholder="Pflanze suchen..."
onParseCreate={handleParseCreate}
onCreate={handleCreate}
placeholder="Neue Pflanze oder suchen..."
emptyText="Keine Pflanzen gefunden"
searchingText="Suche..."
createText="Erstellen"
deferSearch={true}
locale="de"
appIcon="search"
bottomOffset="70px"
@ -100,6 +136,7 @@
</div>
</main>
</div>
<SessionExpiredBanner locale="de" loginHref="/login" />
{:else}
<div class="flex min-h-screen items-center justify-center">
<div

View file

@ -13,6 +13,7 @@ import {
formatDatePreview,
formatTimePreview,
} from '@manacore/shared-utils';
import type { ParserLocale } from '@manacore/shared-utils';
import type { TaskPriority } from '@todo/shared';
export interface ParsedTask {
@ -41,20 +42,42 @@ export interface ParsedTaskWithIds {
labelIds: string[];
}
// Priority patterns (task-specific)
// Supports: später, normal, wichtig, dringend (with or without !) and shortcuts !, !!, !!!
const PRIORITY_PATTERNS: { pattern: RegExp; priority: TaskPriority }[] = [
{ pattern: /!{3,}|!?dringend\b/i, priority: 'urgent' },
{ pattern: /!{2}|!?wichtig\b/i, priority: 'high' },
{ pattern: /!?normal\b/i, priority: 'medium' },
{ pattern: /!?sp[aä]ter\b/i, priority: 'low' },
];
// Priority keyword translations per locale
const PRIORITY_KEYWORDS: Record<
ParserLocale,
{ urgent: string; high: string; medium: string; low: string }
> = {
de: { urgent: 'dringend', high: 'wichtig', medium: 'normal', low: 'sp[aä]ter' },
en: { urgent: 'urgent', high: 'important', medium: 'normal', low: 'later' },
fr: { urgent: 'urgent', high: 'important', medium: 'normal', low: 'plus\\s+tard' },
es: { urgent: 'urgente', high: 'importante', medium: 'normal', low: 'despu[eé]s' },
it: { urgent: 'urgente', high: 'importante', medium: 'normale', low: 'dopo' },
};
/**
* Build locale-aware priority patterns
*/
function buildPriorityPatterns(
locale: ParserLocale
): { pattern: RegExp; priority: TaskPriority }[] {
const kw = PRIORITY_KEYWORDS[locale];
return [
{ pattern: new RegExp(`!{3,}|!?${kw.urgent}\\b`, 'i'), priority: 'urgent' },
{ pattern: new RegExp(`!{2}|!?${kw.high}\\b`, 'i'), priority: 'high' },
{ pattern: new RegExp(`!?${kw.medium}\\b`, 'i'), priority: 'medium' },
{ pattern: new RegExp(`!?${kw.low}\\b`, 'i'), priority: 'low' },
];
}
/**
* Extract priority from text
*/
function extractPriority(text: string): { priority?: TaskPriority; remaining: string } {
for (const { pattern, priority } of PRIORITY_PATTERNS) {
function extractPriority(
text: string,
locale: ParserLocale = 'de'
): { priority?: TaskPriority; remaining: string } {
const patterns = buildPriorityPatterns(locale);
for (const { pattern, priority } of patterns) {
if (pattern.test(text)) {
return {
priority,
@ -73,11 +96,11 @@ function extractPriority(text: string): { priority?: TaskPriority; remaining: st
* - "Einkaufen heute #privat"
* - "Report in 3 Tagen !!"
*/
export function parseTaskInput(input: string): ParsedTask {
export function parseTaskInput(input: string, locale: ParserLocale = 'de'): ParsedTask {
let text = input.trim();
// Extract priority first (task-specific)
const priorityResult = extractPriority(text);
const priorityResult = extractPriority(text, locale);
text = priorityResult.remaining;
const priority = priorityResult.priority;
@ -87,7 +110,7 @@ export function parseTaskInput(input: string): ParsedTask {
const projectName = projectResult.value;
// Use base parser for common patterns (date, time, tags)
const base = parseBaseInput(text);
const base = parseBaseInput(text, locale);
// Combine date and time
const dueDate = combineDateAndTime(base.date, base.time);
@ -139,10 +162,19 @@ export function resolveTaskIds(
};
}
// Priority display labels per locale
const PRIORITY_LABELS: Record<ParserLocale, Record<TaskPriority, string>> = {
de: { low: '🟢 Später', medium: '🟡 Normal', high: '🟠 Wichtig', urgent: '🔴 Dringend' },
en: { low: '🟢 Later', medium: '🟡 Normal', high: '🟠 Important', urgent: '🔴 Urgent' },
fr: { low: '🟢 Plus tard', medium: '🟡 Normal', high: '🟠 Important', urgent: '🔴 Urgent' },
es: { low: '🟢 Después', medium: '🟡 Normal', high: '🟠 Importante', urgent: '🔴 Urgente' },
it: { low: '🟢 Dopo', medium: '🟡 Normale', high: '🟠 Importante', urgent: '🔴 Urgente' },
};
/**
* Format parsed task for preview display
*/
export function formatParsedTaskPreview(parsed: ParsedTask): string {
export function formatParsedTaskPreview(parsed: ParsedTask, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
if (parsed.dueDate) {
@ -160,13 +192,7 @@ export function formatParsedTaskPreview(parsed: ParsedTask): string {
}
if (parsed.priority) {
const priorityLabels: Record<TaskPriority, string> = {
low: '🟢 Später',
medium: '🟡 Normal',
high: '🟠 Wichtig',
urgent: '🔴 Dringend',
};
parts.push(priorityLabels[parsed.priority]);
parts.push(PRIORITY_LABELS[locale][parsed.priority]);
}
if (parsed.projectName) {

View file

@ -0,0 +1,697 @@
import { describe, it, expect } from 'vitest';
import {
extractDate,
extractDateRange,
extractTime,
extractTimezone,
extractAtReferences,
extractRecurrence,
extractRelativeTime,
fuzzyMatchDateKeyword,
createAppParser,
parseBaseInput,
formatDatePreview,
} from './base-parser';
import { addDays } from 'date-fns';
// ============================================================================
// German (de) - was already working, verify still works
// ============================================================================
describe('German (de)', () => {
it('should parse "heute"', () => {
const result = extractDate('Meeting heute', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(new Date().toDateString());
expect(result.remaining).toBe('Meeting');
});
it('should parse "morgen"', () => {
const result = extractDate('morgen Termin', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 1).toDateString());
});
it('should parse "übermorgen"', () => {
const result = extractDate('übermorgen', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 2).toDateString());
});
it('should parse "in 3 Tagen"', () => {
const result = extractDate('in 3 Tagen', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 3).toDateString());
});
it('should parse "14 Uhr"', () => {
const result = extractTime('um 14 Uhr', 'de');
expect(result.value).toEqual({ hours: 14, minutes: 0 });
});
it('should parse DD.MM. date', () => {
const result = extractDate('15.12.', 'de');
expect(result.value).toBeDefined();
expect(result.value!.getDate()).toBe(15);
expect(result.value!.getMonth()).toBe(11); // December
});
it('should format preview as Heute/Morgen', () => {
expect(formatDatePreview(new Date(), 'de')).toBe('Heute');
expect(formatDatePreview(addDays(new Date(), 1), 'de')).toBe('Morgen');
});
});
// ============================================================================
// English (en)
// ============================================================================
describe('English (en)', () => {
it('should parse "today"', () => {
const result = extractDate('Meeting today', 'en');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(new Date().toDateString());
expect(result.remaining).toBe('Meeting');
});
it('should parse "tomorrow"', () => {
const result = extractDate('tomorrow meeting', 'en');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 1).toDateString());
});
it('should parse "in 5 days"', () => {
const result = extractDate('in 5 days', 'en');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 5).toDateString());
});
it('should parse "next week"', () => {
const result = extractDate('next week', 'en');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 7).toDateString());
});
it('should parse weekday "monday"', () => {
const result = extractDate('monday meeting', 'en');
expect(result.value).toBeDefined();
expect(result.value!.getDay()).toBe(1); // Monday
});
it('should parse "next friday"', () => {
const result = extractDate('next friday', 'en');
expect(result.value).toBeDefined();
expect(result.value!.getDay()).toBe(5); // Friday
});
it('should parse "at 2pm"', () => {
const result = extractTime('at 2pm', 'en');
expect(result.value).toEqual({ hours: 14, minutes: 0 });
});
it('should parse "3:30"', () => {
const result = extractTime('3:30', 'en');
expect(result.value).toEqual({ hours: 3, minutes: 30 });
});
it('should parse MM/DD date', () => {
const result = extractDate('12/25', 'en');
expect(result.value).toBeDefined();
expect(result.value!.getMonth()).toBe(11); // December
expect(result.value!.getDate()).toBe(25);
});
it('should format preview as Today/Tomorrow', () => {
expect(formatDatePreview(new Date(), 'en')).toBe('Today');
expect(formatDatePreview(addDays(new Date(), 1), 'en')).toBe('Tomorrow');
});
it('should parse full input in English', () => {
const result = parseBaseInput('Meeting tomorrow 14:00 #important', 'en');
expect(result.title).toBe('Meeting');
expect(result.date).toBeDefined();
expect(result.time).toEqual({ hours: 14, minutes: 0 });
expect(result.tagNames).toEqual(['important']);
});
});
// ============================================================================
// French (fr)
// ============================================================================
describe('French (fr)', () => {
it('should parse "demain"', () => {
const result = extractDate('réunion demain', 'fr');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 1).toDateString());
});
it('should parse "dans 3 jours"', () => {
const result = extractDate('dans 3 jours', 'fr');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 3).toDateString());
});
it('should parse weekday "lundi"', () => {
const result = extractDate('lundi réunion', 'fr');
expect(result.value).toBeDefined();
expect(result.value!.getDay()).toBe(1); // Monday
});
it('should parse time "14h30"', () => {
const result = extractTime('à 14h30', 'fr');
expect(result.value).toEqual({ hours: 14, minutes: 30 });
});
it("should format preview as Aujourd'hui/Demain", () => {
expect(formatDatePreview(new Date(), 'fr')).toBe("Aujourd'hui");
expect(formatDatePreview(addDays(new Date(), 1), 'fr')).toBe('Demain');
});
});
// ============================================================================
// Spanish (es)
// ============================================================================
describe('Spanish (es)', () => {
it('should parse "hoy"', () => {
const result = extractDate('reunión hoy', 'es');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(new Date().toDateString());
});
it('should parse "mañana"', () => {
const result = extractDate('mañana reunión', 'es');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 1).toDateString());
});
it('should parse "en 2 días"', () => {
const result = extractDate('en 2 días', 'es');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 2).toDateString());
});
it('should parse weekday "lunes"', () => {
const result = extractDate('lunes', 'es');
expect(result.value).toBeDefined();
expect(result.value!.getDay()).toBe(1);
});
it('should format preview as Hoy/Mañana', () => {
expect(formatDatePreview(new Date(), 'es')).toBe('Hoy');
expect(formatDatePreview(addDays(new Date(), 1), 'es')).toBe('Mañana');
});
});
// ============================================================================
// Italian (it)
// ============================================================================
describe('Italian (it)', () => {
it('should parse "oggi"', () => {
const result = extractDate('riunione oggi', 'it');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(new Date().toDateString());
});
it('should parse "domani"', () => {
const result = extractDate('domani riunione', 'it');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 1).toDateString());
});
it('should parse "dopodomani"', () => {
const result = extractDate('dopodomani', 'it');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 2).toDateString());
});
it('should parse "tra 5 giorni"', () => {
const result = extractDate('tra 5 giorni', 'it');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 5).toDateString());
});
it('should parse weekday "lunedì"', () => {
const result = extractDate('lunedì', 'it');
expect(result.value).toBeDefined();
expect(result.value!.getDay()).toBe(1);
});
it('should format preview as Oggi/Domani', () => {
expect(formatDatePreview(new Date(), 'it')).toBe('Oggi');
expect(formatDatePreview(addDays(new Date(), 1), 'it')).toBe('Domani');
});
});
// ============================================================================
// Multiple @references
// ============================================================================
describe('extractAtReferences', () => {
it('should extract multiple @references', () => {
const result = extractAtReferences('Meeting @Arbeit @Max');
expect(result.value).toEqual(['Arbeit', 'Max']);
expect(result.remaining).toBe('Meeting');
});
it('should extract single @reference', () => {
const result = extractAtReferences('Task @Projekt');
expect(result.value).toEqual(['Projekt']);
});
it('should return undefined for no references', () => {
const result = extractAtReferences('Just text');
expect(result.value).toBeUndefined();
});
});
// ============================================================================
// Timezone Extraction
// ============================================================================
describe('Timezone', () => {
it('should extract CET', () => {
const result = extractTimezone('Meeting 14 Uhr CET');
expect(result.value).toBe('Europe/Berlin');
expect(result.remaining).toBe('Meeting 14 Uhr');
});
it('should extract EST', () => {
const result = extractTimezone('Call at 3pm EST');
expect(result.value).toBe('America/New_York');
});
it('should extract UTC', () => {
const result = extractTimezone('Deploy 10:00 UTC');
expect(result.value).toBe('UTC');
});
it('should return undefined for no timezone', () => {
const result = extractTimezone('Normal text');
expect(result.value).toBeUndefined();
});
});
// ============================================================================
// Past Dates
// ============================================================================
describe('Past dates', () => {
it('should parse "gestern" (de)', () => {
const result = extractDate('gestern gemacht', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), -1).toDateString());
});
it('should parse "vorgestern" (de)', () => {
const result = extractDate('vorgestern', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), -2).toDateString());
});
it('should parse "yesterday" (en)', () => {
const result = extractDate('done yesterday', 'en');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), -1).toDateString());
});
it('should parse "hier" (fr)', () => {
const result = extractDate('fait hier', 'fr');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), -1).toDateString());
});
it('should parse "ieri" (it)', () => {
const result = extractDate('fatto ieri', 'it');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), -1).toDateString());
});
});
// ============================================================================
// Relative Week Expressions
// ============================================================================
describe('Relative weeks', () => {
it('should parse "übernächste Woche" (de)', () => {
const result = extractDate('übernächste Woche', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 14).toDateString());
});
it('should parse "week after next" (en)', () => {
const result = extractDate('week after next', 'en');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 14).toDateString());
});
it('should parse "in 3 Wochen" (de)', () => {
const result = extractDate('in 3 Wochen', 'de');
expect(result.value).toBeDefined();
// 3 weeks = 21 days
const expected = addDays(new Date(), 21);
expect(result.value!.toDateString()).toBe(expected.toDateString());
});
it('should parse "in 2 weeks" (en)', () => {
const result = extractDate('in 2 weeks', 'en');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 14).toDateString());
});
});
// ============================================================================
// Month Names & Ordinal Dates
// ============================================================================
describe('Month names', () => {
it('should parse "im März" (de)', () => {
const result = extractDate('Termin im März', 'de');
expect(result.value).toBeDefined();
expect(result.value!.getMonth()).toBe(2); // March
});
it('should parse "in January" (en)', () => {
const result = extractDate('meeting in January', 'en');
expect(result.value).toBeDefined();
expect(result.value!.getMonth()).toBe(0); // January
});
it('should parse "en février" (fr)', () => {
const result = extractDate('réunion en février', 'fr');
expect(result.value).toBeDefined();
expect(result.value!.getMonth()).toBe(1); // February
});
});
describe('Ordinal dates', () => {
it('should parse "5. Dezember" (de)', () => {
const result = extractDate('Termin 5. Dezember', 'de');
expect(result.value).toBeDefined();
expect(result.value!.getDate()).toBe(5);
expect(result.value!.getMonth()).toBe(11); // December
});
it('should parse "3rd of May" (en)', () => {
const result = extractDate('meeting 3rd of May', 'en');
expect(result.value).toBeDefined();
expect(result.value!.getDate()).toBe(3);
expect(result.value!.getMonth()).toBe(4); // May
});
it('should parse "le 15 mars" (fr)', () => {
const result = extractDate('réunion le 15 mars', 'fr');
expect(result.value).toBeDefined();
expect(result.value!.getDate()).toBe(15);
expect(result.value!.getMonth()).toBe(2); // March
});
it('should parse "el 3 de mayo" (es)', () => {
const result = extractDate('reunión el 3 de mayo', 'es');
expect(result.value).toBeDefined();
expect(result.value!.getDate()).toBe(3);
expect(result.value!.getMonth()).toBe(4); // May
});
});
// ============================================================================
// Recurrence Extraction
// ============================================================================
describe('Recurrence (de)', () => {
it('should parse "täglich"', () => {
const result = extractRecurrence('Standup täglich', 'de');
expect(result.value).toBe('FREQ=DAILY');
expect(result.remaining).toBe('Standup');
});
it('should parse "wöchentlich"', () => {
const result = extractRecurrence('Meeting wöchentlich', 'de');
expect(result.value).toBe('FREQ=WEEKLY');
});
it('should parse "jeden Montag"', () => {
const result = extractRecurrence('Standup jeden Montag', 'de');
expect(result.value).toBe('FREQ=WEEKLY;BYDAY=MO');
});
it('should parse "monatlich"', () => {
const result = extractRecurrence('Review monatlich', 'de');
expect(result.value).toBe('FREQ=MONTHLY');
});
it('should parse "alle 2 Wochen"', () => {
const result = extractRecurrence('Sprint alle 2 Wochen', 'de');
expect(result.value).toBe('FREQ=WEEKLY;INTERVAL=2');
});
it('should return undefined for no recurrence', () => {
const result = extractRecurrence('Einfacher Termin', 'de');
expect(result.value).toBeUndefined();
});
});
describe('Recurrence (en)', () => {
it('should parse "daily"', () => {
const result = extractRecurrence('Standup daily', 'en');
expect(result.value).toBe('FREQ=DAILY');
});
it('should parse "every Monday"', () => {
const result = extractRecurrence('Standup every Monday', 'en');
expect(result.value).toBe('FREQ=WEEKLY;BYDAY=MO');
});
it('should parse "every 3 weeks"', () => {
const result = extractRecurrence('Sprint every 3 weeks', 'en');
expect(result.value).toBe('FREQ=WEEKLY;INTERVAL=3');
});
it('should parse "monthly"', () => {
const result = extractRecurrence('Review monthly', 'en');
expect(result.value).toBe('FREQ=MONTHLY');
});
});
describe('Recurrence (fr)', () => {
it('should parse "quotidien"', () => {
const result = extractRecurrence('Standup quotidien', 'fr');
expect(result.value).toBe('FREQ=DAILY');
});
it('should parse "chaque lundi"', () => {
const result = extractRecurrence('Réunion chaque lundi', 'fr');
expect(result.value).toBe('FREQ=WEEKLY;BYDAY=MO');
});
});
// ============================================================================
// Relative Time Expressions
// ============================================================================
describe('Relative time (de)', () => {
it('should parse "in 2 Stunden"', () => {
const now = Date.now();
const result = extractRelativeTime('Meeting in 2 Stunden', 'de');
expect(result.value).toBeDefined();
const diff = result.value!.getTime() - now;
expect(diff).toBeGreaterThan(110 * 60_000); // ~2h
expect(diff).toBeLessThan(130 * 60_000);
expect(result.remaining).toBe('Meeting');
});
it('should parse "in 30 Minuten"', () => {
const now = Date.now();
const result = extractRelativeTime('Call in 30 Minuten', 'de');
expect(result.value).toBeDefined();
const diff = result.value!.getTime() - now;
expect(diff).toBeGreaterThan(25 * 60_000);
expect(diff).toBeLessThan(35 * 60_000);
});
it('should parse "in einer halben Stunde"', () => {
const now = Date.now();
const result = extractRelativeTime('Termin in einer halben Stunde', 'de');
expect(result.value).toBeDefined();
const diff = result.value!.getTime() - now;
expect(diff).toBeGreaterThan(25 * 60_000);
expect(diff).toBeLessThan(35 * 60_000);
});
it('should return undefined for no match', () => {
const result = extractRelativeTime('Normaler Text', 'de');
expect(result.value).toBeUndefined();
});
});
describe('Relative time (en)', () => {
it('should parse "in 2 hours"', () => {
const now = Date.now();
const result = extractRelativeTime('Meeting in 2 hours', 'en');
expect(result.value).toBeDefined();
const diff = result.value!.getTime() - now;
expect(diff).toBeGreaterThan(110 * 60_000);
expect(diff).toBeLessThan(130 * 60_000);
});
it('should parse "in half an hour"', () => {
const now = Date.now();
const result = extractRelativeTime('Call in half an hour', 'en');
expect(result.value).toBeDefined();
const diff = result.value!.getTime() - now;
expect(diff).toBeGreaterThan(25 * 60_000);
expect(diff).toBeLessThan(35 * 60_000);
});
it('should parse "in 15 minutes"', () => {
const result = extractRelativeTime('Break in 15 minutes', 'en');
expect(result.value).toBeDefined();
});
});
// ============================================================================
// Fuzzy Matching
// ============================================================================
describe('Fuzzy matching', () => {
it('should match "morge" → "morgen"', () => {
expect(fuzzyMatchDateKeyword('morge', 'de')).toBe('morgen');
});
it('should match "motag" → "montag"', () => {
expect(fuzzyMatchDateKeyword('motag', 'de')).toBe('montag');
});
it('should match "donerstag" → "donnerstag"', () => {
expect(fuzzyMatchDateKeyword('donerstag', 'de')).toBe('donnerstag');
});
it('should match "tomorow" → "tomorrow" (en)', () => {
expect(fuzzyMatchDateKeyword('tomorow', 'en')).toBe('tomorrow');
});
it('should match "wedensday" → "wednesday" (en)', () => {
expect(fuzzyMatchDateKeyword('wedensday', 'en')).toBe('wednesday');
});
it('should not match completely wrong words', () => {
expect(fuzzyMatchDateKeyword('hallo', 'de')).toBeUndefined();
});
it('should extract date from fuzzy input "morge"', () => {
const result = extractDate('Meeting morge', 'de');
expect(result.value).toBeDefined();
expect(result.value!.toDateString()).toBe(addDays(new Date(), 1).toDateString());
});
it('should extract date from fuzzy input "donerstag"', () => {
const result = extractDate('Termin donerstag', 'de');
expect(result.value).toBeDefined();
expect(result.value!.getDay()).toBe(4); // Thursday
});
});
// ============================================================================
// Date Range
// ============================================================================
describe('Date range', () => {
it('should parse "15.-17. März" (de)', () => {
const result = extractDateRange('Urlaub 15.-17. März', 'de');
expect(result.value).toBeDefined();
expect(result.value!.start.getDate()).toBe(15);
expect(result.value!.end.getDate()).toBe(17);
expect(result.value!.start.getMonth()).toBe(2); // March
});
it('should parse "3-5 May" (en)', () => {
const result = extractDateRange('Holiday 3-5 May', 'en');
expect(result.value).toBeDefined();
expect(result.value!.start.getDate()).toBe(3);
expect(result.value!.end.getDate()).toBe(5);
expect(result.value!.start.getMonth()).toBe(4); // May
});
it('should return undefined for no range', () => {
const result = extractDateRange('Normal text', 'de');
expect(result.value).toBeUndefined();
});
});
// ============================================================================
// Confidence Score
// ============================================================================
describe('Confidence score', () => {
it('should return 1.0 for input with clear extractions', () => {
const result = parseBaseInput('Meeting morgen 14 Uhr #wichtig', 'de');
expect(result.confidence).toBe(1.0);
});
it('should return 0.5 for plain text with no extractions', () => {
const result = parseBaseInput('Einfacher Text', 'de');
expect(result.confidence).toBe(0.5);
});
});
// ============================================================================
// Compose Helper
// ============================================================================
describe('createAppParser', () => {
it('should compose base + custom extractions', () => {
const { parse } = createAppParser('de', [
{
name: 'priority',
extract: (text: string) => {
if (/!!!/.test(text)) {
return { value: 'urgent', remaining: text.replace(/!!!/, '').trim() };
}
return { value: undefined, remaining: text };
},
},
]);
const result = parse('Task morgen !!! #arbeit');
expect(result.extractions.priority).toBe('urgent');
expect(result.base.date).toBeDefined();
expect(result.base.tagNames).toEqual(['arbeit']);
expect(result.base.title).toBe('Task');
});
it('should work with no custom steps', () => {
const { parse } = createAppParser('en', []);
const result = parse('Meeting tomorrow 14:00');
expect(result.base.date).toBeDefined();
expect(result.base.time).toEqual({ hours: 14, minutes: 0 });
});
});
// ============================================================================
// Default locale (backward compat)
// ============================================================================
describe('Default locale (de)', () => {
it('extractDate defaults to de', () => {
const result = extractDate('heute');
expect(result.value).toBeDefined();
});
it('extractTime defaults to de', () => {
const result = extractTime('14 Uhr');
expect(result.value).toEqual({ hours: 14, minutes: 0 });
});
it('parseBaseInput defaults to de', () => {
const result = parseBaseInput('Meeting morgen 14 Uhr #wichtig');
expect(result.date).toBeDefined();
expect(result.time).toEqual({ hours: 14, minutes: 0 });
expect(result.tagNames).toEqual(['wichtig']);
});
it('formatDatePreview defaults to de', () => {
expect(formatDatePreview(new Date())).toBe('Heute');
});
});

File diff suppressed because it is too large Load diff

View file

@ -2,17 +2,26 @@
* Natural Language Parsers
*
* Base parser with common patterns, extended by app-specific parsers.
* Supports locales: de, en, fr, es, it
*/
export {
// Types
type BaseParsedInput,
type ExtractResult,
type ParserLocale,
type DateRange,
type ExtractionStep,
// Extraction functions
extractDate,
extractDateRange,
extractTime,
extractTimezone,
extractTags,
extractAtReference,
extractAtReferences,
extractRecurrence,
extractRelativeTime,
// Combination
combineDateAndTime,
// Preview formatting
@ -21,6 +30,9 @@ export {
formatDateTimePreview,
// Main parser
parseBaseInput,
// Compose helper
createAppParser,
// Utilities
cleanTitle,
fuzzyMatchDateKeyword,
} from './base-parser';