Repository: achabotl/popthings

Issue:
Parse dates in popthings instead of Things.app
As of Things 3.8, it still parse dates with a `-` as if it was a `+`, e.g., "Today - 1 day" is actually tomorrow…

I could write my own, possible with the help of `dateutil`, or consider using [dateparser](https://dateparser.readthedocs.io/en/latest/) by [Scraping Hub](https://blog.scrapinghub.com/2015/11/09/parse-natural-language-dates-with-dateparser).


Comments:

Patch:
diff --git a/popthings.py b/popthings.py
index eeda9e6..4310e2d 100644
--- a/popthings.py
+++ b/popthings.py
@@ -24,7 +24,7 @@ what's possible. They would be added to Things as two separate projects.
         Note under project 1
         - Task 1 @due($start + 1w) @$where
             A note under task 1
-        - Task 2 @start($start)
+        - Task 2 @start($start - 1)
             - Checklist item under task 2
                 - Also a checklist item under task 2
         Heading 1:
@@ -40,9 +40,11 @@ Attributes
 ----------
 
 """
+from datetime import datetime, timedelta
 from io import open
 import json
 import logging
+import operator
 import re
 import sys
 try:
@@ -87,6 +89,65 @@ PATTERN_TAG = re.compile(r"""(?:^|\s+)@             # space and @ before tag
                                                     # space or EOL
                              """, re.VERBOSE)
 
+# Patterns to match dates and dates with day offsets
+ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+DATE_OFFSET_RE = re.compile(r"""
+    (?P<base_date>\d{4}-\d{2}-\d{2})
+    \s*
+    (?P<op>[+-])
+    \s*
+    (?P<offset_count>\d+)
+    """, re.X)
+
+
+def compute_date(date_str):
+    """
+    Compute new date string given dates with day offsets.
+
+    Parameters
+    ==========
+    date_str : string
+        Text string representing a date. The format must be a standalone date:
+        'YYYY-MM-DD', a date with a day offset: 'YYYY-MM-DD + 1' or 'YYYY-MM-DD
+        - 10'. Other values as just passed through, assuming Things knows how
+        to handled them,  like 'today', 'tomorrow', 'evening', 'next month',
+        etc.
+
+    Returns
+    =======
+    new_date_str : string
+        Date where the offset has been added or subtracted from the date.
+
+    """
+    date_str = date_str.strip()
+
+    # Simple ISO date
+    if ISO_DATE_RE.match(date_str):
+        return date_str
+
+    elif DATE_OFFSET_RE.match(date_str):
+        # Precompute dates with offsets of days
+        m = DATE_OFFSET_RE.match(date_str)
+        if m is None:
+            raise ValueError(f"Unable to parse date '{date_str}' as 'YYYY-MM-DD+/-offset' date.")
+
+        try:
+            op = {
+                '-': operator.sub,
+                '+': operator.add,
+            }.get(m.group('op'))
+        except KeyError:
+            raise ValueError(f"Unable to parse date {date_str} as YYYY-MM-DD +/- D date. The operator is wrong.")
+
+        count = int(m.group('offset_count'))
+        date_delta = timedelta(count)
+
+        date = datetime.strptime(m.group("base_date"), '%Y-%m-%d')
+
+        return op(date, date_delta).strftime('%Y-%m-%d')
+
+    return date_str
+
 
 class TPNode(object):
     def __init__(self, line, text, indent, type, line_number=None, tags=None):
@@ -442,8 +503,8 @@ class _ThingsRichObject(ThingsObject):
         """
         super(_ThingsRichObject, self).__init__(title)
         self.notes = notes
-        self.when = when
-        self.deadline = deadline
+        self.when = compute_date(when) if when else when
+        self.deadline = compute_date(deadline) if deadline else deadline
         if tags is None:
             tags = []
         self.tags = tags


Test patch:
diff --git a/tests/test_date_parsing.py b/tests/test_date_parsing.py
new file mode 100644
index 0000000..127090a
--- /dev/null
+++ b/tests/test_date_parsing.py
@@ -0,0 +1,33 @@
+from unittest import TestCase
+from popthings import compute_date
+
+
+class TestDateParsing(TestCase):
+    def test_iso_date(self):
+        """ ISO formatted dates: YYYY-MM-DD."""
+        date = "2018-12-31"
+        self.assertEqual(date, compute_date(date))
+
+    def test_iso_date_with_space(self):
+        date = " 2018-12-31 "
+        self.assertEqual("2018-12-31", compute_date(date))
+
+    def test_invalid_date(self):
+        self.assertEqual("31/12/2018", compute_date("31/12/2018"))
+
+    def test_iso_date_with_positive_offset(self):
+        date = "2018-12-30 + 1"
+        self.assertEqual("2018-12-31", compute_date(date))
+
+    def test_iso_date_with_negative_offset(self):
+        date = "2018-12-30 - 1"
+        self.assertEqual("2018-12-29", compute_date(date))
+
+    def test_iso_date_with_negative_large_offset(self):
+        date = "2018-12-30 - 10"
+        self.assertEqual("2018-12-20", compute_date(date))
+
+    def test_other_values(self):
+        for word in ('today', 'tomorrow', 'evening', 'anytime', 'someday', 'next month'):
+            with self.subTest(word=word):
+                self.assertEqual(word, compute_date(word))