about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/orgparse
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/orgparse')
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/__init__.py166
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/date.py717
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/extra.py116
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/inline.py48
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/node.py1459
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/py.typed0
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py0
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org17
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py33
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org29
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py73
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org31
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py44
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org5
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py13
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org7
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py11
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org9
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py23
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py0
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py154
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py42
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py30
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py299
-rw-r--r--.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py89
25 files changed, 3415 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/orgparse/__init__.py b/.venv/lib/python3.12/site-packages/orgparse/__init__.py
new file mode 100644
index 00000000..416a3b7c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/__init__.py
@@ -0,0 +1,166 @@
+# Import README.rst using cog
+# [[[cog
+# from cog import out
+# out('"""\n{0}\n"""'.format(open('../README.rst').read()))
+# ]]]
+"""
+===========================================================
+  orgparse - Python module for reading Emacs org-mode files
+===========================================================
+
+
+* `Documentation (Read the Docs) <https://orgparse.readthedocs.org>`_
+* `Repository (at GitHub) <https://github.com/karlicoss/orgparse>`_
+* `PyPI <https://pypi.python.org/pypi/orgparse>`_
+
+Install
+-------
+
+  pip install orgparse
+
+
+Usage
+-----
+
+There are pretty extensive doctests if you're interested in some specific method. Otherwise here are some example snippets:
+
+
+Load org node
+^^^^^^^^^^^^^
+::
+
+    from orgparse import load, loads
+
+    load('PATH/TO/FILE.org')
+    load(file_like_object)
+
+    loads('''
+    * This is org-mode contents
+      You can load org object from string.
+    ** Second header
+    ''')
+
+
+Traverse org tree
+^^^^^^^^^^^^^^^^^
+
+>>> root = loads('''
+... * Heading 1
+... ** Heading 2
+... *** Heading 3
+... ''')
+>>> for node in root[1:]:  # [1:] for skipping root itself
+...     print(node)
+* Heading 1
+** Heading 2
+*** Heading 3
+>>> h1 = root.children[0]
+>>> h2 = h1.children[0]
+>>> h3 = h2.children[0]
+>>> print(h1)
+* Heading 1
+>>> print(h2)
+** Heading 2
+>>> print(h3)
+*** Heading 3
+>>> print(h2.get_parent())
+* Heading 1
+>>> print(h3.get_parent(max_level=1))
+* Heading 1
+
+
+Accessing node attributes
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+>>> root = loads('''
+... * DONE Heading          :TAG:
+...   CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun>
+...   CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] =>  0:05
+...   :PROPERTIES:
+...   :Effort:   1:00
+...   :OtherProperty:   some text
+...   :END:
+...   Body texts...
+... ''')
+>>> node = root.children[0]
+>>> node.heading
+'Heading'
+>>> node.scheduled
+OrgDateScheduled((2012, 2, 26))
+>>> node.closed
+OrgDateClosed((2012, 2, 26, 21, 15, 0))
+>>> node.clock
+[OrgDateClock((2012, 2, 26, 21, 10, 0), (2012, 2, 26, 21, 15, 0))]
+>>> bool(node.deadline)   # it is not specified
+False
+>>> node.tags == set(['TAG'])
+True
+>>> node.get_property('Effort')
+60
+>>> node.get_property('UndefinedProperty')  # returns None
+>>> node.get_property('OtherProperty')
+'some text'
+>>> node.body
+'  Body texts...'
+
+"""
+# [[[end]]]
+
+from io import IOBase
+from pathlib import Path
+from typing import Iterable, Union, Optional, TextIO
+
+
+from .node import parse_lines, OrgEnv, OrgNode  # todo basenode??
+
+__all__ = ["load", "loads", "loadi"]
+
+
+def load(path: Union[str, Path, TextIO], env: Optional[OrgEnv] = None) -> OrgNode:
+    """
+    Load org-mode document from a file.
+
+    :type path: str or file-like
+    :arg  path: Path to org file or file-like object of an org document.
+
+    :rtype: :class:`orgparse.node.OrgRootNode`
+
+    """
+    # Make sure it is a Path object.
+    if isinstance(path, str):
+        path = Path(path)
+
+    # if it is a Path
+    if isinstance(path, Path):
+        # open that Path
+        with path.open('r', encoding='utf8') as orgfile:
+            # try again loading
+            return load(orgfile, env)
+
+    # We assume it is a file-like object (e.g. io.StringIO)
+    all_lines = (line.rstrip('\n') for line in path)
+
+    # get the filename
+    filename = path.name if hasattr(path, 'name') else '<file-like>'
+
+    return loadi(all_lines, filename=filename, env=env)
+
+
+def loads(string: str, filename: str='<string>', env: Optional[OrgEnv]=None) -> OrgNode:
+    """
+    Load org-mode document from a string.
+
+    :rtype: :class:`orgparse.node.OrgRootNode`
+
+    """
+    return loadi(string.splitlines(), filename=filename, env=env)
+
+
+def loadi(lines: Iterable[str], filename: str='<lines>', env: Optional[OrgEnv]=None) -> OrgNode:
+    """
+    Load org-mode document from an iterative object.
+
+    :rtype: :class:`orgparse.node.OrgRootNode`
+
+    """
+    return parse_lines(lines, filename=filename, env=env)
diff --git a/.venv/lib/python3.12/site-packages/orgparse/date.py b/.venv/lib/python3.12/site-packages/orgparse/date.py
new file mode 100644
index 00000000..dd407b78
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/date.py
@@ -0,0 +1,717 @@
+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
diff --git a/.venv/lib/python3.12/site-packages/orgparse/extra.py b/.venv/lib/python3.12/site-packages/orgparse/extra.py
new file mode 100644
index 00000000..cd51abaf
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/extra.py
@@ -0,0 +1,116 @@
+import re
+from typing import List, Sequence, Dict, Iterator, Iterable, Union, Optional, Type
+
+
+RE_TABLE_SEPARATOR = re.compile(r'\s*\|(\-+\+)*\-+\|')
+RE_TABLE_ROW = re.compile(r'\s*\|([^|]+)+\|')
+STRIP_CELL_WHITESPACE = True
+
+
+Row = Sequence[str]
+
+class Table:
+    def __init__(self, lines: List[str]) -> None:
+        self._lines = lines
+
+    @property
+    def blocks(self) -> Iterator[Sequence[Row]]:
+        group: List[Row] = []
+        first = True
+        for r in self._pre_rows():
+            if r is None:
+                if not first or len(group) > 0:
+                    yield group
+                    first = False
+                group = []
+            else:
+                group.append(r)
+        if len(group) > 0:
+            yield group
+
+    def __iter__(self) -> Iterator[Row]:
+        return self.rows
+
+    @property
+    def rows(self) -> Iterator[Row]:
+        for r in self._pre_rows():
+            if r is not None:
+                yield r
+
+    def _pre_rows(self) -> Iterator[Optional[Row]]:
+        for l in self._lines:
+            if RE_TABLE_SEPARATOR.match(l):
+                yield None
+            else:
+                pr = l.strip().strip('|').split('|')
+                if STRIP_CELL_WHITESPACE:
+                    pr = [x.strip() for x in pr]
+                yield pr
+        # TODO use iparse helper?
+
+    @property
+    def as_dicts(self) -> 'AsDictHelper':
+        bl = list(self.blocks)
+        if len(bl) != 2:
+            raise RuntimeError('Need two-block table to non-ambiguously guess column names')
+        hrows = bl[0]
+        if len(hrows) != 1:
+            raise RuntimeError(f'Need single row heading to guess column names, got: {hrows}')
+        columns = hrows[0]
+        assert len(set(columns)) == len(columns), f'Duplicate column names: {columns}'
+        return AsDictHelper(
+            columns=columns,
+            rows=bl[1],
+        )
+
+
+class AsDictHelper:
+    def __init__(self, columns: Sequence[str], rows: Sequence[Row]) -> None:
+        self.columns = columns
+        self._rows = rows
+
+    def __iter__(self) -> Iterator[Dict[str, str]]:
+        for x in self._rows:
+            yield {k: v for k, v in zip(self.columns, x)}
+
+
+class Gap:
+    # todo later, add indices etc
+    pass
+
+
+Rich = Union[Table, Gap]
+def to_rich_text(text: str) -> Iterator[Rich]:
+    '''
+    Convert an org-mode text into a 'rich' text, e.g. tables/lists/etc, interleaved by gaps.
+    NOTE: you shouldn't rely on the number of items returned by this function,
+    it might change in the future when more types are supported.
+
+    At the moment only tables are supported.
+    '''
+    lines = text.splitlines(keepends=True)
+    group: List[str] = []
+    last: Type[Rich] = Gap
+    def emit() -> Rich:
+        nonlocal group, last
+        if   last is Gap:
+            res = Gap()
+        elif last is Table:
+            res = Table(group) # type: ignore
+        else:
+            raise RuntimeError(f'Unexpected type {last}')
+        group = []
+        return res
+
+    for line in lines:
+        if RE_TABLE_ROW.match(line) or RE_TABLE_SEPARATOR.match(line):
+            cur = Table
+        else:
+            cur = Gap  # type: ignore
+        if cur is not last:
+            if len(group) > 0:
+                yield emit()
+            last = cur
+        group.append(line)
+    if len(group) > 0:
+        yield emit()
diff --git a/.venv/lib/python3.12/site-packages/orgparse/inline.py b/.venv/lib/python3.12/site-packages/orgparse/inline.py
new file mode 100644
index 00000000..043c99d5
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/inline.py
@@ -0,0 +1,48 @@
+"""
+Org-mode inline markup parser.
+"""
+
+import re
+
+
+def to_plain_text(org_text):
+    """
+    Convert an org-mode text into a plain text.
+
+    >>> to_plain_text('there is a [[link]] in text')
+    'there is a link in text'
+    >>> to_plain_text('some [[link][more complex link]] here')
+    'some more complex link here'
+    >>> print(to_plain_text('''It can handle
+    ... [[link][multi
+    ... line
+    ... link]].
+    ... See also: [[info:org#Link%20format][info:org#Link format]]'''))
+    It can handle
+    multi
+    line
+    link.
+    See also: info:org#Link format
+
+    """
+    return RE_LINK.sub(
+        lambda m: m.group('desc0') or m.group('desc1'),
+        org_text)
+
+
+RE_LINK = re.compile(
+    r"""
+    (?:
+        \[ \[
+            (?P<desc0> [^\]]+)
+        \] \]
+    ) |
+    (?:
+        \[ \[
+            (?P<link1> [^\]]+)
+        \] \[
+            (?P<desc1> [^\]]+)
+        \] \]
+    )
+    """,
+    re.VERBOSE)
diff --git a/.venv/lib/python3.12/site-packages/orgparse/node.py b/.venv/lib/python3.12/site-packages/orgparse/node.py
new file mode 100644
index 00000000..7ed1cdba
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/node.py
@@ -0,0 +1,1459 @@
+import re
+import itertools
+from typing import List, Iterable, Iterator, Optional, Union, Tuple, cast, Dict, Set, Sequence, Any
+
+from .date import OrgDate, OrgDateClock, OrgDateRepeatedTask, parse_sdc, OrgDateScheduled, OrgDateDeadline, OrgDateClosed
+from .inline import to_plain_text
+from .extra import to_rich_text, Rich
+
+
+def lines_to_chunks(lines: Iterable[str]) -> Iterable[List[str]]:
+    chunk: List[str] = []
+    for l in lines:
+        if RE_NODE_HEADER.search(l):
+            yield chunk
+            chunk = []
+        chunk.append(l)
+    yield chunk
+
+RE_NODE_HEADER = re.compile(r"^\*+ ")
+
+
+def parse_heading_level(heading):
+    """
+    Get star-stripped heading and its level
+
+    >>> parse_heading_level('* Heading')
+    ('Heading', 1)
+    >>> parse_heading_level('******** Heading')
+    ('Heading', 8)
+    >>> parse_heading_level('*') # None since no space after star
+    >>> parse_heading_level('*bold*') # None
+    >>> parse_heading_level('not heading')  # None
+
+    """
+    match = RE_HEADING_STARS.search(heading)
+    if match:
+        return (match.group(2), len(match.group(1)))
+
+RE_HEADING_STARS = re.compile(r'^(\*+)\s+(.*?)\s*$')
+
+
+def parse_heading_tags(heading: str) -> Tuple[str, List[str]]:
+    """
+    Get first tags and heading without tags
+
+    >>> parse_heading_tags('HEADING')
+    ('HEADING', [])
+    >>> parse_heading_tags('HEADING :TAG1:TAG2:')
+    ('HEADING', ['TAG1', 'TAG2'])
+    >>> parse_heading_tags('HEADING: this is still heading :TAG1:TAG2:')
+    ('HEADING: this is still heading', ['TAG1', 'TAG2'])
+    >>> parse_heading_tags('HEADING :@tag:_tag_:')
+    ('HEADING', ['@tag', '_tag_'])
+
+    Here is the spec of tags from Org Mode manual:
+
+      Tags are normal words containing letters, numbers, ``_``, and
+      ``@``.  Tags must be preceded and followed by a single colon,
+      e.g., ``:work:``.
+
+      -- (info "(org) Tags")
+
+    """
+    match = RE_HEADING_TAGS.search(heading)
+    if match:
+        heading = match.group(1)
+        tagstr = match.group(2)
+        tags = tagstr.split(':')
+    else:
+        tags = []
+    return (heading, tags)
+
+# Tags are normal words containing letters, numbers, '_', and '@'. https://orgmode.org/manual/Tags.html
+RE_HEADING_TAGS = re.compile(r'(.*?)\s*:([\w@:]+):\s*$')
+
+
+def parse_heading_todos(heading: str, todo_candidates: List[str]) -> Tuple[str, Optional[str]]:
+    """
+    Get TODO keyword and heading without TODO keyword.
+
+    >>> todos = ['TODO', 'DONE']
+    >>> parse_heading_todos('Normal heading', todos)
+    ('Normal heading', None)
+    >>> parse_heading_todos('TODO Heading', todos)
+    ('Heading', 'TODO')
+
+    """
+    for todo in todo_candidates:
+        if heading == todo:
+            return ('', todo)
+        if heading.startswith(todo + ' '):
+            return (heading[len(todo) + 1:], todo)
+    return (heading, None)
+
+
+def parse_heading_priority(heading):
+    """
+    Get priority and heading without priority field.
+
+    >>> parse_heading_priority('HEADING')
+    ('HEADING', None)
+    >>> parse_heading_priority('[#A] HEADING')
+    ('HEADING', 'A')
+    >>> parse_heading_priority('[#0] HEADING')
+    ('HEADING', '0')
+    >>> parse_heading_priority('[#A]')
+    ('', 'A')
+
+    """
+    match = RE_HEADING_PRIORITY.search(heading)
+    if match:
+        return (match.group(2), match.group(1))
+    else:
+        return (heading, None)
+
+RE_HEADING_PRIORITY = re.compile(r'^\s*\[#([A-Z0-9])\] ?(.*)$')
+
+PropertyValue = Union[str, int, float]
+def parse_property(line: str) -> Tuple[Optional[str], Optional[PropertyValue]]:
+    """
+    Get property from given string.
+
+    >>> parse_property(':Some_property: some value')
+    ('Some_property', 'some value')
+    >>> parse_property(':Effort: 1:10')
+    ('Effort', 70)
+
+    """
+    prop_key = None
+    prop_val: Optional[Union[str, int, float]] = None
+    match = RE_PROP.search(line)
+    if match:
+        prop_key = match.group(1)
+        prop_val = match.group(2)
+        if prop_key == 'Effort':
+            prop_val = parse_duration_to_minutes(prop_val)
+    return (prop_key, prop_val)
+
+RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$')
+
+def parse_duration_to_minutes(duration: str) -> Union[float, int]:
+    """
+    Parse duration minutes from given string.
+    Convert to integer if number has no decimal points
+
+    >>> parse_duration_to_minutes('3:12')
+    192
+    >>> parse_duration_to_minutes('1:23:45')
+    83.75
+    >>> parse_duration_to_minutes('1y 3d 3h 4min')
+    530464
+    >>> parse_duration_to_minutes('1d3h5min')
+    1625
+    >>> parse_duration_to_minutes('3d 13:35')
+    5135
+    >>> parse_duration_to_minutes('2.35h')
+    141
+    >>> parse_duration_to_minutes('10')
+    10
+    >>> parse_duration_to_minutes('10.')
+    10
+    >>> parse_duration_to_minutes('1 h')
+    60
+    >>> parse_duration_to_minutes('')
+    0
+    """
+
+    minutes = parse_duration_to_minutes_float(duration)
+    return int(minutes) if minutes.is_integer() else minutes
+
+def parse_duration_to_minutes_float(duration: str) -> float:
+    """
+    Parse duration minutes from given string.
+    The following code is fully compatible with the 'org-duration-to-minutes' function in org mode:
+    https://github.com/emacs-mirror/emacs/blob/master/lisp/org/org-duration.el
+
+    >>> parse_duration_to_minutes_float('3:12')
+    192.0
+    >>> parse_duration_to_minutes_float('1:23:45')
+    83.75
+    >>> parse_duration_to_minutes_float('1y 3d 3h 4min')
+    530464.0
+    >>> parse_duration_to_minutes_float('1d3h5min')
+    1625.0
+    >>> parse_duration_to_minutes_float('3d 13:35')
+    5135.0
+    >>> parse_duration_to_minutes_float('2.35h')
+    141.0
+    >>> parse_duration_to_minutes_float('10')
+    10.0
+    >>> parse_duration_to_minutes_float('10.')
+    10.0
+    >>> parse_duration_to_minutes_float('1 h')
+    60.0
+    >>> parse_duration_to_minutes_float('')
+    0.0
+    """
+
+    match: Optional[Any]
+    if duration == "":
+        return 0.0
+    if isinstance(duration, float):
+        return float(duration)
+    if RE_ORG_DURATION_H_MM.fullmatch(duration):
+        hours, minutes, *seconds_ = map(float, duration.split(":"))
+        seconds = seconds_[0] if seconds_ else 0
+        return seconds / 60.0 + minutes + 60 * hours
+    if RE_ORG_DURATION_FULL.fullmatch(duration):
+        minutes = 0
+        for match in RE_ORG_DURATION_UNIT.finditer(duration):
+            value = float(match.group(1))
+            unit = match.group(2)
+            minutes += value * ORG_DURATION_UNITS[unit]
+        return float(minutes)
+    match = RE_ORG_DURATION_MIXED.fullmatch(duration)
+    if match:
+        units_part = match.groupdict()['A']
+        hms_part = match.groupdict()['B']
+        return parse_duration_to_minutes_float(units_part) + parse_duration_to_minutes_float(hms_part)
+    if RE_FLOAT.fullmatch(duration):
+        return float(duration)
+    raise ValueError("Invalid duration format %s" % duration)
+
+# Conversion factor to minutes for a duration.
+ORG_DURATION_UNITS = {
+    "min": 1,
+    "h": 60,
+    "d": 60 * 24,
+    "w": 60 * 24 * 7,
+    "m": 60 * 24 * 30,
+    "y": 60 * 24 * 365.25,
+}
+# Regexp matching for all units.
+ORG_DURATION_UNITS_RE = r'(%s)' % r'|'.join(ORG_DURATION_UNITS.keys())
+# Regexp matching a duration expressed with H:MM or H:MM:SS format.
+# Hours can use any number of digits.
+ORG_DURATION_H_MM_RE = r'[ \t]*[0-9]+(?::[0-9]{2}){1,2}[ \t]*'
+RE_ORG_DURATION_H_MM = re.compile(ORG_DURATION_H_MM_RE)
+# Regexp matching a duration with an unit.
+# Allowed units are defined in ORG_DURATION_UNITS.
+# Match group 1 contains the bare number.
+# Match group 2 contains the unit.
+ORG_DURATION_UNIT_RE = r'([0-9]+(?:[.][0-9]*)?)[ \t]*' + ORG_DURATION_UNITS_RE
+RE_ORG_DURATION_UNIT = re.compile(ORG_DURATION_UNIT_RE)
+# Regexp matching a duration expressed with units.
+# Allowed units are defined in ORG_DURATION_UNITS.
+ORG_DURATION_FULL_RE = r'(?:[ \t]*%s)+[ \t]*' % ORG_DURATION_UNIT_RE
+RE_ORG_DURATION_FULL = re.compile(ORG_DURATION_FULL_RE)
+# Regexp matching a duration expressed with units and H:MM or H:MM:SS format.
+# Allowed units are defined in ORG_DURATION_UNITS.
+# Match group A contains units part.
+# Match group B contains H:MM or H:MM:SS part.
+ORG_DURATION_MIXED_RE = r'(?P<A>([ \t]*%s)+)[ \t]*(?P<B>[0-9]+(?::[0-9][0-9]){1,2})[ \t]*' % ORG_DURATION_UNIT_RE
+RE_ORG_DURATION_MIXED = re.compile(ORG_DURATION_MIXED_RE)
+# Regexp matching float numbers.
+RE_FLOAT = re.compile(r'[0-9]+([.][0-9]*)?')
+
+def parse_comment(line: str): #  -> Optional[Tuple[str, Sequence[str]]]: # todo wtf?? it says 'ABCMeta isn't subscriptable??'
+    """
+    Parse special comment such as ``#+SEQ_TODO``
+
+    >>> parse_comment('#+SEQ_TODO: TODO | DONE')
+    ('SEQ_TODO', ['TODO | DONE'])
+    >>> parse_comment('# not a special comment')  # None
+
+    >>> parse_comment('#+FILETAGS: :tag1:tag2:')
+    ('FILETAGS', ['tag1', 'tag2'])
+    """
+    match = re.match(r'\s*#\+', line)
+    if match:
+        end = match.end(0)
+        comment = line[end:].split(':', maxsplit=1)
+        if len(comment) >= 2:
+            key   = comment[0]
+            value = comment[1].strip()
+            if key.upper() == 'FILETAGS':
+                # just legacy behaviour; it seems like filetags is the only one that separated by ':'
+                # see https://orgmode.org/org.html#In_002dbuffer-Settings
+                return (key, [c.strip() for c in value.split(':') if len(c.strip()) > 0])
+            else:
+                return (key, [value])
+    return None
+
+
+def parse_seq_todo(line):
+    """
+    Parse value part of SEQ_TODO/TODO/TYP_TODO comment.
+
+    >>> parse_seq_todo('TODO | DONE')
+    (['TODO'], ['DONE'])
+    >>> parse_seq_todo(' Fred  Sara   Lucy Mike  |  DONE  ')
+    (['Fred', 'Sara', 'Lucy', 'Mike'], ['DONE'])
+    >>> parse_seq_todo('| CANCELED')
+    ([], ['CANCELED'])
+    >>> parse_seq_todo('REPORT(r) BUG(b) KNOWNCAUSE(k) | FIXED(f)')
+    (['REPORT', 'BUG', 'KNOWNCAUSE'], ['FIXED'])
+
+    See also:
+
+    * (info "(org) Per-file keywords")
+    * (info "(org) Fast access to TODO states")
+
+    """
+    todo_done = line.split('|', 1)
+    if len(todo_done) == 2:
+        (todos, dones) = todo_done
+    else:
+        (todos, dones) = (line, '')
+    strip_fast_access_key = lambda x: x.split('(', 1)[0]
+    return (list(map(strip_fast_access_key, todos.split())),
+            list(map(strip_fast_access_key, dones.split())))
+
+
+class OrgEnv(object):
+
+    """
+    Information global to the file (e.g, TODO keywords).
+    """
+
+    def __init__(self, todos=['TODO'], dones=['DONE'],
+                 filename='<undefined>'):
+        self._todos = list(todos)
+        self._dones = list(dones)
+        self._todo_not_specified_in_comment = True
+        self._filename = filename
+        self._nodes = []
+
+    @property
+    def nodes(self):
+        """
+        A list of org nodes.
+
+        >>> OrgEnv().nodes   # default is empty (of course)
+        []
+
+        >>> from orgparse import loads
+        >>> loads('''
+        ... * Heading 1
+        ... ** Heading 2
+        ... *** Heading 3
+        ... ''').env.nodes      # doctest: +ELLIPSIS  +NORMALIZE_WHITESPACE
+        [<orgparse.node.OrgRootNode object at 0x...>,
+         <orgparse.node.OrgNode object at 0x...>,
+         <orgparse.node.OrgNode object at 0x...>,
+         <orgparse.node.OrgNode object at 0x...>]
+
+        """
+        return self._nodes
+
+    def add_todo_keys(self, todos, dones):
+        if self._todo_not_specified_in_comment:
+            self._todos = []
+            self._dones = []
+            self._todo_not_specified_in_comment = False
+        self._todos.extend(todos)
+        self._dones.extend(dones)
+
+    @property
+    def todo_keys(self):
+        """
+        TODO keywords defined for this document (file).
+
+        >>> env = OrgEnv()
+        >>> env.todo_keys
+        ['TODO']
+
+        """
+        return self._todos
+
+    @property
+    def done_keys(self):
+        """
+        DONE keywords defined for this document (file).
+
+        >>> env = OrgEnv()
+        >>> env.done_keys
+        ['DONE']
+
+        """
+        return self._dones
+
+    @property
+    def all_todo_keys(self):
+        """
+        All TODO keywords (including DONEs).
+
+        >>> env = OrgEnv()
+        >>> env.all_todo_keys
+        ['TODO', 'DONE']
+
+        """
+        return self._todos + self._dones
+
+    @property
+    def filename(self):
+        """
+        Return a path to the source file or similar information.
+
+        If the org objects are not loaded from a file, this value
+        will be a string of the form ``<SOME_TEXT>``.
+
+        :rtype: str
+
+        """
+        return self._filename
+
+    # parser
+
+    def from_chunks(self, chunks):
+        yield OrgRootNode.from_chunk(self, next(chunks))
+        for chunk in chunks:
+            yield OrgNode.from_chunk(self, chunk)
+
+
+class OrgBaseNode(Sequence):
+
+    """
+    Base class for :class:`OrgRootNode` and :class:`OrgNode`
+
+    .. attribute:: env
+
+       An instance of :class:`OrgEnv`.
+       All nodes in a same file shares same instance.
+
+    :class:`OrgBaseNode` is an iterable object:
+
+    >>> from orgparse import loads
+    >>> root = loads('''
+    ... * Heading 1
+    ... ** Heading 2
+    ... *** Heading 3
+    ... * Heading 4
+    ... ''')
+    >>> for node in root:
+    ...     print(node)
+    <BLANKLINE>
+    * Heading 1
+    ** Heading 2
+    *** Heading 3
+    * Heading 4
+
+    Note that the first blank line is due to the root node, as
+    iteration contains the object itself.  To skip that, use
+    slice access ``[1:]``:
+
+    >>> for node in root[1:]:
+    ...     print(node)
+    * Heading 1
+    ** Heading 2
+    *** Heading 3
+    * Heading 4
+
+    It also supports sequence protocol.
+
+    >>> print(root[1])
+    * Heading 1
+    >>> root[0] is root  # index 0 means itself
+    True
+    >>> len(root)   # remember, sequence contains itself
+    5
+
+    Note the difference between ``root[1:]`` and ``root[1]``:
+
+    >>> for node in root[1]:
+    ...     print(node)
+    * Heading 1
+    ** Heading 2
+    *** Heading 3
+
+    Nodes remember the line number information (1-indexed):
+
+    >>> print(root.children[1].linenumber)
+    5
+    """
+
+    _body_lines: List[str] # set by the child classes
+
+    def __init__(self, env, index=None) -> None:
+        """
+        Create an :class:`OrgBaseNode` object.
+
+        :type env: :class:`OrgEnv`
+        :arg  env: This will be set to the :attr:`env` attribute.
+
+        """
+        self.env = env
+
+        self.linenumber = cast(int, None) # set in parse_lines
+
+        # content
+        self._lines: List[str] = []
+
+        self._properties: Dict[str, PropertyValue] = {}
+        self._timestamps: List[OrgDate] = []
+
+        # FIXME: use `index` argument to set index.  (Currently it is
+        # done externally in `parse_lines`.)
+        if index is not None:
+            self._index = index
+            """
+            Index of `self` in `self.env.nodes`.
+
+            It must satisfy an equality::
+
+                self.env.nodes[self._index] is self
+
+            This value is used for quick access for iterator and
+            tree-like traversing.
+
+            """
+
+    def __iter__(self):
+        yield self
+        level = self.level
+        for node in self.env._nodes[self._index + 1:]:
+            if node.level > level:
+                yield node
+            else:
+                break
+
+    def __len__(self):
+        return sum(1 for _ in self)
+
+    def __nonzero__(self):
+        # As self.__len__ returns non-zero value always this is not
+        # needed.  This function is only for performance.
+        return True
+
+    __bool__ = __nonzero__  # PY3
+
+    def __getitem__(self, key):
+        if isinstance(key, slice):
+            return itertools.islice(self, key.start, key.stop, key.step)
+        elif isinstance(key, int):
+            if key < 0:
+                key += len(self)
+            for (i, node) in enumerate(self):
+                if i == key:
+                    return node
+            raise IndexError("Out of range {0}".format(key))
+        else:
+            raise TypeError("Inappropriate type {0} for {1}"
+                            .format(type(key), type(self)))
+
+    # tree structure
+
+    def _find_same_level(self, iterable):
+        for node in iterable:
+            if node.level < self.level:
+                return
+            if node.level == self.level:
+                return node
+
+    @property
+    def previous_same_level(self):
+        """
+        Return previous node if exists or None otherwise.
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node 1
+        ... * Node 2
+        ... ** Node 3
+        ... ''')
+        >>> (n1, n2, n3) = list(root[1:])
+        >>> n1.previous_same_level is None
+        True
+        >>> n2.previous_same_level is n1
+        True
+        >>> n3.previous_same_level is None  # n2 is not at the same level
+        True
+
+        """
+        return self._find_same_level(reversed(self.env._nodes[:self._index]))
+
+    @property
+    def next_same_level(self):
+        """
+        Return next node if exists or None otherwise.
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node 1
+        ... * Node 2
+        ... ** Node 3
+        ... ''')
+        >>> (n1, n2, n3) = list(root[1:])
+        >>> n1.next_same_level is n2
+        True
+        >>> n2.next_same_level is None  # n3 is not at the same level
+        True
+        >>> n3.next_same_level is None
+        True
+
+        """
+        return self._find_same_level(self.env._nodes[self._index + 1:])
+
+    # FIXME: cache parent node
+    def _find_parent(self):
+        for node in reversed(self.env._nodes[:self._index]):
+            if node.level < self.level:
+                return node
+
+    def get_parent(self, max_level=None):
+        """
+        Return a parent node.
+
+        :arg int max_level:
+            In the normally structured org file, it is a level
+            of the ancestor node to return.  For example,
+            ``get_parent(max_level=0)`` returns a root node.
+
+            In the general case, it specify a maximum level of the
+            desired ancestor node.  If there is no ancestor node
+            whose level is equal to ``max_level``, this function
+            try to find an ancestor node which level is smaller
+            than ``max_level``.
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node 1
+        ... ** Node 2
+        ... ** Node 3
+        ... ''')
+        >>> (n1, n2, n3) = list(root[1:])
+        >>> n1.get_parent() is root
+        True
+        >>> n2.get_parent() is n1
+        True
+        >>> n3.get_parent() is n1
+        True
+
+        For simplicity, accessing :attr:`parent` is alias of calling
+        :meth:`get_parent` without argument.
+
+        >>> n1.get_parent() is n1.parent
+        True
+        >>> root.parent is None
+        True
+
+        This is a little bit pathological situation -- but works.
+
+        >>> root = loads('''
+        ... * Node 1
+        ... *** Node 2
+        ... ** Node 3
+        ... ''')
+        >>> (n1, n2, n3) = list(root[1:])
+        >>> n1.get_parent() is root
+        True
+        >>> n2.get_parent() is n1
+        True
+        >>> n3.get_parent() is n1
+        True
+
+        Now let's play with `max_level`.
+
+        >>> root = loads('''
+        ... * Node 1 (level 1)
+        ... ** Node 2 (level 2)
+        ... *** Node 3 (level 3)
+        ... ''')
+        >>> (n1, n2, n3) = list(root[1:])
+        >>> n3.get_parent() is n2
+        True
+        >>> n3.get_parent(max_level=2) is n2  # same as default
+        True
+        >>> n3.get_parent(max_level=1) is n1
+        True
+        >>> n3.get_parent(max_level=0) is root
+        True
+
+        """
+        if max_level is None:
+            max_level = self.level - 1
+        parent = self._find_parent()
+        while parent.level > max_level:
+            parent = parent.get_parent()
+        return parent
+
+    @property
+    def parent(self):
+        """
+        Alias of :meth:`get_parent()` (calling without argument).
+        """
+        return self.get_parent()
+
+    # FIXME: cache children nodes
+    def _find_children(self):
+        nodeiter = iter(self.env._nodes[self._index + 1:])
+        try:
+            node = next(nodeiter)
+        except StopIteration:
+            return
+        if node.level <= self.level:
+            return
+        yield node
+        last_child_level = node.level
+        for node in nodeiter:
+            if node.level <= self.level:
+                return
+            if node.level <= last_child_level:
+                yield node
+                last_child_level = node.level
+
+    @property
+    def children(self):
+        """
+        A list of child nodes.
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node 1
+        ... ** Node 2
+        ... *** Node 3
+        ... ** Node 4
+        ... ''')
+        >>> (n1, n2, n3, n4) = list(root[1:])
+        >>> (c1, c2) = n1.children
+        >>> c1 is n2
+        True
+        >>> c2 is n4
+        True
+
+        Note the difference to ``n1[1:]``, which returns the Node 3 also:
+
+        >>> (m1, m2, m3) = list(n1[1:])
+        >>> m2 is n3
+        True
+
+        """
+        return list(self._find_children())
+
+    @property
+    def root(self):
+        """
+        The root node.
+
+        >>> from orgparse import loads
+        >>> root = loads('* Node 1')
+        >>> n1 = root[1]
+        >>> n1.root is root
+        True
+
+        """
+        root = self
+        while True:
+            parent = root.get_parent()
+            if not parent:
+                return root
+            root = parent
+
+    @property
+    def properties(self) -> Dict[str, PropertyValue]:
+        """
+        Node properties as a dictionary.
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node
+        ...   :PROPERTIES:
+        ...   :SomeProperty: value
+        ...   :END:
+        ... ''')
+        >>> root.children[0].properties['SomeProperty']
+        'value'
+
+        """
+        return self._properties
+
+    def get_property(self, key, val=None) -> Optional[PropertyValue]:
+        """
+        Return property named ``key`` if exists or ``val`` otherwise.
+
+        :arg str key:
+            Key of property.
+
+        :arg val:
+            Default value to return.
+
+        """
+        return self._properties.get(key, val)
+
+    # parser
+
+    @classmethod
+    def from_chunk(cls, env, lines):
+        self = cls(env)
+        self._lines = lines
+        self._parse_comments()
+        return self
+
+    def _parse_comments(self):
+        special_comments: Dict[str, List[str]] = {}
+        for line in self._lines:
+            parsed = parse_comment(line)
+            if parsed:
+                (key, vals) = parsed
+                key = key.upper() # case insensitive, so keep as uppercase
+                special_comments.setdefault(key, []).extend(vals)
+        self._special_comments = special_comments
+        # parse TODO keys and store in OrgEnv
+        for todokey in ['TODO', 'SEQ_TODO', 'TYP_TODO']:
+            for val in special_comments.get(todokey, []):
+                self.env.add_todo_keys(*parse_seq_todo(val))
+
+    def _iparse_properties(self, ilines: Iterator[str]) -> Iterator[str]:
+        self._properties = {}
+        in_property_field = False
+        for line in ilines:
+            if in_property_field:
+                if line.find(":END:") >= 0:
+                    break
+                else:
+                    (key, val) = parse_property(line)
+                    if key is not None and val is not None:
+                        self._properties.update({key: val})
+            elif line.find(":PROPERTIES:") >= 0:
+                in_property_field = True
+            else:
+                yield line
+        for line in ilines:
+            yield line
+
+    # misc
+
+    @property
+    def level(self):
+        """
+        Level of this node.
+
+        :rtype: int
+
+        """
+        raise NotImplementedError
+
+    def _get_tags(self, inher=False) -> Set[str]:
+        """
+        Return tags
+
+        :arg bool inher:
+            Mix with tags of all ancestor nodes if ``True``.
+
+        :rtype: set
+
+        """
+        return set()
+
+    @property
+    def tags(self) -> Set[str]:
+        """
+        Tags of this and parent's node.
+
+        >>> from orgparse import loads
+        >>> n2 = loads('''
+        ... * Node 1    :TAG1:
+        ... ** Node 2   :TAG2:
+        ... ''')[2]
+        >>> n2.tags == set(['TAG1', 'TAG2'])
+        True
+
+        """
+        return self._get_tags(inher=True)
+
+    @property
+    def shallow_tags(self) -> Set[str]:
+        """
+        Tags defined for this node (don't look-up parent nodes).
+
+        >>> from orgparse import loads
+        >>> n2 = loads('''
+        ... * Node 1    :TAG1:
+        ... ** Node 2   :TAG2:
+        ... ''')[2]
+        >>> n2.shallow_tags == set(['TAG2'])
+        True
+
+        """
+        return self._get_tags(inher=False)
+
+    @staticmethod
+    def _get_text(text, format='plain'):
+        if format == 'plain':
+            return to_plain_text(text)
+        elif format == 'raw':
+            return text
+        elif format == 'rich':
+            return to_rich_text(text)
+        else:
+            raise ValueError('format={0} is not supported.'.format(format))
+
+    def get_body(self, format='plain') -> str:
+        """
+        Return a string of body text.
+
+        See also: :meth:`get_heading`.
+
+        """
+        return self._get_text(
+            '\n'.join(self._body_lines), format) if self._lines else ''
+
+    @property
+    def body(self) -> str:
+        """Alias of ``.get_body(format='plain')``."""
+        return self.get_body()
+
+    @property
+    def body_rich(self) -> Iterator[Rich]:
+        r = self.get_body(format='rich')
+        return cast(Iterator[Rich], r) # meh..
+
+    @property
+    def heading(self) -> str:
+        raise NotImplementedError
+
+    def is_root(self):
+        """
+        Return ``True`` when it is a root node.
+
+        >>> from orgparse import loads
+        >>> root = loads('* Node 1')
+        >>> root.is_root()
+        True
+        >>> n1 = root[1]
+        >>> n1.is_root()
+        False
+
+        """
+        return False
+
+    def get_timestamps(self, active=False, inactive=False,
+                       range=False, point=False):
+        """
+        Return a list of timestamps in the body text.
+
+        :type   active: bool
+        :arg    active: Include active type timestamps.
+        :type inactive: bool
+        :arg  inactive: Include inactive type timestamps.
+        :type    range: bool
+        :arg     range: Include timestamps which has end date.
+        :type    point: bool
+        :arg     point: Include timestamps which has no end date.
+
+        :rtype: list of :class:`orgparse.date.OrgDate` subclasses
+
+
+        Consider the following org node:
+
+        >>> from orgparse import loads
+        >>> node = loads('''
+        ... * Node
+        ...   CLOSED: [2012-02-26 Sun 21:15] SCHEDULED: <2012-02-26 Sun>
+        ...   CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] =>  0:05
+        ...   Some inactive timestamp [2012-02-23 Thu] in body text.
+        ...   Some active timestamp <2012-02-24 Fri> in body text.
+        ...   Some inactive time range [2012-02-25 Sat]--[2012-02-27 Mon].
+        ...   Some active time range <2012-02-26 Sun>--<2012-02-28 Tue>.
+        ... ''').children[0]
+
+        The default flags are all off, so it does not return anything.
+
+        >>> node.get_timestamps()
+        []
+
+        You can fetch appropriate timestamps using keyword arguments.
+
+        >>> node.get_timestamps(inactive=True, point=True)
+        [OrgDate((2012, 2, 23), None, False)]
+        >>> node.get_timestamps(active=True, point=True)
+        [OrgDate((2012, 2, 24))]
+        >>> node.get_timestamps(inactive=True, range=True)
+        [OrgDate((2012, 2, 25), (2012, 2, 27), False)]
+        >>> node.get_timestamps(active=True, range=True)
+        [OrgDate((2012, 2, 26), (2012, 2, 28))]
+
+        This is more complex example.  Only active timestamps,
+        regardless of range/point type.
+
+        >>> node.get_timestamps(active=True, point=True, range=True)
+        [OrgDate((2012, 2, 24)), OrgDate((2012, 2, 26), (2012, 2, 28))]
+
+        """
+        return [
+            ts for ts in self._timestamps if
+            (((active and ts.is_active()) or
+              (inactive and not ts.is_active())) and
+             ((range and ts.has_end()) or
+              (point and not ts.has_end())))]
+
+    @property
+    def datelist(self):
+        """
+        Alias of ``.get_timestamps(active=True, inactive=True, point=True)``.
+
+        :rtype: list of :class:`orgparse.date.OrgDate` subclasses
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node with point dates <2012-02-25 Sat>
+        ...   CLOSED: [2012-02-25 Sat 21:15]
+        ...   Some inactive timestamp [2012-02-26 Sun] in body text.
+        ...   Some active timestamp <2012-02-27 Mon> in body text.
+        ... ''')
+        >>> root.children[0].datelist      # doctest: +NORMALIZE_WHITESPACE
+        [OrgDate((2012, 2, 25)),
+         OrgDate((2012, 2, 26), None, False),
+         OrgDate((2012, 2, 27))]
+
+        """
+        return self.get_timestamps(active=True, inactive=True, point=True)
+
+    @property
+    def rangelist(self):
+        """
+        Alias of ``.get_timestamps(active=True, inactive=True, range=True)``.
+
+        :rtype: list of :class:`orgparse.date.OrgDate` subclasses
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node with range dates <2012-02-25 Sat>--<2012-02-28 Tue>
+        ...   CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] => 0:05
+        ...   Some inactive time range [2012-02-25 Sat]--[2012-02-27 Mon].
+        ...   Some active time range <2012-02-26 Sun>--<2012-02-28 Tue>.
+        ...   Some time interval <2012-02-27 Mon 11:23-12:10>.
+        ... ''')
+        >>> root.children[0].rangelist     # doctest: +NORMALIZE_WHITESPACE
+        [OrgDate((2012, 2, 25), (2012, 2, 28)),
+         OrgDate((2012, 2, 25), (2012, 2, 27), False),
+         OrgDate((2012, 2, 26), (2012, 2, 28)),
+         OrgDate((2012, 2, 27, 11, 23, 0), (2012, 2, 27, 12, 10, 0))]
+
+        """
+        return self.get_timestamps(active=True, inactive=True, range=True)
+
+    def __str__(self) -> str:
+        return "\n".join(self._lines)
+
+    # todo hmm, not sure if it really belongs here and not to OrgRootNode?
+    def get_file_property_list(self, property):
+        """
+        Return a list of the selected property
+        """
+        vals = self._special_comments.get(property.upper(), None)
+        return [] if vals is None else vals
+
+    def get_file_property(self, property):
+        """
+        Return a single element of the selected property or None if it doesn't exist
+        """
+        vals = self._special_comments.get(property.upper(), None)
+        if vals is None:
+            return None
+        elif len(vals) == 1:
+            return vals[0]
+        else:
+            raise RuntimeError('Multiple values for property {}: {}'.format(property, vals))
+
+
+class OrgRootNode(OrgBaseNode):
+
+    """
+    Node to represent a file. Its body contains all lines before the first
+    headline
+
+    See :class:`OrgBaseNode` for other available functions.
+    """
+
+    @property
+    def heading(self) -> str:
+        return ''
+
+    def _get_tags(self, inher=False) -> Set[str]:
+        filetags = self.get_file_property_list('FILETAGS')
+        return set(filetags)
+
+    @property
+    def level(self):
+        return 0
+
+    def get_parent(self, max_level=None):
+        return None
+
+    def is_root(self):
+        return True
+
+    # parsers
+
+    def _parse_pre(self):
+        """Call parsers which must be called before tree structuring"""
+        ilines: Iterator[str] = iter(self._lines)
+        ilines = self._iparse_properties(ilines)
+        ilines = self._iparse_timestamps(ilines)
+        self._body_lines = list(ilines)
+
+    def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]:
+        self._timestamps = []
+        for line in ilines:
+            self._timestamps.extend(OrgDate.list_from_str(line))
+            yield line
+
+
+class OrgNode(OrgBaseNode):
+
+    """
+    Node to represent normal org node
+
+    See :class:`OrgBaseNode` for other available functions.
+
+    """
+
+    def __init__(self, *args, **kwds) -> None:
+        super(OrgNode, self).__init__(*args, **kwds)
+        # fixme instead of casts, should organize code in such a way that they aren't necessary
+        self._heading = cast(str, None)
+        self._level = None
+        self._tags = cast(List[str], None)
+        self._todo: Optional[str] = None
+        self._priority = None
+        self._scheduled = OrgDateScheduled(None)
+        self._deadline = OrgDateDeadline(None)
+        self._closed = OrgDateClosed(None)
+        self._clocklist: List[OrgDateClock] = []
+        self._body_lines: List[str] = []
+        self._repeated_tasks: List[OrgDateRepeatedTask] = []
+
+    # parser
+
+    def _parse_pre(self):
+        """Call parsers which must be called before tree structuring"""
+        self._parse_heading()
+        # FIXME: make the following parsers "lazy"
+        ilines: Iterator[str] = iter(self._lines)
+        try:
+            next(ilines)            # skip heading
+        except StopIteration:
+            return
+        ilines = self._iparse_sdc(ilines)
+        ilines = self._iparse_clock(ilines)
+        ilines = self._iparse_properties(ilines)
+        ilines = self._iparse_repeated_tasks(ilines)
+        ilines = self._iparse_timestamps(ilines)
+        self._body_lines = list(ilines)
+
+    def _parse_heading(self) -> None:
+        heading = self._lines[0]
+        (heading, self._level) = parse_heading_level(heading)
+        (heading, self._tags) = parse_heading_tags(heading)
+        (heading, self._todo) = parse_heading_todos(
+            heading, self.env.all_todo_keys)
+        (heading, self._priority) = parse_heading_priority(heading)
+        self._heading = heading
+
+    # The following ``_iparse_*`` methods are simple generator based
+    # parser.  See ``_parse_pre`` for how it is used.  The principle
+    # is simple: these methods get an iterator and returns an iterator.
+    # If the item returned by the input iterator must be dedicated to
+    # the parser, do not yield the item or yield it as-is otherwise.
+
+    def _iparse_sdc(self, ilines: Iterator[str]) -> Iterator[str]:
+        """
+        Parse SCHEDULED, DEADLINE and CLOSED time tamps.
+
+        They are assumed be in the first line.
+
+        """
+        try:
+            line = next(ilines)
+        except StopIteration:
+            return
+        (self._scheduled, self._deadline, self._closed) = parse_sdc(line)
+
+        if not (self._scheduled or
+                self._deadline or
+                self._closed):
+            yield line  # when none of them were found
+
+        for line in ilines:
+            yield line
+
+    def _iparse_clock(self, ilines: Iterator[str]) -> Iterator[str]:
+        self._clocklist = []
+        for line in ilines:
+            cl = OrgDateClock.from_str(line)
+            if cl:
+                self._clocklist.append(cl)
+            else:
+                yield line
+
+    def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]:
+        self._timestamps = []
+        self._timestamps.extend(OrgDate.list_from_str(self._heading))
+        for l in ilines:
+            self._timestamps.extend(OrgDate.list_from_str(l))
+            yield l
+
+    def _iparse_repeated_tasks(self, ilines: Iterator[str]) -> Iterator[str]:
+        self._repeated_tasks = []
+        for line in ilines:
+            match = self._repeated_tasks_re.search(line)
+            if match:
+                # FIXME: move this parsing to OrgDateRepeatedTask.from_str
+                mdict = match.groupdict()
+                done_state = mdict['done']
+                todo_state = mdict['todo']
+                date = OrgDate.from_str(mdict['date'])
+                self._repeated_tasks.append(
+                    OrgDateRepeatedTask(date.start, todo_state, done_state))
+            else:
+                yield line
+
+    _repeated_tasks_re = re.compile(
+        r'''
+        \s*- \s+
+        State \s+ "(?P<done> [^"]+)" \s+
+        from  \s+ "(?P<todo> [^"]+)" \s+
+        \[ (?P<date> [^\]]+) \]''',
+        re.VERBOSE)
+
+    def get_heading(self, format='plain'):
+        """
+        Return a string of head text without tags and TODO keywords.
+
+        >>> from orgparse import loads
+        >>> node = loads('* TODO Node 1').children[0]
+        >>> node.get_heading()
+        'Node 1'
+
+        It strips off inline markup by default (``format='plain'``).
+        You can get the original raw string by specifying
+        ``format='raw'``.
+
+        >>> node = loads('* [[link][Node 1]]').children[0]
+        >>> node.get_heading()
+        'Node 1'
+        >>> node.get_heading(format='raw')
+        '[[link][Node 1]]'
+
+        """
+        return self._get_text(self._heading, format)
+
+    @property
+    def heading(self) -> str:
+        """Alias of ``.get_heading(format='plain')``."""
+        return self.get_heading()
+
+    @property
+    def level(self):
+        return self._level
+        """
+        Level attribute of this node.  Top level node is level 1.
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node 1
+        ... ** Node 2
+        ... ''')
+        >>> (n1, n2) = root.children
+        >>> root.level
+        0
+        >>> n1.level
+        1
+        >>> n2.level
+        2
+
+        """
+
+    @property
+    def priority(self):
+        """
+        Priority attribute of this node.  It is None if undefined.
+
+        >>> from orgparse import loads
+        >>> (n1, n2) = loads('''
+        ... * [#A] Node 1
+        ... * Node 2
+        ... ''').children
+        >>> n1.priority
+        'A'
+        >>> n2.priority is None
+        True
+
+        """
+        return self._priority
+
+    def _get_tags(self, inher=False) -> Set[str]:
+        tags = set(self._tags)
+        if inher:
+            parent = self.get_parent()
+            if parent:
+                return tags | parent._get_tags(inher=True)
+        return tags
+
+    @property
+    def todo(self) -> Optional[str]:
+        """
+        A TODO keyword of this node if exists or None otherwise.
+
+        >>> from orgparse import loads
+        >>> root = loads('* TODO Node 1')
+        >>> root.children[0].todo
+        'TODO'
+
+        """
+        return self._todo
+
+    @property
+    def scheduled(self):
+        """
+        Return scheduled timestamp
+
+        :rtype: a subclass of :class:`orgparse.date.OrgDate`
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node
+        ...   SCHEDULED: <2012-02-26 Sun>
+        ... ''')
+        >>> root.children[0].scheduled
+        OrgDateScheduled((2012, 2, 26))
+
+        """
+        return self._scheduled
+
+    @property
+    def deadline(self):
+        """
+        Return deadline timestamp.
+
+        :rtype: a subclass of :class:`orgparse.date.OrgDate`
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node
+        ...   DEADLINE: <2012-02-26 Sun>
+        ... ''')
+        >>> root.children[0].deadline
+        OrgDateDeadline((2012, 2, 26))
+
+        """
+        return self._deadline
+
+    @property
+    def closed(self):
+        """
+        Return timestamp of closed time.
+
+        :rtype: a subclass of :class:`orgparse.date.OrgDate`
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node
+        ...   CLOSED: [2012-02-26 Sun 21:15]
+        ... ''')
+        >>> root.children[0].closed
+        OrgDateClosed((2012, 2, 26, 21, 15, 0))
+
+        """
+        return self._closed
+
+    @property
+    def clock(self):
+        """
+        Return a list of clocked timestamps
+
+        :rtype: a list of a subclass of :class:`orgparse.date.OrgDate`
+
+        >>> from orgparse import loads
+        >>> root = loads('''
+        ... * Node
+        ...   CLOCK: [2012-02-26 Sun 21:10]--[2012-02-26 Sun 21:15] =>  0:05
+        ... ''')
+        >>> root.children[0].clock
+        [OrgDateClock((2012, 2, 26, 21, 10, 0), (2012, 2, 26, 21, 15, 0))]
+
+        """
+        return self._clocklist
+
+    def has_date(self):
+        """
+        Return ``True`` if it has any kind of timestamp
+        """
+        return (self.scheduled or
+                self.deadline or
+                self.datelist or
+                self.rangelist)
+
+    @property
+    def repeated_tasks(self):
+        """
+        Get repeated tasks marked DONE in an entry having repeater.
+
+        :rtype: list of :class:`orgparse.date.OrgDateRepeatedTask`
+
+        >>> from orgparse import loads
+        >>> node = loads('''
+        ... * TODO Pay the rent
+        ...   DEADLINE: <2005-10-01 Sat +1m>
+        ...   - State "DONE"  from "TODO"  [2005-09-01 Thu 16:10]
+        ...   - State "DONE"  from "TODO"  [2005-08-01 Mon 19:44]
+        ...   - State "DONE"  from "TODO"  [2005-07-01 Fri 17:27]
+        ... ''').children[0]
+        >>> node.repeated_tasks            # doctest: +NORMALIZE_WHITESPACE
+        [OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE'),
+         OrgDateRepeatedTask((2005, 8, 1, 19, 44, 0), 'TODO', 'DONE'),
+         OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), 'TODO', 'DONE')]
+        >>> node.repeated_tasks[0].before
+        'TODO'
+        >>> node.repeated_tasks[0].after
+        'DONE'
+
+        Repeated tasks in ``:LOGBOOK:`` can be fetched by the same code.
+
+        >>> node = loads('''
+        ... * TODO Pay the rent
+        ...   DEADLINE: <2005-10-01 Sat +1m>
+        ...   :LOGBOOK:
+        ...   - State "DONE"  from "TODO"  [2005-09-01 Thu 16:10]
+        ...   - State "DONE"  from "TODO"  [2005-08-01 Mon 19:44]
+        ...   - State "DONE"  from "TODO"  [2005-07-01 Fri 17:27]
+        ...   :END:
+        ... ''').children[0]
+        >>> node.repeated_tasks            # doctest: +NORMALIZE_WHITESPACE
+        [OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE'),
+         OrgDateRepeatedTask((2005, 8, 1, 19, 44, 0), 'TODO', 'DONE'),
+         OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), 'TODO', 'DONE')]
+
+        See: `(info "(org) Repeated tasks")
+        <http://orgmode.org/manual/Repeated-tasks.html>`_
+
+        """
+        return self._repeated_tasks
+
+
+def parse_lines(lines: Iterable[str], filename, env=None) -> OrgNode:
+    if not env:
+        env = OrgEnv(filename=filename)
+    elif env.filename != filename:
+        raise ValueError('If env is specified, filename must match')
+
+    # parse into node of list (environment will be parsed)
+    ch1, ch2 = itertools.tee(lines_to_chunks(lines))
+    linenos = itertools.accumulate(itertools.chain([0], (len(c) for c in ch1)))
+    nodes = env.from_chunks(ch2)
+    nodelist = []
+    for lineno, node in zip(linenos, nodes):
+        lineno += 1 # in text editors lines are 1-indexed
+        node.linenumber = lineno
+        nodelist.append(node)
+    # parse headings (level, TODO, TAGs, and heading)
+    nodelist[0]._index = 0
+    # parse the root node
+    nodelist[0]._parse_pre()
+    for (i, node) in enumerate(nodelist[1:], 1):   # nodes except root node
+        node._index = i
+        node._parse_pre()
+    env._nodes = nodelist
+    return nodelist[0]  # root
diff --git a/.venv/lib/python3.12/site-packages/orgparse/py.typed b/.venv/lib/python3.12/site-packages/orgparse/py.typed
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/py.typed
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py b/.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/__init__.py
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org
new file mode 100644
index 00000000..3a373334
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.org
@@ -0,0 +1,17 @@
+#+STARTUP: hidestars
+#+SEQ_TODO: TODO1 TODO2 TODO3 TODO4
+
+* TODO1 Heading 0 						       :TAG1:
+** TODO2 Heading 1 						       :TAG2:
+*** TODO3 Heading 2 						       :TAG3:
+**** TODO4 Heading 3 						       :TAG4:
+     CLOSED: [2010-08-06 Fri 21:45]
+** Heading 4
+** Heading 5
+* Heading 6 							       :TAG2:
+** Heading 7 							       :TAG2:
+*** Heading 8
+***** Heading 9 						  :TAG3:TAG4:
+**** Heading 10 						       :TAG1:
+** Heading 11
+* Heading 12
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py
new file mode 100644
index 00000000..c0b23d1d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/00_simple.py
@@ -0,0 +1,33 @@
+from typing import Any, Dict, Set
+
+
+def nodedict(i, level, todo=None, shallow_tags=set([]), tags=set([])) -> Dict[str, Any]:
+    return dict(
+        heading="Heading {0}".format(i),
+        level=level,
+        todo=todo,
+        shallow_tags=shallow_tags,
+        tags=tags,
+    )
+
+
+def tags(nums) -> Set[str]:
+    return set(map('TAG{0}'.format, nums))
+
+
+data = [
+    nodedict(i, *vals) for (i, vals) in enumerate([  # type: ignore[misc]
+        [1, 'TODO1', tags([1])   , tags(range(1, 2))],
+        [2, 'TODO2', tags([2])   , tags(range(1, 3))],
+        [3, 'TODO3', tags([3])   , tags(range(1, 4))],
+        [4, 'TODO4', tags([4])   , tags(range(1, 5))],
+        [2, None   , tags([])    , tags([1])        ],
+        [2, None   , tags([])    , tags([1])        ],
+        [1, None   , tags([2])   , tags([2])        ],
+        [2, None   , tags([2])   , tags([2])        ],
+        [3, None   , tags([])    , tags([2])        ],
+        [5, None   , tags([3, 4]), tags([2, 3, 4])  ],
+        [4, None   , tags([1])   , tags([1, 2])     ],
+        [2, None   , tags([])    , tags([2])        ],
+        [1],
+    ])]
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org
new file mode 100644
index 00000000..99e202b5
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.org
@@ -0,0 +1,29 @@
+#+STARTUP: hidestars
+* DONE [#A] A node with a lot of attributes
+  SCHEDULED: <2010-08-06 Fri> DEADLINE: <2010-08-10 Tue> CLOSED: [2010-08-08 Sun 18:00]
+  CLOCK: [2010-08-08 Sun 17:40]--[2010-08-08 Sun 17:50] =>  0:10
+  CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] =>  0:30
+  :PROPERTIES:
+  :Effort: 1:10
+  :END:
+  - <2010-08-16 Mon> DateList
+  - <2010-08-07 Sat>--<2010-08-08 Sun>
+  - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList
+  - <2019-08-10 Sat 16:30-17:30> TimeRange
+* A node without any attributed
+* DONE [#A] A node with a lot of attributes
+  SCHEDULED: <2010-08-06 Fri> DEADLINE: <2010-08-10 Tue> CLOSED: [2010-08-08 Sun 18:00]
+  CLOCK: [2010-08-08 Sun 17:40]--[2010-08-08 Sun 17:50] =>  0:10
+  CLOCK: [2010-08-08 Sun 17:00]--[2010-08-08 Sun 17:30] =>  0:30
+  :PROPERTIES:
+  :Effort: 1:10
+  :END:
+  - <2010-08-16 Mon> DateList
+  - <2010-08-07 Sat>--<2010-08-08 Sun>
+  - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList
+  - <2019-08-10 Sat 16:30-17:30> TimeRange
+* range in deadline
+DEADLINE: <2019-09-06 Fri 10:00--11:20>
+  body
+* node with a second line but no date
+body
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py
new file mode 100644
index 00000000..d4555dea
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/01_attributes.py
@@ -0,0 +1,73 @@
+from typing import Dict, Any
+
+from orgparse.date import (
+    OrgDate, OrgDateScheduled, OrgDateDeadline, OrgDateClosed,
+    OrgDateClock,
+)
+
+Raw = Dict[str, Any]
+
+node1: Raw = dict(
+    heading="A node with a lot of attributes",
+    priority='A',
+    scheduled=OrgDateScheduled((2010, 8, 6)),
+    deadline=OrgDateDeadline((2010, 8, 10)),
+    closed=OrgDateClosed((2010, 8, 8, 18, 0)),
+    clock=[
+        OrgDateClock((2010, 8, 8, 17, 40), (2010, 8, 8, 17, 50), 10),
+        OrgDateClock((2010, 8, 8, 17, 00), (2010, 8, 8, 17, 30), 30),
+        ],
+    properties=dict(Effort=70),
+    datelist=[OrgDate((2010, 8, 16))],
+    rangelist=[
+        OrgDate((2010, 8, 7), (2010, 8, 8)),
+        OrgDate((2010, 8, 9, 0, 30), (2010, 8, 10, 13, 20)),
+        OrgDate((2019, 8, 10, 16, 30, 0), (2019, 8, 10, 17, 30, 0)),
+        ],
+    body="""\
+  - <2010-08-16 Mon> DateList
+  - <2010-08-07 Sat>--<2010-08-08 Sun>
+  - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList
+  - <2019-08-10 Sat 16:30-17:30> TimeRange"""
+)
+
+node2: Raw = dict(
+    heading="A node without any attributed",
+    priority=None,
+    scheduled=OrgDateScheduled(None),
+    deadline=OrgDateDeadline(None),
+    closed=OrgDateClosed(None),
+    clock=[],
+    properties={},
+    datelist=[],
+    rangelist=[],
+    body="",
+)
+
+node3: Raw = dict(
+    heading="range in deadline",
+    priority=None,
+    scheduled=OrgDateScheduled(None),
+    deadline=OrgDateDeadline((2019, 9, 6, 10, 0), (2019, 9, 6, 11, 20)),
+    closed=OrgDateClosed(None),
+    clock=[],
+    properties={},
+    datelist=[],
+    rangelist=[],
+    body="  body",
+)
+
+node4: Raw = dict(
+    heading="node with a second line but no date",
+    priority=None,
+    scheduled=OrgDateScheduled(None),
+    deadline=OrgDateDeadline(None),
+    closed=OrgDateClosed(None),
+    clock=[],
+    properties={},
+    datelist=[],
+    rangelist=[],
+    body="body",
+)
+
+data = [node1, node2, node1, node3, node4]
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org
new file mode 100644
index 00000000..5f4b6fb2
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.org
@@ -0,0 +1,31 @@
+* G0-H1
+
+* G1-H1
+** G1-H2
+*** G1-H3
+
+* G2-H1
+*** G2-H2
+** G2-H3
+
+* G3-H1
+**** G3-H2
+** G3-H3
+
+* G4-H1
+**** G4-H2
+*** G4-H3
+** G4-H4
+
+* G5-H1
+** G5-H2
+*** G5-H3
+** G5-H4
+
+* G6-H1
+** G6-H2
+**** G6-H3
+*** G6-H4
+** G6-H5
+
+* G7-H1
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py
new file mode 100644
index 00000000..80a8e779
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/02_tree_struct.py
@@ -0,0 +1,44 @@
+from typing import Any, Dict
+
+
+def nodedict(parent, children=[], previous=None, next=None) -> Dict[str, Any]:
+    return dict(parent_heading=parent,
+                children_heading=children,
+                previous_same_level_heading=previous,
+                next_same_level_heading=next)
+
+
+data = [nodedict(*args) for args in [
+    # G0
+    (None, [], None, 'G1-H1'),
+    # G1
+    (None, ['G1-H2'], 'G0-H1', 'G2-H1'),
+    ('G1-H1', ['G1-H3']),
+    ('G1-H2',),
+    # G2
+    (None, ['G2-H2', 'G2-H3'], 'G1-H1', 'G3-H1'),
+    ('G2-H1',),
+    ('G2-H1',),
+    # G3
+    (None, ['G3-H2', 'G3-H3'], 'G2-H1', 'G4-H1'),
+    ('G3-H1',),
+    ('G3-H1',),
+    # G4
+    (None, ['G4-H2', 'G4-H3', 'G4-H4'], 'G3-H1', 'G5-H1'),
+    ('G4-H1',),
+    ('G4-H1',),
+    ('G4-H1',),
+    # G5
+    (None, ['G5-H2', 'G5-H4'], 'G4-H1', 'G6-H1'),
+    ('G5-H1', ['G5-H3'], None, 'G5-H4'),
+    ('G5-H2',),
+    ('G5-H1', [], 'G5-H2'),
+    # G6
+    (None, ['G6-H2', 'G6-H5'], 'G5-H1', 'G7-H1'),
+    ('G6-H1', ['G6-H3', 'G6-H4'], None, 'G6-H5'),
+    ('G6-H2',),
+    ('G6-H2',),
+    ('G6-H1', [], 'G6-H2'),
+    # G7
+    (None, [], 'G6-H1'),
+]]
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org
new file mode 100644
index 00000000..9e9c79b5
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.org
@@ -0,0 +1,5 @@
+* TODO Pay the rent
+  DEADLINE: <2005-10-01 Sat +1m>
+  - State "DONE"  from "TODO"  [2005-09-01 Thu 16:10]
+  - State "DONE"  from "TODO"  [2005-08-01 Mon 19:44]
+  - State "DONE"  from "TODO"  [2005-07-01 Fri 17:27]
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py
new file mode 100644
index 00000000..18cfe121
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/03_repeated_tasks.py
@@ -0,0 +1,13 @@
+from orgparse.date import OrgDateRepeatedTask, OrgDateDeadline
+
+
+data = [dict(
+    heading='Pay the rent',
+    todo='TODO',
+    deadline=OrgDateDeadline((2005, 10, 1)),
+    repeated_tasks=[
+        OrgDateRepeatedTask((2005, 9, 1, 16, 10, 0), 'TODO', 'DONE'),
+        OrgDateRepeatedTask((2005, 8, 1, 19, 44, 0), 'TODO', 'DONE'),
+        OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), 'TODO', 'DONE'),
+    ]
+)]
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org
new file mode 100644
index 00000000..e89ec262
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.org
@@ -0,0 +1,7 @@
+* LOGBOOK drawer test
+  :LOGBOOK:
+  CLOCK: [2012-10-26 Fri 16:01]
+  CLOCK: [2012-10-26 Fri 14:50]--[2012-10-26 Fri 15:00] =>  0:10
+  CLOCK: [2012-10-26 Fri 14:30]--[2012-10-26 Fri 14:40] =>  0:10
+  CLOCK: [2012-10-26 Fri 14:10]--[2012-10-26 Fri 14:20] =>  0:10
+  :END:
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py
new file mode 100644
index 00000000..457c5fa1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/04_logbook.py
@@ -0,0 +1,11 @@
+from orgparse.date import OrgDateClock
+
+data = [dict(
+    heading='LOGBOOK drawer test',
+    clock=[
+        OrgDateClock((2012, 10, 26, 16, 1)),
+        OrgDateClock((2012, 10, 26, 14, 50), (2012, 10, 26, 15, 00)),
+        OrgDateClock((2012, 10, 26, 14, 30), (2012, 10, 26, 14, 40)),
+        OrgDateClock((2012, 10, 26, 14, 10), (2012, 10, 26, 14, 20)),
+    ]
+)]
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org
new file mode 100644
index 00000000..651d7e09
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.org
@@ -0,0 +1,9 @@
+* Node 0                                                                :tag:
+* Node 1                                                               :@tag:
+* Node 2                                                          :tag1:tag2:
+* Node 3                                                                  :_:
+* Node 4                                                                  :@:
+* Node 5                                                                 :@_:
+* Node 6                                                              :_tag_:
+* Heading: :with:colon:                                                 :tag:
+* unicode                                                      :ёж:tag:háček:
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py
new file mode 100644
index 00000000..52aee638
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/05_tags.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+
+def nodedict(i, tags):
+    return dict(
+        heading="Node {0}".format(i),
+        tags=set(tags),
+    )
+
+
+data = [
+    nodedict(i, *vals) for (i, vals) in enumerate([
+        [["tag"]],
+        [["@tag"]],
+        [["tag1", "tag2"]],
+        [["_"]],
+        [["@"]],
+        [["@_"]],
+        [["_tag_"]],
+    ])] + [
+        dict(heading='Heading: :with:colon:', tags=set(["tag"])),
+    ] + [
+        dict(heading='unicode', tags=set(['ёж', 'tag', 'háček'])),
+    ]
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py b/.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/data/__init__.py
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py
new file mode 100644
index 00000000..f315878e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_data.py
@@ -0,0 +1,154 @@
+from glob import glob
+import os
+from pathlib import Path
+import pickle
+
+from .. import load, loads
+
+import pytest
+
+DATADIR = os.path.join(os.path.dirname(__file__), 'data')
+
+
+def load_data(path):
+    """Load data from python file"""
+    ns = {}  # type: ignore
+    # read_bytes() and compile hackery to avoid encoding issues (e.g. see 05_tags)
+    exec(compile(Path(path).read_bytes(), path, 'exec'), ns)
+    return ns['data']
+
+
+def value_from_data_key(node, key):
+    """
+    Helper function for check_data. Get value from Orgnode by key.
+    """
+    if key == 'tags_inher':
+        return node.tags
+    elif key == 'children_heading':
+        return [c.heading for c in node.children]
+    elif key in ('parent_heading',
+                 'previous_same_level_heading',
+                 'next_same_level_heading',
+                 ):
+        othernode = getattr(node, key.rsplit('_', 1)[0])
+        if othernode and not othernode.is_root():
+            return othernode.heading
+        else:
+            return
+    else:
+        return getattr(node, key)
+
+
+def data_path(dataname, ext):
+    return os.path.join(DATADIR, '{0}.{1}'.format(dataname, ext))
+
+
+def get_datanames():
+    for oname in sorted(glob(os.path.join(DATADIR, '*.org'))):
+        yield os.path.splitext(os.path.basename(oname))[0]
+
+
+@pytest.mark.parametrize('dataname', get_datanames())
+def test_data(dataname):
+    """
+    Compare parsed data from 'data/*.org' and its correct answer 'data/*.py'
+    """
+    oname = data_path(dataname, "org")
+    data = load_data(data_path(dataname, "py"))
+    root = load(oname)
+
+    for (i, (node, kwds)) in enumerate(zip(root[1:], data)):
+        for key in kwds:
+            val = value_from_data_key(node, key)
+            assert kwds[key] == val, 'check value of {0}-th node of key "{1}" from "{2}".\n\nParsed:\n{3}\n\nReal:\n{4}'.format(i, key, dataname, val, kwds[key])
+            assert type(kwds[key]) == type(val), 'check type of {0}-th node of key "{1}" from "{2}".\n\nParsed:\n{3}\n\nReal:\n{4}'.format(i, key, dataname, type(val), type(kwds[key]))
+
+    assert root.env.filename == oname
+
+
+@pytest.mark.parametrize('dataname', get_datanames())
+def test_picklable(dataname):
+    oname = data_path(dataname, "org")
+    root = load(oname)
+    pickle.dumps(root)
+
+
+
+def test_iter_node():
+    root = loads("""
+* H1
+** H2
+*** H3
+* H4
+** H5
+""")
+    node = root[1]
+    assert node.heading == 'H1'
+
+    by_iter = [n.heading for n in node]
+    assert by_iter == ['H1', 'H2', 'H3']
+
+
+def test_commented_headings_do_not_appear_as_children():
+    root = loads("""\
+* H1
+#** H2
+** H3
+#* H4
+#** H5
+* H6
+""")
+    assert root.linenumber == 1
+    top_level = root.children
+    assert len(top_level) == 2
+
+    h1 = top_level[0]
+    assert h1.heading == "H1"
+    assert h1.get_body() == "#** H2"
+    assert h1.linenumber == 1
+
+    [h3] = h1.children
+    assert h3.heading == "H3"
+    assert h3.get_body() == "#* H4\n#** H5"
+    assert h3.linenumber == 3
+
+    h6 = top_level[1]
+    assert h6.heading == "H6"
+    assert len(h6.children) == 0
+    assert h6.linenumber == 6
+
+
+def test_commented_clock_entries_are_ignored_by_node_clock():
+    root = loads("""\
+* Heading
+# * Floss
+# SCHEDULED: <2019-06-22 Sat 08:30 .+1w>
+# :LOGBOOK:
+# CLOCK: [2019-06-04 Tue 16:00]--[2019-06-04 Tue 17:00] =>  1:00
+# :END:
+""")
+    [node] = root.children[0]
+    assert node.heading == "Heading"
+    assert node.clock == []
+
+
+def test_commented_scheduled_marker_is_ignored_by_node_scheduled():
+    root = loads("""\
+* Heading
+# SCHEDULED: <2019-06-22 Sat 08:30 .+1w>
+""")
+    [node] = root.children[0]
+    assert node.heading == "Heading"
+    assert node.scheduled.start is None
+
+
+def test_commented_property_is_ignored_by_node_get_property():
+    root = loads("""\
+* Heading
+# :PROPERTIES:
+# :PROPER-TEA: backup
+# :END:
+""")
+    [node] = root.children[0]
+    assert node.heading == "Heading"
+    assert node.get_property("PROPER-TEA") is None
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py
new file mode 100644
index 00000000..0f39575b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_date.py
@@ -0,0 +1,42 @@
+from orgparse.date import OrgDate, OrgDateScheduled, OrgDateDeadline, OrgDateClock, OrgDateClosed
+import datetime
+
+
+def test_date_as_string() -> None:
+
+    testdate = datetime.date(2021, 9, 3)
+    testdate2 = datetime.date(2021, 9, 5)
+    testdatetime = datetime.datetime(2021, 9, 3, 16, 19, 13)
+    testdatetime2 = datetime.datetime(2021, 9, 3, 17, 0, 1)
+    testdatetime_nextday = datetime.datetime(2021, 9, 4, 0, 2, 1)
+
+    assert str(OrgDate(testdate)) == "<2021-09-03 Fri>"
+    assert str(OrgDate(testdatetime)) == "<2021-09-03 Fri 16:19>"
+    assert str(OrgDate(testdate, active=False)) == "[2021-09-03 Fri]"
+    assert str(OrgDate(testdatetime, active=False)) == "[2021-09-03 Fri 16:19]"
+
+    assert str(OrgDate(testdate, testdate2)) == "<2021-09-03 Fri>--<2021-09-05 Sun>"
+    assert str(OrgDate(testdate, testdate2)) == "<2021-09-03 Fri>--<2021-09-05 Sun>"
+    assert str(OrgDate(testdatetime, testdatetime2)) == "<2021-09-03 Fri 16:19--17:00>"
+    assert str(OrgDate(testdate, testdate2, active=False)) == "[2021-09-03 Fri]--[2021-09-05 Sun]"
+    assert str(OrgDate(testdate, testdate2, active=False)) == "[2021-09-03 Fri]--[2021-09-05 Sun]"
+    assert str(OrgDate(testdatetime, testdatetime2, active=False)) == "[2021-09-03 Fri 16:19--17:00]"
+
+    assert str(OrgDateScheduled(testdate)) == "<2021-09-03 Fri>"
+    assert str(OrgDateScheduled(testdatetime)) == "<2021-09-03 Fri 16:19>"
+    assert str(OrgDateDeadline(testdate)) == "<2021-09-03 Fri>"
+    assert str(OrgDateDeadline(testdatetime)) == "<2021-09-03 Fri 16:19>"
+    assert str(OrgDateClosed(testdate)) == "[2021-09-03 Fri]"
+    assert str(OrgDateClosed(testdatetime)) == "[2021-09-03 Fri 16:19]"
+
+    assert str(OrgDateClock(testdatetime, testdatetime2)) == "[2021-09-03 Fri 16:19]--[2021-09-03 Fri 17:00]"
+    assert str(OrgDateClock(testdatetime, testdatetime_nextday)) == "[2021-09-03 Fri 16:19]--[2021-09-04 Sat 00:02]"
+    assert str(OrgDateClock(testdatetime)) == "[2021-09-03 Fri 16:19]"
+
+
+def test_date_as_datetime() -> None:
+    testdate = (2021, 9, 3)
+    testdatetime = (2021, 9, 3, 16, 19, 13)
+
+    assert OrgDate._as_datetime(datetime.date(*testdate)) == datetime.datetime(*testdate, 0, 0, 0)
+    assert OrgDate._as_datetime(datetime.datetime(*testdatetime)) == datetime.datetime(*testdatetime)
\ No newline at end of file
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py
new file mode 100644
index 00000000..f7248ca7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_hugedata.py
@@ -0,0 +1,30 @@
+import pickle
+
+from .. import loadi
+
+
+def generate_org_lines(num_top_nodes, depth=3, nodes_per_level=1, _level=1):
+    if depth == 0:
+        return
+    for i in range(num_top_nodes):
+        yield ("*" * _level) + ' {0}-th heading of level {1}'.format(i, _level)
+        for child in generate_org_lines(
+                nodes_per_level, depth - 1, nodes_per_level, _level + 1):
+            yield child
+
+
+def num_generate_org_lines(num_top_nodes, depth=3, nodes_per_level=1):
+    if depth == 0:
+        return 0
+    return num_top_nodes * (
+        1 + num_generate_org_lines(
+            nodes_per_level, depth - 1, nodes_per_level))
+
+
+def test_picklable() -> None:
+    num = 1000
+    depth = 3
+    nodes_per_level = 1
+    root = loadi(generate_org_lines(num, depth, nodes_per_level))
+    assert sum(1 for _ in root) == num_generate_org_lines(num, depth, nodes_per_level) + 1
+    pickle.dumps(root)  # should not fail
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py
new file mode 100644
index 00000000..4cd73e4c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_misc.py
@@ -0,0 +1,299 @@
+from .. import load, loads
+from ..node import OrgEnv
+from orgparse.date import OrgDate
+
+
+def test_empty_heading() -> None:
+    root = loads('''
+* TODO :sometag:
+  has no heading but still a todo?
+  it's a bit unclear, but seems to be highligted by emacs..
+''')
+    [h] = root.children
+    assert h.todo == 'TODO'
+    assert h.heading == ''
+    assert h.tags == {'sometag'}
+
+
+def test_root() -> None:
+    root = loads('''
+#+STARTUP: hidestars
+Whatever
+# comment
+* heading 1
+    '''.strip())
+    assert len(root.children) == 1
+    # todo not sure if should strip special comments??
+    assert root.body.endswith('Whatever\n# comment')
+    assert root.heading == ''
+
+
+def test_stars():
+    # https://github.com/karlicoss/orgparse/issues/7#issuecomment-533732660
+    root = loads("""
+* Heading with text (A)
+
+The following line is not a heading, because it begins with a
+star but has no spaces afterward, just a newline:
+
+*
+
+** Subheading with text (A1)
+
+*this_is_just*
+
+ *some_bold_text*
+
+This subheading is a child of (A).
+
+The next heading has no text, but it does have a space after
+the star, so it's a heading:
+
+* 
+
+This text is under the "anonymous" heading above, which would be (B).
+
+** Subheading with text (B1)
+
+This subheading is a child of the "anonymous" heading (B), not of heading (A).
+    """)
+    [h1, h2] = root.children
+    assert h1.heading == 'Heading with text (A)'
+    assert h2.heading == ''
+
+
+def test_parse_custom_todo_keys():
+    todo_keys = ['TODO', 'CUSTOM1', 'ANOTHER_KEYWORD']
+    done_keys = ['DONE', 'A']
+    filename = '<string>'  # default for loads
+    content = """
+* TODO Heading with a default todo keyword
+
+* DONE Heading with a default done keyword
+
+* CUSTOM1 Heading with a custom todo keyword
+
+* ANOTHER_KEYWORD Heading with a long custom todo keyword
+
+* A Heading with a short custom done keyword
+    """
+
+    env = OrgEnv(todos=todo_keys, dones=done_keys, filename=filename)
+    root = loads(content, env=env)
+
+    assert root.env.all_todo_keys == ['TODO', 'CUSTOM1',
+                                      'ANOTHER_KEYWORD', 'DONE', 'A']
+    assert len(root.children) == 5
+    assert root.children[0].todo == 'TODO'
+    assert root.children[1].todo == 'DONE'
+    assert root.children[2].todo == 'CUSTOM1'
+    assert root.children[3].todo == 'ANOTHER_KEYWORD'
+    assert root.children[4].todo == 'A'
+
+
+def test_add_custom_todo_keys():
+    todo_keys = ['CUSTOM_TODO']
+    done_keys = ['CUSTOM_DONE']
+    filename = '<string>'  # default for loads
+    content = """#+TODO: COMMENT_TODO | COMMENT_DONE 
+    """
+
+    env = OrgEnv(filename=filename)
+    env.add_todo_keys(todos=todo_keys, dones=done_keys)
+
+    # check that only the custom keys are know before parsing
+    assert env.all_todo_keys == ['CUSTOM_TODO', 'CUSTOM_DONE']
+
+    # after parsing, all keys are set
+    root = loads(content, filename, env)
+    assert root.env.all_todo_keys == ['CUSTOM_TODO', 'COMMENT_TODO',
+                                      'CUSTOM_DONE', 'COMMENT_DONE']
+
+def test_get_file_property() -> None:
+     content = """#+TITLE:   Test: title
+     * Node 1
+     test 1
+     * Node 2
+     test 2
+     """
+
+     # after parsing, all keys are set
+     root = loads(content)
+     assert root.get_file_property('Nosuchproperty') is None
+     assert root.get_file_property_list('TITLE') == ['Test: title']
+     # also it's case insensitive
+     assert root.get_file_property('title') == 'Test: title'
+     assert root.get_file_property_list('Nosuchproperty') == []
+
+
+def test_get_file_property_multivalued() -> None:
+     content = """ #+TITLE: Test
+     #+OTHER: Test title
+     #+title: alternate title
+
+     * Node 1
+     test 1
+     * Node 2
+     test 2
+     """
+
+     # after parsing, all keys are set
+     root = loads(content)
+     import pytest
+
+     assert root.get_file_property_list('TITLE') == ['Test', 'alternate title']
+     with pytest.raises(RuntimeError):
+         # raises because there are multiple of them
+         root.get_file_property('TITLE')
+
+
+def test_filetags_are_tags() -> None:
+    content = '''
+#+FILETAGS: :f1:f2:
+
+* heading :h1:
+** child :f2:
+    '''.strip()
+    root = loads(content)
+    # breakpoint()
+    assert root.tags == {'f1', 'f2'}
+    child = root.children[0].children[0]
+    assert child.tags == {'f1', 'f2', 'h1'}
+
+
+def test_load_filelike() -> None:
+    import io
+    stream = io.StringIO('''
+* heading1
+* heading 2
+''')
+    root = load(stream)
+    assert len(root.children) == 2
+    assert root.env.filename == '<file-like>'
+
+
+def test_level_0_properties() -> None:
+    content = '''
+foo bar
+
+:PROPERTIES:
+:PROP-FOO: Bar
+:PROP-BAR: Bar bar
+:END:
+
+* heading :h1:
+:PROPERTIES:
+:HEADING-PROP: foo
+:END:
+** child :f2:
+    '''.strip()
+    root = loads(content)
+    assert root.get_property('PROP-FOO') == 'Bar'
+    assert root.get_property('PROP-BAR') == 'Bar bar'
+    assert root.get_property('PROP-INVALID') is None
+    assert root.get_property('HEADING-PROP') is None
+    assert root.children[0].get_property('HEADING-PROP') == 'foo'
+
+
+def test_level_0_timestamps() -> None:
+    content = '''
+foo bar
+
+  - <2010-08-16 Mon> DateList
+  - <2010-08-07 Sat>--<2010-08-08 Sun>
+  - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList
+  - <2019-08-10 Sat 16:30-17:30> TimeRange"
+
+* heading :h1:
+** child :f2:
+    '''.strip()
+    root = loads(content)
+    assert root.datelist == [OrgDate((2010, 8, 16))]
+    assert root.rangelist == [
+        OrgDate((2010, 8, 7), (2010, 8, 8)),
+        OrgDate((2010, 8, 9, 0, 30), (2010, 8, 10, 13, 20)),
+        OrgDate((2019, 8, 10, 16, 30, 0), (2019, 8, 10, 17, 30, 0)),
+    ]
+
+def test_date_with_cookies() -> None:
+    testcases = [
+        ('<2010-06-21 Mon +1y>',
+         "OrgDate((2010, 6, 21), None, True, ('+', 1, 'y'))"),
+        ('<2005-10-01 Sat +1m>',
+         "OrgDate((2005, 10, 1), None, True, ('+', 1, 'm'))"),
+        ('<2005-10-01 Sat +1m -3d>',
+         "OrgDate((2005, 10, 1), None, True, ('+', 1, 'm'), ('-', 3, 'd'))"),
+        ('<2005-10-01 Sat -3d>',
+         "OrgDate((2005, 10, 1), None, True, None, ('-', 3, 'd'))"),
+        ('<2008-02-10 Sun ++1w>',
+         "OrgDate((2008, 2, 10), None, True, ('++', 1, 'w'))"),
+        ('<2008-02-08 Fri 20:00 ++1d>',
+         "OrgDate((2008, 2, 8, 20, 0, 0), None, True, ('++', 1, 'd'))"),
+        ('<2019-04-05 Fri 08:00 .+1h>',
+         "OrgDate((2019, 4, 5, 8, 0, 0), None, True, ('.+', 1, 'h'))"),
+        ('[2019-04-05 Fri 08:00 .+1h]',
+         "OrgDate((2019, 4, 5, 8, 0, 0), None, False, ('.+', 1, 'h'))"),
+        ('<2007-05-16 Wed 12:30 +1w>',
+         "OrgDate((2007, 5, 16, 12, 30, 0), None, True, ('+', 1, 'w'))"),
+    ]
+    for (input, expected) in testcases:
+        root = loads(input)
+        output = root[0].datelist[0]
+        assert str(output) == input
+        assert repr(output) == expected
+    testcases = [
+        ('<2006-11-02 Thu 20:00-22:00 +1w>',
+         "OrgDate((2006, 11, 2, 20, 0, 0), (2006, 11, 2, 22, 0, 0), True, ('+', 1, 'w'))"),
+        ('<2006-11-02 Thu 20:00--22:00 +1w>',
+         "OrgDate((2006, 11, 2, 20, 0, 0), (2006, 11, 2, 22, 0, 0), True, ('+', 1, 'w'))"),
+    ]
+    for (input, expected) in testcases:
+        root = loads(input)
+        output = root[0].rangelist[0]
+        assert str(output) == "<2006-11-02 Thu 20:00--22:00 +1w>"
+        assert repr(output) == expected
+    # DEADLINE and SCHEDULED
+    testcases2 = [
+        ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat +1m>',
+         "<2005-10-01 Sat +1m>",
+         "OrgDateDeadline((2005, 10, 1), None, True, ('+', 1, 'm'))"),
+        ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat +1m -3d>',
+         "<2005-10-01 Sat +1m -3d>",
+         "OrgDateDeadline((2005, 10, 1), None, True, ('+', 1, 'm'), ('-', 3, 'd'))"),
+        ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat -3d>',
+         "<2005-10-01 Sat -3d>",
+         "OrgDateDeadline((2005, 10, 1), None, True, None, ('-', 3, 'd'))"),
+        ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat ++1m>',
+         "<2005-10-01 Sat ++1m>",
+         "OrgDateDeadline((2005, 10, 1), None, True, ('++', 1, 'm'))"),
+        ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat .+1m>',
+         "<2005-10-01 Sat .+1m>",
+         "OrgDateDeadline((2005, 10, 1), None, True, ('.+', 1, 'm'))"),
+    ]
+    for (input, expected_str, expected_repr) in testcases2:
+        root = loads(input)
+        output = root[1].deadline
+        assert str(output) == expected_str
+        assert repr(output) == expected_repr
+    testcases2 = [
+        ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat +1m>',
+         "<2005-10-01 Sat +1m>",
+         "OrgDateScheduled((2005, 10, 1), None, True, ('+', 1, 'm'))"),
+        ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat +1m -3d>',
+         "<2005-10-01 Sat +1m -3d>",
+         "OrgDateScheduled((2005, 10, 1), None, True, ('+', 1, 'm'), ('-', 3, 'd'))"),
+        ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat -3d>',
+         "<2005-10-01 Sat -3d>",
+         "OrgDateScheduled((2005, 10, 1), None, True, None, ('-', 3, 'd'))"),
+        ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat ++1m>',
+         "<2005-10-01 Sat ++1m>",
+         "OrgDateScheduled((2005, 10, 1), None, True, ('++', 1, 'm'))"),
+        ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat .+1m>',
+         "<2005-10-01 Sat .+1m>",
+         "OrgDateScheduled((2005, 10, 1), None, True, ('.+', 1, 'm'))"),
+    ]
+    for (input, expected_str, expected_repr) in testcases2:
+        root = loads(input)
+        output = root[1].scheduled
+        assert str(output) == expected_str
+        assert repr(output) == expected_repr
diff --git a/.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py b/.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py
new file mode 100644
index 00000000..7fb911b9
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/orgparse/tests/test_rich.py
@@ -0,0 +1,89 @@
+'''
+Tests for rich formatting: tables etc.
+'''
+from .. import load, loads
+from ..extra import Table
+
+import pytest
+
+
+def test_table() -> None:
+    root = loads('''
+|       |           |     |
+|       | "heading" |     |
+|       |           |     |
+|-------+-----------+-----|
+| reiwf | fef       |     |
+|-------+-----------+-----|
+|-------+-----------+-----|
+| aba   | caba      | 123 |
+| yeah  |           |   X |
+
+    |------------------------+-------|
+    | when                   | count |
+    | datetime               | int   |
+    |------------------------+-------|
+    |                        | -1    |
+    | [2020-11-05 Thu 23:44] |       |
+    | [2020-11-06 Fri 01:00] | 1     |
+    |------------------------+-------|
+
+some irrelevant text
+
+| simple |
+|--------|
+| value1 |
+| value2 |
+    ''')
+
+    [gap1, t1, gap2, t2, gap3, t3, gap4] = root.body_rich
+
+    t1 = Table(root._lines[1:10])
+    t2 = Table(root._lines[11:19])
+    t3 = Table(root._lines[22:26])
+
+    assert ilen(t1.blocks) == 4
+    assert list(t1.blocks)[2] == []
+    assert ilen(t1.rows) == 6
+
+    with pytest.raises(RuntimeError):
+        list(t1.as_dicts) # not sure what should it be
+
+    assert ilen(t2.blocks) == 2
+    assert ilen(t2.rows) == 5
+    assert list(t2.rows)[3] == ['[2020-11-05 Thu 23:44]', '']
+
+
+    assert ilen(t3.blocks) == 2
+    assert list(t3.rows) == [['simple'], ['value1'], ['value2']]
+    assert t3.as_dicts.columns == ['simple']
+    assert list(t3.as_dicts) == [{'simple': 'value1'}, {'simple': 'value2'}]
+
+
+def test_table_2() -> None:
+    root = loads('''
+* item
+
+#+tblname: something
+| date                 | value | comment                       |
+|----------------------+-------+-------------------------------|
+| 14.04.17             |  11   | aaaa                          |
+| May 26 2017 08:00    |  12   | what + about + pluses?        |
+| May 26 09:00 - 10:00 |  13   | time is                       |
+
+    some comment
+
+#+BEGIN_SRC python :var fname="plot.png" :var table=something :results file
+fig.savefig(fname)
+return fname
+#+END_SRC
+
+#+RESULTS:
+[[file:plot.png]]
+''')
+    [_, t, _] = root.children[0].body_rich
+    assert ilen(t.as_dicts) == 3
+
+
+def ilen(x) -> int:
+    return len(list(x))