mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
c6b48d8f95
commit
b1877c4a08
11 changed files with 1487 additions and 732 deletions
|
|
@ -31,6 +31,7 @@
|
|||
"dependencies": {
|
||||
"@calendar/shared": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
261
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal file
261
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Event Parser for Calendar App
|
||||
*
|
||||
* Extends the base parser with event-specific patterns:
|
||||
* - Calendar: @CalendarName
|
||||
* - Duration: für 2 Stunden, 30 min
|
||||
* - Location: in Berlin, bei Firma XY
|
||||
*/
|
||||
|
||||
import {
|
||||
parseBaseInput,
|
||||
extractAtReference,
|
||||
combineDateAndTime,
|
||||
formatDatePreview,
|
||||
formatTimePreview,
|
||||
} from '@manacore/shared-utils';
|
||||
|
||||
export interface ParsedEvent {
|
||||
title: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
calendarName?: string;
|
||||
location?: string;
|
||||
tagNames: string[];
|
||||
isAllDay: boolean;
|
||||
}
|
||||
|
||||
interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ParsedEventWithIds {
|
||||
title: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
calendarId?: string;
|
||||
tagIds: string[];
|
||||
location?: string;
|
||||
isAllDay: boolean;
|
||||
}
|
||||
|
||||
// Duration patterns (event-specific)
|
||||
const DURATION_PATTERNS: { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] = [
|
||||
// "für X Stunden" or "X Stunden"
|
||||
{
|
||||
pattern: /(?:für\s+)?(\d+(?:[.,]\d+)?)\s*(?:stunde?n?|h)\b/i,
|
||||
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
|
||||
},
|
||||
// "für X Minuten" or "X min"
|
||||
{
|
||||
pattern: /(?:für\s+)?(\d+)\s*(?:minuten?|min)\b/i,
|
||||
getMinutes: (match) => parseInt(match[1], 10),
|
||||
},
|
||||
// "1,5h" or "1.5h"
|
||||
{
|
||||
pattern: /(\d+[.,]\d+)\s*h\b/i,
|
||||
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
|
||||
},
|
||||
];
|
||||
|
||||
// Location patterns (event-specific)
|
||||
const LOCATION_PATTERNS: RegExp[] = [
|
||||
/\bin\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
|
||||
/\bbei\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract duration from text
|
||||
*/
|
||||
function extractDuration(text: string): { minutes?: number; remaining: string } {
|
||||
for (const { pattern, getMinutes } of DURATION_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
minutes: getMinutes(match),
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { minutes: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract location from text
|
||||
*/
|
||||
function extractLocation(text: string): { location?: string; remaining: string } {
|
||||
for (const pattern of LOCATION_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
location: match[1].trim(),
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { location: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse natural language event input
|
||||
*
|
||||
* Examples:
|
||||
* - "Meeting morgen 14 Uhr für 1 Stunde @Arbeit in Büro #wichtig"
|
||||
* - "Arzttermin Montag 10:30 30 min bei Dr. Müller"
|
||||
* - "Geburtstag 15.12. ganztägig #privat"
|
||||
*/
|
||||
export function parseEventInput(input: string): ParsedEvent {
|
||||
let text = input.trim();
|
||||
|
||||
// Check for all-day indicator first
|
||||
const allDayPattern = /\bganztägig\b|\ball[- ]?day\b/i;
|
||||
const isAllDay = allDayPattern.test(text);
|
||||
text = text.replace(allDayPattern, '').trim();
|
||||
|
||||
// Extract calendar (@CalendarName) - event-specific
|
||||
const calendarResult = extractAtReference(text);
|
||||
text = calendarResult.remaining;
|
||||
const calendarName = calendarResult.value;
|
||||
|
||||
// Extract duration first (before base parser)
|
||||
const durationResult = extractDuration(text);
|
||||
text = durationResult.remaining;
|
||||
const durationMinutes = durationResult.minutes;
|
||||
|
||||
// Extract location (before base parser to avoid conflicts)
|
||||
const locationResult = extractLocation(text);
|
||||
text = locationResult.remaining;
|
||||
const location = locationResult.location;
|
||||
|
||||
// Use base parser for common patterns (date, time, tags)
|
||||
const base = parseBaseInput(text);
|
||||
|
||||
// Combine date and time for start
|
||||
const startTime = combineDateAndTime(base.date, base.time);
|
||||
|
||||
// Calculate end time based on duration (default 1 hour)
|
||||
let endTime: Date | undefined;
|
||||
if (startTime && !isAllDay) {
|
||||
const duration = durationMinutes || 60; // Default 1 hour
|
||||
endTime = new Date(startTime.getTime() + duration * 60 * 1000);
|
||||
} else if (startTime && isAllDay) {
|
||||
// All-day events: end time is end of day
|
||||
endTime = new Date(startTime);
|
||||
endTime.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
return {
|
||||
title: base.title,
|
||||
startTime,
|
||||
endTime,
|
||||
calendarName,
|
||||
location,
|
||||
tagNames: base.tagNames,
|
||||
isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve calendar and tag names to IDs
|
||||
*/
|
||||
export function resolveEventIds(
|
||||
parsed: ParsedEvent,
|
||||
calendars: Calendar[],
|
||||
tags: Tag[]
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Use default calendar if none specified
|
||||
if (!calendarId && calendars.length > 0) {
|
||||
const defaultCalendar = calendars.find((c: any) => c.isDefault) || calendars[0];
|
||||
calendarId = defaultCalendar.id;
|
||||
}
|
||||
|
||||
// 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.startTime?.toISOString(),
|
||||
endTime: parsed.endTime?.toISOString(),
|
||||
calendarId,
|
||||
tagIds,
|
||||
location: parsed.location,
|
||||
isAllDay: parsed.isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parsed event for preview display
|
||||
*/
|
||||
export function formatParsedEventPreview(parsed: ParsedEvent): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (parsed.startTime) {
|
||||
let dateStr = `📅 ${formatDatePreview(parsed.startTime)}`;
|
||||
|
||||
if (!parsed.isAllDay && parsed.startTime.getHours() !== 0) {
|
||||
dateStr += ` ${formatTimePreview({
|
||||
hours: parsed.startTime.getHours(),
|
||||
minutes: parsed.startTime.getMinutes(),
|
||||
})}`;
|
||||
|
||||
// Add duration if end time differs
|
||||
if (parsed.endTime) {
|
||||
const durationMs = parsed.endTime.getTime() - parsed.startTime.getTime();
|
||||
const durationMins = Math.round(durationMs / 60000);
|
||||
if (durationMins > 0 && durationMins !== 60) {
|
||||
if (durationMins >= 60) {
|
||||
const hours = Math.floor(durationMins / 60);
|
||||
const mins = durationMins % 60;
|
||||
dateStr += mins > 0 ? ` (${hours}h ${mins}min)` : ` (${hours}h)`;
|
||||
} else {
|
||||
dateStr += ` (${durationMins}min)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.isAllDay) {
|
||||
dateStr += ' (Ganztägig)';
|
||||
}
|
||||
|
||||
parts.push(dateStr);
|
||||
}
|
||||
|
||||
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(' · ');
|
||||
}
|
||||
|
|
@ -9,12 +9,15 @@
|
|||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
CreatePreview,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
|
|
@ -32,6 +35,11 @@
|
|||
import { searchEvents } from '$lib/api/events';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import {
|
||||
parseEventInput,
|
||||
resolveEventIds,
|
||||
formatParsedEventPreview,
|
||||
} from '$lib/utils/event-parser';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('calendar');
|
||||
|
|
@ -72,6 +80,54 @@
|
|||
goto(`/event/${item.id}`);
|
||||
}
|
||||
|
||||
// CommandBar Quick-Create handlers
|
||||
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
|
||||
const parsed = parseEventInput(query);
|
||||
if (!parsed.title) return null;
|
||||
|
||||
return {
|
||||
title: parsed.title,
|
||||
subtitle: formatParsedEventPreview(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||
const parsed = parseEventInput(query);
|
||||
if (!parsed.title) return;
|
||||
|
||||
// Resolve calendar and tag names to IDs
|
||||
const calendars = calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name }));
|
||||
const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
|
||||
const resolved = resolveEventIds(parsed, calendars, tags);
|
||||
|
||||
// Ensure we have a calendar
|
||||
if (!resolved.calendarId) {
|
||||
console.error('No calendar available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have start and end times
|
||||
if (!resolved.startTime) {
|
||||
// Default to now + 1 hour
|
||||
const now = new Date();
|
||||
resolved.startTime = now.toISOString();
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000);
|
||||
resolved.endTime = end.toISOString();
|
||||
}
|
||||
|
||||
await eventsStore.createEvent({
|
||||
calendarId: resolved.calendarId,
|
||||
title: resolved.title,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime || resolved.startTime,
|
||||
isAllDay: resolved.isAllDay,
|
||||
location: resolved.location,
|
||||
tagIds: resolved.tagIds,
|
||||
});
|
||||
}
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
|
|
@ -127,6 +183,7 @@
|
|||
{ href: '/', label: 'Kalender', icon: 'calendar' },
|
||||
{ href: '/agenda', label: 'Agenda', icon: 'list' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ 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' },
|
||||
|
|
@ -200,8 +257,9 @@
|
|||
// Initialize view state
|
||||
viewStore.initialize();
|
||||
|
||||
// Load calendars and user settings
|
||||
// Load calendars, tags, and user settings
|
||||
await calendarsStore.fetchCalendars();
|
||||
await eventTagsStore.fetchTags();
|
||||
await userSettings.load();
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
|
|
@ -283,9 +341,13 @@
|
|||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Termin suchen..."
|
||||
placeholder="Termin suchen oder erstellen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCommandBarCreate}
|
||||
onParseCreate={handleCommandBarParseCreate}
|
||||
createText="Als Termin erstellen"
|
||||
createShortcut="⌘↵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal file
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
/**
|
||||
* Task Parser for Todo App
|
||||
*
|
||||
* Extends the base parser with task-specific patterns:
|
||||
* - Priority: !hoch, !!, !!!, !dringend
|
||||
* - Project: @ProjectName
|
||||
*/
|
||||
|
||||
import {
|
||||
addDays,
|
||||
nextMonday,
|
||||
nextTuesday,
|
||||
nextWednesday,
|
||||
nextThursday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
nextSunday,
|
||||
setHours,
|
||||
setMinutes,
|
||||
parse,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
parseBaseInput,
|
||||
extractAtReference,
|
||||
combineDateAndTime,
|
||||
formatDatePreview,
|
||||
formatTimePreview,
|
||||
} from '@manacore/shared-utils';
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
|
||||
export interface ParsedTask {
|
||||
|
|
@ -40,7 +41,7 @@ export interface ParsedTaskWithIds {
|
|||
labelIds: string[];
|
||||
}
|
||||
|
||||
// Priority patterns
|
||||
// Priority patterns (task-specific)
|
||||
const PRIORITY_PATTERNS: { pattern: RegExp; priority: TaskPriority }[] = [
|
||||
{ pattern: /!{3,}|!dringend|!urgent/i, priority: 'urgent' },
|
||||
{ pattern: /!{2}|!hoch|!high/i, priority: 'high' },
|
||||
|
|
@ -48,128 +49,54 @@ const PRIORITY_PATTERNS: { pattern: RegExp; priority: TaskPriority }[] = [
|
|||
{ pattern: /!niedrig|!low/i, priority: 'low' },
|
||||
];
|
||||
|
||||
// Date patterns (German)
|
||||
const DATE_PATTERNS: { pattern: RegExp; getDate: () => Date }[] = [
|
||||
{ pattern: /\bheute\b/i, getDate: () => new Date() },
|
||||
{ pattern: /\bmorgen\b/i, getDate: () => addDays(new Date(), 1) },
|
||||
{ pattern: /\bübermorgen\b/i, getDate: () => addDays(new Date(), 2) },
|
||||
{ pattern: /\bin\s*(\d+)\s*tage?n?\b/i, getDate: () => new Date() }, // Handled specially
|
||||
{ pattern: /\bnächste[nr]?\s*woche\b/i, getDate: () => addDays(new Date(), 7) },
|
||||
{ pattern: /\bnächste[nr]?\s*montag\b/i, getDate: () => nextMonday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*dienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*mittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*donnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*freitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*samstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*sonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||
{ pattern: /\bmontag\b/i, getDate: () => nextMonday(new Date()) },
|
||||
{ pattern: /\bdienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||
{ pattern: /\bmittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||
{ pattern: /\bdonnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||
{ pattern: /\bfreitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||
{ pattern: /\bsamstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||
{ pattern: /\bsonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||
];
|
||||
|
||||
// Time pattern
|
||||
const TIME_PATTERN = /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
|
||||
|
||||
// Specific date pattern (DD.MM. or DD.MM.YYYY)
|
||||
const SPECIFIC_DATE_PATTERN = /\b(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\b/;
|
||||
/**
|
||||
* Extract priority from text
|
||||
*/
|
||||
function extractPriority(text: string): { priority?: TaskPriority; remaining: string } {
|
||||
for (const { pattern, priority } of PRIORITY_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
priority,
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { priority: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse natural language task input
|
||||
*
|
||||
* Examples:
|
||||
* - "Meeting morgen 14 Uhr !hoch @Arbeit #wichtig"
|
||||
* - "Einkaufen heute #privat"
|
||||
* - "Report in 3 Tagen !!"
|
||||
*/
|
||||
export function parseTaskInput(input: string): ParsedTask {
|
||||
let text = input.trim();
|
||||
let dueDate: Date | undefined;
|
||||
let priority: TaskPriority | undefined;
|
||||
let projectName: string | undefined;
|
||||
const labelNames: string[] = [];
|
||||
|
||||
// Extract priority (!hoch, !!, etc.)
|
||||
for (const { pattern, priority: p } of PRIORITY_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
priority = p;
|
||||
text = text.replace(pattern, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Extract priority first (task-specific)
|
||||
const priorityResult = extractPriority(text);
|
||||
text = priorityResult.remaining;
|
||||
const priority = priorityResult.priority;
|
||||
|
||||
// Extract project (@ProjectName)
|
||||
const projectMatch = text.match(/@(\S+)/);
|
||||
if (projectMatch) {
|
||||
projectName = projectMatch[1];
|
||||
text = text.replace(/@\S+/, '').trim();
|
||||
}
|
||||
// Extract project (@ProjectName) - task-specific
|
||||
const projectResult = extractAtReference(text);
|
||||
text = projectResult.remaining;
|
||||
const projectName = projectResult.value;
|
||||
|
||||
// Extract labels (#label1 #label2)
|
||||
const labelRegex = /#(\S+)/g;
|
||||
let labelMatch;
|
||||
while ((labelMatch = labelRegex.exec(text)) !== null) {
|
||||
labelNames.push(labelMatch[1]);
|
||||
}
|
||||
text = text.replace(/#\S+/g, '').trim();
|
||||
// Use base parser for common patterns (date, time, tags)
|
||||
const base = parseBaseInput(text);
|
||||
|
||||
// Extract specific date (DD.MM. or DD.MM.YYYY)
|
||||
const specificDateMatch = text.match(SPECIFIC_DATE_PATTERN);
|
||||
if (specificDateMatch) {
|
||||
const day = parseInt(specificDateMatch[1], 10);
|
||||
const month = parseInt(specificDateMatch[2], 10) - 1;
|
||||
const year = specificDateMatch[3]
|
||||
? parseInt(specificDateMatch[3], 10) < 100
|
||||
? 2000 + parseInt(specificDateMatch[3], 10)
|
||||
: parseInt(specificDateMatch[3], 10)
|
||||
: new Date().getFullYear();
|
||||
|
||||
dueDate = new Date(year, month, day);
|
||||
text = text.replace(SPECIFIC_DATE_PATTERN, '').trim();
|
||||
}
|
||||
|
||||
// Extract relative date (heute, morgen, nächsten Montag, etc.)
|
||||
if (!dueDate) {
|
||||
// Special handling for "in X Tagen"
|
||||
const inDaysMatch = text.match(/\bin\s*(\d+)\s*tage?n?\b/i);
|
||||
if (inDaysMatch) {
|
||||
const days = parseInt(inDaysMatch[1], 10);
|
||||
dueDate = addDays(new Date(), days);
|
||||
text = text.replace(/\bin\s*\d+\s*tage?n?\b/i, '').trim();
|
||||
} else {
|
||||
for (const { pattern, getDate } of DATE_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
dueDate = getDate();
|
||||
text = text.replace(pattern, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract time (um 14 Uhr, 14:00, etc.)
|
||||
const timeMatch = text.match(TIME_PATTERN);
|
||||
if (timeMatch && dueDate) {
|
||||
const hours = parseInt(timeMatch[1], 10);
|
||||
const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
|
||||
dueDate = setHours(setMinutes(dueDate, minutes), hours);
|
||||
text = text.replace(TIME_PATTERN, '').trim();
|
||||
} else if (timeMatch && !dueDate) {
|
||||
// Time without date = today
|
||||
dueDate = new Date();
|
||||
const hours = parseInt(timeMatch[1], 10);
|
||||
const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
|
||||
dueDate = setHours(setMinutes(dueDate, minutes), hours);
|
||||
text = text.replace(TIME_PATTERN, '').trim();
|
||||
}
|
||||
|
||||
// Clean up multiple spaces
|
||||
const title = text.replace(/\s+/g, ' ').trim();
|
||||
// Combine date and time
|
||||
const dueDate = combineDateAndTime(base.date, base.time);
|
||||
|
||||
return {
|
||||
title,
|
||||
title: base.title,
|
||||
dueDate,
|
||||
priority,
|
||||
projectName,
|
||||
labelNames,
|
||||
labelNames: base.tagNames,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -218,24 +145,17 @@ export function formatParsedTaskPreview(parsed: ParsedTask): string {
|
|||
const parts: string[] = [];
|
||||
|
||||
if (parsed.dueDate) {
|
||||
const now = new Date();
|
||||
const tomorrow = addDays(now, 1);
|
||||
|
||||
if (parsed.dueDate.toDateString() === now.toDateString()) {
|
||||
parts.push('📅 Heute');
|
||||
} else if (parsed.dueDate.toDateString() === tomorrow.toDateString()) {
|
||||
parts.push('📅 Morgen');
|
||||
} else {
|
||||
parts.push(
|
||||
`📅 ${parsed.dueDate.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short' })}`
|
||||
);
|
||||
}
|
||||
let dateStr = `📅 ${formatDatePreview(parsed.dueDate)}`;
|
||||
|
||||
// Add time if not midnight
|
||||
if (parsed.dueDate.getHours() !== 0 || parsed.dueDate.getMinutes() !== 0) {
|
||||
parts[parts.length - 1] +=
|
||||
` ${parsed.dueDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
dateStr += ` ${formatTimePreview({
|
||||
hours: parsed.dueDate.getHours(),
|
||||
minutes: parsed.dueDate.getMinutes(),
|
||||
})}`;
|
||||
}
|
||||
|
||||
parts.push(dateStr);
|
||||
}
|
||||
|
||||
if (parsed.priority) {
|
||||
|
|
|
|||
|
|
@ -22,3 +22,6 @@ export * from './keyboard';
|
|||
|
||||
// IndexedDB Cache
|
||||
export * from './cache';
|
||||
|
||||
// Natural Language Parsers
|
||||
export * from './parsers';
|
||||
|
|
|
|||
320
packages/shared-utils/src/parsers/base-parser.ts
Normal file
320
packages/shared-utils/src/parsers/base-parser.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
/**
|
||||
* Base Natural Language Parser
|
||||
*
|
||||
* Shared parsing utilities for date, time, and tags across all apps.
|
||||
* App-specific parsers (task-parser, event-parser, contact-parser) extend this.
|
||||
*/
|
||||
|
||||
import {
|
||||
addDays,
|
||||
nextMonday,
|
||||
nextTuesday,
|
||||
nextWednesday,
|
||||
nextThursday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
nextSunday,
|
||||
setHours,
|
||||
setMinutes,
|
||||
} from 'date-fns';
|
||||
|
||||
export interface BaseParsedInput {
|
||||
title: string;
|
||||
date?: Date;
|
||||
time?: { hours: number; minutes: number };
|
||||
tagNames: string[];
|
||||
rawInput: string;
|
||||
}
|
||||
|
||||
export interface ExtractResult<T> {
|
||||
value: T | undefined;
|
||||
remaining: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Date Extraction
|
||||
// ============================================================================
|
||||
|
||||
interface DatePattern {
|
||||
pattern: RegExp;
|
||||
getDate: (match?: RegExpMatchArray) => Date;
|
||||
}
|
||||
|
||||
const DATE_PATTERNS: DatePattern[] = [
|
||||
{ pattern: /\bheute\b/i, getDate: () => new Date() },
|
||||
{ pattern: /\bmorgen\b/i, getDate: () => addDays(new Date(), 1) },
|
||||
{ pattern: /\bübermorgen\b/i, getDate: () => addDays(new Date(), 2) },
|
||||
{ pattern: /\bnächste[nr]?\s*woche\b/i, getDate: () => addDays(new Date(), 7) },
|
||||
{ pattern: /\bnächste[nr]?\s*montag\b/i, getDate: () => nextMonday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*dienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*mittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*donnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*freitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*samstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*sonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||
{ pattern: /\bmontag\b/i, getDate: () => nextMonday(new Date()) },
|
||||
{ pattern: /\bdienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||
{ pattern: /\bmittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||
{ pattern: /\bdonnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||
{ pattern: /\bfreitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||
{ pattern: /\bsamstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||
{ pattern: /\bsonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||
];
|
||||
|
||||
// Pattern for "in X Tagen"
|
||||
const IN_DAYS_PATTERN = /\bin\s*(\d+)\s*tage?n?\b/i;
|
||||
|
||||
// Pattern for specific date (DD.MM. or DD.MM.YYYY)
|
||||
const SPECIFIC_DATE_PATTERN = /\b(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\b/;
|
||||
|
||||
/**
|
||||
* Extract date from text
|
||||
*/
|
||||
export function extractDate(text: string): ExtractResult<Date> {
|
||||
let remaining = text;
|
||||
let date: Date | undefined;
|
||||
|
||||
// Try "in X Tagen" pattern first
|
||||
const inDaysMatch = remaining.match(IN_DAYS_PATTERN);
|
||||
if (inDaysMatch) {
|
||||
const days = parseInt(inDaysMatch[1], 10);
|
||||
date = addDays(new Date(), days);
|
||||
remaining = remaining.replace(IN_DAYS_PATTERN, '').trim();
|
||||
return { value: date, remaining };
|
||||
}
|
||||
|
||||
// Try specific date (DD.MM. or DD.MM.YYYY)
|
||||
const specificDateMatch = remaining.match(SPECIFIC_DATE_PATTERN);
|
||||
if (specificDateMatch) {
|
||||
const day = parseInt(specificDateMatch[1], 10);
|
||||
const month = parseInt(specificDateMatch[2], 10) - 1;
|
||||
const year = specificDateMatch[3]
|
||||
? parseInt(specificDateMatch[3], 10) < 100
|
||||
? 2000 + parseInt(specificDateMatch[3], 10)
|
||||
: parseInt(specificDateMatch[3], 10)
|
||||
: new Date().getFullYear();
|
||||
|
||||
date = new Date(year, month, day);
|
||||
remaining = remaining.replace(SPECIFIC_DATE_PATTERN, '').trim();
|
||||
return { value: date, remaining };
|
||||
}
|
||||
|
||||
// Try relative date patterns
|
||||
for (const { pattern, getDate } of DATE_PATTERNS) {
|
||||
if (pattern.test(remaining)) {
|
||||
date = getDate();
|
||||
remaining = remaining.replace(pattern, '').trim();
|
||||
return { value: date, remaining };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: undefined, remaining };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Time Extraction
|
||||
// ============================================================================
|
||||
|
||||
// Pattern for time (um 14 Uhr, 14:00, etc.)
|
||||
const TIME_PATTERN = /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
|
||||
|
||||
/**
|
||||
* Extract time from text
|
||||
*/
|
||||
export function extractTime(text: string): ExtractResult<{ hours: number; minutes: number }> {
|
||||
const match = text.match(TIME_PATTERN);
|
||||
|
||||
if (match) {
|
||||
const hours = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
// Validate time
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
const remaining = text.replace(TIME_PATTERN, '').trim();
|
||||
return { value: { hours, minutes }, remaining };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: undefined, remaining: text };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag Extraction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract tags (#tag1 #tag2) from text
|
||||
*/
|
||||
export function extractTags(text: string): ExtractResult<string[]> {
|
||||
const tags: string[] = [];
|
||||
const tagRegex = /#(\S+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = tagRegex.exec(text)) !== null) {
|
||||
tags.push(match[1]);
|
||||
}
|
||||
|
||||
const remaining = text.replace(/#\S+/g, '').trim();
|
||||
return { value: tags, remaining };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// @ Reference Extraction (Projects, Calendars, Companies)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract @reference from text
|
||||
*/
|
||||
export function extractAtReference(text: string): ExtractResult<string> {
|
||||
const match = text.match(/@(\S+)/);
|
||||
|
||||
if (match) {
|
||||
const remaining = text.replace(/@\S+/, '').trim();
|
||||
return { value: match[1], remaining };
|
||||
}
|
||||
|
||||
return { value: undefined, remaining: text };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Combined Date + Time
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Combine date and time into a single Date object
|
||||
*/
|
||||
export function combineDateAndTime(
|
||||
date?: Date,
|
||||
time?: { hours: number; minutes: number }
|
||||
): Date | undefined {
|
||||
if (!date) return undefined;
|
||||
|
||||
if (time) {
|
||||
return setHours(setMinutes(date, time.minutes), time.hours);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Preview Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format date for preview display
|
||||
*/
|
||||
export function formatDatePreview(date: Date): string {
|
||||
const now = new Date();
|
||||
const tomorrow = addDays(now, 1);
|
||||
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return 'Heute';
|
||||
}
|
||||
if (date.toDateString() === tomorrow.toDateString()) {
|
||||
return 'Morgen';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for preview display
|
||||
*/
|
||||
export function formatTimePreview(time: { hours: number; minutes: number }): string {
|
||||
return `${time.hours.toString().padStart(2, '0')}:${time.minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time for preview
|
||||
*/
|
||||
export function formatDateTimePreview(
|
||||
date?: Date,
|
||||
time?: { hours: number; minutes: number }
|
||||
): string {
|
||||
if (!date) return '';
|
||||
|
||||
let result = formatDatePreview(date);
|
||||
|
||||
if (time) {
|
||||
result += ` ${formatTimePreview(time)}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Parser Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse base input - extracts common patterns (date, time, tags, @reference)
|
||||
*
|
||||
* App-specific parsers should call this first, then extract their own patterns.
|
||||
*/
|
||||
export function parseBaseInput(input: string): BaseParsedInput {
|
||||
let text = input.trim();
|
||||
const rawInput = text;
|
||||
|
||||
// Extract tags first (they're clearly delimited)
|
||||
const tagsResult = extractTags(text);
|
||||
text = tagsResult.remaining;
|
||||
const tagNames = tagsResult.value || [];
|
||||
|
||||
// Extract date
|
||||
const dateResult = extractDate(text);
|
||||
text = dateResult.remaining;
|
||||
const date = dateResult.value;
|
||||
|
||||
// Extract time
|
||||
const timeResult = extractTime(text);
|
||||
text = timeResult.remaining;
|
||||
const time = timeResult.value;
|
||||
|
||||
// If we got time but no date, assume today
|
||||
const finalDate = time && !date ? new Date() : date;
|
||||
|
||||
// Clean up multiple spaces
|
||||
const title = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return {
|
||||
title,
|
||||
date: finalDate,
|
||||
time,
|
||||
tagNames,
|
||||
rawInput,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility: Clean title from all patterns
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Remove all recognized patterns from text to get clean title
|
||||
*/
|
||||
export function cleanTitle(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// Remove tags
|
||||
result = result.replace(/#\S+/g, '');
|
||||
|
||||
// Remove @references
|
||||
result = result.replace(/@\S+/g, '');
|
||||
|
||||
// Remove dates
|
||||
result = result.replace(IN_DAYS_PATTERN, '');
|
||||
result = result.replace(SPECIFIC_DATE_PATTERN, '');
|
||||
for (const { pattern } of DATE_PATTERNS) {
|
||||
result = result.replace(pattern, '');
|
||||
}
|
||||
|
||||
// Remove time
|
||||
result = result.replace(TIME_PATTERN, '');
|
||||
|
||||
// Clean up
|
||||
return result.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
26
packages/shared-utils/src/parsers/index.ts
Normal file
26
packages/shared-utils/src/parsers/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Natural Language Parsers
|
||||
*
|
||||
* Base parser with common patterns, extended by app-specific parsers.
|
||||
*/
|
||||
|
||||
export {
|
||||
// Types
|
||||
type BaseParsedInput,
|
||||
type ExtractResult,
|
||||
// Extraction functions
|
||||
extractDate,
|
||||
extractTime,
|
||||
extractTags,
|
||||
extractAtReference,
|
||||
// Combination
|
||||
combineDateAndTime,
|
||||
// Preview formatting
|
||||
formatDatePreview,
|
||||
formatTimePreview,
|
||||
formatDateTimePreview,
|
||||
// Main parser
|
||||
parseBaseInput,
|
||||
// Utilities
|
||||
cleanTitle,
|
||||
} from './base-parser';
|
||||
1055
pnpm-lock.yaml
generated
1055
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue