Commits

Andy Mikhailenko  committed 866b665

ext.hamster: Improved handling of time zones; fixed some other bugs.

  • Participants
  • Parent commits 67075e0

Comments (0)

Files changed (2)

File orgtool/ext/hamster/__init__.py

 class HamsterPlugin(BasePlugin):
     """CLI interface.
     """
-    identity = 'hamster'
-    requires = ('tool.ext.documents',)
+    features = 'hamster'
+    requires = ('{document_storage}',)
     commands = [import_facts, update_facts, purge_facts]
+
+    def make_env(self, default_timezone='UTC'):
+        return {'tz': default_timezone}

File orgtool/ext/hamster/commands.py

 import dbus.exceptions
 import hamster.client
 import pytz
-from tool.cli import arg
+import time
+from tool import app
+from tool.cli import arg, confirm, CommandError
 from doqu import Document
-from tool.ext.documents import default_storage
 from orgtool.ext.events import Plan
 from schema import HamsterFact
 
 
-# Hamster stores dates in local timezone, darn
-# TODO: at least use config; maybe smth else?
-#
-# FIXME здесь полная путаница с часовыми поясами; я точно несколько раз забывал
-# их поменять, так что придется всё импортировать ЗАНОВО.
-# И просто необходимо какую-то сделать возм-ть указать точки смены часовых
-# поясов. Или уже положить на Хамстер и делать свое приложение.
-#
-TIMEZONE = 'Asia/Yekaterinburg'
+# Hamster stores dates in local timezone, darn.
+TIMEZONE_NAME = 'UTC'
 
 
-@arg('--date')
+@arg('--since', help='start date (YYYY-MM-DD[-HH:MM], local time)')
+@arg('--until', help='end date (YYYY-MM-DD[-HH:MM], local time)')
 @arg('-d', '--dry-run', default=False, help='do not really save anything')
+@arg('-z', '--timezone')
+@arg('--guess-tz', default=False, help='obtain timezone from adjacent facts')
 def import_facts(args):
-    """Imports all available facts from Hamster. Checks uniqueness."""
-    db = default_storage()
-    if args.date:
-        start_date = datetime.datetime.strptime(args.date, '%Y-%m-%d').date()
+    """Imports all available facts from Hamster. Checks uniqueness.
+
+    :param guess-tz:
+
+        The timezone of every imported fact is obtained from adjacent facts.
+        Useful if the changeset spans previously imported data from multiple
+        timezones. Very slow.
+
+    """
+    db = app.get_feature('document_storage').default_db
+    ext = app.get_feature('hamster')
+
+    def get_date_or_datetime(string):
+        try:
+            return datetime.datetime.strptime(string, '%Y-%m-%d-%H:%M')
+        except ValueError:
+            try:
+                return datetime.datetime.strptime(string, '%Y-%m-%d')
+            except ValueError:
+                raise ValueError('Could not parse date "{0}"'.format(string))
+
+    if args.since:
+        start_time = get_date_or_datetime(args.since)
     else:
-        imported = HamsterFact.objects(db).order_by('date_time', reverse=True)
+        imported = db.find(HamsterFact).order_by('date_time', reverse=True)
         if imported:
-            start_date = imported[0].date_time
+            start_time = imported[0].date_time
         else:
-            start_date = datetime.date(1980,1,1)
+            start_time = datetime.date(1980,1,1)
 
-    print('Importing all facts since {0}'.format(start_date))
+    if args.until:
+        end_time = get_date_or_datetime(args.until)
+    else:
+        end_time = datetime.datetime.utcnow() + datetime.timedelta(hours=12)
+
+    # timezone: argument or config or UTC
+    tz_name = args.timezone or ext.env.get('tz', TIMEZONE_NAME)
+    if not args.guess_tz and not args.timezone:
+        if not confirm('All facts between {start_time} and {end_time} took '
+                       'place in {tz_name}'.format(**locals()), default=True):
+            raise CommandError('Please specify another time zone or provide '
+                               'more precise dates.')
+
     if args.dry_run:
         print('(dry run, no data will be actually saved.)')
 
-    imported_cnt = 0
+    seen_cnt = imported_cnt = 0
+    errors = []
 
     storage = hamster.client.Storage()
 
-    for data in storage.get_facts(start_date, datetime.date.today()):
-        saved = _import_fact(data, dry_run=args.dry_run)
+    for data in storage.get_facts(start_time, end_time):
+        seen_cnt += 1
+        if not all([data.start_time, data.end_time]):
+            print 'WARNING: missing date in', data
+            errors.append(data)
+            time.sleep(1)
+            continue
+        if data.start_time < start_time or end_time < data.end_time:
+            # This should have been caught by the Hamster API but it seems to
+            # round date/time up to date, damnit. When you switch TZ, it's
+            # really important to specify precise *time*.
+            continue
+        saved = _import_fact(data, dry_run=args.dry_run, tz_name=tz_name,
+                             guess_tz=args.guess_tz)
         if saved:
             imported_cnt += 1
 
