odoo-timesheets/src/timesheets/utils.py
Jef Roosens 615bfe30e0
feat(cli): add natural language date parsing via dateparser
- Add _parse_natural() to utils.py using dateparser as a fallback when
  structured date formats (YYYY-MM-DD, MM-DD, DD-MM) don't match
- Supports expressions like 'today', 'yesterday', 'monday', '3 days ago'
- Change day argument to nargs='*' and join tokens so unquoted
  multi-word expressions like: uv run timesheets 3 days ago work correctly
- Pin dateparser to English to avoid locale-dependent behaviour
- Update tests to cover natural language cases and fix test_last_monday
  (dateparser does not support 'last monday'; use 'monday' instead)
2026-06-02 09:31:05 +02:00

130 lines
4.2 KiB
Python

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")