harvestthings 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +2 -0
- data/README.mdown +81 -0
- data/Rakefile +19 -0
- data/TODO +3 -0
- data/VERSION +1 -0
- data/harvestthings.gemspec +51 -0
- data/lib/harvestthings.rb +49 -0
- data/lib/harvestthings/application.rb +50 -0
- data/lib/harvestthings/harvest.rb +148 -0
- data/lib/harvestthings/sync.rb +219 -0
- data/lib/harvestthings/things.rb +40 -0
- data/lib/harvestthings/things/projects.rb +66 -0
- data/lib/harvestthings/things/tasks.rb +43 -0
- data/pkg/harvestthings-0.1.0.gem +0 -0
- metadata +69 -0
data/.gitignore
ADDED
data/README.mdown
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
HarvestThings
|
2
|
+
=============
|
3
|
+
|
4
|
+
|
5
|
+
HarvestThings is a Ruby Gem that syncs clients, projects, and tasks from
|
6
|
+
Things for Mac and pushed them into Harvest–the best time tracking utility out
|
7
|
+
there.
|
8
|
+
|
9
|
+
To learn more about Things:
|
10
|
+
[http://culturedcode.com/things/][]
|
11
|
+
|
12
|
+
To learn more about Harvest:
|
13
|
+
[http://www.getharvest.com/][]
|
14
|
+
|
15
|
+
|
16
|
+
The source for this gem is located on Github:
|
17
|
+
[http://github.com/mkrisher/harvestthings/][]
|
18
|
+
|
19
|
+
The gem can be installed from gemcutter using:
|
20
|
+
`gem install harvestthings`
|
21
|
+
|
22
|
+
Details
|
23
|
+
=======
|
24
|
+
|
25
|
+
Harvest is an amazing web based time tracking utility from Iridesco. They
|
26
|
+
offer a clean Web interface, a Dashboard widget, and an API. However, I didn't
|
27
|
+
want to have to retype all of my clients, projects, and tasks twice. I was
|
28
|
+
already keeping track of all of these items in Things for Mac. And when
|
29
|
+
recording time, I want to record against the actual task from Things that I
|
30
|
+
was working on. So, this gem was created as a way to take the project and tasks
|
31
|
+
from Things and push them into Harvest via the API.
|
32
|
+
|
33
|
+
A typical workflow then becomes. Task gets created in Things and assigned to a
|
34
|
+
project. That project belongs to an area of responsibility. Syncing to Harvest
|
35
|
+
can become really easy. This gem assumes that "Areas of Responsibility" in
|
36
|
+
Things represent "clients" in Harvest. Projects in Things are projects in
|
37
|
+
Harvest. Tasks belong to projects whether in Things or Harvest. The image
|
38
|
+
below shows how these three items match up.
|
39
|
+
|
40
|
+
|
41
|
+
The QUnit overlay looks like this in the browser:
|
42
|
+
|
43
|
+
[](http://img.skitch.com/20091125-jptpbxfbcg4irp81ytnwf3fkxf.jpg)
|
44
|
+
|
45
|
+
|
46
|
+
Requirements
|
47
|
+
=======
|
48
|
+
|
49
|
+
The HarvestThings gem requires a few other gems and libraries in order to make
|
50
|
+
the API calls:
|
51
|
+
|
52
|
+
* hpricot
|
53
|
+
* net/http
|
54
|
+
* net/http
|
55
|
+
* uri
|
56
|
+
* base64
|
57
|
+
* bigdecimal
|
58
|
+
* date
|
59
|
+
* time
|
60
|
+
* jcode
|
61
|
+
|
62
|
+
Usage
|
63
|
+
=====
|
64
|
+
as a gem
|
65
|
+
|
66
|
+
|
67
|
+
rquire rubygems
|
68
|
+
require harvestthings
|
69
|
+
harvestthings
|
70
|
+
|
71
|
+
|
72
|
+
TODO
|
73
|
+
====
|
74
|
+
* add tests
|
75
|
+
|
76
|
+
|
77
|
+
Copyright (c) 2009 Michael Krisher, released under the MIT license
|
78
|
+
|
79
|
+
[http://culturedcode.com/things/]: http://culturedcode.com/things/
|
80
|
+
[http://www.getharvest.com/]: http://www.getharvest.com/
|
81
|
+
[http://github.com/mkrisher/harvestthings/]: http://github.com/mkrisher/harvestthings/
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'rake/testtask'
|
3
|
+
require 'rake/rdoctask'
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'jeweler'
|
7
|
+
Jeweler::Tasks.new do |gemspec|
|
8
|
+
gemspec.name = "harvestthings"
|
9
|
+
gemspec.summary = "sync projects and tasks between Things and Harvest"
|
10
|
+
gemspec.description = "harvestthings will sync your clients, projects, and tasks between Things and Harvest, where areas in Things correspond to clients in Harvest"
|
11
|
+
gemspec.email = "mike@mikekrisher.com"
|
12
|
+
gemspec.homepage = "http://github.com/mkrisher/HarvestThings"
|
13
|
+
gemspec.authors = ["Michael Krisher"]
|
14
|
+
end
|
15
|
+
rescue LoadError
|
16
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
17
|
+
end
|
18
|
+
|
19
|
+
Jeweler::GemcutterTasks.new
|
data/TODO
ADDED
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{harvestthings}
|
8
|
+
s.version = "1.0.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Michael Krisher"]
|
12
|
+
s.date = %q{2009-12-03}
|
13
|
+
s.description = %q{harvestthings will sync your clients, projects, and tasks between Things and Harvest, where areas in Things correspond to clients in Harvest}
|
14
|
+
s.email = %q{mike@mikekrisher.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"README.mdown",
|
17
|
+
"TODO"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".gitignore",
|
21
|
+
"README.mdown",
|
22
|
+
"Rakefile",
|
23
|
+
"TODO",
|
24
|
+
"VERSION",
|
25
|
+
"harvestthings.gemspec",
|
26
|
+
"lib/harvestthings.rb",
|
27
|
+
"lib/harvestthings/application.rb",
|
28
|
+
"lib/harvestthings/harvest.rb",
|
29
|
+
"lib/harvestthings/sync.rb",
|
30
|
+
"lib/harvestthings/things.rb",
|
31
|
+
"lib/harvestthings/things/projects.rb",
|
32
|
+
"lib/harvestthings/things/tasks.rb",
|
33
|
+
"pkg/harvestthings-0.1.0.gem"
|
34
|
+
]
|
35
|
+
s.homepage = %q{http://github.com/mkrisher/HarvestThings}
|
36
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
37
|
+
s.require_paths = ["lib"]
|
38
|
+
s.rubygems_version = %q{1.3.5}
|
39
|
+
s.summary = %q{sync projects and tasks between Things and Harvest}
|
40
|
+
|
41
|
+
if s.respond_to? :specification_version then
|
42
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
43
|
+
s.specification_version = 3
|
44
|
+
|
45
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
46
|
+
else
|
47
|
+
end
|
48
|
+
else
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
@@ -0,0 +1,49 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
#--
|
4
|
+
|
5
|
+
# Copyright 2009 by Michael Krisher (mike@mikekrisher.com)
|
6
|
+
#
|
7
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
8
|
+
# of this software and associated documentation files (the "Software"), to
|
9
|
+
# deal in the Software without restriction, including without limitation the
|
10
|
+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
11
|
+
# sell copies of the Software, and to permit persons to whom the Software is
|
12
|
+
# furnished to do so, subject to the following conditions:
|
13
|
+
#
|
14
|
+
# The above copyright notice and this permission notice shall be included in
|
15
|
+
# all copies or substantial portions of the Software.
|
16
|
+
#
|
17
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
18
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
19
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
20
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
21
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
22
|
+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
23
|
+
# IN THE SOFTWARE.
|
24
|
+
#++
|
25
|
+
|
26
|
+
HARVESTTHINGSVERSION = '0.0.1'
|
27
|
+
|
28
|
+
# load gem dependancies
|
29
|
+
begin
|
30
|
+
require 'rubygems'
|
31
|
+
gem "hpricot", ">= 0.8.1"
|
32
|
+
require 'hpricot'
|
33
|
+
require 'net/http'
|
34
|
+
require 'uri'
|
35
|
+
rescue LoadError => e
|
36
|
+
puts "there was an error loading a gem: #{e}"
|
37
|
+
end
|
38
|
+
|
39
|
+
require 'harvestthings/application'
|
40
|
+
|
41
|
+
def harvestthings
|
42
|
+
HarvestThings::Application.new
|
43
|
+
return true
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
|
48
|
+
|
49
|
+
|
@@ -0,0 +1,50 @@
|
|
1
|
+
begin
|
2
|
+
require 'harvestthings/harvest'
|
3
|
+
require 'harvestthings/things'
|
4
|
+
require 'harvestthings/sync'
|
5
|
+
rescue LoadError => e
|
6
|
+
puts "there was an error loading a dependancy: #{e}"
|
7
|
+
end
|
8
|
+
|
9
|
+
module HarvestThings
|
10
|
+
|
11
|
+
class Application
|
12
|
+
|
13
|
+
# include sync mixin
|
14
|
+
include Sync
|
15
|
+
|
16
|
+
# initialize - defines a harvest and things object
|
17
|
+
#
|
18
|
+
# @return [Boolean]
|
19
|
+
def initialize
|
20
|
+
@harvest = Harvest.new
|
21
|
+
@things = Things.new
|
22
|
+
init_sync if config_checks?
|
23
|
+
end
|
24
|
+
|
25
|
+
# init_sync - kicks off the syncing
|
26
|
+
#
|
27
|
+
# @return [String]
|
28
|
+
def init_sync
|
29
|
+
print "starting sync"
|
30
|
+
things_projects_to_harvest
|
31
|
+
puts ".finished. ciao!"
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
# config_checks? - makes sure the config credentials are correct
|
37
|
+
#
|
38
|
+
# @return [Boolean]
|
39
|
+
def config_checks?
|
40
|
+
begin
|
41
|
+
response = @harvest.request '/clients', :get
|
42
|
+
rescue
|
43
|
+
exception = true
|
44
|
+
end
|
45
|
+
return exception == true ? false : true
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
########################################################################
|
2
|
+
#
|
3
|
+
# The full HARVEST API documentation can be found at:
|
4
|
+
#
|
5
|
+
# http://getharvest.com/api
|
6
|
+
#
|
7
|
+
|
8
|
+
# everything is in utf8
|
9
|
+
$KCODE = 'u'
|
10
|
+
|
11
|
+
require 'base64'
|
12
|
+
require 'bigdecimal'
|
13
|
+
require 'date'
|
14
|
+
require 'jcode'
|
15
|
+
require 'net/http'
|
16
|
+
require 'net/https'
|
17
|
+
require 'time'
|
18
|
+
|
19
|
+
class Harvest
|
20
|
+
|
21
|
+
# define Harvest config file path
|
22
|
+
CONFIG_PATH = File.join(Dir.pwd, "harvestthings", "harvest", "config.rb")
|
23
|
+
|
24
|
+
def initialize
|
25
|
+
generate_config unless File.exists?(CONFIG_PATH)
|
26
|
+
load CONFIG_PATH
|
27
|
+
|
28
|
+
@company = HarvestConfig.attrs[:subdomain]
|
29
|
+
@preferred_protocols = [HarvestConfig.attrs[:has_ssl], ! HarvestConfig.attrs[:has_ssl]]
|
30
|
+
connect!
|
31
|
+
end
|
32
|
+
|
33
|
+
# generate a config file if one doesn't exist
|
34
|
+
def generate_config
|
35
|
+
# define email
|
36
|
+
puts "enter the email you use to log into Harvest:"
|
37
|
+
email = gets
|
38
|
+
# define password
|
39
|
+
puts "enter the password for this Harvest account:"
|
40
|
+
password = gets
|
41
|
+
# define subdomain
|
42
|
+
puts "enter the subdomain for your Harvest account:"
|
43
|
+
subdomain = gets
|
44
|
+
|
45
|
+
str = <<EOS
|
46
|
+
class HarvestConfig
|
47
|
+
def self.attrs(overwrite = {})
|
48
|
+
{
|
49
|
+
:email => "#{email.chomp!}",
|
50
|
+
:password => "#{password.chomp!}",
|
51
|
+
:subdomain => "#{subdomain.chomp!}",
|
52
|
+
:has_ssl => false,
|
53
|
+
:user_agent => "Ruby/HarvestThings"
|
54
|
+
}.merge(overwrite)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
EOS
|
58
|
+
|
59
|
+
File.open(CONFIG_PATH, 'w') {|f| f.write(str) }
|
60
|
+
end
|
61
|
+
|
62
|
+
# HTTP headers you need to send with every request.
|
63
|
+
def headers
|
64
|
+
{
|
65
|
+
# Declare that you expect response in XML after a _successful_
|
66
|
+
# response.
|
67
|
+
"Accept" => "application/xml",
|
68
|
+
|
69
|
+
# Promise to send XML.
|
70
|
+
"Content-Type" => "application/xml; charset=utf-8",
|
71
|
+
|
72
|
+
# All requests will be authenticated using HTTP Basic Auth, as
|
73
|
+
# described in rfc2617. Your library probably has support for
|
74
|
+
# basic_auth built in, I've passed the Authorization header
|
75
|
+
# explicitly here only to show what happens at HTTP level.
|
76
|
+
"Authorization" => "Basic #{auth_string}",
|
77
|
+
|
78
|
+
# Tell Harvest a bit about your application.
|
79
|
+
"User-Agent" => HarvestConfig.attrs[:user_agent]
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
def auth_string
|
84
|
+
Base64.encode64("#{HarvestConfig.attrs[:email]}:#{HarvestConfig.attrs[:password]}").delete("\r\n")
|
85
|
+
end
|
86
|
+
|
87
|
+
def request path, method = :get, body = ""
|
88
|
+
response = send_request( path, method, body)
|
89
|
+
if response.class < Net::HTTPSuccess
|
90
|
+
# response in the 2xx range
|
91
|
+
on_completed_request
|
92
|
+
return response
|
93
|
+
elsif response.class == Net::HTTPServiceUnavailable
|
94
|
+
# response status is 503, you have reached the API throttle
|
95
|
+
# limit. Harvest will send the "Retry-After" header to indicate
|
96
|
+
# the number of seconds your boot needs to be silent.
|
97
|
+
raise "Got HTTP 503 three times in a row" if retry_counter > 3
|
98
|
+
sleep(response['Retry-After'].to_i + 5)
|
99
|
+
request(path, method, body)
|
100
|
+
elsif response.class == Net::HTTPFound
|
101
|
+
# response was a redirect, most likely due to protocol
|
102
|
+
# mismatch. Retry again with a different protocol.
|
103
|
+
@preferred_protocols.shift
|
104
|
+
raise "Failed connection using http or https" if @preferred_protocols.empty?
|
105
|
+
connect!
|
106
|
+
request(path, method, body)
|
107
|
+
else
|
108
|
+
dump_headers = response.to_hash.map { |h,v| [h.upcase,v].join(': ') }.join("\n")
|
109
|
+
raise "#{response.message} (#{response.code})\n\n#{dump_headers}\n\n#{response.body}\n"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def connect!
|
116
|
+
port = has_ssl ? 443 : 80
|
117
|
+
@connection = Net::HTTP.new("#{@company}.harvestapp.com", port)
|
118
|
+
@connection.use_ssl = has_ssl
|
119
|
+
@connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if has_ssl
|
120
|
+
end
|
121
|
+
|
122
|
+
def has_ssl
|
123
|
+
@preferred_protocols.first
|
124
|
+
end
|
125
|
+
|
126
|
+
def send_request path, method = :get, body = ''
|
127
|
+
case method
|
128
|
+
when :get
|
129
|
+
@connection.get(path, headers)
|
130
|
+
when :post
|
131
|
+
@connection.post(path, body, headers)
|
132
|
+
when :put
|
133
|
+
@connection.put(path, body, headers)
|
134
|
+
when :delete
|
135
|
+
@connection.delete(path, headers)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def on_completed_request
|
140
|
+
@retry_counter = 0
|
141
|
+
end
|
142
|
+
|
143
|
+
def retry_counter
|
144
|
+
@retry_counter ||= 0
|
145
|
+
@retry_counter += 1
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
module Sync
|
2
|
+
|
3
|
+
# things_projects_to_harvest - detemines which Things projects get sent to Harvest
|
4
|
+
#
|
5
|
+
# @return [Array] - array of projects
|
6
|
+
def things_projects_to_harvest
|
7
|
+
define_harvest_projects
|
8
|
+
define_harvest_tasks
|
9
|
+
define_harvest_clients
|
10
|
+
|
11
|
+
@things.projects.each do |project|
|
12
|
+
print "."
|
13
|
+
name = @things.project_title(project).downcase
|
14
|
+
client = @things.project_area(project).downcase
|
15
|
+
client_id = harvest_client?(client) ? harvest_client_id(client) : add_client_to_harvest(client)
|
16
|
+
add_project_to_harvest(name, client_id) unless harvest_project?(name)
|
17
|
+
things_tasks_to_harvest(project)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# things_tasks_to_harvest - determines which Things tasks get sent to Harvest
|
22
|
+
#
|
23
|
+
# @return [Array] - array of tasks
|
24
|
+
def things_tasks_to_harvest(project)
|
25
|
+
@things.tasks(project).each do |task|
|
26
|
+
unless @things.task_complete?(task) # complete in Things
|
27
|
+
task_desc = @things.task_description(task).downcase
|
28
|
+
add_task_to_harvest(@things.project_title(project).downcase, task_desc) unless harvest_task?(task_desc) # unless already exists in Harvest
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# add_project_to_harvest - saves a Things project as a Harvest project
|
34
|
+
#
|
35
|
+
# @param [str] - the name of the Things project
|
36
|
+
# @param [str] - the Harvest client id
|
37
|
+
# @return [Boolean]
|
38
|
+
def add_project_to_harvest(proj_name, client)
|
39
|
+
puts " adding #{proj_name} to Harvest"
|
40
|
+
str = <<EOS
|
41
|
+
<project>
|
42
|
+
<name>#{proj_name}</name>
|
43
|
+
<active type="boolean">true</active>
|
44
|
+
<bill-by>none</bill-by>
|
45
|
+
<client-id type="integer">#{client}</client-id>
|
46
|
+
<code></code>
|
47
|
+
<notes></notes>
|
48
|
+
<budget type="decimal"></budget>
|
49
|
+
<budget-by>none</budget-by>
|
50
|
+
</project>
|
51
|
+
EOS
|
52
|
+
response = @harvest.request '/projects', :post, str
|
53
|
+
end
|
54
|
+
|
55
|
+
# add_task_to_harvest - saves a Things task as a Harvest task
|
56
|
+
#
|
57
|
+
# @param [str] - the Thing project
|
58
|
+
# @param [str] - the Things task description
|
59
|
+
# @return [String] - the cleaned string
|
60
|
+
def add_task_to_harvest(project_name, task_desc)
|
61
|
+
str = <<EOS
|
62
|
+
<task>
|
63
|
+
<billable-by-default type="boolean">true</billable-by-default>
|
64
|
+
<default-hourly-rate type="decimal"></default-hourly-rate>
|
65
|
+
<is-default type="boolean">false</is-default>
|
66
|
+
<name>#{task_desc}</name>
|
67
|
+
</task>
|
68
|
+
EOS
|
69
|
+
response = @harvest.request '/tasks', :post, str
|
70
|
+
new_task_location = response['Location']
|
71
|
+
new_task_id = new_task_location.gsub(/\/tasks\//, '')
|
72
|
+
assign_task_assignment(new_task_id, project_name)
|
73
|
+
end
|
74
|
+
|
75
|
+
# assign_task_assignment - assigns a newly created task to a Project in Harvest
|
76
|
+
#
|
77
|
+
# @param [Integer] - the new Harvest task ID
|
78
|
+
# @return [Integer] - the Harvest project ID
|
79
|
+
def assign_task_assignment(new_task_id, project_name)
|
80
|
+
str = <<EOS
|
81
|
+
<task>
|
82
|
+
<id type="integer">#{new_task_id}</id>
|
83
|
+
</task>
|
84
|
+
EOS
|
85
|
+
response = @harvest.request "/projects/#{harvest_project_id(project_name)}/task_assignments", :post, str
|
86
|
+
end
|
87
|
+
|
88
|
+
# add_client_to_harvest - saves a Things area_name as a Harvest client
|
89
|
+
#
|
90
|
+
# @param [str] - the Things area_name
|
91
|
+
# @return [Integer] - the new Harvest client id
|
92
|
+
def add_client_to_harvest(area_name)
|
93
|
+
str = <<EOS
|
94
|
+
<client>
|
95
|
+
<name>#{area_name}</name>
|
96
|
+
<details></details>
|
97
|
+
</client>
|
98
|
+
EOS
|
99
|
+
response = @harvest.request '/clients', :post, str
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
# define_harvest_tasks - loads all of the existing tasks from Harvest
|
107
|
+
#
|
108
|
+
# @return [Array] - array of tasks
|
109
|
+
def define_harvest_tasks
|
110
|
+
@harvest_tasks = []
|
111
|
+
|
112
|
+
response = @harvest.request '/tasks', :get
|
113
|
+
doc = Hpricot::XML(response.body)
|
114
|
+
(doc/:tasks/:task).each do |task|
|
115
|
+
temp = {}
|
116
|
+
['name', 'id'].each do |el|
|
117
|
+
temp[el] = task.at(el).innerHTML.downcase
|
118
|
+
end
|
119
|
+
@harvest_tasks.push temp
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# define_harvest_projects - loads all of the existing projects from Harvest
|
124
|
+
#
|
125
|
+
# @return [Array] - array of projects names
|
126
|
+
def define_harvest_projects
|
127
|
+
@harvest_projects = []
|
128
|
+
|
129
|
+
response = @harvest.request '/projects', :get
|
130
|
+
doc = Hpricot::XML(response.body)
|
131
|
+
(doc/:projects/:project).each do |project|
|
132
|
+
temp = {}
|
133
|
+
['name', 'id'].each do |el|
|
134
|
+
temp[el] = project.at(el).innerHTML.downcase
|
135
|
+
end
|
136
|
+
@harvest_projects.push temp
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# define_harvest_clients - loads all of the existing clients from Harvest
|
141
|
+
#
|
142
|
+
# @return [Array] - array of clients names
|
143
|
+
def define_harvest_clients
|
144
|
+
@harvest_clients = []
|
145
|
+
|
146
|
+
response = @harvest.request '/clients', :get
|
147
|
+
doc = Hpricot::XML(response.body)
|
148
|
+
(doc/:clients/:client).each do |client|
|
149
|
+
temp = {}
|
150
|
+
['name', 'id'].each do |el|
|
151
|
+
temp[el] = client.at(el).innerHTML.downcase
|
152
|
+
end
|
153
|
+
@harvest_clients.push temp
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# harvest_project? - checks to see if Things project already exists in Harvest
|
158
|
+
#
|
159
|
+
# @param [str] - the project name to check for
|
160
|
+
# @return [Boolean]
|
161
|
+
def harvest_project?(proj_name)
|
162
|
+
match = false
|
163
|
+
@harvest_projects.each do |project|
|
164
|
+
if project['name'] == proj_name
|
165
|
+
match = true
|
166
|
+
end
|
167
|
+
end
|
168
|
+
return match == false ? false : true
|
169
|
+
end
|
170
|
+
|
171
|
+
# harvest_client? - checks to see if Things area already exists as Harvest client
|
172
|
+
#
|
173
|
+
# @param [str] - the Things area name
|
174
|
+
# @return [integer] - the matching client id if it exists, otherwise false
|
175
|
+
def harvest_client?(area_name)
|
176
|
+
match = false
|
177
|
+
@harvest_clients.each do |client|
|
178
|
+
if client['name'] == area_name
|
179
|
+
match = true
|
180
|
+
end
|
181
|
+
end
|
182
|
+
return match == false ? false : true
|
183
|
+
end
|
184
|
+
|
185
|
+
# harvest_task? - checks to see if a Things task already exists in Harvest
|
186
|
+
#
|
187
|
+
# @param [str] - the task description
|
188
|
+
# @return [Boolean]
|
189
|
+
def harvest_task?(task_name)
|
190
|
+
match = false
|
191
|
+
@harvest_tasks.each do |task|
|
192
|
+
if task['name'] == task_name
|
193
|
+
match = true
|
194
|
+
end
|
195
|
+
end
|
196
|
+
return match == false ? false : true
|
197
|
+
end
|
198
|
+
|
199
|
+
# harvest_client_id - get the Harvest client id for a Things area name
|
200
|
+
#
|
201
|
+
# @param [str] - the Things area name
|
202
|
+
# @return [Integer] - the Harvest client id
|
203
|
+
def harvest_client_id(area_name)
|
204
|
+
@harvest_clients.each do |client|
|
205
|
+
return client['id'] if client['name'] == area_name
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
# harvest_project_id - get the Harvest project id for a Things project
|
210
|
+
#
|
211
|
+
# @param [str] - the Things project id number
|
212
|
+
# @return [Integer] - the Harvest project id
|
213
|
+
def harvest_project_id(proj_name)
|
214
|
+
@harvest_projects.each do |project|
|
215
|
+
return project['id'] if project['name'].downcase.to_s == proj_name.to_s
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'harvestthings/things/projects'
|
2
|
+
require 'harvestthings/things/tasks'
|
3
|
+
|
4
|
+
class Things
|
5
|
+
# include the projects mixin
|
6
|
+
include Projects
|
7
|
+
|
8
|
+
# include the tasks mixin
|
9
|
+
include Tasks
|
10
|
+
|
11
|
+
# Hpricot doc of Things xml file
|
12
|
+
attr_reader :xml
|
13
|
+
|
14
|
+
# Define default Things database file path and file name
|
15
|
+
DATABASE_PATH = "Library/Application\ Support/Cultured\ Code/Things"
|
16
|
+
DATABASE_FILE = "Database.xml"
|
17
|
+
|
18
|
+
# initialize - change to the default Things directory and load the xml
|
19
|
+
#
|
20
|
+
# @return [Boolean]
|
21
|
+
def initialize
|
22
|
+
current_pwd = Dir.pwd
|
23
|
+
Dir.chdir() # changes to HOME environment variable
|
24
|
+
Dir.chdir(DATABASE_PATH)
|
25
|
+
if File.exists?(DATABASE_FILE)
|
26
|
+
load_database
|
27
|
+
methods
|
28
|
+
else
|
29
|
+
raise SystemError, "can't find the default Things database file"
|
30
|
+
end
|
31
|
+
Dir.chdir(current_pwd)
|
32
|
+
end
|
33
|
+
|
34
|
+
# load_database - loads the databse file into the xml property
|
35
|
+
#
|
36
|
+
# @return [Hpricot]
|
37
|
+
def load_database
|
38
|
+
@xml = Hpricot.XML(open(DATABASE_FILE))
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Projects
|
2
|
+
# projects - grab an array of the various project ids from the xml
|
3
|
+
#
|
4
|
+
# @param [id] - the string of the project's id
|
5
|
+
# @return [Hpricot] - an Hpricot XML object
|
6
|
+
def projects
|
7
|
+
# find all projects from the first OBJECT node
|
8
|
+
first_obj = @xml.at('object')
|
9
|
+
|
10
|
+
if first_obj.search("relationship[@destination='TODO']").length != 0
|
11
|
+
first_obj.search("relationship[@destination='TODO']") do |elem| # older versions of Things
|
12
|
+
return elem.attributes["idrefs"].to_s.split(" ")
|
13
|
+
end
|
14
|
+
else
|
15
|
+
@xml.search("attribute[@name='title']") do |elem| # newer versions of Things
|
16
|
+
if elem.html == "Projects"
|
17
|
+
elem.parent.search("relationship[@name='focustodos']") do |e|
|
18
|
+
return e.attributes["idrefs"].to_s.split(" ")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
# project - grab the Hpricot element of the project using the id
|
27
|
+
#
|
28
|
+
# @param [id] - the string of the project's id
|
29
|
+
# @return [Hpricot] - an Hpricot XML object
|
30
|
+
def project(id)
|
31
|
+
@xml.search("object[@id='#{id}']")
|
32
|
+
end
|
33
|
+
|
34
|
+
# project_title - grab the title of the project using the id
|
35
|
+
#
|
36
|
+
# @param [id] - the string of the project's attribute id
|
37
|
+
# @return [String] - a cleaned and formatted title string
|
38
|
+
def project_title(id)
|
39
|
+
project = @xml.search("object[@id='#{id}']")
|
40
|
+
title = project.search("attribute[@name='title']")
|
41
|
+
clean(title.innerHTML.to_s)
|
42
|
+
end
|
43
|
+
|
44
|
+
# project_area - grab the area of the project using the id
|
45
|
+
#
|
46
|
+
# @param [id] - the string of the project's attribute id
|
47
|
+
# @return [String] - a cleaned and formatted area string
|
48
|
+
def project_area(id)
|
49
|
+
project = @xml.search("object[@id='#{id}']")
|
50
|
+
area = project.search("relationship[@name='parent']")
|
51
|
+
area_id = area.attr('idrefs').to_s
|
52
|
+
area_id == "" ? "default" : project_title(area_id)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
# clean - clean a title string with specific rules
|
58
|
+
#
|
59
|
+
# @param [str] - the string to clean and return
|
60
|
+
# @return [String] - the cleaned string
|
61
|
+
def clean(str)
|
62
|
+
# remove any underscores
|
63
|
+
$temp = str.gsub("_", " ")
|
64
|
+
$temp = $temp.gsub(/^[a-z]|\s+[a-z]/) { |a| a.upcase }
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Tasks
|
2
|
+
# tasks - grab an array of the various task ids from the xml
|
3
|
+
#
|
4
|
+
# @param [id] - the string of the project's id
|
5
|
+
# @return [Array] - array of the various task ids
|
6
|
+
def tasks(id)
|
7
|
+
project = @xml.search("object[@id='#{id}']")
|
8
|
+
project.search("relationship[@name='children']") do |elem|
|
9
|
+
return elem.attributes["idrefs"].to_s.split(" ")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# task_description - grab formatted version of a task's description
|
14
|
+
#
|
15
|
+
# @param [id] - the tasks id
|
16
|
+
# @return [String] - a formatted string of the task description
|
17
|
+
def task_description(id)
|
18
|
+
task = @xml.search("object[@id='#{id}']")
|
19
|
+
title = task.search("attribute[@name='title']")
|
20
|
+
clean(title.innerHTML.to_s)
|
21
|
+
end
|
22
|
+
|
23
|
+
# task_complete? - boolean of whether the task is complete
|
24
|
+
#
|
25
|
+
# @param [id] - the task id
|
26
|
+
# @return [Boolean] - returns true of false
|
27
|
+
def task_complete?(id)
|
28
|
+
task = @xml.search("object[@id='#{id}']")
|
29
|
+
task.search("attribute[@name='datecompleted']").any?
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
# clean - clean a title string with specific rules
|
35
|
+
#
|
36
|
+
# @param [str] - the string to clean and return
|
37
|
+
# @return [String] - the cleaned string
|
38
|
+
def clean(str)
|
39
|
+
# remove any underscores
|
40
|
+
$temp = str.gsub("_", " ")
|
41
|
+
$temp = $temp.gsub(/^[a-z]|\s+[a-z]/) { |a| a.upcase }
|
42
|
+
end
|
43
|
+
end
|
Binary file
|
metadata
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: harvestthings
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Michael Krisher
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-12-03 00:00:00 -05:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: harvestthings will sync your clients, projects, and tasks between Things and Harvest, where areas in Things correspond to clients in Harvest
|
17
|
+
email: mike@mikekrisher.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- README.mdown
|
24
|
+
- TODO
|
25
|
+
files:
|
26
|
+
- .gitignore
|
27
|
+
- README.mdown
|
28
|
+
- Rakefile
|
29
|
+
- TODO
|
30
|
+
- VERSION
|
31
|
+
- harvestthings.gemspec
|
32
|
+
- lib/harvestthings.rb
|
33
|
+
- lib/harvestthings/application.rb
|
34
|
+
- lib/harvestthings/harvest.rb
|
35
|
+
- lib/harvestthings/sync.rb
|
36
|
+
- lib/harvestthings/things.rb
|
37
|
+
- lib/harvestthings/things/projects.rb
|
38
|
+
- lib/harvestthings/things/tasks.rb
|
39
|
+
- pkg/harvestthings-0.1.0.gem
|
40
|
+
has_rdoc: true
|
41
|
+
homepage: http://github.com/mkrisher/HarvestThings
|
42
|
+
licenses: []
|
43
|
+
|
44
|
+
post_install_message:
|
45
|
+
rdoc_options:
|
46
|
+
- --charset=UTF-8
|
47
|
+
require_paths:
|
48
|
+
- lib
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: "0"
|
54
|
+
version:
|
55
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: "0"
|
60
|
+
version:
|
61
|
+
requirements: []
|
62
|
+
|
63
|
+
rubyforge_project:
|
64
|
+
rubygems_version: 1.3.5
|
65
|
+
signing_key:
|
66
|
+
specification_version: 3
|
67
|
+
summary: sync projects and tasks between Things and Harvest
|
68
|
+
test_files: []
|
69
|
+
|