import re from datetime import date # Separator is either - or / _SEP = r"[-/]" _DATE_PATTERNS = [ # YYYY-MM-DD or YYYY/MM/DD re.compile(r"^(\d{4})" + _SEP + r"(\d{1,2})" + _SEP + r"(\d{1,2})$"), # Two-part: first two digits, sep, last two digits re.compile(r"^(\d{1,2})" + _SEP + r"(\d{1,2})$"), ] class AmbiguousDateError(ValueError): """Raised when a two-part date is valid as both MM-DD and DD-MM.""" def _valid_date(year: int, month: int, day: int) -> date | None: """Return a date if the triple is valid, else None.""" try: return date(year, month, day) except ValueError: return None def parse_date_arg(value: str) -> date: """ Parse a flexible date string into a date object. Accepted formats (separator is - or /): YYYY-MM-DD unambiguous MM-DD month then day; year assumed to be current year DD-MM day then month; year assumed to be current year If a two-part value is valid as both MM-DD and DD-MM, raises AmbiguousDateError. If it is valid as neither, raises ValueError. """ value = value.strip() today = date.today() # Try YYYY-MM-DD first m = re.match(r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$", value) if m: y, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3)) d = _valid_date(y, a, b) if d is None: raise ValueError(f"Invalid date: {value!r}") return d # Try two-part MM-DD / DD-MM m = re.match(r"^(\d{1,2})[-/](\d{1,2})$", value) if m: a, b = int(m.group(1)), int(m.group(2)) as_md = _valid_date(today.year, a, b) # interpret as MM-DD as_dm = _valid_date(today.year, b, a) # interpret as DD-MM if as_md is not None and as_dm is not None and as_md != as_dm: raise AmbiguousDateError( f"{value!r} is ambiguous: could be {as_md} (MM-DD) or {as_dm} (DD-MM)" ) result = as_md or as_dm if result is None: raise ValueError(f"Invalid date: {value!r}") return result # Nothing matched structured formats — try natural language return _parse_natural(value) def _parse_natural(value: str) -> date: """ Parse a natural language date string using dateparser. Raises ValueError if the string cannot be interpreted. """ import dateparser result = dateparser.parse( value, languages=["en"], settings={"PREFER_DATES_FROM": "past", "RETURN_AS_TIMEZONE_AWARE": False}, ) if result is None: raise ValueError( f"Could not interpret {value!r} as a date. " "Try a structured format like '2026-05-22' or '05-22', " "or a relative expression like 'today', 'yesterday', 'monday', or '3 days ago'." ) return result.date() def parse_duration(duration_str: str) -> float: """Convert HH:MM duration string to decimal hours.""" duration_str = duration_str.strip() match = re.match(r"^(\d+):(\d{2})$", duration_str) if not match: raise ValueError(f"Invalid duration format: {duration_str!r}") return int(match.group(1)) + int(match.group(2)) / 60.0 def duration_from_start_end(start_str: str, end_str: str) -> float: """Calculate duration in decimal hours from two HH:MM time strings.""" def to_minutes(t: str) -> int: match = re.match(r"^(\d+):(\d{2})$", t.strip()) if not match: raise ValueError(f"Invalid time format: {t!r}") return int(match.group(1)) * 60 + int(match.group(2)) start_minutes = to_minutes(start_str) end_minutes = to_minutes(end_str) if end_minutes < start_minutes: end_minutes += 24 * 60 # midnight rollover return (end_minutes - start_minutes) / 60.0 def decimal_to_hhmm(hours: float) -> str: """Convert decimal hours to a HH:MM string.""" total_minutes = round(hours * 60) h, m = divmod(total_minutes, 60) return f"{h:02d}:{m:02d}" def strip_markdown_link(text: str) -> str: """Strip markdown link syntax [label](url), keeping only the label.""" return re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) def format_date(d: date) -> str: """Format date as DD/MM/YY.""" return d.strftime("%d/%m/%y")