-    print('Imported {imported_cnt} facts.'.format(**locals()))
+    print('Processed {seen_cnt}, imported {imported_cnt} '
+          'facts.'.format(**locals()))
+    if errors:
+        print '!!! ERRORS:'
+        print
+        for data in errors:
+            print '*', data.id, data.start_time, '-', data.end_time, ':', data
+            print
     if args.dry_run:
         print('(None was actually saved in dry run mode.)')
 
     facts instead of updating them when other fields change their values, e.g.
     description or tags.
     """
-    imported = HamsterFact.objects(db).order_by('date_time')
+    db = app.get_feature('document_storage').default_db
+    imported = db.find(HamsterFact).order_by('date_time')
     start_date = imported[0].date_time if imported else datetime.date(1980,1,1)
 
     print('Updating all facts since {0}'.format(start_date))
     facts are in the scope of given Hamster storage. That is, all facts
     gathered from an older storage will be DROPPED. This should be fixed later.
     """
-    db = default_storage()
-    imported = HamsterFact.objects(db).order_by('date_time')
+    db = app.get_feature('document_storage').default_db
+    imported = db.find(HamsterFact).order_by('date_time')
     storage = hamster.client.Storage()
 
     seen_cnt = deleted_cnt = 0
     if args.dry_run:
         print('(None was actually deleted in dry run mode.)')
 
-def _convert_date(date):
+
+# TODO
+#@arg('since')
+#@arg('until')
+#@arg('timezone')
+#def change_tz(args):
+#    "Changes timezone of given events."
+#    raise NotImplementedError
+
+def _convert_date(date, tz_name):
     if date is None:
         # this really can happen, e.g. importing current activity = no end time
         return
-    timezone = pytz.timezone(TIMEZONE)
+    timezone = pytz.timezone(tz_name)
     local_date = timezone.localize(date)  # just add tzinfo
     utc_date = local_date.astimezone(pytz.utc)
     return pytz.utc.normalize(utc_date)
 
-def _prepare_data(data):
+def _prepare_data(data, tz_name):
     assert data.id
     return dict(
         summary = unicode(data.activity),
         details = unicode(data.description or ''),  # can be None
-        date_time = _convert_date(data.start_time),
-        date_time_end = _convert_date(data.end_time),
+        date_time = _convert_date(data.start_time, tz_name),
+        date_time_end = _convert_date(data.end_time, tz_name),
         tags = [unicode(x) for x in data.tags],
 
         x_hamster_type = u'fact',
         x_hamster_category = unicode(data.category),
         x_hamster_activity_id = int(data.activity_id),
         #x_hamster_delta = data['delta'],  # a datetime.timedelta obj!
+
+        x_hamster_orig_datetime = data.start_time,
+        x_hamster_timezone = unicode(tz_name),
     )
 
-def _import_fact(data, dry_run=False):
-    db = default_storage()
+def _import_fact(data, tz_name, dry_run=False, guess_tz=False):
+    db = app.get_feature('document_storage').default_db
     assert data.id
-    if HamsterFact.objects(db).where(x_hamster_id=int(data.id)).count():
+    if db.find(HamsterFact, x_hamster_id=int(data.id)).count():
         return False
-    prepared = _prepare_data(data)
+    if guess_tz:
+        # TODO: peek at the next one, too; ask if different
+        prevs = db.find(HamsterFact,
+                       x_hamster_orig_datetime__lte=data.start_time
+        ).order_by('x_hamster_orig_datetime', reverse=True)
+        if prevs.count():
+            tz_name = prevs[0].x_hamster_timezone
+    prepared = _prepare_data(data, tz_name=tz_name)
     fact = HamsterFact(**prepared)
     if not dry_run:
         fact.save(db)
-    print 'ADD', fact
+    print 'ADD', fact, tz_name
     return fact
 
 def _update_fact(data, dry_run=False):
-    db = default_storage()
-    facts = HamsterFact.objects(db).where(x_hamster_id=int(data['id']))
+    db = app.get_feature('document_storage').default_db
+    facts = db.find(HamsterFact, x_hamster_id=int(data.id))
     if not facts:
-        print 'no fact with id', repr(data['id'])
+        print 'no fact with id', repr(data.id)
         return False
     assert 1 == len(facts)
     fact = facts[0]
-    prepared = _prepare_data(data)
+    prepared = _prepare_data(data, fact.x_hamster_timezone)
     if prepared['tags'] == []:
         prepared['tags'] = None  # this is how the schema works
     for key in prepared:
         old_value = fact[key]
         if isinstance(old_value, datetime.datetime):
-            old_value = pytz.utc.localize(old_value)
+            # TODO: don't skip (messing with TZ-naive/aware datetimes)
+            continue
+#            if prepared[key].tzname():
+#                old_value = pytz.utc.localize(old_value)
         if prepared[key] != old_value:
             print '---', fact
-            print 'NOTEQ {0}: {1} vs. {2}'.format(
-                key, repr(prepared[key]), repr(fact[key]))
+            print 'changed {0}: {1} → {2}'.format(
+                key, repr(fact[key]), repr(prepared[key]))
             break
         #print 'EQ:', repr(prepared[key]), 'vs.', repr(fact[key])
     else: