Toxic Elephant

Don't bury it in your back yard!

Importing GTG tasks into Taskwarrior

Posted by matijs 06/06/2018 at 12h47

I used to use Getting Things Gnome (GTG) to keep my TODO list. However, the project seems dead right in the middle of its Gtk+ 3.0 port, so I’ve been looking around for an alternative. After much consideration, I decided on Taskwarrior. I wanted to keep my old tasks and couldn’t find a nice way to export them from GTG, let alone import them into Taskwarrior. So in the end I decided to create my own exporter.

Getting Things Gnome keeps your tasks in some simple XML files in a known location. HappyMapper is ideal for this. I started out using its automatic mapping, but as my understanding of the GTG format deepened, I switched to explicit mapping of a Task’s attributes and elements.

On the other side, Taskwarrior can import simple JSON files that are super easy to create using JSON from the standard library. The script below will output this format to STDOUT. It’s up to you to use task import to process it further.

I implemented this as a spike, so there are no tests, but I like to think the design I ended up with is quite testable. I get annoyed whenever code becomes cluttered, or top-level instance variables start to appear. So I tend to quickly split off classes that have a distinct responsibility. I may yet convert this to a real gem and see how easy it is to bring everything under test.

Finally, before showing the code, I should warn you that it’s probably a good idea to back up your existing Taskwarrior data before playing with this.

Here’s the code:

#!/usr/bin/env ruby

require 'happymapper'
require 'json'

class Task
  include HappyMapper

  attribute :id, String
  attribute :status, String
  attribute :tags, String
  attribute :uuid, String

  element :title, String
  element :startdate, String
  element :duedate, String
  element :modified, DateTime
  element :donedate, String
  has_many :subtasks, String, tag: 'subtask'
  element :content, String
end

class TaskList
  def initialize(tasks)
    @tasks = tasks

    @tasks_hash = {}
    @tasks.each do |task|
      @tasks_hash[task.id] = task
    end
  end

  def each_task(&block)
    @tasks.each &block
  end

  def find(task_id)
    @tasks_hash[task_id]
  end

  def root_task(task)
    parent = @tasks.find { |it| it.subtasks.include? task.id }
    parent && root_task(parent) || task
  end
end

class TaskProcessor
  def initialize(task_list, handler)
    @task_list = task_list
    @handler = handler
    @processed = {}
  end

  def process
    @processed.clear
    @task_list.each_task do |task|
      next if @processed[task.id]
      root = @task_list.root_task(task)
      process_task root
    end

    @task_list.each_task do |task|
      raise "Task #{task.id} not processed" unless @processed[task.id]
    end
  end

  def self.process(tasks, handler)
    new(tasks, handler).process
  end

  private

  def process_task(task, level = 0)
    @handler.handle(task, level)
    @processed[task.id] = true
    process_subtasks task.subtasks, level + 1
  end

  def process_subtasks(subtask_ids, level)
    subtask_ids.each do |task_id|
      raise "Task #{task_id} already processed" if @processed[task_id]
      task = @task_list.find(task_id)
      process_task task, level
    end
  end
end

class TaskWarriorExporter
  def initialize(task_list)
    @task_list = task_list
  end

  def handle(task, level)
    status = case task.status
             when 'Dismiss'
               'deleted'
             when 'Done'
               'completed'
             when 'Active'
               'pending'
             else
               raise "Unknown: #{task.status}"
             end

    data = {
      description: task.title,
      status: status,
      uuid: task.uuid,
    }
    if task.duedate
      if task.duedate == 'soon'
        data[:priority] = 'H'
      else
        data[:due] = task.duedate
      end
    end
    data[:end] = task.donedate if task.donedate
    data[:scheduled] = task.startdate if task.startdate

    entry = guess_entry(task)
    data[:entry] = entry

    subtask_uuids = task.subtasks.map do |subtask_id|
      @task_list.find(subtask_id).uuid
    end
    if subtask_uuids.any?
      data[:depends] = subtask_uuids.join(',')
    end
    data[:tags] = task.tags unless task.tags.empty?
    if task.content
      data[:annotations] = [ { entry: entry, description: task.content } ]
    end
    puts data.to_json
  end

  private

  def guess_entry(task)
    dates = [task.duedate, task.donedate, task.startdate].compact.
      reject { |it| %w(someday soon).include? it }.
      sort
    dates.first || task.modified.to_s
  end
end

projects_file = File.expand_path '~/.local/share/gtg/projects.xml'
projects = HappyMapper.parse File.read projects_file
tasks_file = projects.backend.path
tasks = Task.parse File.read tasks_file
task_list = TaskList.new tasks

TaskProcessor.process(task_list, TaskWarriorExporter.new(task_list))

Tags , , , , 1 comment no trackbacks

Comments

  1. Matijs van Zuijlen said 06/06/2018 at 12h51:

    If you want to suggest changes, there’s a gist for this.

(leave url/email »)

Comment Markup Help Preview comment