#!/usr/bin/env python

"""Logs into the REDMINE-URL, retrieves the open issues, and inserts
them into ISSUE-FOLDER-PATH in OmniFocus.

  - REDMINE-URL: the URL of the redmine installation, e.g. "http://redmine.org"
  - ISSUE-FOLDER-PATH: colon-separated path to the OmniFocus folder
    under which Redmine tasks will be inserted e.g.
    "Earn a Living:Do work for clients"
  - MY-NAME: your full name in Redmine (not your login name); this is
    a regular expression, so you can match against a number of names,
    or compensate for changes in name representation.
  - ASSIGNED-CONTEXT-PATH: colon-separated path to the OmniFocus
    context that is set on tasks that are assigned to MY-NAME.
  - WAITING-CONTEXT-PATH: colon-separated path to the OmniFocus context that
    is set on tasks that are assigned to someone else.
  - AVAILABLE-CONTEXT-PATH: colon-separated path to the OmniFocus
    context that is set on tasks that are not assigned to anyone.

The idea is to get all your external tasks into one place.  You can
organise the imported tasks any way you like (including nesting them
inside each other), and the importer won't move them around _unless_
the project changes.  The context won't be changed unles the assignee
changes.  The task subject, descript, due date and completion status
will be overwritten on subsequent imports.

To use it, you should set the "Issues export limit" in Redmine's
"Issue Tracking" Settings tab to a high enough value such that all
issues are exported. The script also expects the date format to be set
in Redmine on the Display tab in Settings to the DD MMM YYYY format,
such as 19 Sep 2011.

The requirements are:
 
  1. Nothing is lost: If a task is assigned to you, then you must be
     able to see it in OmniFocus.

  2. Don't waste my time: Don't import things that you don't need to
     pay attention to:

     2.1. If you're not interested in a particular project and there are no
          tasks assigned to you in that project, you shouldn't have to track
          it in OmniFocus.

Tasks are inserted according to the following rules:

  1. A new task in Redmine will be added to a project with the same
     name in the ISSUE-FOLDER-PATH or one of its child folders.  If no
     matching project can be found, then a new project will be created
     under ISSUE-FOLDER-PATH if the task is assigned to MY-NAME.  If
     the issue has a target version, then the task will be inserted as
     a child of a task with that target version's name; the version
     task will be created if it doesn't exist.

  2. An OmniFocus task is considered to be a Redmine task if its name
     starts with '#'.

  3. The following attributes of an OmniFocus task will be set:

     - name: '#REDMINE-TASK-ID: REDMINE-SUBJECT'
     - note: The Redmine task description, a URL pointing to the task,
       misc. other useful stuff.
     - due date: The Redmine due date.
     - start date: The Redmine start date.
     - context:
       - If the Redmine task is assigned to MY-NAME, and the current
         context is WAITING, then make it AVAILABLE.
       - If the Redmine task is not assigned to ME, set the context to
         WAITING

  4. Tasks that have moved to a different project/version will be
     moved to that project/version; if the project isn't being tracked
     by OmniFocus, then the task is deleted.

  5. Tasks that are in OmniFocus and no longer in Redmine's open
     issues are set to be completed.
"""

#==============================================================================
from urllib2 import *
from urllib import urlencode
from urlparse import urljoin
import cookielib
from contextlib import closing
from BeautifulSoup import BeautifulSoup

class RedmineConnection(object):
    def __init__(self, redmine_url, username=None, password=None):
        self.url = redmine_url 
        self.cookiejar = cookielib.CookieJar()
        self.opener = build_opener(HTTPCookieProcessor(self.cookiejar))
        self.logged_in = False
        if username is not None and password is not None:
            self._login(username, password)
            self.logged_in = True

    def open(self, path, data=None):
        return self.opener.open(Request(urljoin(self.url, path), data))

    def close(self):
        if self.logged_in:
            self._logout()

    def _get(self, path, data=None):
        with closing(self.open(path, data)) as f:
            return f.read()

    def _login(self, username, password):
        data = {"username": username,
                "password": password,
                "login": "Login"}

        # If there's an authenticity token involved (introduced in
        # recent versions of redmine) then get it and submit it with the
        # login details
        loginform = self._get("login")
        soup = BeautifulSoup(loginform)
        token = soup.find(attrs={'name': 'authenticity_token'})
        if token is not None:
            data['authenticity_token'] = token['value']
        
        self._get("login", urlencode(data))

    def _logout(self):
        self._get("logout")

def redmine_12_fields(row):
    """Patch redmine 1.2 fields into pre-1.2 format"""
    if 'Assignee' in row:
        row['Assigned to'] = row['Assignee']
    if 'Subject' in row:
        row['Description'] = row['Subject']
    if 'Start date' in row:
        row['Start'] = row['Start date']
    return row

