Newer
Older
navi-1 / navi / tools / _internal / time_parser.py
"""Lightweight natural-language time parser for schedule_recall."""

import re
from datetime import datetime, timedelta, timezone


def _try_iso(value: str) -> datetime | None:
    try:
        return datetime.fromisoformat(value)
    except ValueError:
        return None


def _try_relative(value: str) -> datetime | None:
    """Parse strings like 'in 2 days', '3h 30m', '1d 2h 3m 4s'."""
    val = value.strip().lower()

    # 'in X days/hours/minutes/seconds'
    m = re.match(
        r"in\s+(\d+)\s+(day|days|hour|hours|minute|minutes|min|mins|second|seconds|sec|secs)s?",
        val,
    )
    if m:
        amount = int(m.group(1))
        unit = m.group(2)
        delta = _unit_to_delta(amount, unit)
        if delta:
            return datetime.now(timezone.utc) + delta

    # Compact notation: '2d 6h 30m', '1d', '3h30m'
    pattern = re.compile(r"(\d+)\s*(d|h|m|s)")
    matches = pattern.findall(val)
    if matches:
        total_delta = timedelta()
        for amount_str, unit in matches:
            delta = _unit_to_delta(int(amount_str), unit)
            if delta:
                total_delta += delta
        if total_delta.total_seconds() > 0:
            return datetime.now(timezone.utc) + total_delta

    return None


def _try_tomorrow(value: str) -> datetime | None:
    """Parse 'tomorrow at HH:MM' or 'tomorrow HH:MM'."""
    val = value.strip().lower()
    if not val.startswith("tomorrow"):
        return None
    # Extract time portion after 'tomorrow'
    rest = val[8:].strip()
    rest = rest.lstrip("at").strip()
    for fmt in ("%H:%M", "%H.%M", "%I:%M %p", "%I%p"):
        try:
            t = datetime.strptime(rest, fmt).time()
            now = datetime.now(timezone.utc)
            target = datetime.combine(now.date() + timedelta(days=1), t)
            return target.replace(tzinfo=timezone.utc)
        except ValueError:
            continue
    return None


def _try_dateutil(value: str) -> datetime | None:
    try:
        from dateutil import parser as _dateutil_parser  # type: ignore[import-untyped]

        parsed = _dateutil_parser.parse(value)
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=timezone.utc)
        return parsed
    except Exception:
        return None


def _unit_to_delta(amount: int, unit: str) -> timedelta | None:
    if unit in ("d", "day", "days"):
        return timedelta(days=amount)
    if unit in ("h", "hour", "hours"):
        return timedelta(hours=amount)
    if unit in ("m", "min", "mins", "minute", "minutes"):
        return timedelta(minutes=amount)
    if unit in ("s", "sec", "secs", "second", "seconds"):
        return timedelta(seconds=amount)
    return None


def parse_when(value: str) -> tuple[datetime | None, str | None]:
    """Parse a natural-language time string into a UTC datetime.

    Returns (datetime, None) on success, (None, error_message) on failure.
    """
    if not value or not value.strip():
        return None, "Empty time string."

    # 1. ISO datetime
    result = _try_iso(value.strip())
    if result:
        if result.tzinfo is None:
            result = result.replace(tzinfo=timezone.utc)
        return result, None

    # 2. Relative expressions
    result = _try_relative(value.strip())
    if result:
        return result, None

    # 3. Tomorrow at time
    result = _try_tomorrow(value.strip())
    if result:
        return result, None

    # 4. dateutil (optional soft dependency)
    result = _try_dateutil(value.strip())
    if result:
        return result, None

    return None, (
        f"Could not parse '{value}'. "
        "Supported formats: ISO datetime, relative like '2d 6h 30m' or 'in 3 hours', "
        "or 'tomorrow at 09:00'."
    )