aboutsummaryrefslogtreecommitdiff
import datetime
import re
from typing import Union, Tuple, Optional, List

DateIsh = Union[datetime.date, datetime.datetime]


def total_seconds(td):
    """Equivalent to `datetime.timedelta.total_seconds`."""
    return float(td.microseconds +
                 (td.seconds + td.days * 24 * 3600) * 10 ** 6) / 10 ** 6


def total_minutes(td):
    """Alias for ``total_seconds(td) / 60``."""
    return total_seconds(td) / 60


def gene_timestamp_regex(brtype, prefix=None, nocookie=False):
    """
    Generate timestamp regex for active/inactive/nobrace brace type

    :type brtype: {'active', 'inactive', 'nobrace'}
    :arg  brtype:
        It specifies a type of brace.
        active: <>-type; inactive: []-type; nobrace: no braces.

    :type prefix: str or None
    :arg  prefix:
        It will be appended to the head of keys of the "groupdict".
        For example, if prefix is ``'active_'`` the groupdict has
        keys such as ``'active_year'``, ``'active_month'``, and so on.
        If it is None it will be set to ``brtype`` + ``'_'``.

    :type nocookie: bool
    :arg  nocookie:
        Cookie part (e.g., ``'-3d'`` or ``'+6m'``) is not included if
        it is ``True``.  Default value is ``False``.

    >>> timestamp_re = re.compile(
    ...     gene_timestamp_regex('active', prefix=''),
    ...     re.VERBOSE)
    >>> timestamp_re.match('no match')  # returns None
    >>> m = timestamp_re.match('<2010-06-21 Mon>')
    >>> m.group()
    '<2010-06-21 Mon>'
    >>> '{year}-{month}-{day}'.format(**m.groupdict())
    '2010-06-21'
    >>> m = timestamp_re.match('<2005-10-01 Sat 12:30 +7m -3d>')
    >>> from collections import OrderedDict
    >>> sorted(m.groupdict().items())
    ... # doctest: +NORMALIZE_WHITESPACE
    [('day', '01'),
     ('end_hour', None), ('end_min', None),
     ('hour', '12'), ('min', '30'),
     ('month', '10'),
     ('repeatdwmy', 'm'), ('repeatnum', '7'), ('repeatpre', '+'),
     ('warndwmy', 'd'), ('warnnum', '3'), ('warnpre', '-'), ('year', '2005')]

    When ``brtype = 'nobrace'``, cookie part cannot be retrieved.

    >>> timestamp_re = re.compile(
    ...     gene_timestamp_regex('nobrace', prefix=''),
    ...     re.VERBOSE)
    >>> timestamp_re.match('no match')  # returns None
    >>> m = timestamp_re.match('2010-06-21 Mon')
    >>> m.group()
    '2010-06-21'
    >>> '{year}-{month}-{day}'.format(**m.groupdict())
    '2010-06-21'
    >>> m = timestamp_re.match('2005-10-01 Sat 12:30 +7m -3d')
    >>> sorted(m.groupdict().items())
    ... # doctest: +NORMALIZE_WHITESPACE
    [('day', '01'),
     ('end_hour', None), ('end_min', None),
     ('hour', '12'), ('min', '30'),
     ('month', '10'), ('year', '2005')]
    """

    if brtype == 'active':
        (bo, bc) = ('<', '>')
    elif brtype == 'inactive':
        (bo, bc) = (r'\[', r'\]')
    elif brtype == 'nobrace':
        (bo, bc) = ('', '')
    else:
        raise ValueError("brtype='{0!r}' is invalid".format(brtype))

    if brtype == 'nobrace':
        ignore = r'[\s\w]'
    else:
        ignore = '[^{bc}]'.format(bc=bc)

    if prefix is None:
        prefix = '{0}_'.format(brtype)

    regex_date_time = r"""
        (?P<{prefix}year>\d{{4}}) -
        (?P<{prefix}month>\d{{2}}) -
        (?P<{prefix}day>\d{{2}})
        (  # optional time field
           ({ignore}+?)
           (?P<{prefix}hour>\d{{2}}) :
           (?P<{prefix}min>\d{{2}})
           (  # optional end time range
               --?
               (?P<{prefix}end_hour>\d{{2}}) :
               (?P<{prefix}end_min>\d{{2}})
           )?
        )?
        """
    regex_cookie = r"""
        (  # optional repeater
           ({ignore}+?)
           (?P<{prefix}repeatpre>  [\.\+]{{1,2}})
           (?P<{prefix}repeatnum>  \d+)
           (?P<{prefix}repeatdwmy> [hdwmy])
        )?
        (  # optional warning
           ({ignore}+?)
           (?P<{prefix}warnpre>  \-)
           (?P<{prefix}warnnum>  \d+)
           (?P<{prefix}warndwmy> [hdwmy])
        )?
        """
    # http://www.pythonregex.com/
    regex = ''.join([
        bo,
        regex_date_time,
        regex_cookie if nocookie or brtype != 'nobrace' else '',
        '({ignore}*?)',
        bc])
    return regex.format(prefix=prefix, ignore=ignore)


def date_time_format(date) -> str:
    """
    Format a date or datetime in default org format

    @param date The date

    @return Formatted date(time)
    """
    default_format_date = "%Y-%m-%d %a"
    default_format_datetime = "%Y-%m-%d %a %H:%M"
    is_datetime = isinstance(date, datetime.datetime)

    return date.strftime(default_format_datetime if is_datetime else default_format_date)


def is_same_day(date0, date1) -> bool:
    """
    Check if two dates or datetimes are on the same day
    """
    return (OrgDate._date_to_tuple(date0)[:3] == OrgDate._date_to_tuple(date1)[:3])


TIMESTAMP_NOBRACE_RE = re.compile(
    gene_timestamp_regex('nobrace', prefix=''),
    re.VERBOSE)

TIMESTAMP_RE = re.compile(
    '|'.join((gene_timestamp_regex('active'),
              gene_timestamp_regex('inactive'))),
    re.VERBOSE)


class OrgDate(object):

    _active_default = True
    """
    The default active value.

    When the `active` argument to ``__init__`` is ``None``,
    This value will be used.

    """

    """
    When formatting the date to string via __str__, and there is an end date on
    the same day as the start date, allow formatting in the short syntax
    <2021-09-03 Fri 16:01--17:30>? Otherwise the string represenation would be
    <2021-09-03 Fri 16:01>--<2021-09-03 Fri 17:30>
    """
    _allow_short_range = True

    def __init__(self, start, end=None, active=None, repeater=None,
                 warning=None):
        """
        Create :class:`OrgDate` object

        :type start: datetime, date, tuple, int, float or None
        :type   end: datetime, date, tuple, int, float or None
        :arg  start: Starting date.
        :arg    end: Ending date.

        :type active: bool or None
        :arg  active: Active/inactive flag.
                      None means using its default value, which
                      may be different for different subclasses.
        :type repeater: tuple or None
        :arg  repeater: Repeater interval.
        :type warning: tuple or None
        :arg  warning: Deadline warning interval.

        >>> OrgDate(datetime.date(2012, 2, 10))
        OrgDate((2012, 2, 10))
        >>> OrgDate((2012, 2, 10))
        OrgDate((2012, 2, 10))
        >>> OrgDate((2012, 2))  #doctest: +NORMALIZE_WHITESPACE
        Traceback (most recent call last):
            ...
        ValueError: Automatic conversion to the datetime object
        requires at least 3 elements in the tuple.
        Only 2 elements are in the given tuple '(2012, 2)'.
        >>> OrgDate((2012, 2, 10, 12, 20, 30))
        OrgDate((2012, 2, 10, 12, 20, 30))
        >>> OrgDate((2012, 2, 10), (2012, 2, 15), active=False)
        OrgDate((2012, 2, 10), (2012, 2, 15), False)

        OrgDate can be created using unix timestamp:

        >>> OrgDate(datetime.datetime.fromtimestamp(0)) == OrgDate(0)
        True

        """
        self._start = self._to_date(start)
        self._end = self._to_date(end)
        self._active = self._active_default if active is None else active
        # repeater and warning are tuples of (prefix, number, interval)
        self._repeater = repeater
        self._warning = warning

    @staticmethod
    def _to_date(date) -> DateIsh:
        if isinstance(date, (tuple, list)):
            if len(date) == 3:
                return datetime.date(*date)
            elif len(date) > 3:
                return datetime.datetime(*date)
            else:
                raise ValueError(
                    "Automatic conversion to the datetime object "
                    "requires at least 3 elements in the tuple. "
                    "Only {0} elements are in the given tuple '{1}'."
                    .format(len(date), date))
        elif isinstance(date, (int, float)):
            return datetime.datetime.fromtimestamp(date)
        else:
            return date

    @staticmethod
    def _date_to_tuple(date):
        if isinstance(date, datetime.datetime):
            return tuple(date.timetuple()[:6])
        elif isinstance(date, datetime.date):
            return tuple(date.timetuple()[:3])

    def __repr__(self):
        args = [
            self.__class__.__name__,
            self._date_to_tuple(self.start),
            self._date_to_tuple(self.end) if self.has_end() else None,
            None if self._active is self._active_default else self._active,
            self._repeater,
            self._warning,
        ]
        while args[-1] is None:
            args.pop()
        if len(args) > 3 and args[3] is None:
            args[3] = self._active_default
        return '{0}({1})'.format(args[0], ', '.join(map(repr, args[1:])))

    def __str__(self):
        fence = ("<", ">") if self.is_active() else ("[", "]")

        start = date_time_format(self.start)
        end = None

        if self.has_end():
            if self._allow_short_range and is_same_day(self.start, self.end):
                start += "--%s" % self.end.strftime("%H:%M")
            else:
                end = date_time_format(self.end)

        if self._repeater:
            start += " %s%d%s" % self._repeater
        if self._warning:
            start += " %s%d%s" % self._warning
        ret = "%s%s%s" % (fence[0], start, fence[1])
        if end:
            ret += "--%s%s%s" % (fence[0], end, fence[1])

        return ret

    def __nonzero__(self):
        return bool(self._start)

    __bool__ = __nonzero__  # PY3

    def __eq__(self, other):
        if (isinstance(other, OrgDate) and
            self._start is None and
            other._start is None):
            return True
        return (isinstance(other, self.__class__) and
                self._start == other._start and
                self._end == other._end and
                self._active == other._active)

    @property
    def start(self):
        """
        Get date or datetime object

        >>> OrgDate((2012, 2, 10)).start
        datetime.date(2012, 2, 10)
        >>> OrgDate((2012, 2, 10, 12, 10)).start
        datetime.datetime(2012, 2, 10, 12, 10)

        """
        return self._start

    @property
    def end(self):
        """
        Get date or datetime object

        >>> OrgDate((2012, 2, 10), (2012, 2, 15)).end
        datetime.date(2012, 2, 15)
        >>> OrgDate((2012, 2, 10, 12, 10), (2012, 2, 15, 12, 10)).end
        datetime.datetime(2012, 2, 15, 12, 10)

        """
        return self._end

    def is_active(self) -> bool:
        """Return true if the date is active"""
        return self._active

    def has_end(self) -> bool:
        """Return true if it has the end date"""
        return bool(self._end)

    def has_time(self) -> bool:
        """
        Return true if the start date has time field

        >>> OrgDate((2012, 2, 10)).has_time()
        False
        >>> OrgDate((2012, 2, 10, 12, 10)).has_time()
        True

        """
        return isinstance(self._start, datetime.datetime)

    def has_overlap(self, other) -> bool:
        """
        Test if it has overlap with other :class:`OrgDate` instance

        If the argument is not an instance of :class:`OrgDate`, it is
        converted to :class:`OrgDate` instance by ``OrgDate(other)``
        first.

        >>> od = OrgDate((2012, 2, 10), (2012, 2, 15))
        >>> od.has_overlap(OrgDate((2012, 2, 11)))
        True
        >>> od.has_overlap(OrgDate((2012, 2, 20)))
        False
        >>> od.has_overlap(OrgDate((2012, 2, 11), (2012, 2, 20)))
        True
        >>> od.has_overlap((2012, 2, 11))
        True

        """
        if not isinstance(other, OrgDate):
            other = OrgDate(other)
        if self.has_end():
            return (self._datetime_in_range(other.start) or
                    self._datetime_in_range(other.end))
        elif other.has_end():
            return other._datetime_in_range(self.start)
        elif self.start == other.get_start:
            return True
        else:
            return False

    def _datetime_in_range(self, date):
        if not isinstance(date, (datetime.datetime, datetime.date)):
            return False
        asdt = self._as_datetime
        if asdt(self.start) <= asdt(date) <= asdt(self.end):
            return True
        return False

    @staticmethod
    def _as_datetime(date) -> datetime.datetime:
        """
        Convert the given date into datetime (if it already is, return it
        unmodified
        """
        if not isinstance(date, datetime.datetime):
            return datetime.datetime(*date.timetuple()[:3])
        return date

    @staticmethod
    def _daterange_from_groupdict(dct, prefix='') -> Tuple[List, Optional[List]]:
        start_keys = ['year', 'month', 'day', 'hour'    , 'min']
        end_keys   = ['year', 'month', 'day', 'end_hour', 'end_min']
        start_range = list(map(int, filter(None, (dct[prefix + k] for k in start_keys))))
        end_range: Optional[List]
        end_range   = list(map(int, filter(None, (dct[prefix + k] for k in end_keys))))
        if len(end_range) < len(end_keys):
            end_range = None
        return (start_range, end_range)

    @classmethod
    def _datetuple_from_groupdict(cls, dct, prefix=''):
        return cls._daterange_from_groupdict(dct, prefix=prefix)[0]

    @classmethod
    def list_from_str(cls, string: str) -> List['OrgDate']:
        """
        Parse string and return a list of :class:`OrgDate` objects

        >>> OrgDate.list_from_str("... <2012-02-10 Fri> and <2012-02-12 Sun>")
        [OrgDate((2012, 2, 10)), OrgDate((2012, 2, 12))]
        >>> OrgDate.list_from_str("<2012-02-10 Fri>--<2012-02-12 Sun>")
        [OrgDate((2012, 2, 10), (2012, 2, 12))]
        >>> OrgDate.list_from_str("<2012-02-10 Fri>--[2012-02-12 Sun]")
        [OrgDate((2012, 2, 10)), OrgDate((2012, 2, 12), None, False)]
        >>> OrgDate.list_from_str("this is not timestamp")
        []
        >>> OrgDate.list_from_str("<2012-02-11 Sat 10:11--11:20>")
        [OrgDate((2012, 2, 11, 10, 11, 0), (2012, 2, 11, 11, 20, 0))]
        """
        cookie_suffix = ['pre', 'num', 'dwmy']
        match = TIMESTAMP_RE.search(string)
        if match:
            rest = string[match.end():]
            mdict = match.groupdict()
            if mdict['active_year']:
                prefix = 'active_'
                active = True
                rangedash = '--<'
            else:
                prefix = 'inactive_'
                active = False
                rangedash = '--['
            repeater: Optional[Tuple[str, int, str]] = None
            warning: Optional[Tuple[str, int, str]] = None
            if mdict[prefix + 'repeatpre'] is not None:
                keys = [prefix + 'repeat' + suffix for suffix in cookie_suffix]
                values = [mdict[k] for k in keys]
                repeater = (values[0], int(values[1]), values[2])
            if mdict[prefix + 'warnpre'] is not None:
                keys = [prefix + 'warn' + suffix for suffix in cookie_suffix]
                values = [mdict[k] for k in keys]
                warning = (values[0], int(values[1]), values[2])
            has_rangedash = rest.startswith(rangedash)
            match2 = TIMESTAMP_RE.search(rest) if has_rangedash else None
            if has_rangedash and match2:
                rest = rest[match2.end():]
                # no need for check activeness here because of the rangedash
                mdict2 = match2.groupdict()
                odate = cls(
                    cls._datetuple_from_groupdict(mdict, prefix),
                    cls._datetuple_from_groupdict(mdict2, prefix),
                    active=active, repeater=repeater, warning=warning)
            else:
                odate = cls(
                    *cls._daterange_from_groupdict(mdict, prefix),
                    active=active, repeater=repeater, warning=warning)
            return [odate] + cls.list_from_str(rest)
        else:
            return []

    @classmethod
    def from_str(cls, string):
        """
        Parse string and return an :class:`OrgDate` objects.

        >>> OrgDate.from_str('2012-02-10 Fri')
        OrgDate((2012, 2, 10))
        >>> OrgDate.from_str('2012-02-10 Fri 12:05')
        OrgDate((2012, 2, 10, 12, 5, 0))

        """
        match = cls._from_str_re.match(string)
        if match:
            mdict = match.groupdict()
            return cls(cls._datetuple_from_groupdict(mdict),
                       active=cls._active_default)
        else:
            return cls(None)

    _from_str_re = TIMESTAMP_NOBRACE_RE