def redmine_issues(url, username, password):
    """Return a generator over a list of dicts representing the issues"""
    with closing(RedmineConnection(url, username, password)) as conn:
        with closing(conn.open("issues?format=csv")) as f:
        #    open("issues.csv", "w").write(f.read())
        #with open("issues.csv") as f:
            reader = csv.DictReader(f)
            for row in reader:
                #sys.stderr.write('.')
                #print row
                row = dict([(k, unicode(v, 'utf-8', 'replace')) for
                            (k, v) in row.iteritems()])
                row = redmine_12_fields(row)
                yield row

#==============================================================================
# logging

import codecs
import sys
sys.stdout = codecs.getwriter("UTF-8")(sys.stdout)

STATUS='status'
CHANGED='changed'
COMPLETED='completed'

console_log_type = ""
def console_log(type, msg):
    global console_log_type
    if console_log_type != type:
        print("=== " + type)
        console_log_type = type
    print(msg)

log = console_log

def use_growl_logger():
    from gntp.notifier import GrowlNotifier
    growl = GrowlNotifier(applicationName='redmine-to-omnifocus',
                          notifications=[STATUS, CHANGED, COMPLETED],
                          hostname='localhost', password='')
                          
    growl.register()

    def growl_log(type, msg):
        console_log(type, msg)
        growl.notify(title=type,
                     description=msg,
                     noteType=type)

    global log
    log = growl_log

#==============================================================================
import csv
import appscript
from appscript import k, its
import sys
from datetime import datetime

def sync_task(url, folder, projects, tasks,
              available, waiting, assigned, me, row):

    def get_existing_task(tasks, id):
        t = tasks.get(id)
        if t is not None:
            del tasks[id]
        return t

    assigned_to_me = re.match(me, row['Assigned to'], re.I) is not None
    
    p = projects.get(row['Project'])
    t = get_existing_task(tasks, int(row['#']))

    # Ugly: a container so we can create a closure on it in set()
    # below
    changed = [None]

    if p is None and not assigned_to_me:
        # The task exists but the project has gone: delete the task
        if t is not None:
            t.delete()
    else:
        if p is None:
            p = folder.make(new=k.project,
                            with_properties={k.name: row['Project']})
            projects[row['Project']] = p
        
        name = "#%s: %s" % (row['#'], row['Subject'])
        note = "%s\n\nAssigned to: %s\tStatus: %s\n\n%s" % \
               (urljoin(url, "issues/show/" + row['#']),
                row['Assigned to'],
                row['Status'],
                row['Description'].replace("\r\n","\n").replace("\r","\n"))
        context = waiting
        due_date = k.missing_value
        if len(row['Due date']):
            due_date = datetime.strptime(row['Due date'], "%d %b %Y")
        start_date = k.missing_value
        if len(row['Start']):
            start_date = datetime.strptime(row['Start'], "%d %b %Y")
        estimated_minutes = k.missing_value
        if len(row['Estimated time']):
            estimated_minutes = int(round(float(row['Estimated time']) * 60))
        
        
        if assigned_to_me:
            context = assigned
        elif not len(row['Assigned to']):
            context = available
        else:
            # Don't assign a due date to items that we can't do anything
            # about because they're not available or assigned to us
            due_date = k.missing_value

        # Don't set things if they haven't changed, otherwise
        # OmniFocus will record large numbers of changes => big
        # transactions if you sync.
        def set(prop, val):
            # estimated_minutes is sometimes 0, sometimes k.missing_value.
            # No idea why.  Anyway, we bodge comparisons so that 0 and
            # k.missing_value are considered equal.
            if (val == k.missing_value and prop.get() == 0) or \
               (val == 0 and prop.get() == k.missing_value):
                return
            
            if prop.get() != val:
                print prop, "=", prop.get(), "->", val
                changed[0] = t
                prop.set(val)

        if t is None:
            t = p.make(new=k.task, with_properties={k.name: name,
                                                    k.note: note,
                                                    k.context: context})
            t.due_date.set(due_date)
            t.start_date.set(start_date)
            t.estimated_minutes.set(estimated_minutes)
            set(p.status, k.active)
            changed[0] = t
        else:
            set(t.name, name)
            set(t.note, note)
            set(t.due_date, due_date)
            set(t.start_date, start_date)
            set(t.estimated_minutes, estimated_minutes)
            set(t.completed, False)
            if context is assigned:
                if t.context.id.get() in (waiting.id.get(), available.id.get()):
                    t.context.set(context)
                    set(p.status, k.active)
            else:
                if t.context.id.get() != context.id.get():
                    set(t.context, context)

        tv = None
        if len(row['Target version']):
            tv = find_task(p, row['Target version'])
            if tv is None:
                tv = p.make(new=k.task,
                            with_properties={k.name: row['Target version']})

        # Move the task to the right place if necessary
        if tv is not None and not is_descendant_of(t, tv):
            t.move(to=tv.tasks.beginning)
            changed[0] = t
        elif t.containing_project.get() != p.get():
            t.move(to=p.tasks.beginning)
            changed[0] = t

    return changed[0]

