feat: add unified CommandBar Quick-Create for Calendar and Contacts

Implements the same CommandBar quick-create functionality from Todo
in Calendar and Contacts apps with a shared base parser architecture.

- Add base-parser in shared-utils with common patterns (date, time, tags)
- Refactor task-parser to use base-parser
- Create event-parser for Calendar with duration, location, @calendar
- Create contact-parser for Contacts with email, phone, @company detection
- Integrate Quick-Create into Calendar and Contacts layouts

Natural language syntax:
- Common: heute, morgen, Montag, 15.12., um 14 Uhr, #tags
- Calendar: für 2h, 30 min, in Berlin, @Kalender, ganztägig
- Contacts: @Firma, bei Company, auto email/phone detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 14:37:01 +01:00 committed by Wuesteon
parent c6b48d8f95
commit b1877c4a08
11 changed files with 1487 additions and 732 deletions

View file

@ -0,0 +1,227 @@
/**
* Contact Parser for Contacts App
*
* Extends the base parser with contact-specific patterns:
* - Company: @CompanyName or bei CompanyName
* - Email: Recognizes email addresses
* - Phone: Recognizes phone numbers
* - Name: First and last name extraction
*/
import { extractTags, extractAtReference } from '@manacore/shared-utils';
export interface ParsedContact {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagNames: string[];
}
interface Tag {
id: string;
name: string;
}
export interface ParsedContactWithIds {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagIds: string[];
}
// Email pattern
const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/;
// Phone patterns (various formats)
const PHONE_PATTERNS: RegExp[] = [
// International format: +49 123 456789, +49-123-456789
/\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/,
// German format: 0123 456789, 0123/456789
/\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/,
// Simple format: 123456789 (at least 6 digits)
/\b\d{6,}\b/,
];
// Company patterns (alternative to @company)
const COMPANY_PATTERNS: RegExp[] = [
/\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
/\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
];
/**
* Extract email from text
*/
function extractEmail(text: string): { email?: string; remaining: string } {
const match = text.match(EMAIL_PATTERN);
if (match) {
return {
email: match[1],
remaining: text.replace(EMAIL_PATTERN, '').trim(),
};
}
return { email: undefined, remaining: text };
}
/**
* Extract phone number from text
*/
function extractPhone(text: string): { phone?: string; remaining: string } {
for (const pattern of PHONE_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
phone: match[0].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { phone: undefined, remaining: text };
}
/**
* Extract company from text (bei/von patterns)
*/
function extractCompanyPattern(text: string): { company?: string; remaining: string } {
for (const pattern of COMPANY_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
company: match[1].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { company: undefined, remaining: text };
}
/**
* Extract first and last name from display name
*/
function parseNames(displayName: string): { firstName?: string; lastName?: string } {
const parts = displayName.trim().split(/\s+/);
if (parts.length === 0) {
return {};
}
if (parts.length === 1) {
return { firstName: parts[0] };
}
// First part is first name, rest is last name
return {
firstName: parts[0],
lastName: parts.slice(1).join(' '),
};
}
/**
* Parse natural language contact input
*
* Examples:
* - "Max Mustermann @ACME Corp max@example.com #kunde #wichtig"
* - "Anna Schmidt bei Google +49 123 456789"
* - "Peter Müller peter@mail.de #privat"
*/
export function parseContactInput(input: string): ParsedContact {
let text = input.trim();
// Extract tags first (#tag1 #tag2)
const tagsResult = extractTags(text);
text = tagsResult.remaining;
const tagNames = tagsResult.value || [];
// Extract company via @CompanyName
const atRefResult = extractAtReference(text);
text = atRefResult.remaining;
let company = atRefResult.value;
// If no @company, try bei/von patterns
if (!company) {
const companyPatternResult = extractCompanyPattern(text);
text = companyPatternResult.remaining;
company = companyPatternResult.company;
}
// Extract email
const emailResult = extractEmail(text);
text = emailResult.remaining;
const email = emailResult.email;
// Extract phone
const phoneResult = extractPhone(text);
text = phoneResult.remaining;
const phone = phoneResult.phone;
// Clean up multiple spaces and get display name
const displayName = text.replace(/\s+/g, ' ').trim();
// Parse first and last name
const { firstName, lastName } = parseNames(displayName);
return {
displayName,
firstName,
lastName,
company,
email,
phone,
tagNames,
};
}
/**
* Resolve tag names to IDs
*/
export function resolveContactIds(parsed: ParsedContact, tags: Tag[]): ParsedContactWithIds {
const tagIds: string[] = [];
// 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 {
displayName: parsed.displayName,
firstName: parsed.firstName,
lastName: parsed.lastName,
company: parsed.company,
email: parsed.email,
phone: parsed.phone,
tagIds,
};
}
/**
* Format parsed contact for preview display
*/
export function formatParsedContactPreview(parsed: ParsedContact): string {
const parts: string[] = [];
if (parsed.company) {
parts.push(`🏢 ${parsed.company}`);
}
if (parsed.email) {
parts.push(`📧 ${parsed.email}`);
}
if (parsed.phone) {
parts.push(`📞 ${parsed.phone}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -9,6 +9,7 @@
PillDropdownItem,
CommandBarItem,
QuickAction,
CreatePreview,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
@ -28,13 +29,21 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsApi } from '$lib/api/contacts';
import { contactsApi, tagsApi } from '$lib/api/contacts';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsSettings } from '$lib/stores/settings.svelte';
import {
parseContactInput,
resolveContactIds,
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
// Search modal state
let searchModalOpen = $state(false);
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
// Check if we're on a contact detail route
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
const showContactModal = $derived(!!contactDetailMatch);
@ -102,6 +111,7 @@
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
@ -193,6 +203,47 @@
goto(`/contacts/${item.id}`);
}
// CommandBar Quick-Create handlers
function handleCommandBarParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseContactInput(query);
if (!parsed.displayName) return null;
return {
title: parsed.displayName,
subtitle: formatParsedContactPreview(parsed),
};
}
async function handleCommandBarCreate(query: string): Promise<void> {
const parsed = parseContactInput(query);
if (!parsed.displayName) return;
// Resolve tag names to IDs
const resolved = resolveContactIds(parsed, availableTags);
try {
const contact = await contactsStore.createContact({
displayName: resolved.displayName,
firstName: resolved.firstName,
lastName: resolved.lastName,
company: resolved.company,
email: resolved.email,
phone: resolved.phone,
});
// Add tags to the created contact
if (resolved.tagIds.length > 0 && contact) {
for (const tagId of resolved.tagIds) {
await tagsApi.addToContact(tagId, contact.id);
}
}
} catch (e) {
console.error('Failed to create contact:', e);
}
}
// CommandBar quick actions
const commandBarQuickActions: QuickAction[] = [
{
@ -214,9 +265,17 @@
return;
}
// Load user settings
// Load user settings and tags
await userSettings.load();
// Load tags for Quick-Create
try {
const tagsResult = await tagsApi.list();
availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name }));
} catch (e) {
console.error('Failed to load tags:', e);
}
// Initialize contacts settings and view mode
contactsSettings.initialize();
viewModeStore.initialize();
@ -302,9 +361,13 @@
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Kontakt suchen..."
placeholder="Kontakt suchen oder erstellen..."
emptyText="Keine Kontakte gefunden"
searchingText="Suche..."
onCreate={handleCommandBarCreate}
onParseCreate={handleCommandBarParseCreate}
createText="Als Kontakt erstellen"
createShortcut="⌘↵"
/>
</div>