freshtrack 0.2.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/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)