def compile_sdc_re(sdctype):
    brtype = 'inactive' if sdctype == 'CLOSED' else 'active'
    return re.compile(
        r'^(?!\#).*{0}:\s+{1}'.format(
            sdctype,
            gene_timestamp_regex(brtype, prefix='', nocookie=True)),
        re.VERBOSE)


class OrgDateSDCBase(OrgDate):

    _re = None  # override this!

    # FIXME: use OrgDate.from_str
    @classmethod
    def from_str(cls, string):
        rgx = cls._re
        assert rgx is not None
        match = rgx.search(string)
        if match:
            mdict = match.groupdict()
            start = cls._datetuple_from_groupdict(mdict)
            end = None
            end_hour = mdict['end_hour']
            end_min  = mdict['end_min']
            if end_hour is not None and end_min is not None:
                end_dict = {}
                end_dict.update(mdict)
                end_dict.update({'hour': end_hour, 'min': end_min})
                end = cls._datetuple_from_groupdict(end_dict)
            cookie_suffix = ['pre', 'num', 'dwmy']
            repeater: Optional[Tuple[str, int, str]] = None
            warning: Optional[Tuple[str, int, str]] = None
            prefix = ''
            if mdict[prefix + 'repeatpre'] is not None:
                keys = [prefix + 'repeat' + suffix for suffix in cookie_suffix]
                values = [mdict[k] for k in keys]
                repeater = (values[0], int(values[1]), values[2])
            if mdict[prefix + 'warnpre'] is not None:
                keys = [prefix + 'warn' + suffix for suffix in cookie_suffix]
                values = [mdict[k] for k in keys]
                warning = (values[0], int(values[1]), values[2])
            return cls(start, end, active=cls._active_default,
                       repeater=repeater, warning=warning)
        else:
            return cls(None)


