mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
5c2a8d07e3
commit
5286404129
16 changed files with 3291 additions and 94 deletions
|
|
@ -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}
|
||||
|
|
|
|||
205
apps/calendar/apps/web/src/lib/utils/event-parser.test.ts
Normal file
205
apps/calendar/apps/web/src/lib/utils/event-parser.test.ts
Normal 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 "9–17 Uhr"', () => {
|
||||
const result = parseEventInput('Arbeitstag 9–17 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(' · ');
|
||||
});
|
||||
});
|
||||
404
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal file
404
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
104
apps/mukke/apps/web/src/lib/utils/song-parser.test.ts
Normal file
104
apps/mukke/apps/web/src/lib/utils/song-parser.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
193
apps/mukke/apps/web/src/lib/utils/song-parser.ts
Normal file
193
apps/mukke/apps/web/src/lib/utils/song-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
83
apps/nutriphi/apps/web/src/lib/utils/meal-parser.test.ts
Normal file
83
apps/nutriphi/apps/web/src/lib/utils/meal-parser.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
165
apps/nutriphi/apps/web/src/lib/utils/meal-parser.ts
Normal file
165
apps/nutriphi/apps/web/src/lib/utils/meal-parser.ts
Normal 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(' ');
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
89
apps/planta/apps/web/src/lib/utils/plant-parser.test.ts
Normal file
89
apps/planta/apps/web/src/lib/utils/plant-parser.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
117
apps/planta/apps/web/src/lib/utils/plant-parser.ts
Normal file
117
apps/planta/apps/web/src/lib/utils/plant-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
697
packages/shared-utils/src/parsers/base-parser.test.ts
Normal file
697
packages/shared-utils/src/parsers/base-parser.test.ts
Normal 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
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue