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

View file

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

View file

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