class OrgDateScheduled(OrgDateSDCBase):
    """Date object to represent SCHEDULED attribute."""
    _re = compile_sdc_re('SCHEDULED')
    _active_default = True


class OrgDateDeadline(OrgDateSDCBase):
    """Date object to represent DEADLINE attribute."""
    _re = compile_sdc_re('DEADLINE')
    _active_default = True


class OrgDateClosed(OrgDateSDCBase):
    """Date object to represent CLOSED attribute."""
    _re = compile_sdc_re('CLOSED')
    _active_default = False


def parse_sdc(string):
    return (OrgDateScheduled.from_str(string),
            OrgDateDeadline.from_str(string),
            OrgDateClosed.from_str(string))


class OrgDateClock(OrgDate):

    """
    Date object to represent CLOCK attributes.

    >>> OrgDateClock.from_str(
    ...   'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] =>  0:30')
    OrgDateClock((2010, 8, 8, 17, 0, 0), (2010, 8, 8, 17, 30, 0))

    """

    _active_default = False

    _allow_short_range = False

    def __init__(self, start, end=None, duration=None, active=None):
        """
        Create OrgDateClock object
        """
        super(OrgDateClock, self).__init__(start, end, active=active)
        self._duration = duration

    @property
    def duration(self):
        """
        Get duration of CLOCK.

        >>> duration = OrgDateClock.from_str(
        ...   'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:30'
        ... ).duration
        >>> duration.seconds
        1800
        >>> total_minutes(duration)
        30.0

        """
        return self.end - self.start

    def is_duration_consistent(self):
        """
        Check duration value of CLOCK line.

        >>> OrgDateClock.from_str(
        ...   'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:30'
        ... ).is_duration_consistent()
        True
        >>> OrgDateClock.from_str(
        ...   'CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] => 0:15'
        ... ).is_duration_consistent()
        False

        """
        return (self._duration is None or
                self._duration == total_minutes(self.duration))

    @classmethod
    def from_str(cls, line: str) -> 'OrgDateClock':
        """
        Get CLOCK from given string.

        Return three tuple (start, stop, length) which is datetime object
        of start time, datetime object of stop time and length in minute.

        """
        match = cls._re.search(line)
        if not match:
            return cls(None, None)

        ymdhm1 = [int(d) for d in match.groups()[:5]]

        # second part starting with "--", does not exist for open clock dates
        has_end = bool(match.group(6))
        ymdhm2_dt: Optional[datetime.datetime]
        len_min: Optional[int]
        if has_end:
            ymdhm2 = [int(d) for d in match.groups()[6:11]]
            hm3 = [int(d) for d in match.groups()[11:]]

            ymdhm2_dt = datetime.datetime(*ymdhm2)  # type: ignore[arg-type]
            len_min = hm3[0] * 60 + hm3[1]
        else:
            ymdhm2_dt = None
            len_min = None

        return cls(
            datetime.datetime(*ymdhm1),  # type: ignore[arg-type]
            ymdhm2_dt,
            len_min,
        )

    _re = re.compile(
        r'^(?!#).*CLOCK:\s+'
        r'\[(\d+)\-(\d+)\-(\d+)[^\]\d]*(\d+)\:(\d+)\]'
        r'(--\[(\d+)\-(\d+)\-(\d+)[^\]\d]*(\d+)\:(\d+)\]\s+=>\s+(\d+)\:(\d+))?'
        )


class OrgDateRepeatedTask(OrgDate):

    """
    Date object to represent repeated tasks.
    """

    _active_default = False

    def __init__(self, start, before, after, active=None):
        super(OrgDateRepeatedTask, self).__init__(start, active=active)
        self._before = before
        self._after = after

    def __repr__(self):
        args = [self._date_to_tuple(self.start), self.before, self.after]
        if self._active is not self._active_default:
            args.append(self._active)
        return '{0}({1})'.format(
            self.__class__.__name__, ', '.join(map(repr, args)))

    def __eq__(self, other):
        return super(OrgDateRepeatedTask, self).__eq__(other) and \
            isinstance(other, self.__class__) and \
            self._before == other._before and \
            self._after == other._after

    @property
    def before(self):
        """
        The state of task before marked as done.

        >>> od = OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE')
        >>> od.before
        'TODO'

        """
        return self._before

    @property
    def after(self):
        """
        The state of task after marked as done.

        >>> od = OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE')
        >>> od.after
        'DONE'

        """
        return self._after