gcal2org / gcal2org.py

#!/usr/bin/env python
# Copyright (C) 2011 by Felix Geller
# 
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# There are two main usages:
# - updating a the title of a single event in default calendar,
# - downloading calendar entries for a given calendar that are at most
#   30 days in the past or future and printing them as a org-entry to
#   a given file.
#
# The first is mainly useful for me, or rather how I use org-mode. I
# use it in connection with the following ELisp snippet to update
# tasks from TODO to DONE. If I toggle the TODO state of an entry and
# if it has a property named GCalId, this script is invoked to publish
# the new title.
#
# (defun fg/publish-state-entry-state-change-to-gcal ()
#   (let ((new-title (org-get-heading))
# 	(gcal-id (org-entry-get nil "GCalId")))
#     (when gcal-id
#       (start-process 
#        "push2gcal" "*push2gcal*" 
#        "gcal2org.py" "me@gmail.com" "update" gcal-id "text" new-title))))
# (add-hook 'org-after-todo-state-change-hook
# 	  'fg/publish-state-entry-state-change-to-gcal) 
#
# Invoke without arguments for usage examples. Or scroll down.
#
# N.B.: Uses authinfo_pw to retrieve a password from an encrypted
# file. Please change this according to your preferences.
#
# Acknowledgment: Thanks to Rasmus for adding the capability of
# specifying other calendar URIs.

import gdata.calendar.client
from datetime import date, datetime, timedelta
from time import strftime, strptime, mktime, localtime, sleep
import sys, re, os, getopt


USAGE = """Download default calendar:
 > gcal2org.py download me@gmail.com file.org

Download other (kitty's) calendar:
 > gcal2org.py download me@gmail.com file.org "http://www.google.com/calendar/feeds/kitty@gmail.com/private/full/"

Update single event:
 > gcal2org.py me@gmail.com update strange-event-id title new-title"""

EVENT_URI_PREFIX = 'http://www.google.com/calendar/feeds/default/private/full/'

PREAMBLE = """#+STARTUP: overview
#+DESCRIPTION: Converted on %s
* Events
"""

ITEM = """** %s
   :PROPERTIES:
   :GCalId:       %s
   :When:     %s
   :Where:    %s
   :Who:      %s
   :END:
 - Description:
%s
"""

def date_range_query(cal_client,
                     cal_uri,
                     max_results = 333, 
                     start_date = str(date.today() - timedelta(30)), 
                     end_date = str(date.today() + timedelta(30))):
  query = gdata.calendar.client.CalendarEventQuery()
  query.start_min = start_date
  query.start_max = end_date
  query.max_results = max_results
  feed = cal_client.GetCalendarEventFeed(q=query, uri=cal_uri)
  return feed.entry

def date_range(start_time, end_time):
  if 'T' in start_time:
    st = strptime(start_time[:16], '%Y-%m-%dT%H:%M')
    et = strptime(end_time[:16], '%Y-%m-%dT%H:%M')
    tstamp = '<%Y-%m-%d %a %H:%M>'
  else:
    st = strptime(start_time, '%Y-%m-%d')
    et = localtime(mktime(strptime(end_time, '%Y-%m-%d')) - 86400.0)
    tstamp = '<%Y-%m-%d %a>'
  return '%s--%s' % tuple([strftime(tstamp, t) for t in (st, et)])

def write_item(event, org_file):
  event_id = event.id.text
  title = event.title.text
  when = date_range(event.when[0].start, event.when[0].end)
  where = event.where[0].text or 'N/A'
  who = ', '.join([x.value for x in event.who])
  description = event.content.text or 'N/A'
  itemdata =  tuple([s.encode('UTF-8') for s in (title, event_id, when, where, who, description)])
  org_file.write(ITEM % itemdata)

def authinfo_pw(login):
  p = re.compile('login %s password ([^ ]+)' % login)
  authinfo = os.popen('gpg -q --no-tty -d ~/.gcal.gpg').read()
  return p.search(authinfo).group(1)

def create_calendar_client(login):
  client = gdata.calendar.client.CalendarClient(source='acme-gcal2org-v1')
  client.ssl = True
  client.ClientLogin(login, authinfo_pw(login), client.source)
  return client

def create_org_file(filename, events):
  org_file = open(filename, 'w')
  org_file.write(PREAMBLE % datetime.now())
  for event in events:
    write_item(event, org_file)
  org_file.close()

def update_event(client, event_id, fields):
  uri = EVENT_URI_PREFIX + event_id
  event = client.GetEventEntry(uri, auth_token=client.auth_token)
  for k,v in fields.items():
    if k == 'title': event.title.text = v
  client.Update(event)

class Usage(Exception):
    def __init__(self, msg):
        self.msg = msg

def restructure_field_args(fs):
  if len(fs) % 2 != 0:
    raise Usage("Please supply key/value pairs, rather than: %s." % fs)
  fields = {}
  for i in range(len(fs)):
    if i % 2 == 0:
      fields[fs[i]] = fs[i+1]
  return fields

def main(retry_count=5):
  try:
    if 4 > len(sys.argv): raise Usage(USAGE)
    else:
      client = create_calendar_client(sys.argv[1])
      op = sys.argv[2]
      if op == 'update':
        event_id = sys.argv[3]
        fields = restructure_field_args(sys.argv[4:])
        update_event(client, event_id, fields)
      elif op == 'download':
        filename = sys.argv[3]
        cal_uri = sys.argv[4] if (5 == len(sys.argv)) else None
        events = date_range_query(client, cal_uri)
        create_org_file(filename, events)
      else: raise Usage('Operation "%s" not supported.' % op)

  except Usage, err:
    print >>sys.stderr, msg
    return 2
  except gdata.client.RedirectError, err:
    if retry_count > 0:
      print >>sys.stderr, ("gcal2org: Retry #%s" % (5 - retry_count))
      sleep(1)
      main(retry_count=(retry_count - 1))
    else:
      raise err

if __name__ == '__main__':
  sys.exit(main())
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.