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

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

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

View file

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

File diff suppressed because it is too large Load diff

View file

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