def find_task(container, name):
    for t in container.tasks[its.name == name].get():
        if t.name.get() == name:
            return t
    for t in container.tasks.get():
        t0 = find_task(t, name)
        if t0 is not None:
            return t0
    return None

def is_descendant_of(taska, taskb):
    """Return whether taska is a descendant of taskb"""
    while taska.parent_task.get() != taska.containing_project.root_task.get():
        if taska.parent_task.get() == taskb.get():
            return True
        taska = taska.parent_task
    return False

def of_folder(doc, path):
    """Get the task container represented by path"""
    folder = doc
    for f in path:
        folder = folder.folders[f]
    return folder

def of_context(doc, path):
    """Get the context represented by path"""
    context = doc
    for f in path:
        context = context.contexts[f]
    return context

def existing_tasks_(container, tasks):
    for t in container.tasks.get():
        if t.name.get().startswith('#'):
            (issue, subject) = t.name.get().split(':', 1)
            tasks[int(issue[1:])] = t
        existing_tasks_(t, tasks)
        
def existing_tasks(folder, tasks = None):
    if tasks is None:
        tasks = {}
    for p in folder.projects.get():
        existing_tasks_(p, tasks)
    for f in folder.folders.get():
        existing_tasks(f, tasks)
    return tasks

def existing_projects(folder, projects = None):
    if projects is None:
        projects = {}
    for p in folder.projects.get():
        projects[p.name.get()] = p
    for f in folder.folders.get():
        existing_projects(f, projects)
    return projects

def process(rows, url, folder_path, me,
            available_path, waiting_path, assigned_path):

    doc = appscript.app('OmniFocus').default_document
    folder = of_folder(doc, folder_path)
    available = of_context(doc, available_path)
    waiting = of_context(doc, waiting_path)
    assigned = of_context(doc, assigned_path)
    tasks = existing_tasks(folder)
    projects = existing_projects(folder)

    changed = []

    for row in rows:
        changed_task = sync_task(url, folder, projects, tasks,
                                 available, waiting, assigned, me, row)
        if changed_task is not None:
            changed.append(changed_task)

    # Any existing tasks that were not seen must either have been
    # deleted or completed; we err on the side of caution, and set
    # them to complete.
    completed = []
    for t in tasks.itervalues():
        if not t.completed.get():
            t.completed.set(True)
            completed.append(t)

    def logformat(t):
        return t.containing_project.name.get() + ": " + t.name.get()

    for t in changed:
        log("changed", logformat(t))
            
    for t in completed:
        log("completed", logformat(t))

#==============================================================================

import optparse

class OptionParser(optparse.OptionParser):
    def format_description(self, formatter):
        return self.get_description()

def main():
    parser = OptionParser(usage="usage: %prog [options] "
                          "REDMINE-URL "
                          "MY-NAME "
                          "ISSUE-FOLDER-PATH "
                          "ASSIGNED-CONTEXT-PATH "
                          "WAITING-CONTEXT-PATH "
                          "AVAILABLE-CONTEXT-PATH",
                          description=__doc__)

    parser.add_option('--username', help="Login as USERNAME")
    parser.add_option('--password', help="Login with PASSWORD")
    parser.add_option('--use-growl', help="Use growl for notifications",
                      action="store_true", default=False)

    opts, args = parser.parse_args()

    if len(args) != 6:
        parser.error("incorrect number of arguments")

    if opts.use_growl:
        use_growl_logger()

    # We have to strip the args because Automator uses \r as a
    # line-ending character and this is not recognised by Python,
    # which means that WAITING-CONTEXT-PATH gets a \r as its last
    # character which we definitely don't want.
    (url, my_name, folder_path,
     assigned_path, waiting_path, available_path) = [a.strip() for a in args]

    if not url.endswith('/'):
        url = url + '/'

    (username, password) = (opts.username, opts.password)

    log("status", "synchronising redmine...")

    process(redmine_issues(url, username, password),
            url,
            folder_path.split(':'),
            my_name,
            available_path.split(':'),
            waiting_path.split(':'),
            assigned_path.split(':'))

    log("status", "synchronising redmine...done")

    return 0

#==============================================================================

if __name__ == "__main__":
    sys.exit(main())
