From 71a7d37e0c8ad8d65a8beed872550a662bb93797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Schmidts?= Date: Fri, 26 Mar 2021 23:56:33 +0100 Subject: [PATCH 1/2] New Date implementation * less internal values for the Date class, introducing the use of `__slots__` * introducing Accuracy * allowing comparison across accuracies * cleaning some unused Task method related to Dates * Redoing date xml dumping (dates are now dumped and read in xml at the best of their accuracy, getting rid of xml_str, which is a duplicate of str) --- GTG/core/dates.py | 483 ++++++++++++++++--------------- GTG/core/task.py | 37 +-- GTG/core/versioning.py | 10 +- GTG/core/xml.py | 80 ++--- GTG/gtk/editor/editor.py | 42 +-- GTG/gtk/editor/recurring_menu.py | 6 +- GTG/plugins/export/task_str.py | 2 +- 7 files changed, 315 insertions(+), 345 deletions(-) diff --git a/GTG/core/dates.py b/GTG/core/dates.py index c9a81ff7a5..8decb84464 100644 --- a/GTG/core/dates.py +++ b/GTG/core/dates.py @@ -24,22 +24,19 @@ Date.parse() parses all possible representations of a date. """ import calendar -import datetime import locale +from datetime import date, datetime, timedelta, timezone +from enum import Enum +from gettext import gettext as _ +from gettext import ngettext -from gettext import gettext as _, ngettext - -__all__ = 'Date', +__all__ = ['Date', 'Accuracy'] +# trick to obtain the timezone of the machine GTG is executed on +LOCAL_TIMEZONE = datetime.now(timezone.utc).astimezone().tzinfo NOW, SOON, SOMEDAY, NODATE = list(range(4)) -# strings representing fuzzy dates + no date -ENGLISH_STRINGS = { - NOW: 'now', - SOON: 'soon', - SOMEDAY: 'someday', - NODATE: '', -} +# Localized strings for fuzzy values STRINGS = { # Translators: Used for display NOW: _('now'), @@ -50,275 +47,283 @@ NODATE: '', } +# Allows looking up any value which is not a date but points towards one and +# find one of the four constant for fuzzy dates: NOW, SOON, SOMEDAY, and NODATE LOOKUP = { + NOW: NOW, 'now': NOW, # Translators: Used in parsing, made lowercased in code _('now').lower(): NOW, + SOON: SOON, 'soon': SOON, # Translators: Used in parsing, made lowercased in code _('soon').lower(): SOON, + SOMEDAY: SOMEDAY, 'later': SOMEDAY, # Translators: Used in parsing, made lowercased in code _('later').lower(): SOMEDAY, 'someday': SOMEDAY, # Translators: Used in parsing, made lowercased in code _('someday').lower(): SOMEDAY, + NODATE: NODATE, '': NODATE, -} -# functions giving absolute dates for fuzzy dates + no date -FUNCS = { - NOW: datetime.date.today(), - SOON: datetime.date.today() + datetime.timedelta(15), - SOMEDAY: datetime.date.max, - NODATE: datetime.date.max - datetime.timedelta(1), + None: NODATE, + 'none': NODATE, } -# ISO 8601 date format -ISODATE = '%Y-%m-%d' -# get date format from locale -locale_format = locale.nl_langinfo(locale.D_FMT) +class Accuracy(Enum): + """ GTg.core.dates.Date supported accuracies -def convert_datetime_to_date(aday): - """ Convert python's datetime to date. - Strip unusable time information. """ - return datetime.date(aday.year, aday.month, aday.day) + From less accurate to the most : + * fuzzy is when a date is just a string not representing a real date + (like `someday`) + * date is a date accurate to the day (see datetime.date) + * datetime is a datetime accurate to the microseconds + (see datetime.datetime) + * timezone ia a datetime accurate to the microseconds with tzinfo + """ + fuzzy = 'fuzzy' + date = 'date' + datetime = 'datetime' + timezone = 'timezone' -class Date(): +# ISO 8601 date format +# get date format from locale +DATE_FORMATS = [(locale.nl_langinfo(locale.D_T_FMT), Accuracy.datetime), + ('%Y-%m-%dT%H:%M%S.%f%z', Accuracy.timezone), + ('%Y-%m-%d %H:%M%S.%f%z', Accuracy.timezone), + ('%Y-%m-%dT%H:%M%S.%f', Accuracy.datetime), + ('%Y-%m-%d %H:%M%S.%f', Accuracy.datetime), + ('%Y-%m-%dT%H:%M%S', Accuracy.datetime), + ('%Y-%m-%d %H:%M%S', Accuracy.datetime), + (locale.nl_langinfo(locale.D_FMT), Accuracy.date), + ('%Y-%m-%d', Accuracy.date)] + + +class Date: """A date class that supports fuzzy dates. - Date supports all the methods of the standard datetime.date class. A Date + Date supports all the methods of the standard date class. A Date can be constructed with: - the fuzzy strings 'now', 'soon', '' (no date, default), or 'someday' - a string containing an ISO format date: YYYY-MM-DD, or - - a datetime.date or Date instance, or + - a date or Date instance, or - a string containing a locale format date. """ - _cached_date = None - _real_date = None - _fuzzy = None - - def __init__(self, value=''): - self._parse_init_value(value) - - def _parse_init_value(self, value): - """ Parse many possible values and setup date """ - if value is None: - self._parse_init_value(NODATE) - elif isinstance(value, datetime.date): - self._real_date = value - self._cached_date = value + + __slots__ = ['dt_value'] + + def __init__(self, value=None): + self.dt_value = None + if isinstance(value, (date, datetime)): + self.dt_value = value elif isinstance(value, Date): # Copy internal values from other Date object - self._real_date = value._real_date - self._fuzzy = value._fuzzy - self._cached_date = value._cached_date - elif isinstance(value, str) or isinstance(value, str): + self.dt_value = value.dt_value + elif value in {'None', None, ''}: + self.dt_value = NODATE + elif isinstance(value, str): + self.dt_value = self.__parse_dt_str(value) + elif value in LOOKUP: + self.dt_value = LOOKUP[value] + if self.dt_value is None: + raise ValueError(f"Unknown value for date: '{value}'") + + @staticmethod + def __parse_dt_str(string): + "Will try casting given string into a datetime or a date." + for cls in date, datetime: try: - da_ti = datetime.datetime.strptime(value, locale_format).date() - self._real_date = convert_datetime_to_date(da_ti) - self._cached_date = self._real_date + return cls.fromisoformat(string) + except (ValueError, # ignoring no iso format value + AttributeError): # ignoring python < 3.7 + pass + for date_format, accuracy in DATE_FORMATS: + try: + dt_value = datetime.strptime(string, date_format) + if accuracy is Accuracy.date: + dt_value = dt_value.date() + return dt_value except ValueError: - try: - # allow both locale format and ISO format - da_ti = datetime.datetime.strptime(value, ISODATE).date() - self._real_date = convert_datetime_to_date(da_ti) - self._cached_date = self._real_date - except ValueError: - # it must be a fuzzy date - try: - value = str(value.lower()) - self._parse_init_value(LOOKUP[value]) - except KeyError: - raise ValueError(f"Unknown value for date: '{value}'") - elif isinstance(value, int): - self._fuzzy = value - self._cached_date = FUNCS[self._fuzzy] - else: - raise ValueError(f"Unknown value for date: '{value}'") + pass + return LOOKUP.get(str(string).lower(), None) + + @property + def accuracy(self): + if isinstance(self.dt_value, datetime): + if self.dt_value.tzinfo: + return Accuracy.timezone + return Accuracy.datetime + if isinstance(self.dt_value, date): + return Accuracy.date + return Accuracy.fuzzy def date(self): """ Map date into real date, i.e. convert fuzzy dates """ - return self._cached_date + return self.dt_by_accuracy(Accuracy.date) + + @staticmethod + def _dt_by_accuracy(dt_value, accuracy: Accuracy, + wanted_accuracy: Accuracy): + if wanted_accuracy is Accuracy.timezone: + if accuracy is Accuracy.date: + return datetime(dt_value.year, dt_value.month, dt_value.day, + tzinfo=LOCAL_TIMEZONE) + assert accuracy is Accuracy.datetime, f"{accuracy} wasn't expected" + # datetime is naive and assuming local timezone + return dt_value.replace(tzinfo=LOCAL_TIMEZONE) + if wanted_accuracy is Accuracy.datetime: + if accuracy is Accuracy.date: + return datetime(dt_value.year, dt_value.month, dt_value.day) + assert accuracy is Accuracy.timezone, f"{accuracy} wasn't expected" + # returning UTC naive + return dt_value.astimezone(LOCAL_TIMEZONE).replace(tzinfo=None) + if wanted_accuracy is Accuracy.date: + return dt_value.date() + raise AssertionError(f"Shouldn't get in that position for '{dt_value}'" + f" actual {accuracy.value} " + f"and wanted {wanted_accuracy.value}") + + def dt_by_accuracy(self, wanted_accuracy: Accuracy): + """Cast Date to the desired accuracy and returns either string + for fuzzy, date, datetime or datetime with tzinfo. + """ + if wanted_accuracy == self.accuracy: + return self.dt_value + if self.accuracy is Accuracy.fuzzy: + now = datetime.now() + delta_days = {NOW: 0, SOON: 15, SOMEDAY: 365, NODATE: 9999} + gtg_date = Date(now + timedelta(delta_days[self.dt_value])) + if gtg_date.accuracy is wanted_accuracy: + return gtg_date.dt_value + return self._dt_by_accuracy(gtg_date.dt_value, gtg_date.accuracy, + wanted_accuracy) + return self._dt_by_accuracy(self.dt_value, self.accuracy, + wanted_accuracy) + + def _cast_for_operation(self, other, is_comparison: bool = True): + """Returns two values compatibles for operation or comparison. + Will settle for the less accuracy : comparing a date and a datetime + will cast the datetime to a date to allow comparison. + """ + if isinstance(other, timedelta): + if is_comparison: + raise ValueError("can't compare with %r" % other) + return self.dt_value, other + if not isinstance(other, self.__class__): + other = self.__class__(other) + if self.accuracy is other.accuracy: + return self.dt_value, other.dt_value + for accuracy in Accuracy.date, Accuracy.datetime, Accuracy.timezone: + if accuracy in {self.accuracy, other.accuracy}: + return (self.dt_by_accuracy(accuracy), + other.dt_by_accuracy(accuracy)) + return (self.dt_by_accuracy(Accuracy.fuzzy), + other.dt_by_accuracy(Accuracy.fuzzy)) def __add__(self, other): - if isinstance(other, datetime.timedelta): - return Date(self.date() + other) - else: - raise NotImplementedError - __radd__ = __add__ + a, b = self._cast_for_operation(other, is_comparison=False) + return a + b def __sub__(self, other): - if hasattr(other, 'date'): - return self.date() - other.date() - else: - return self.date() - other + a, b = self._cast_for_operation(other, is_comparison=False) + return a - b - def __rsub__(self, other): - if hasattr(other, 'date'): - return other.date() - self.date() - else: - return other - self.date() + __radd__ = __add__ + __rsub__ = __sub__ def __lt__(self, other): - """ Judge whehter less than other Date instance """ - if isinstance(other, Date): - # Keep fuzzy dates below normal dates - if self.date() == other.date(): - if not self.is_fuzzy() and other.is_fuzzy(): - return True - else: - return False - return self.date() < other.date() - elif isinstance(other, datetime.date): - return self.date() < other - else: - raise NotImplementedError + a, b = self._cast_for_operation(other) + return a < b def __le__(self, other): - """ Judge whehter less than or equal to other Date instance """ - if isinstance(other, Date): - # Keep fuzzy dates below normal dates - if self.date() == other.date(): - if self.is_fuzzy() and not other.is_fuzzy(): - return False - else: - return True - return self.date() <= other.date() - elif isinstance(other, datetime.date): - return self.date() <= other - else: - raise NotImplementedError + a, b = self._cast_for_operation(other) + return a <= b def __eq__(self, other): - """ Judge whehter equal to other Date instance """ - if isinstance(other, Date): - # Handle fuzzy dates situations - if self.date() == other.date(): - return self.is_fuzzy() == other.is_fuzzy() - else: - return False - elif isinstance(other, datetime.date): - return self.date() == other - else: - raise NotImplementedError + a, b = self._cast_for_operation(other) + return a == b def __ne__(self, other): - """ Judge whehter not equal to other Date instance """ - if isinstance(other, Date): - # Handle fuzzy dates situations - if self.date() == other.date(): - return self.is_fuzzy() != other.is_fuzzy() - else: - return True - elif isinstance(other, datetime.date): - return self.date() != other - else: - raise NotImplementedError + return not self.__eq__(other) def __gt__(self, other): - """ Judge whehter greater than other Date instance """ - if isinstance(other, Date): - # Keep fuzzy dates below normal dates - if self.date() == other.date(): - if self.is_fuzzy() and not other.is_fuzzy(): - return True - else: - return False - return self.date() > other.date() - elif isinstance(other, datetime.date): - return self.date() > other - else: - raise NotImplementedError + a, b = self._cast_for_operation(other) + return a > b def __ge__(self, other): - """ Judge whehter greater than or equal to other Date instance """ - if isinstance(other, Date): - # Keep fuzzy dates below normal dates - if self.date() == other.date(): - if not self.is_fuzzy() and other.is_fuzzy(): - return False - else: - return True - return self.date() >= other.date() - elif isinstance(other, datetime.date): - return self.date() >= other - else: - raise NotImplementedError + a, b = self._cast_for_operation(other) + return a >= b def __str__(self): - if self._fuzzy is not None: - return STRINGS[self._fuzzy] - else: - return self._real_date.isoformat() + """ String representation - fuzzy dates are in English """ + if self.accuracy is Accuracy.fuzzy: + strs = {NOW: 'now', SOON: 'soon', SOMEDAY: 'someday', NODATE: ''} + return strs[self.dt_value] + return self.dt_value.isoformat() + + @property + def localized_str(self): + """Will return displayable and localized string representation + of the GTG.core.dates.Date. + """ + if self.accuracy is Accuracy.fuzzy: + return STRINGS[self.dt_value] + return self.date().strftime(locale.nl_langinfo(locale.D_FMT)) def __repr__(self): - return "GTG_Date(%s)" % str(self) - - def xml_str(self): - """ Representation for XML - fuzzy dates are in English """ - if self._fuzzy is not None: - return ENGLISH_STRINGS[self._fuzzy] - else: - return self._real_date.isoformat() + return f"" def __bool__(self): - return self._fuzzy != NODATE - - def __getattr__(self, name): - """ Provide access to the wrapped datetime.date """ - try: - return self.__dict__[name] - except KeyError: - return getattr(self.date(), name) + return self.dt_value != NODATE def is_fuzzy(self): """ True if the Date is one of the fuzzy values: now, soon, someday or no_date """ - return self._fuzzy is not None + return self.accuracy is Accuracy.fuzzy def days_left(self): """ Return the difference between the date and today in dates """ - if self._fuzzy == NODATE: + if self.dt_value == NODATE: return None - else: - return (self.date() - datetime.date.today()).days + return (self.dt_by_accuracy(Accuracy.date) - date.today()).days @classmethod def today(cls): """ Return date for today """ - return Date(datetime.date.today()) + return cls(date.today()) @classmethod def tomorrow(cls): """ Return date for tomorrow """ - return Date(datetime.date.today() + datetime.timedelta(1)) + return cls(date.today() + timedelta(days=1)) - @classmethod - def now(cls): + @staticmethod + def now(): """ Return date representing fuzzy date now """ return _GLOBAL_DATE_NOW - @classmethod - def no_date(cls): + @staticmethod + def no_date(): """ Return date representing no (set) date """ return _GLOBAL_DATE_NODATE - @classmethod - def soon(cls): + @staticmethod + def soon(): """ Return date representing fuzzy date soon """ return _GLOBAL_DATE_SOON - @classmethod - def someday(cls): + @staticmethod + def someday(): """ Return date representing fuzzy date someday """ return _GLOBAL_DATE_SOMEDAY - @classmethod - def _parse_only_month_day(cls, string): + @staticmethod + def _parse_only_month_day(string): """ Parse next Xth day in month """ try: mday = int(string) @@ -327,7 +332,7 @@ def _parse_only_month_day(cls, string): except ValueError: return None - today = datetime.date.today() + today = date.today() try: result = today.replace(day=mday) except ValueError: @@ -342,21 +347,20 @@ def _parse_only_month_day(cls, string): next_year = today.year try: - result = datetime.date(next_year, next_month, mday) + result = date(next_year, next_month, mday) except ValueError: pass return result - @classmethod - def _parse_numerical_format(cls, string): + @staticmethod + def _parse_numerical_format(string): """ Parse numerical formats like %Y/%m/%d, %Y%m%d or %m%d """ result = None - today = datetime.date.today() + today = date.today() for fmt in ['%Y/%m/%d', '%Y%m%d', '%m%d']: try: - da_ti = datetime.datetime.strptime(string, fmt) - result = convert_datetime_to_date(da_ti) + result = datetime.strptime(string, fmt).date() if '%Y' not in fmt: # If the day has passed, assume the next year if result.month > today.month or \ @@ -370,10 +374,10 @@ def _parse_numerical_format(cls, string): continue return result - @classmethod - def _parse_text_representation(cls, string): + @staticmethod + def _parse_text_representation(string): """ Match common text representation for date """ - today = datetime.date.today() + today = date.today() # accepted date formats formats = { @@ -411,8 +415,7 @@ def _parse_text_representation(cls, string): offset = formats.get(string, None) if offset is None: return None - else: - return today + datetime.timedelta(offset) + return today + timedelta(offset) @classmethod def parse(cls, string): @@ -432,7 +435,7 @@ def parse(cls, string): # try the default formats try: - return Date(string) + return cls(string) except ValueError: pass @@ -445,13 +448,15 @@ def parse(cls, string): # Announce the result if result is not None: - return Date(result) + return cls(result) else: raise ValueError(f"Can't parse date '{string}'") def _parse_only_month_day_for_recurrency(self, string, newtask=True): """ Parse next Xth day in month from a certain date""" - if not newtask: self += datetime.timedelta(1) + self_date = self.dt_by_accuracy(Accuracy.date) + if not newtask: + self_date += timedelta(1) try: mday = int(string) if not 1 <= mday <= 31 or string.startswith('0'): @@ -460,46 +465,48 @@ def _parse_only_month_day_for_recurrency(self, string, newtask=True): return None try: - result = self.replace(day=mday) + result = self_date.replace(day=mday) except ValueError: result = None - if result is None or result <= self: - if self.month == 12: + if result is None or result <= self_date: + if self_date.month == 12: next_month = 1 - next_year = self.year + 1 + next_year = self_date.year + 1 else: - next_month = self.month + 1 - next_year = self.year + next_month = self_date.month + 1 + next_year = self_date.year try: - result = datetime.date(next_year, next_month, mday) + result = date(next_year, next_month, mday) except ValueError: pass return result def _parse_numerical_format_for_recurrency(self, string, newtask=True): - """ Parse numerical formats like %Y/%m/%d, %Y%m%d or %m%d and calculated from a certain date""" + """ Parse numerical formats like %Y/%m/%d, + %Y%m%d or %m%d and calculated from a certain date""" + self_date = self.dt_by_accuracy(Accuracy.date) result = None - if not newtask: self += datetime.timedelta(1) + if not newtask: + self_date += timedelta(1) for fmt in ['%Y/%m/%d', '%Y%m%d', '%m%d']: try: - da_ti = datetime.datetime.strptime(string, fmt) - result = convert_datetime_to_date(da_ti) + result = datetime.strptime(string, fmt).date() if '%Y' not in fmt: # If the day has passed, assume the next year - if (result.month > self.month or - (result.month == self.month and - result.day >= self.day)): - year = self.year + if (result.month > self_date.month or + (result.month == self_date.month and + result.day >= self_date.day)): + year = self_date.year else: - year = self.year + 1 + year = self_date.year + 1 result = result.replace(year=year) except ValueError: continue return result - + def _parse_text_representation_for_recurrency(self, string, newtask=False): """Match common text representation from a certain date(self) @@ -508,6 +515,7 @@ def _parse_text_representation_for_recurrency(self, string, newtask=False): newtask (bool, optional): depending on the task if it is a new one or not, the offset changes """ # accepted date formats + self_date = self.dt_by_accuracy(Accuracy.date) formats = { # change the offset depending on the task. 'day': 0 if newtask else 1, @@ -519,12 +527,12 @@ def _parse_text_representation_for_recurrency(self, string, newtask=False): 'week': 0 if newtask else 7, # Translators: Used in recurring parsing, made lowercased in code _('week').lower(): 0 if newtask else 7, - 'month': 0 if newtask else calendar.mdays[self.month], + 'month': 0 if newtask else calendar.mdays[self_date.month], # Translators: Used in recurring parsing, made lowercased in code - _('month').lower(): 0 if newtask else calendar.mdays[self.month], - 'year': 0 if newtask else 365 + int(calendar.isleap(self.year)), + _('month').lower(): 0 if newtask else calendar.mdays[self_date.month], + 'year': 0 if newtask else 365 + int(calendar.isleap(self_date.year)), # Translators: Used in recurring parsing, made lowercased in code - _('year').lower(): 0 if newtask else 365 + int(calendar.isleap(self.year)), + _('year').lower(): 0 if newtask else 365 + int(calendar.isleap(self_date.year)), } # add week day names in the current locale @@ -537,7 +545,7 @@ def _parse_text_representation_for_recurrency(self, string, newtask=False): ("Saturday", _("Saturday")), ("Sunday", _("Sunday")), ]): - offset = i - self.weekday() + 7 * int(i <= self.weekday()) + offset = i - self_date.weekday() + 7 * int(i <= self_date.weekday()) formats[english.lower()] = offset formats[local.lower()] = offset @@ -545,15 +553,16 @@ def _parse_text_representation_for_recurrency(self, string, newtask=False): if offset is None: return None else: - return self + datetime.timedelta(offset) + return self_date + timedelta(offset) def parse_from_date(self, string, newtask=False): - """parse_from_date returns the date from a string but counts since a given date""" + """parse_from_date returns the date from a string + but counts since a given date""" if string is None: string = '' else: string = string.lower() - + try: return Date(string) except ValueError: @@ -564,7 +573,7 @@ def parse_from_date(self, string, newtask=False): result = self._parse_numerical_format_for_recurrency(string, newtask) if result is None: result = self._parse_text_representation_for_recurrency(string, newtask) - + if result is not None: return Date(result) else: @@ -577,8 +586,8 @@ def to_readable_string(self): Close dates => Today, Tomorrow, In X days Other => with locale dateformat, stripping year for this year """ - if self._fuzzy is not None: - return STRINGS[self._fuzzy] + if self.accuracy is Accuracy.fuzzy: + return STRINGS[self.dt_value] days_left = self.days_left() if days_left == 0: @@ -592,7 +601,7 @@ def to_readable_string(self): {'days': days_left} else: locale_format = locale.nl_langinfo(locale.D_FMT) - if calendar.isleap(datetime.date.today().year): + if calendar.isleap(date.today().year): year_len = 366 else: year_len = 365 @@ -600,7 +609,7 @@ def to_readable_string(self): # if it's in less than a year, don't show the year field locale_format = locale_format.replace('/%Y', '') locale_format = locale_format.replace('.%Y', '.') - return self._real_date.strftime(locale_format) + return self.dt_by_accuracy(Accuracy.date).strftime(locale_format) _GLOBAL_DATE_NOW = Date(NOW) diff --git a/GTG/core/task.py b/GTG/core/task.py index aa516a9f66..cabc9e3bd3 100644 --- a/GTG/core/task.py +++ b/GTG/core/task.py @@ -27,8 +27,7 @@ import xml.sax.saxutils as saxutils from gettext import gettext as _ -from GTG.core.dates import Date, convert_datetime_to_date -from GTG.core.tag import extract_tags_from_text +from GTG.core.dates import Date from liblarch import TreeNode log = logging.getLogger(__name__) @@ -61,7 +60,7 @@ def __init__(self, task_id, requester, newtask=False): self.added_date = Date.no_date() if newtask: - self.added_date = datetime.now() + self.added_date = Date(datetime.now()) self.closed_date = Date.no_date() self.due_date = Date.no_date() @@ -87,19 +86,8 @@ def __init__(self, task_id, requester, newtask=False): def get_added_date(self): return self.added_date - def get_added_date_string(self): - FORMAT = '%Y-%m-%dT%H:%M:%S' - - if self.added_date: - return self.added_date.strftime(FORMAT) - else: - return datetime.now().strftime(FORMAT) - - def get_added_date_simple(self): - return self.added_date.strftime("%Y/%m/%d") if self.added_date else "" - def set_added_date(self, date): - self.added_date = date + self.added_date = Date(date) def is_loaded(self): return self.loaded @@ -256,11 +244,8 @@ def get_status(self): def get_modified(self): return self.last_modified - def get_modified_string(self): - return self.last_modified.strftime("%Y-%m-%dT%H:%M:%S") - - def set_modified(self, modified): - self.last_modified = modified + def set_modified(self, value): + self.last_modified = Date(value) def recursive_sync(self): """Recursively sync the task and all task children. Defined""" @@ -310,14 +295,14 @@ def is_valid_term(): # If a start date is already set, # we should calculate the next date from that day. if self.start_date == Date.no_date(): - start_from = Date(convert_datetime_to_date(date.today())) + start_from = Date(datetime.now()) else: start_from = self.start_date newdate = start_from.parse_from_date(recurring_term, newtask) - return (True, newdate) - except ValueError as e: - return (False, None) + return True, newdate + except ValueError: + return False, None self.recurring = recurring # We verifiy if the term passed is valid @@ -370,7 +355,7 @@ def get_recurring_updated_date(self): return self.recurring_updated_date def set_recurring_updated_date(self, date): - self.recurring_updated_date = date + self.recurring_updated_date = Date(date) def inherit_recursion(self): """ Inherits the recurrent state of the parent. @@ -917,5 +902,5 @@ def __str__(self): self.tid, self.status, str(self.tags), - str(self.added_date), + self.added_date, str(self.recurring)) diff --git a/GTG/core/versioning.py b/GTG/core/versioning.py index e3d6055857..ea6094f21f 100644 --- a/GTG/core/versioning.py +++ b/GTG/core/versioning.py @@ -208,7 +208,7 @@ def convert_task(task: et.Element, ds: datastore) -> Optional[et.Element]: new_modified = et.SubElement(dates, 'modified') if added: - added = Date(added).xml_str() + added = str(Date(added)) else: added = date.today().isoformat() @@ -216,7 +216,7 @@ def convert_task(task: et.Element, ds: datastore) -> Optional[et.Element]: if modified: modified = modified[:10] - modified = Date(modified).xml_str() + modified = str(Date(modified)) else: modified = date.today().isoformat() @@ -224,7 +224,7 @@ def convert_task(task: et.Element, ds: datastore) -> Optional[et.Element]: if done_date: new_done = et.SubElement(dates, 'done') - new_done.text = Date(done_date).xml_str() + new_done.text = str(Date(done_date)) if start: start = Date(start) @@ -234,7 +234,7 @@ def convert_task(task: et.Element, ds: datastore) -> Optional[et.Element]: else: new_start = et.SubElement(dates, 'start') - new_start.text = start.xml_str() + new_start.text = str(start) if due_date: due_date = Date(due_date) @@ -244,7 +244,7 @@ def convert_task(task: et.Element, ds: datastore) -> Optional[et.Element]: else: new_due = et.SubElement(dates, 'due') - new_due.text = due_date.xml_str() + new_due.text = str(due_date) recurring = et.SubElement(new_task, 'recurring') recurring.set('enabled', 'false') diff --git a/GTG/core/xml.py b/GTG/core/xml.py index ade4d2d183..64b6a49e15 100644 --- a/GTG/core/xml.py +++ b/GTG/core/xml.py @@ -37,40 +37,26 @@ def task_from_element(task, element: etree.Element): task.set_title(element.find('title').text) task.set_uuid(element.get('id')) + task.set_status(element.attrib['status']) + # Retrieving all dates dates = element.find('dates') - - modified = dates.find('modified').text - task.set_modified(datetime.fromisoformat(modified)) - - added = dates.find('added').text - task.set_added_date(datetime.fromisoformat(added)) - - # Dates - try: - done_date = Date.parse(dates.find('done').text) - task.set_status(element.attrib['status'], donedate=done_date) - except AttributeError: - pass - - - fuzzy_due_date = Date.parse(dates.findtext('fuzzyDue')) - due_date = Date.parse(dates.findtext('due')) - - if fuzzy_due_date: - task.set_due_date(fuzzy_due_date) - elif due_date: - task.set_due_date(due_date) - - - fuzzy_start = dates.findtext('fuzzyStart') - start = dates.findtext('start') - - if fuzzy_start: - task.set_start_date(fuzzy_start) - elif start: - task.set_start_date(start) - + for key, set_date in (('modified', task.set_modified), + ('added', task.set_added_date), + ('due', task.set_due_date), + ('done', task.set_closed_date), + ('start', task.set_start_date)): + value = dates.find(key) + if value is not None and value.text: + set_date(Date(value.text)) + + # supporting old ways of salvaging fuzzy dates + for key, get_date, set_date in ( + ('fuzzyDue', task.get_due_date, task.set_due_date), + ('fuzzyStart', task.get_start_date, task.set_start_date)): + if not get_date() and dates.find(key) is not None \ + and dates.find(key).text: + set_date(Date(dates.find(key).text)) # Recurring tasks recurring = element.find('recurring') @@ -85,7 +71,7 @@ def task_from_element(task, element: etree.Element): try: recurring_updated_date = recurring.find('updated_date').text - task.set_recurring_updated_date(datetime.fromisoformat(recurring_updated_date)) + task.set_recurring_updated_date(Date(recurring_updated_date)) except AttributeError: pass @@ -131,24 +117,14 @@ def task_to_element(task) -> etree.Element: dates = etree.SubElement(element, 'dates') - added_date = etree.SubElement(dates, 'added') - added_date.text = task.get_added_date().isoformat() - - modified_date = etree.SubElement(dates, 'modified') - modified_date.text = Date(task.get_modified()).xml_str() - - done_date = etree.SubElement(dates, 'done') - done_date.text = task.get_closed_date().xml_str() - - due_date = task.get_due_date() - due_tag = 'fuzzyDue' if due_date.is_fuzzy() else 'due' - due = etree.SubElement(dates, due_tag) - due.text = due_date.xml_str() - - start_date = task.get_start_date() - start_tag = 'fuzzyStart' if start_date.is_fuzzy() else 'start' - start = etree.SubElement(dates, start_tag) - start.text = start_date.xml_str() + for key, get_date in (('added', task.get_added_date), + ('modified', task.get_modified), + ('done', task.get_closed_date), + ('due', task.get_due_date), + ('start', task.get_start_date)): + value = get_date() + if value: + etree.SubElement(dates, key).text = str(value) recurring = etree.SubElement(element, 'recurring') recurring.set('enabled', str(task.recurring).lower()) @@ -157,7 +133,7 @@ def task_to_element(task) -> etree.Element: recurring_term.text = str(task.get_recurring_term()) recurring_updated_date = etree.SubElement(recurring, 'updated_date') - recurring_updated_date.text = task.get_recurring_updated_date().isoformat() + recurring_updated_date.text = str(task.get_recurring_updated_date()) subtasks = etree.SubElement(element, 'subtasks') diff --git a/GTG/gtk/editor/editor.py b/GTG/gtk/editor/editor.py index dc626cfdba..645c48b523 100644 --- a/GTG/gtk/editor/editor.py +++ b/GTG/gtk/editor/editor.py @@ -22,25 +22,26 @@ The main text widget is a home-made TextView called TaskView (see taskview.py) The rest is the logic of the widget: date changing widgets, buttons, ... """ -import time import datetime import logging import os +import time +from gettext import gettext as _ +from gettext import ngettext from gi.repository import Gdk, Gtk, Pango from gi.repository.GObject import signal_handler_block - +from GTG.core.dates import Accuracy, Date from GTG.core.dirs import UI_DIR from GTG.core.plugins.api import PluginAPI from GTG.core.plugins.engine import PluginEngine from GTG.core.task import Task -from gettext import gettext as _, ngettext from GTG.gtk.editor import GnomeConfig from GTG.gtk.editor.calendar import GTGCalendar from GTG.gtk.editor.recurring_menu import RecurringMenu from GTG.gtk.editor.taskview import TaskView from GTG.gtk.tag_completion import tag_filter -from GTG.core.dates import Date + """ TODO (jakubbrindza): re-factor tag_filter into a separate module """ @@ -48,7 +49,7 @@ log = logging.getLogger(__name__) -class TaskEditor(): +class TaskEditor: EDITOR_UI_FILE = os.path.join(UI_DIR, "task_editor.ui") @@ -264,7 +265,7 @@ def __init__(self, def show_popover_start(self, widget, event): """Open the start date calendar popup.""" - start_date = self.task.get_start_date() or Date.today() + start_date = (self.task.get_start_date() or Date.today()).date() with signal_handler_block(self.start_calendar, self.start_handle): self.start_calendar.select_day(start_date.day) @@ -281,6 +282,8 @@ def show_popover_due(self, widget, popover): if not due_date or due_date.is_fuzzy(): due_date = Date.today() + due_date = due_date.date() + with signal_handler_block(self.due_calendar, self.due_handle): self.due_calendar.select_day(due_date.day) self.due_calendar.select_month(due_date.month - 1, @@ -291,7 +294,7 @@ def show_popover_due(self, widget, popover): def show_popover_closed(self, widget, popover): """Open the closed date calendar popup.""" - closed_date = self.task.get_closed_date() + closed_date = self.task.get_closed_date().date() with signal_handler_block(self.closed_calendar, self.closed_handle): self.closed_calendar.select_day(closed_date.day) @@ -395,13 +398,10 @@ def search_function(self, model, column, key, iter, *search_data): # otherwise. return not model.get(iter, column)[0].startswith(key) - - def get_monitor_dimensions(self) -> Gdk.Rectangle: + @staticmethod + def get_monitor_dimensions() -> Gdk.Rectangle: """Get dimensions for the first monitor.""" - - monitor = Gdk.Display.get_default().get_monitor(0) - return monitor.get_geometry() - + return Gdk.Display.get_default().get_monitor(0).get_geometry() def init_dimensions(self): """ Restores position and size of task if possible """ @@ -427,7 +427,7 @@ def init_dimensions(self): else: device_manager = Gdk.Display.get_default().get_device_manager() pointer = device_manager.get_client_pointer() - screen, x, y = pointer.get_position() + _, x, y = pointer.get_position() x = int(x) y = int(y) @@ -501,7 +501,7 @@ def refresh_editor(self, title=None, refreshtext=False): update_date = True if update_date: - self.start_entry.set_text(str(startdate)) + self.start_entry.set_text(startdate.localized_str) # refreshing the due date field duedate = self.task.get_due_date() @@ -512,13 +512,13 @@ def refresh_editor(self, title=None, refreshtext=False): update_date = True if update_date: - self.due_entry.set_text(str(duedate)) + self.due_entry.set_text(duedate.localized_str) # refreshing the closed date field closeddate = self.task.get_closed_date() prevcldate = Date.parse(self.closed_entry.get_text()) if closeddate != prevcldate: - self.closed_entry.set_text(str(closeddate)) + self.closed_entry.set_text(closeddate.localized_str) # refreshing the day left label """ @@ -642,7 +642,7 @@ def on_duedate_fuzzy(self, widget, date): """ Callback when a fuzzy date is selected through the popup. """ self.task.set_due_date(date) - self.due_entry.set_text(str(date)) + self.due_entry.set_text(date.localized_str) def on_date_cleared(self, widget, kind): """ Callback when a date is cleared through the popups. """ @@ -662,15 +662,15 @@ def on_date_selected(self, calendar, kind): if kind == GTGCalendar.DATE_KIND_START: self.task.set_start_date(Date(date)) - self.start_entry.set_text(str(Date(date))) + self.start_entry.set_text(Date(date).localized_str) elif kind == GTGCalendar.DATE_KIND_DUE: self.task.set_due_date(Date(date)) - self.due_entry.set_text(str(Date(date))) + self.due_entry.set_text(Date(date).localized_str) elif kind == GTGCalendar.DATE_KIND_CLOSED: self.task.set_closed_date(Date(date)) - self.closed_entry.set_text(str(Date(date))) + self.closed_entry.set_text(Date(date).localized_str) def on_date_changed(self, calendar): date, date_kind = calendar.get_selected_date() diff --git a/GTG/gtk/editor/recurring_menu.py b/GTG/gtk/editor/recurring_menu.py index dda0f06b9e..8681e592c7 100644 --- a/GTG/gtk/editor/recurring_menu.py +++ b/GTG/gtk/editor/recurring_menu.py @@ -104,11 +104,11 @@ def update_header(self): elif self.selected_recurring_term == 'other-day': # Recurring every other day self.title.set_markup(_('Every other day')) elif self.selected_recurring_term == 'week': # Recurring weekly from today - self.title.set_markup(_('Every {week_day}').format(week_day=self.task.get_recurring_updated_date().strftime('%A'))) + self.title.set_markup(_('Every {week_day}').format(week_day=self.task.get_recurring_updated_date().date().strftime('%A'))) elif self.selected_recurring_term == 'month': # Recurring monthly from today - self.title.set_markup(_('Every {month_day} of the month').format(month_day=self.task.get_recurring_updated_date().strftime('%d'))) + self.title.set_markup(_('Every {month_day} of the month').format(month_day=self.task.get_recurring_updated_date().date().strftime('%d'))) elif self.selected_recurring_term == 'year': # Recurring yearly from today - date = self.task.get_recurring_updated_date() + date = self.task.get_recurring_updated_date().date() self.title.set_markup(_('Every {month} {day}').format(month=date.strftime('%B'), day=date.strftime('%d'))) else: # Recurring weekly from selected week day week_day = _(self.selected_recurring_term) diff --git a/GTG/plugins/export/task_str.py b/GTG/plugins/export/task_str.py index cbf5ff4f54..001bd3a301 100644 --- a/GTG/plugins/export/task_str.py +++ b/GTG/plugins/export/task_str.py @@ -29,7 +29,7 @@ def __init__(self, task, subtasks): self.title = task.get_title() self.text = str(task.get_text()) self.status = task.get_status() - self.modified = str(task.get_modified_string()) + self.modified = str(task.get_modified()) self.added_date = str(task.get_added_date()) self.due_date = str(task.get_due_date()) self.closed_date = str(task.get_closed_date()) From ba367597bc5cc794b739b10f3c5032f6b143cde9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Schmidts?= Date: Fri, 26 Mar 2021 23:56:33 +0100 Subject: [PATCH 2/2] dropping NOW as a fuzzy date value --- GTG/core/dates.py | 24 +++++++++++------------- tests/tools/test_dates.py | 1 - 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/GTG/core/dates.py b/GTG/core/dates.py index 8decb84464..d2d7639e19 100644 --- a/GTG/core/dates.py +++ b/GTG/core/dates.py @@ -34,12 +34,10 @@ # trick to obtain the timezone of the machine GTG is executed on LOCAL_TIMEZONE = datetime.now(timezone.utc).astimezone().tzinfo -NOW, SOON, SOMEDAY, NODATE = list(range(4)) +SOON, SOMEDAY, NODATE = list(range(1, 4)) # Localized strings for fuzzy values STRINGS = { - # Translators: Used for display - NOW: _('now'), # Translators: Used for display SOON: _('soon'), # Translators: Used for display @@ -48,12 +46,9 @@ } # Allows looking up any value which is not a date but points towards one and -# find one of the four constant for fuzzy dates: NOW, SOON, SOMEDAY, and NODATE +# find one of the four constant for fuzzy dates: SOON, SOMEDAY, and NODATE LOOKUP = { - NOW: NOW, - 'now': NOW, # Translators: Used in parsing, made lowercased in code - _('now').lower(): NOW, SOON: SOON, 'soon': SOON, # Translators: Used in parsing, made lowercased in code @@ -126,6 +121,8 @@ def __init__(self, value=None): self.dt_value = NODATE elif isinstance(value, str): self.dt_value = self.__parse_dt_str(value) + elif value == 0: # support for dropped falsly fuzzy NOW + self.dt_value = datetime.now() elif value in LOOKUP: self.dt_value = LOOKUP[value] if self.dt_value is None: @@ -148,6 +145,8 @@ def __parse_dt_str(string): return dt_value except ValueError: pass + if string.lower() in {'now', _('now').lower()}: + return datetime.now() return LOOKUP.get(str(string).lower(), None) @property @@ -194,7 +193,7 @@ def dt_by_accuracy(self, wanted_accuracy: Accuracy): return self.dt_value if self.accuracy is Accuracy.fuzzy: now = datetime.now() - delta_days = {NOW: 0, SOON: 15, SOMEDAY: 365, NODATE: 9999} + delta_days = {SOON: 15, SOMEDAY: 365, NODATE: 9999} gtg_date = Date(now + timedelta(delta_days[self.dt_value])) if gtg_date.accuracy is wanted_accuracy: return gtg_date.dt_value @@ -260,7 +259,7 @@ def __ge__(self, other): def __str__(self): """ String representation - fuzzy dates are in English """ if self.accuracy is Accuracy.fuzzy: - strs = {NOW: 'now', SOON: 'soon', SOMEDAY: 'someday', NODATE: ''} + strs = {SOON: 'soon', SOMEDAY: 'someday', NODATE: ''} return strs[self.dt_value] return self.dt_value.isoformat() @@ -302,10 +301,10 @@ def tomorrow(cls): """ Return date for tomorrow """ return cls(date.today() + timedelta(days=1)) - @staticmethod - def now(): + @classmethod + def now(cls): """ Return date representing fuzzy date now """ - return _GLOBAL_DATE_NOW + return cls.today() @staticmethod def no_date(): @@ -612,7 +611,6 @@ def to_readable_string(self): return self.dt_by_accuracy(Accuracy.date).strftime(locale_format) -_GLOBAL_DATE_NOW = Date(NOW) _GLOBAL_DATE_SOON = Date(SOON) _GLOBAL_DATE_NODATE = Date(NODATE) _GLOBAL_DATE_SOMEDAY = Date(SOMEDAY) diff --git a/tests/tools/test_dates.py b/tests/tools/test_dates.py index 405f239003..798e320467 100644 --- a/tests/tools/test_dates.py +++ b/tests/tools/test_dates.py @@ -71,7 +71,6 @@ def test_parse_local_fuzzy_dates(self): def test_parse_fuzzy_dates_str(self): """ Print fuzzy dates in localized version """ - self.assertEqual(str(Date.parse("now")), _("now")) self.assertEqual(str(Date.parse("soon")), _("soon")) self.assertEqual(str(Date.parse("later")), _("someday")) self.assertEqual(str(Date.parse("someday")), _("someday"))