Spaces:
Sleeping
Sleeping
| """ | |
| Natural language date/time parsing utilities. | |
| Converts human-readable date expressions to structured datetime objects. | |
| Handles timezone conversions and validation for calendar events. | |
| """ | |
| import re | |
| import logging | |
| from datetime import datetime, timedelta | |
| from typing import Optional, Tuple, Dict | |
| import pytz | |
| import dateparser | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| def parse_datetime( | |
| text: str, | |
| reference_time: Optional[datetime] = None, | |
| timezone: str = 'UTC' | |
| ) -> Optional[datetime]: | |
| """ | |
| Parse natural language datetime expression. | |
| Args: | |
| text: Natural language datetime (e.g., "tomorrow at 3pm", "next Monday") | |
| reference_time: Reference datetime (defaults to now) | |
| timezone: Timezone string (e.g., 'UTC', 'America/New_York', 'Europe/London') | |
| Returns: | |
| Parsed datetime object or None if parsing failed | |
| Examples: | |
| >>> parse_datetime("tomorrow at 3pm") | |
| datetime(2026, 1, 17, 15, 0) | |
| >>> parse_datetime("next Monday at 10:30am") | |
| datetime(2026, 1, 20, 10, 30) | |
| >>> parse_datetime("in 2 hours") | |
| datetime(2026, 1, 16, 18, 0) # If current time is 16:00 | |
| >>> parse_datetime("December 25th 2025 at 9am") | |
| datetime(2025, 12, 25, 9, 0) | |
| """ | |
| if not text or not text.strip(): | |
| logger.warning("Empty text provided for datetime parsing") | |
| return None | |
| if reference_time is None: | |
| reference_time = datetime.now() | |
| try: | |
| # Get timezone object | |
| try: | |
| tz = pytz.timezone(timezone) | |
| except pytz.UnknownTimeZoneError: | |
| logger.warning(f"Unknown timezone '{timezone}', falling back to UTC") | |
| tz = pytz.UTC | |
| # Use dateparser library for robust parsing | |
| parsed = dateparser.parse( | |
| text, | |
| settings={ | |
| 'RELATIVE_BASE': reference_time, | |
| 'TIMEZONE': timezone, | |
| 'RETURN_AS_TIMEZONE_AWARE': True, | |
| 'PREFER_DATES_FROM': 'future', | |
| 'PREFER_DAY_OF_MONTH': 'first' | |
| } | |
| ) | |
| if parsed is None: | |
| logger.warning(f"Failed to parse datetime: '{text}'") | |
| return None | |
| # Ensure timezone awareness | |
| if parsed.tzinfo is None: | |
| parsed = tz.localize(parsed) | |
| # Convert to target timezone if needed | |
| if parsed.tzinfo != tz: | |
| parsed = parsed.astimezone(tz) | |
| logger.info(f"Parsed '{text}' -> {parsed.strftime('%Y-%m-%d %H:%M:%S %Z')}") | |
| return parsed | |
| except Exception as e: | |
| logger.error(f"Error parsing datetime '{text}': {e}") | |
| return None | |
| def parse_duration(text: str) -> Optional[timedelta]: | |
| """ | |
| Parse natural language duration expression. | |
| Args: | |
| text: Duration text (e.g., "30 minutes", "1 hour", "2 hours 30 minutes") | |
| Returns: | |
| timedelta object or None if parsing failed | |
| Examples: | |
| >>> parse_duration("30 minutes") | |
| timedelta(minutes=30) | |
| >>> parse_duration("1 hour") | |
| timedelta(hours=1) | |
| >>> parse_duration("2 hours 30 minutes") | |
| timedelta(hours=2, minutes=30) | |
| >>> parse_duration("90 minutes") | |
| timedelta(minutes=90) | |
| """ | |
| if not text or not text.strip(): | |
| return None | |
| text = text.lower().strip() | |
| try: | |
| total_minutes = 0 | |
| # Pattern for hours | |
| hour_patterns = [ | |
| r'(\d+(?:\.\d+)?)\s*(?:hour|hr|h)s?', | |
| r'(\d+(?:\.\d+)?)\s*h\b' | |
| ] | |
| for pattern in hour_patterns: | |
| matches = re.findall(pattern, text) | |
| for match in matches: | |
| total_minutes += float(match) * 60 | |
| # Pattern for minutes | |
| minute_patterns = [ | |
| r'(\d+(?:\.\d+)?)\s*(?:minute|min|m)s?', | |
| r'(\d+(?:\.\d+)?)\s*m\b' | |
| ] | |
| for pattern in minute_patterns: | |
| matches = re.findall(pattern, text) | |
| for match in matches: | |
| total_minutes += float(match) | |
| # Pattern for days | |
| day_pattern = r'(\d+(?:\.\d+)?)\s*(?:day|d)s?' | |
| day_matches = re.findall(day_pattern, text) | |
| for match in day_matches: | |
| total_minutes += float(match) * 24 * 60 | |
| if total_minutes > 0: | |
| duration = timedelta(minutes=total_minutes) | |
| logger.info(f"Parsed duration '{text}' -> {duration}") | |
| return duration | |
| # Try parsing as simple number (assume minutes) | |
| number_match = re.search(r'^\s*(\d+(?:\.\d+)?)\s*$', text) | |
| if number_match: | |
| minutes = float(number_match.group(1)) | |
| duration = timedelta(minutes=minutes) | |
| logger.info(f"Parsed duration '{text}' as {minutes} minutes -> {duration}") | |
| return duration | |
| logger.warning(f"Could not parse duration: '{text}'") | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error parsing duration '{text}': {e}") | |
| return None | |
| def get_default_duration() -> timedelta: | |
| """ | |
| Get default meeting duration. | |
| Returns: | |
| timedelta of 1 hour | |
| """ | |
| return timedelta(hours=1) | |
| def validate_future_datetime(dt: datetime, allow_past: bool = False) -> bool: | |
| """ | |
| Validate that datetime is in the future. | |
| Args: | |
| dt: Datetime to validate | |
| allow_past: If True, allow past datetimes | |
| Returns: | |
| True if valid, False otherwise | |
| """ | |
| if dt is None: | |
| return False | |
| if allow_past: | |
| return True | |
| now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now() | |
| if dt <= now: | |
| logger.warning(f"DateTime {dt} is in the past (now: {now})") | |
| return False | |
| return True | |
| def format_for_google_calendar(dt: datetime) -> str: | |
| """ | |
| Format datetime for Google Calendar API. | |
| Args: | |
| dt: Datetime object | |
| Returns: | |
| ISO 8601 formatted string | |
| Example: | |
| >>> dt = datetime(2026, 1, 17, 15, 0, tzinfo=pytz.UTC) | |
| >>> format_for_google_calendar(dt) | |
| '2026-01-17T15:00:00Z' | |
| """ | |
| if dt is None: | |
| raise ValueError("Cannot format None datetime") | |
| # Ensure timezone awareness | |
| if dt.tzinfo is None: | |
| dt = pytz.UTC.localize(dt) | |
| # Convert to UTC for Google Calendar | |
| dt_utc = dt.astimezone(pytz.UTC) | |
| # Format as ISO 8601 | |
| formatted = dt_utc.strftime('%Y-%m-%dT%H:%M:%S') + 'Z' | |
| return formatted | |
| def parse_datetime_range( | |
| text: str, | |
| reference_time: Optional[datetime] = None, | |
| timezone: str = 'UTC', | |
| default_duration: Optional[timedelta] = None | |
| ) -> Optional[Tuple[datetime, datetime]]: | |
| """ | |
| Parse a datetime range from text. | |
| Handles formats like: | |
| - "tomorrow at 3pm for 2 hours" | |
| - "next Monday 10am to 11:30am" | |
| - "December 25th at 9am" (uses default duration) | |
| Args: | |
| text: Natural language datetime range | |
| reference_time: Reference datetime | |
| timezone: Timezone string | |
| default_duration: Default duration if not specified (defaults to 1 hour) | |
| Returns: | |
| Tuple of (start_datetime, end_datetime) or None | |
| """ | |
| if default_duration is None: | |
| default_duration = get_default_duration() | |
| try: | |
| # Check for duration pattern (e.g., "tomorrow at 3pm for 2 hours") | |
| duration_pattern = r'(.+?)\s+(?:for|lasting)\s+(.+?)$' | |
| duration_match = re.search(duration_pattern, text, re.IGNORECASE) | |
| if duration_match: | |
| datetime_text = duration_match.group(1) | |
| duration_text = duration_match.group(2) | |
| start_dt = parse_datetime(datetime_text, reference_time, timezone) | |
| duration = parse_duration(duration_text) | |
| if start_dt and duration: | |
| end_dt = start_dt + duration | |
| return (start_dt, end_dt) | |
| # Check for "from...to..." pattern | |
| range_pattern = r'(?:from\s+)?(.+?)\s+(?:to|until|-)\s+(.+?)$' | |
| range_match = re.search(range_pattern, text, re.IGNORECASE) | |
| if range_match: | |
| start_text = range_match.group(1) | |
| end_text = range_match.group(2) | |
| start_dt = parse_datetime(start_text, reference_time, timezone) | |
| # Try parsing end as full datetime | |
| end_dt = parse_datetime(end_text, reference_time, timezone) | |
| # If end_dt is before start_dt, it might be just a time on the same day | |
| if end_dt and start_dt and end_dt < start_dt: | |
| # Extract time from end_text and apply to start date | |
| time_pattern = r'(\d{1,2})(?::(\d{2}))?\s*(am|pm)?' | |
| time_match = re.search(time_pattern, end_text, re.IGNORECASE) | |
| if time_match: | |
| hour = int(time_match.group(1)) | |
| minute = int(time_match.group(2)) if time_match.group(2) else 0 | |
| period = time_match.group(3) | |
| if period and period.lower() == 'pm' and hour != 12: | |
| hour += 12 | |
| elif period and period.lower() == 'am' and hour == 12: | |
| hour = 0 | |
| end_dt = start_dt.replace(hour=hour, minute=minute, second=0, microsecond=0) | |
| if start_dt and end_dt: | |
| return (start_dt, end_dt) | |
| # Single datetime with default duration | |
| start_dt = parse_datetime(text, reference_time, timezone) | |
| if start_dt: | |
| end_dt = start_dt + default_duration | |
| return (start_dt, end_dt) | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error parsing datetime range '{text}': {e}") | |
| return None | |
| def create_event_times( | |
| start_text: str, | |
| duration_text: Optional[str] = None, | |
| end_text: Optional[str] = None, | |
| timezone: str = 'UTC', | |
| validate_future: bool = True | |
| ) -> Optional[Dict[str, str]]: | |
| """ | |
| Create properly formatted event times for calendar services. | |
| Args: | |
| start_text: Natural language start time | |
| duration_text: Optional duration text | |
| end_text: Optional end time text (if not using duration) | |
| timezone: Timezone string | |
| validate_future: Whether to validate that start is in the future | |
| Returns: | |
| Dictionary with 'start' and 'end' ISO formatted strings, or None | |
| Example: | |
| >>> create_event_times("tomorrow at 3pm", "2 hours") | |
| {'start': '2026-01-17T15:00:00Z', 'end': '2026-01-17T17:00:00Z'} | |
| """ | |
| try: | |
| # Parse start datetime | |
| start_dt = parse_datetime(start_text, timezone=timezone) | |
| if not start_dt: | |
| logger.error(f"Failed to parse start time: '{start_text}'") | |
| return None | |
| # Validate future if required | |
| if validate_future and not validate_future_datetime(start_dt): | |
| logger.error(f"Start time is in the past: {start_dt}") | |
| return None | |
| # Determine end datetime | |
| end_dt = None | |
| if end_text: | |
| # Parse explicit end time | |
| end_dt = parse_datetime(end_text, timezone=timezone) | |
| # If end is before start, assume same day | |
| if end_dt and end_dt < start_dt: | |
| # Try to interpret as time on same day | |
| time_pattern = r'(\d{1,2})(?::(\d{2}))?\s*(am|pm)?' | |
| time_match = re.search(time_pattern, end_text, re.IGNORECASE) | |
| if time_match: | |
| hour = int(time_match.group(1)) | |
| minute = int(time_match.group(2)) if time_match.group(2) else 0 | |
| period = time_match.group(3) | |
| if period and period.lower() == 'pm' and hour != 12: | |
| hour += 12 | |
| elif period and period.lower() == 'am' and hour == 12: | |
| hour = 0 | |
| end_dt = start_dt.replace(hour=hour, minute=minute, second=0, microsecond=0) | |
| elif duration_text: | |
| # Parse duration and calculate end | |
| duration = parse_duration(duration_text) | |
| if duration: | |
| end_dt = start_dt + duration | |
| else: | |
| logger.warning(f"Failed to parse duration: '{duration_text}', using default") | |
| end_dt = start_dt + get_default_duration() | |
| else: | |
| # Use default duration | |
| end_dt = start_dt + get_default_duration() | |
| if not end_dt: | |
| logger.error("Failed to determine end time") | |
| return None | |
| if end_dt <= start_dt: | |
| logger.error(f"End time ({end_dt}) must be after start time ({start_dt})") | |
| return None | |
| # Format for Google Calendar API | |
| result = { | |
| 'start': format_for_google_calendar(start_dt), | |
| 'end': format_for_google_calendar(end_dt), | |
| 'start_datetime': start_dt, | |
| 'end_datetime': end_dt | |
| } | |
| logger.info(f"Created event times: {result['start']} to {result['end']}") | |
| return result | |
| except Exception as e: | |
| logger.error(f"Error creating event times: {e}") | |
| return None | |
| # Timezone helpers | |
| def get_user_timezone() -> str: | |
| """ | |
| Get user's local timezone. | |
| Returns: | |
| Timezone string (e.g., 'America/New_York') | |
| """ | |
| try: | |
| import tzlocal | |
| local_tz = tzlocal.get_localzone() | |
| return str(local_tz) | |
| except Exception: | |
| logger.warning("Could not determine local timezone, using UTC") | |
| return 'UTC' | |
| def list_common_timezones() -> list: | |
| """Get list of common timezones.""" | |
| return [ | |
| 'UTC', | |
| 'America/New_York', | |
| 'America/Chicago', | |
| 'America/Denver', | |
| 'America/Los_Angeles', | |
| 'America/Toronto', | |
| 'Europe/London', | |
| 'Europe/Paris', | |
| 'Europe/Berlin', | |
| 'Asia/Tokyo', | |
| 'Asia/Shanghai', | |
| 'Asia/Hong_Kong', | |
| 'Asia/Singapore', | |
| 'Australia/Sydney', | |
| 'Pacific/Auckland' | |
| ] | |
| if __name__ == "__main__": | |
| # Test examples | |
| print("=== Date Parser Tests ===\n") | |
| test_cases = [ | |
| "tomorrow at 3pm", | |
| "next Monday at 10:30am", | |
| "in 2 hours", | |
| "December 25th 2025 at 9am", | |
| "January 20th at 2pm", | |
| ] | |
| print("DateTime Parsing:") | |
| for text in test_cases: | |
| result = parse_datetime(text) | |
| print(f" '{text}' -> {result}") | |
| print("\nDuration Parsing:") | |
| duration_cases = [ | |
| "30 minutes", | |
| "1 hour", | |
| "2 hours 30 minutes", | |
| "90 minutes", | |
| "1.5 hours" | |
| ] | |
| for text in duration_cases: | |
| result = parse_duration(text) | |
| print(f" '{text}' -> {result}") | |
| print("\nEvent Time Creation:") | |
| event_result = create_event_times( | |
| "tomorrow at 3pm", | |
| duration_text="2 hours", | |
| timezone="America/New_York" | |
| ) | |
| if event_result: | |
| print(f" Start: {event_result['start']}") | |
| print(f" End: {event_result['end']}") | |
| print(f"\nCurrent timezone: {get_user_timezone()}") | |