mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +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
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue