diff --git a/AUTHORS.txt b/AUTHORS.txt index 80ac4ada6..f236c7b52 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -60,3 +60,4 @@ Alnoman Kamil - noman [at] kamil [dot] gr - https://kamil.gr Leonardo Taccari - iamleot [at] gmail [dot] com Jorenar - dev [at] jorenar [dot] com - https://jorenar.com John Wassilak - john [at] wassilak [dot] com +Khanh Le diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bed684aa9..3653f5804 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,7 @@ may want to subscribe to `GitHub's tag feed 0.14.2 ====== unreleased +* FIX parse ISO-like locale datetime formats with empty timezone names or offsets * FIX ``khal import --format``/``-f`` was accepted and then ignored, always falling back to ``view.event_format`` diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py index 7dc22f276..017eee31b 100644 --- a/khal/parse_datetime.py +++ b/khal/parse_datetime.py @@ -23,6 +23,7 @@ strings to date(time) or event objects""" import datetime as dt +import locale import logging import re from calendar import isleap @@ -39,6 +40,35 @@ logger = logging.getLogger("khal") +def _locale_format_candidates(dateformat: str) -> list[str]: + replacements = { + "%c": locale.D_T_FMT, + "%x": locale.D_FMT, + "%X": locale.T_FMT, + } + expanded_format = dateformat + for directive, nl_item in replacements.items(): + if directive in expanded_format: + expanded_format = expanded_format.replace(directive, locale.nl_langinfo(nl_item)) + + expanded_format = re.sub( + r"%%|%T", lambda match: "%H:%M:%S" if match[0] == "%T" else match[0], expanded_format + ) + + candidates = [dateformat] + if expanded_format != dateformat: + candidates.append(expanded_format) + + if "%Z" in expanded_format or "%z" in expanded_format: + without_timezone = re.sub( + r"%%| ?%[Zz]", lambda match: "%%" if match[0] == "%%" else "", expanded_format + ).strip() + if without_timezone not in candidates: + candidates.append(without_timezone) + + return candidates + + def timefstr(dtime_list: list[str], timeformat: str) -> dt.datetime: """converts the first item of a list (a time as a string) to a datetimeobject @@ -77,10 +107,18 @@ def datetimefstr( now = dt.datetime.now() if default_day is None: default_day = now.date() - parts = dateformat.count(" ") + 1 - dtstring = " ".join(dtime_list[0:parts]) # only time.strptime can parse the 29th of Feb. if no year is given - dtstart_struct = strptime(dtstring, dateformat) + for candidate_format in _locale_format_candidates(dateformat): + parts = candidate_format.count(" ") + 1 + dtstring = " ".join(dtime_list[:parts]) + try: + dtstart_struct = strptime(dtstring, candidate_format) + except ValueError: + pass + else: + break + else: + raise ValueError if ( infer_year and dtstart_struct.tm_mon == 2 diff --git a/tests/parse_datetime_test.py b/tests/parse_datetime_test.py index 687ee3c7e..edb9ed9a9 100644 --- a/tests/parse_datetime_test.py +++ b/tests/parse_datetime_test.py @@ -1,4 +1,5 @@ import datetime as dt +import locale from collections import OrderedDict import pytest @@ -206,6 +207,30 @@ def test_short_format_contains_year(self): ["2017-1-1", "16:30"], locale=locale, default_day=dt.datetime.today() ) + @pytest.mark.parametrize("timezone_directive", ["%Z", "%z"]) + def test_locale_datetime_with_empty_timezone(self, monkeypatch, timezone_directive): + def nl_langinfo(item): + return { + locale.D_T_FMT: f"%Y-%m-%dT%T {timezone_directive}", + locale.D_FMT: "%Y-%m-%d", + locale.T_FMT: "%T", + }[item] + + monkeypatch.setattr(locale, "nl_langinfo", nl_langinfo) + words = ["2026-05-14T12:00:00", "meeting"] + parsed = guessdatetimefstr( + words, + { + "timeformat": "%X", + "dateformat": "%x", + "longdateformat": "%x", + "datetimeformat": "%c", + "longdatetimeformat": "%c", + }, + ) + assert parsed == (dt.datetime(2026, 5, 14, 12), False) + assert words == ["meeting"] + class TestGuessTimedeltafstr: def test_single(self):