diff --git a/didier/data/embeds/hydra/menu.py b/didier/data/embeds/hydra/menu.py index f10e9b3..8d1e8d4 100644 --- a/didier/data/embeds/hydra/menu.py +++ b/didier/data/embeds/hydra/menu.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date from enum import Enum from typing import Optional, cast @@ -105,7 +105,7 @@ class Menu(EmbedPydantic): @overrides def to_embed(self, **kwargs) -> discord.Embed: - day_dt: datetime = cast(datetime, kwargs.get("day_dt")) + day_dt: date = cast(date, kwargs.get("day_dt")) weekday = int_to_weekday(day_dt.weekday()) formatted_date = f"{leading('0', str(day_dt.day))}/{leading('0', str(day_dt.month))}/{day_dt.year}" @@ -119,7 +119,7 @@ class Menu(EmbedPydantic): return embed -def no_menu_found(day_dt: datetime) -> discord.Embed: +def no_menu_found(day_dt: date) -> discord.Embed: """Return a different embed if no menu could be found""" embed = discord.Embed(title="Menu", colour=discord.Colour.red()) embed.description = f"Unable to retrieve menu for {day_dt.strftime('%d/%m/%Y')}." diff --git a/didier/utils/discord/converters/time.py b/didier/utils/discord/converters/time.py new file mode 100644 index 0000000..c768a51 --- /dev/null +++ b/didier/utils/discord/converters/time.py @@ -0,0 +1,48 @@ +import contextlib +from datetime import date, timedelta +from typing import Optional + +from discord.ext.commands import ArgumentParsingError + +from didier.utils.types.datetime import ( + forward_to_next_weekday, + parse_dm_string, + str_to_weekday, +) + +__all__ = ["date_converter"] + + +def date_converter(argument: Optional[str]) -> date: + """Converter to turn a string into a date""" + # Store original argument for error message purposes + original_argument = argument + + # Default to today + if not argument: + return date.today() + + argument = argument.lower() + + # Manual offsets + if argument in ( + "tomorrow", + "tmrw", + "morgen", + ): + return date.today() + timedelta(days=1) + + if argument in ("overmorgen",): + return date.today() + timedelta(days=2) + + # Weekdays passed in words + with contextlib.suppress(ValueError): + weekday = str_to_weekday(argument) + return forward_to_next_weekday(date.today(), weekday, allow_today=False) + + # Date strings + with contextlib.suppress(ValueError): + return parse_dm_string(argument) + + # Unparseable + raise ArgumentParsingError(f"Unable to interpret `{original_argument}` as a date.") diff --git a/didier/utils/types/datetime.py b/didier/utils/types/datetime.py index 7b2d5c1..07ad29a 100644 --- a/didier/utils/types/datetime.py +++ b/didier/utils/types/datetime.py @@ -1,18 +1,91 @@ import datetime +import re import zoneinfo +from typing import TypeVar, Union -__all__ = ["LOCAL_TIMEZONE", "int_to_weekday", "str_to_date", "tz_aware_now"] +__all__ = [ + "LOCAL_TIMEZONE", + "forward_to_next_weekday", + "int_to_weekday", + "parse_dm_string", + "str_to_date", + "str_to_month", + "str_to_weekday", + "tz_aware_now", +] -from typing import Union +DateType = TypeVar("DateType", datetime.date, datetime.datetime) LOCAL_TIMEZONE = zoneinfo.ZoneInfo("Europe/Brussels") +def forward_to_next_weekday(day_dt: DateType, target_weekday: int, *, allow_today: bool = False) -> DateType: + """Forward a date to the next occurence of a weekday""" + if not 0 <= target_weekday <= 6: + raise ValueError + + # Skip at least one day + if not allow_today: + day_dt += datetime.timedelta(days=1) + + while day_dt.weekday() != target_weekday: + day_dt += datetime.timedelta(days=1) + + return day_dt + + def int_to_weekday(number: int) -> str: # pragma: no cover # it's useless to write a test for this """Get the Dutch name of a weekday from the number""" return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][number] +def parse_dm_string(argument: str) -> datetime.date: + """Parse a string to [day]/[month] + + The year is set to the current year by default, as this can be changed easily. + + This supports: + - DD/MM + - DD (month defaults to current) + - DD [Dutch Month, possibly abbreviated] + - DD [English Month, possibly abbreviated] + - [Dutch Month, possibly abbreviated] DD + - [English Month, possibly abbreviated] DD + """ + argument = argument.lower() + today = datetime.date.today() + + # DD/MM + if "/" in argument: + spl = argument.split("/") + if len(spl) != 2: + raise ValueError + + return datetime.date(day=int(spl[0]), month=int(spl[1]), year=today.year) + + # Try to interpret text + spl = argument.split(" ") + if len(spl) != 2: + raise ValueError + + # Day Month + match = re.search(r"\d+", spl[0]).group() + if match is not None: + day = int(match) + month = str_to_month(spl[1]) + return datetime.date(day=day, month=month, year=today.year) + + # Month Day + match = re.search(r"\d+", spl[0]).group() + if match is not None: + day = int(match) + month = str_to_month(spl[0]) + return datetime.date(day=day, month=month, year=today.year) + + # Unparseable + raise ValueError + + def str_to_date(date_str: str, formats: Union[list[str], str] = "%d/%m/%Y") -> datetime.date: """Turn a string into a DD/MM/YYYY date""" # Allow passing multiple formats in a list @@ -28,6 +101,76 @@ def str_to_date(date_str: str, formats: Union[list[str], str] = "%d/%m/%Y") -> d raise ValueError +def str_to_month(argument: str) -> int: + """Turn a string int oa month, bilingual""" + argument = argument.lower() + + month_dict = { + # English + "january": 1, + "february": 2, + "march": 3, + "april": 4, + "may": 5, + "june": 6, + "july": 7, + # August is a prefix of Augustus so it is skipped + "september": 9, + "october": 10, + "november": 11, + "december": 12, + # Dutch + "januari": 1, + "februari": 2, + "maart": 3, + # April is the same in English so it is skipped + "mei": 5, + "juni": 6, + "juli": 7, + "augustus": 8, + # September is the same in English so it is skipped + "oktober": 10, + # November is the same in English so it is skipped + # December is the same in English so it is skipped + } + + for key, value in month_dict.items(): + if key.startswith(argument): + return value + + raise ValueError + + +def str_to_weekday(argument: str) -> int: + """Turn a string into a weekday, bilingual""" + argument = argument.lower() + + weekday_dict = { + # English + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, + "sunday": 6, + # Dutch + "maandag": 0, + "dinsdag": 1, + "woensdag": 2, + "donderdag": 3, + "vrijdag": 4, + "zaterdag": 5, + "zondag": 6, + } + + for key, value in weekday_dict.items(): + if key.startswith(argument): + return value + + raise ValueError + + def tz_aware_now() -> datetime.datetime: """Get the current date & time, but timezone-aware""" return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone(LOCAL_TIMEZONE)