freshtrack 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/History.txt ADDED
@@ -0,0 +1,4 @@
1
+ == 0.2.0 2008-01-30
2
+
3
+ * 1 major enhancement:
4
+ * Initial release
data/License.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Yossef Mendelssohn
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Manifest.txt ADDED
@@ -0,0 +1,37 @@
1
+ History.txt
2
+ License.txt
3
+ Manifest.txt
4
+ README.txt
5
+ Rakefile
6
+ bin/freshtrack
7
+ config/hoe.rb
8
+ config/requirements.rb
9
+ lib/freshtrack.rb
10
+ lib/freshtrack/version.rb
11
+ lib/freshtrack/core_ext.rb
12
+ lib/freshtrack/core_ext/array.rb
13
+ lib/freshtrack/core_ext/numeric.rb
14
+ lib/freshtrack/core_ext/time.rb
15
+ lib/freshbooks/extensions.rb
16
+ lib/freshbooks/extensions/base_object.rb
17
+ lib/freshbooks/extensions/project.rb
18
+ lib/freshbooks/extensions/task.rb
19
+ lib/freshbooks/extensions/time_entry.rb
20
+ log/debug.log
21
+ script/destroy
22
+ script/generate
23
+ setup.rb
24
+ spec/base_object_spec.rb
25
+ spec/freshtrack_spec.rb
26
+ spec/project_spec.rb
27
+ spec/task_spec.rb
28
+ spec/time_entry_spec.rb
29
+ spec/array_spec.rb
30
+ spec/numeric_spec.rb
31
+ spec/time_spec.rb
32
+ spec/spec.opts
33
+ spec/spec_helper.rb
34
+ tasks/deployment.rake
35
+ tasks/environment.rake
36
+ tasks/rspec.rake
37
+ tasks/website.rake
data/README.txt ADDED
@@ -0,0 +1,30 @@
1
+ Freshtrack is used to automatically create time entries in FreshBooks.
2
+
3
+ It presently depends on punch, the gem by Ara T. Howard, and any arguments given to
4
+ freshtrack are passed along to punch as if freshtrack were an alias for 'punch list'.
5
+
6
+ For example
7
+
8
+ freshtrack proj --after 2008-01-16 --before 2008-02-01
9
+
10
+ would get time data for the second half of January 2008 by using the command
11
+
12
+ punch list proj --after 2008-01-16 --before 2008-02-01
13
+
14
+
15
+ Freshtrack requires a configuration file, ~/.freshtrack.yml, that looks something like
16
+
17
+ ---
18
+ company: Company Name
19
+ token: API Token
20
+ project_task_mapping:
21
+ project_name:
22
+ :project: FreshBooks Project Name
23
+ :task: FreshBooks Task Name
24
+
25
+ The 'Company Name' is the XXX in 'XXX.freshbooks.com'. The 'project_name' is the XXX in 'punch list XXX'
26
+
27
+
28
+ NOTE: As of this writing, punch (0.0.1) specifically requires attributes version 5.0.0 even though 5.0.1 is out.
29
+ Because of the way gems work, punch will not work if both are installed. Make sure the specific installed version
30
+ of attributes is 5.0.0.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ require 'config/requirements'
2
+ require 'config/hoe' # setup Hoe + all gem configuration
3
+
4
+ Dir['tasks/**/*.rake'].each { |rake| load rake }
data/bin/freshtrack ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Created on 2008-1-30.
4
+ # Copyright (c) 2008. All rights reserved.
5
+
6
+ begin
7
+ require 'rubygems'
8
+ rescue LoadError
9
+ # no rubygems to load, so we fail silently
10
+ end
11
+
12
+ require 'freshtrack'
13
+
14
+ # do stuff
15
+ project = ARGV.shift
16
+
17
+ unless project
18
+ puts "Usage: #{File.basename($0)} [project] [options]"
19
+ exit
20
+ end
21
+
22
+ Freshtrack.init
23
+ Freshtrack.track(project, ARGV.join(' '))
data/config/hoe.rb ADDED
@@ -0,0 +1,74 @@
1
+ require 'freshtrack/version'
2
+
3
+ AUTHOR = 'Yossef Mendelssohn' # can also be an array of Authors
4
+ EMAIL = 'ymendel@pobox.com'
5
+ DESCRIPTION = "description of gem"
6
+ GEM_NAME = 'freshtrack' # what ppl will type to install your gem
7
+ RUBYFORGE_PROJECT = 'yomendel' # The unix name for your project
8
+ HOMEPATH = "http://#{RUBYFORGE_PROJECT}.rubyforge.org"
9
+ DOWNLOAD_PATH = "http://rubyforge.org/projects/#{RUBYFORGE_PROJECT}"
10
+
11
+ @config_file = "~/.rubyforge/user-config.yml"
12
+ @config = nil
13
+ RUBYFORGE_USERNAME = "unknown"
14
+ def rubyforge_username
15
+ unless @config
16
+ begin
17
+ @config = YAML.load(File.read(File.expand_path(@config_file)))
18
+ rescue
19
+ puts <<-EOS
20
+ ERROR: No rubyforge config file found: #{@config_file}
21
+ Run 'rubyforge setup' to prepare your env for access to Rubyforge
22
+ - See http://newgem.rubyforge.org/rubyforge.html for more details
23
+ EOS
24
+ exit
25
+ end
26
+ end
27
+ RUBYFORGE_USERNAME.replace @config["username"]
28
+ end
29
+
30
+
31
+ REV = nil
32
+ # UNCOMMENT IF REQUIRED:
33
+ # REV = `svn info`.each {|line| if line =~ /^Revision:/ then k,v = line.split(': '); break v.chomp; else next; end} rescue nil
34
+ VERS = Freshtrack::VERSION::STRING + (REV ? ".#{REV}" : "")
35
+ RDOC_OPTS = ['--quiet', '--title', 'freshtrack documentation',
36
+ "--opname", "index.html",
37
+ "--line-numbers",
38
+ "--main", "README",
39
+ "--inline-source"]
40
+
41
+ class Hoe
42
+ def extra_deps
43
+ @extra_deps.reject! { |x| Array(x).first == 'hoe' }
44
+ @extra_deps
45
+ end
46
+ end
47
+
48
+ # Generate all the Rake tasks
49
+ # Run 'rake -T' to see list of generated tasks (from gem root directory)
50
+ hoe = Hoe.new(GEM_NAME, VERS) do |p|
51
+ p.author = AUTHOR
52
+ p.description = DESCRIPTION
53
+ p.email = EMAIL
54
+ p.summary = DESCRIPTION
55
+ p.url = HOMEPATH
56
+ p.rubyforge_name = RUBYFORGE_PROJECT if RUBYFORGE_PROJECT
57
+ p.test_globs = ["test/**/test_*.rb"]
58
+ p.clean_globs |= ['**/.*.sw?', '*.gem', '.config', '**/.DS_Store'] #An array of file patterns to delete on clean.
59
+
60
+ # == Optional
61
+ p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n")
62
+ p.extra_deps = [ # An array of rubygem dependencies [name, version], e.g. [ ['active_support', '>= 1.3.1'] ]
63
+ ['freshbooks', '>= 1.0.0'],
64
+ ['punch', '>= 0.0.1']
65
+ ]
66
+
67
+ #p.spec_extras = {} # A hash of extra values to set in the gemspec.
68
+
69
+ end
70
+
71
+ CHANGES = hoe.paragraphs_of('History.txt', 0..1).join("\\n\\n")
72
+ PATH = (RUBYFORGE_PROJECT == GEM_NAME) ? RUBYFORGE_PROJECT : "#{RUBYFORGE_PROJECT}/#{GEM_NAME}"
73
+ hoe.remote_rdoc_dir = File.join(PATH.gsub(/^#{RUBYFORGE_PROJECT}\/?/,''), 'rdoc')
74
+ hoe.rsync_args = '-av --delete --ignore-errors'
@@ -0,0 +1,17 @@
1
+ require 'fileutils'
2
+ include FileUtils
3
+
4
+ require 'rubygems'
5
+ %w[rake hoe newgem rubigen].each do |req_gem|
6
+ begin
7
+ require req_gem
8
+ rescue LoadError
9
+ puts "This Rakefile requires the '#{req_gem}' RubyGem."
10
+ puts "Installation: gem install #{req_gem} -y"
11
+ exit
12
+ end
13
+ end
14
+
15
+ $:.unshift(File.join(File.dirname(__FILE__), %w[.. lib]))
16
+
17
+ require 'freshtrack'
@@ -0,0 +1,31 @@
1
+ require 'date'
2
+
3
+ module FreshBooks
4
+ class BaseObject
5
+ MAPPING_FNS[Date] = lambda { |xml_val| Date.parse(xml_val.text) }
6
+
7
+ def to_xml
8
+ # The root element is the elem name
9
+ root = Element.new elem_name
10
+
11
+ # Add each BaseObject member to the root elem
12
+ self.members.each do |field_name|
13
+
14
+ value = self.send(field_name)
15
+
16
+ if value.is_a?(Array)
17
+ node = root.add_element(field_name)
18
+ value.each { |array_elem| node.add_element(array_elem.to_xml) }
19
+ elsif !value.nil?
20
+ root.add_element(field_name).text = value
21
+ end
22
+ end
23
+ root
24
+ end
25
+
26
+ # The root element is the class name, downcased (and underscored if there is any CamelCase)
27
+ def elem_name
28
+ elem_name = self.class.to_s.split('::').last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ module FreshBooks
2
+ Project = BaseObject.new(:project_id, :name, :bill_method, :client_id, :rate, :description)
3
+
4
+ class Project
5
+ TYPE_MAPPINGS = { 'project_id' => Fixnum, 'client_id' => Fixnum, 'rate' => Float }
6
+
7
+ class << self
8
+ def get(project_id)
9
+ resp = FreshBooks.call_api('project.get', 'project_id' => project_id)
10
+ return nil unless resp.success?
11
+ new_from_xml(resp.elements[1])
12
+ end
13
+
14
+ def list(options = {})
15
+ resp = FreshBooks.call_api('project.list', options)
16
+ return nil unless resp.success?
17
+ resp.elements.collect { |elem| new_from_xml(elem) }
18
+ end
19
+
20
+ def find_by_name(name)
21
+ list.detect { |p| p.name == name }
22
+ end
23
+
24
+ def delete(project_id)
25
+ resp = FreshBooks.call_api('project.delete', 'project_id' => project_id)
26
+ resp.success?
27
+ end
28
+ end
29
+
30
+ def create
31
+ resp = FreshBooks.call_api('project.create', 'project' => self)
32
+ if resp.success?
33
+ self.project_id = resp.elements[1].text.to_i
34
+ end
35
+ end
36
+
37
+ def update
38
+ resp = FreshBooks.call_api('project.update', 'project' => self)
39
+ resp.success?
40
+ end
41
+
42
+ def delete
43
+ self.class.delete(project_id)
44
+ end
45
+
46
+ def client
47
+ Client.get(client_id)
48
+ end
49
+
50
+ def tasks
51
+ Task.list('project_id' => project_id)
52
+ end
53
+
54
+ def time_entries
55
+ TimeEntry.list('project_id' => project_id)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ module FreshBooks
2
+ Task = BaseObject.new(:task_id, :name, :billable, :rate, :description)
3
+
4
+ class Task
5
+ TYPE_MAPPINGS = { 'task_id' => Fixnum, 'rate' => Float }
6
+
7
+ class << self
8
+ def get(task_id)
9
+ resp = FreshBooks.call_api('task.get', 'task_id' => task_id)
10
+ return nil unless resp.success?
11
+ new_from_xml(resp.elements[1])
12
+ end
13
+
14
+ def list(options = {})
15
+ resp = FreshBooks.call_api('task.list', options)
16
+ return nil unless resp.success?
17
+ resp.elements.collect { |elem| new_from_xml(elem) }
18
+ end
19
+
20
+ def find_by_name(name)
21
+ list.detect { |p| p.name == name }
22
+ end
23
+
24
+ def delete(task_id)
25
+ resp = FreshBooks.call_api('task.delete', 'task_id' => task_id)
26
+ resp.success?
27
+ end
28
+ end
29
+
30
+ def create
31
+ resp = FreshBooks.call_api('task.create', 'task' => self)
32
+ if resp.success?
33
+ self.task_id = resp.elements[1].text.to_i
34
+ end
35
+ end
36
+
37
+ def update
38
+ resp = FreshBooks.call_api('task.update', 'task' => self)
39
+ resp.success?
40
+ end
41
+
42
+ def delete
43
+ self.class.delete(task_id)
44
+ end
45
+
46
+ def time_entries
47
+ TimeEntry.list('task_id' => task_id)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,53 @@
1
+ module FreshBooks
2
+ TimeEntry = BaseObject.new(:time_entry_id, :project_id, :task_id, :hours, :date, :notes)
3
+
4
+ class TimeEntry
5
+ TYPE_MAPPINGS = {
6
+ 'time_entry_id' => Fixnum, 'project_id' => Fixnum, 'task_id' => Fixnum,
7
+ 'hours' => Float, 'date' => Date
8
+ }
9
+
10
+ class << self
11
+ def get(time_entry_id)
12
+ resp = FreshBooks.call_api('time_entry.get', 'time_entry_id' => time_entry_id)
13
+ return nil unless resp.success?
14
+ new_from_xml(resp.elements[1])
15
+ end
16
+
17
+ def list(options = {})
18
+ resp = FreshBooks.call_api('time_entry.list', options)
19
+ return nil unless resp.success?
20
+ resp.elements.collect { |elem| new_from_xml(elem) }
21
+ end
22
+
23
+ def delete(time_entry_id)
24
+ resp = FreshBooks.call_api('time_entry.delete', 'time_entry_id' => time_entry_id)
25
+ resp.success?
26
+ end
27
+ end
28
+
29
+ def create
30
+ resp = FreshBooks.call_api('time_entry.create', 'time_entry' => self)
31
+ if resp.success?
32
+ self.time_entry_id = resp.elements[1].text.to_i
33
+ end
34
+ end
35
+
36
+ def update
37
+ resp = FreshBooks.call_api('time_entry.update', 'time_entry' => self)
38
+ resp.success?
39
+ end
40
+
41
+ def delete
42
+ self.class.delete(time_entry_id)
43
+ end
44
+
45
+ def task
46
+ Task.get(task_id)
47
+ end
48
+
49
+ def project
50
+ Project.get(project_id)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,5 @@
1
+ require 'freshbooks'
2
+ require 'freshbooks/extensions/base_object'
3
+ require 'freshbooks/extensions/project'
4
+ require 'freshbooks/extensions/task'
5
+ require 'freshbooks/extensions/time_entry'
@@ -0,0 +1,11 @@
1
+ class Array
2
+ def group_by(&block)
3
+ raise ArgumentError unless block
4
+ inject({}) do |hash, elem|
5
+ key = block.call(elem)
6
+ hash[key] ||= []
7
+ hash[key].push(elem)
8
+ hash
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ class Numeric
2
+ def secs_to_hours
3
+ self / 3600.0
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ class Time
2
+ public :to_date
3
+ end
@@ -0,0 +1,3 @@
1
+ require 'freshtrack/core_ext/time'
2
+ require 'freshtrack/core_ext/array'
3
+ require 'freshtrack/core_ext/numeric'
@@ -0,0 +1,9 @@
1
+ module Freshtrack #:nodoc:
2
+ module VERSION #:nodoc:
3
+ MAJOR = 0
4
+ MINOR = 2
5
+ TINY = 0
6
+
7
+ STRING = [MAJOR, MINOR, TINY].join('.')
8
+ end
9
+ end
data/lib/freshtrack.rb ADDED
@@ -0,0 +1,107 @@
1
+ $:.unshift File.dirname(__FILE__)
2
+ require 'freshbooks/extensions'
3
+ require 'freshtrack/core_ext'
4
+ require 'yaml'
5
+
6
+ module Freshtrack
7
+ class << self
8
+ attr_reader :config, :project, :task
9
+
10
+ def init
11
+ load_config
12
+ FreshBooks.setup("#{company}.freshbooks.com", token)
13
+ end
14
+
15
+ def load_config
16
+ @config = YAML.load(File.read(File.expand_path('~/.freshtrack.yml')))
17
+ end
18
+
19
+ def company
20
+ config['company']
21
+ end
22
+
23
+ def token
24
+ config['token']
25
+ end
26
+
27
+ def project_task_mapping
28
+ config['project_task_mapping']
29
+ end
30
+
31
+ def get_project_data(project_name)
32
+ raise unless mapping = project_task_mapping[project_name]
33
+ @project = FreshBooks::Project.find_by_name(mapping[:project])
34
+ raise unless @project
35
+ @task = FreshBooks::Task.find_by_name(mapping[:task])
36
+ raise unless @task
37
+ end
38
+
39
+ def get_time_data(project_name, options = '')
40
+ time_data = IO.read("| punch list #{project_name} #{options}")
41
+ convert_time_data(time_data)
42
+ end
43
+
44
+ def convert_time_data(time_data)
45
+ raw = YAML.load(time_data)
46
+ condense_time_data(raw)
47
+ end
48
+
49
+ def condense_time_data(time_data)
50
+ date_data = times_to_dates(time_data)
51
+ group_date_data(date_data)
52
+ end
53
+
54
+ def times_to_dates(time_data)
55
+ time_data.each do |td|
56
+ punch_in = td.delete('in')
57
+ punch_out = td.delete('out')
58
+
59
+ td['date'] = punch_in.to_date
60
+ td['hours'] = (punch_out - punch_in).secs_to_hours
61
+ end
62
+ end
63
+
64
+ def group_date_data(date_data)
65
+ separator = '-' * 20
66
+ grouped = date_data.group_by { |x| x['date'] }
67
+ grouped.sort.inject([]) do |arr, (date, data)|
68
+ this_date = { 'date' => date }
69
+ this_date['hours'] = data.inject(0) { |sum, x| sum + x['hours'] }
70
+ this_date['hours'] = ('%.2f' % this_date['hours']).to_f
71
+ this_date['notes'] = data.collect { |x| x['log'].join("\n") }.join("\n" + separator + "\n")
72
+ arr + [this_date]
73
+ end
74
+ end
75
+
76
+ def get_data(project_name, options = '')
77
+ get_project_data(project_name)
78
+ get_time_data(project_name, options)
79
+ end
80
+
81
+ def track(project_name, options = '')
82
+ data = get_data(project_name, options)
83
+ data.each do |entry_data|
84
+ create_entry(entry_data)
85
+ end
86
+ end
87
+
88
+ def create_entry(entry_data)
89
+ time_entry = FreshBooks::TimeEntry.new
90
+
91
+ time_entry.project_id = project.project_id
92
+ time_entry.task_id = task.task_id
93
+ time_entry.date = entry_data['date']
94
+ time_entry.hours = entry_data['hours']
95
+ time_entry.notes = entry_data['notes']
96
+
97
+ result = time_entry.create
98
+
99
+ if result
100
+ true
101
+ else
102
+ STDERR.puts "warning: unsuccessful time entry creation for date #{entry_data['date']}"
103
+ nil
104
+ end
105
+ end
106
+ end
107
+ end
data/log/debug.log ADDED
File without changes
data/script/destroy ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.join(File.dirname(__FILE__), '..')
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/destroy'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Destroy.new.run(ARGV)
data/script/generate ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.join(File.dirname(__FILE__), '..')
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/generate'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Generate.new.run(ARGV)