tempo-cli 0.1.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.
Files changed (73) hide show
  1. data/.gitignore +4 -0
  2. data/Gemfile +4 -0
  3. data/Gemfile.lock +56 -0
  4. data/README.md +326 -0
  5. data/Rakefile +65 -0
  6. data/bin/tempo +477 -0
  7. data/features/arrange.feature +43 -0
  8. data/features/checkout.feature +63 -0
  9. data/features/end.feature +65 -0
  10. data/features/project.feature +246 -0
  11. data/features/report.feature +62 -0
  12. data/features/start.feature +87 -0
  13. data/features/step_definitions/tempo_steps.rb +138 -0
  14. data/features/support/env.rb +26 -0
  15. data/features/tempo.feature +13 -0
  16. data/features/update.feature +69 -0
  17. data/lib/file_record/directory.rb +11 -0
  18. data/lib/file_record/directory_structure/tempo/README.txt +4 -0
  19. data/lib/file_record/directory_structure/tempo/tempo_projects.yaml +6 -0
  20. data/lib/file_record/record.rb +120 -0
  21. data/lib/tempo/controllers/arrange_controller.rb +52 -0
  22. data/lib/tempo/controllers/base.rb +117 -0
  23. data/lib/tempo/controllers/checkout_controller.rb +42 -0
  24. data/lib/tempo/controllers/end_controller.rb +42 -0
  25. data/lib/tempo/controllers/projects_controller.rb +107 -0
  26. data/lib/tempo/controllers/records_controller.rb +21 -0
  27. data/lib/tempo/controllers/report_controller.rb +55 -0
  28. data/lib/tempo/controllers/start_controller.rb +42 -0
  29. data/lib/tempo/controllers/update_controller.rb +78 -0
  30. data/lib/tempo/models/base.rb +176 -0
  31. data/lib/tempo/models/composite.rb +71 -0
  32. data/lib/tempo/models/log.rb +194 -0
  33. data/lib/tempo/models/project.rb +73 -0
  34. data/lib/tempo/models/time_record.rb +235 -0
  35. data/lib/tempo/version.rb +3 -0
  36. data/lib/tempo/views/arrange_view.rb +27 -0
  37. data/lib/tempo/views/base.rb +82 -0
  38. data/lib/tempo/views/formatters/base.rb +30 -0
  39. data/lib/tempo/views/formatters/screen.rb +86 -0
  40. data/lib/tempo/views/projects_view.rb +82 -0
  41. data/lib/tempo/views/report_view.rb +26 -0
  42. data/lib/tempo/views/reporter.rb +70 -0
  43. data/lib/tempo/views/time_record_view.rb +30 -0
  44. data/lib/tempo/views/view_records/base.rb +117 -0
  45. data/lib/tempo/views/view_records/composite.rb +40 -0
  46. data/lib/tempo/views/view_records/log.rb +28 -0
  47. data/lib/tempo/views/view_records/project.rb +32 -0
  48. data/lib/tempo/views/view_records/time_record.rb +48 -0
  49. data/lib/tempo.rb +26 -0
  50. data/lib/time_utilities.rb +30 -0
  51. data/tempo-cli.gemspec +26 -0
  52. data/test/lib/file_record/directory_test.rb +30 -0
  53. data/test/lib/file_record/record_test.rb +106 -0
  54. data/test/lib/tempo/controllers/base_controller_test.rb +60 -0
  55. data/test/lib/tempo/controllers/project_controller_test.rb +24 -0
  56. data/test/lib/tempo/models/base_test.rb +173 -0
  57. data/test/lib/tempo/models/composite_test.rb +76 -0
  58. data/test/lib/tempo/models/log_test.rb +171 -0
  59. data/test/lib/tempo/models/project_test.rb +105 -0
  60. data/test/lib/tempo/models/time_record_test.rb +212 -0
  61. data/test/lib/tempo/views/base_test.rb +31 -0
  62. data/test/lib/tempo/views/formatters/base_test.rb +13 -0
  63. data/test/lib/tempo/views/formatters/screen_test.rb +94 -0
  64. data/test/lib/tempo/views/reporter_test.rb +40 -0
  65. data/test/lib/tempo/views/view_records/base_test.rb +77 -0
  66. data/test/lib/tempo/views/view_records/composite_test.rb +57 -0
  67. data/test/lib/tempo/views/view_records/log_test.rb +28 -0
  68. data/test/lib/tempo/views/view_records/project_test.rb +0 -0
  69. data/test/lib/tempo/views/view_records/time_record_test.rb +0 -0
  70. data/test/support/factories.rb +177 -0
  71. data/test/support/helpers.rb +69 -0
  72. data/test/test_helper.rb +31 -0
  73. metadata +230 -0
@@ -0,0 +1,120 @@
1
+ module FileRecord
2
+ class Record
3
+ class << self
4
+
5
+ # record text as a string, and all objects as yaml
6
+ # don't write over an existing document unless :force = true
7
+ # @options file file path to record to
8
+ # @options record string or object to record
9
+ # options
10
+ # - force: true, overwrite file
11
+ # - format: 'yaml', 'string'
12
+ def create( file, record, options={} )
13
+
14
+ if record.is_a?(String)
15
+ format = options.fetch(:format, 'string')
16
+ else
17
+ format = options.fetch(:format, 'yaml')
18
+ end
19
+
20
+ if File.exists?(file)
21
+ raise ArgumentError.new "file already exists" unless options[:force]
22
+ end
23
+
24
+ File.open( file,'w' ) do |f|
25
+
26
+ case format
27
+ when 'yaml'
28
+ f.puts YAML::dump( record )
29
+ when 'string'
30
+ f.puts record
31
+ else
32
+ f.puts record
33
+ end
34
+ end
35
+ end
36
+
37
+ def log_dirname( model )
38
+ dir_name = model.name[14..-1].gsub(/([A-Z])/, '_\1').downcase
39
+ dir = "tempo#{dir_name}s"
40
+ end
41
+
42
+ def log_dir( model )
43
+ dir_name = log_dirname model
44
+ dir = File.join(Dir.home,'tempo', dir_name)
45
+ Dir.mkdir(dir, 0700) unless File.exists?(dir)
46
+ dir
47
+ end
48
+
49
+ def log_filename( model, time )
50
+ file = "#{model.day_id( time )}.yaml"
51
+ end
52
+
53
+ def model_filename( model )
54
+ file_name = model.name[14..-1].gsub(/([A-Z])/, '_\1').downcase
55
+ file = "tempo#{file_name}s.yaml"
56
+ end
57
+
58
+ # record a child of Tempo::Model::Base
59
+ def save_model( model )
60
+ file = model_filename model
61
+ file_path = File.join(Dir.home,'tempo', file)
62
+ File.delete( file_path ) if File.exists?( file_path )
63
+
64
+ File.open( file_path,'a' ) do |f|
65
+ model.index.each do |m|
66
+ f.puts YAML::dump( m.freeze_dry )
67
+ end
68
+ end
69
+ end
70
+
71
+ # record a child of Tempo::Model::Log
72
+ def save_log( model )
73
+ dir = log_dir model
74
+
75
+ model.days_index.each do |day, days_logs|
76
+ file = "#{day.to_s}.yaml"
77
+ file_path = File.join(dir, file)
78
+ File.delete( file_path ) if File.exists?( file_path )
79
+
80
+ # don't write to an empty file
81
+ next if days_logs.empty?
82
+
83
+ File.open( file_path,'a' ) do |f|
84
+ days_logs.each do |log|
85
+ f.puts YAML::dump( log.freeze_dry )
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ def read_instances( model, file )
92
+ instances = YAML::load_stream( File.open( file ) )
93
+ instances.each do |i|
94
+ model.new( i )
95
+ end
96
+ end
97
+
98
+ def read_model( model )
99
+ file = File.join(Dir.home,'tempo', model.file)
100
+ read_instances model, file
101
+ end
102
+
103
+ def read_log( model, time )
104
+ dir = File.join(Dir.home,'tempo', model.dir)
105
+ file = File.join(dir, model.file( time ))
106
+ if File.exists? file
107
+ read_instances model, file
108
+ end
109
+ end
110
+ end
111
+
112
+ def update
113
+
114
+ end
115
+
116
+ def delete
117
+
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,52 @@
1
+ module Tempo
2
+ module Controllers
3
+ class Arrange < Tempo::Controllers::Base
4
+ @projects = Model::Project
5
+
6
+ class << self
7
+
8
+ def parse options, args
9
+
10
+ return Views::arrange_parse_error unless args.include? ":"
11
+
12
+ parent_args = []
13
+ child_args = []
14
+ in_parent = true
15
+ args.each do |a|
16
+ if a != ":"
17
+ in_parent ? parent_args << a : child_args << a
18
+ else
19
+ in_parent = false
20
+ end
21
+ end
22
+
23
+ if parent_args.empty?
24
+ make_root_project options, child_args
25
+ else
26
+ make_child_project options, parent_args, child_args
27
+ end
28
+ end
29
+
30
+ def make_root_project options, args
31
+ root = match_project :arrange, options, args
32
+ if root.parent == :root
33
+ Views::arrange_already_root root
34
+ else
35
+ parent = match_project :arrange, {id: true}, root.parent
36
+ parent.remove_child root
37
+ @projects.save_to_file
38
+ Views::arrange_root root
39
+ end
40
+ end
41
+
42
+ def make_child_project options, parent_args, child_args
43
+ parent = match_project :arrange, options, parent_args
44
+ child = match_project :arrange, options, child_args
45
+ parent << child
46
+ @projects.save_to_file
47
+ Views::arrange_parent_child parent, child
48
+ end
49
+ end #class << self
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,117 @@
1
+ module Tempo
2
+ module Controllers
3
+ class Base
4
+ class << self
5
+
6
+ def filter_projects_by_title options, args
7
+ if options[:exact]
8
+ match = reassemble_the args
9
+ match = [match]
10
+ model_match @projects, match, "title", :exact
11
+ else
12
+ model_match @projects, args, "title", :fuzzy
13
+ end
14
+ end
15
+
16
+ # Takes an array of source strings
17
+ # and filters them down to the ones
18
+ # that match positively against every
19
+ # member of the matches array
20
+ #
21
+ def fuzzy_match haystack, matches, attribute="id"
22
+
23
+ matches = [matches] unless matches.is_a? Array
24
+
25
+ if haystack.is_a? Array
26
+ fuzzy_array_match( haystack, matches )
27
+
28
+ elsif haystack.superclass == Model::Base
29
+ model_match( haystack, matches, attribute )
30
+ end
31
+ end
32
+
33
+ # Gli default behavior: When args are sent in a
34
+ # command without quotes, they are broken into an array,
35
+ # and the first block is passed to a flag if present.
36
+ #
37
+ # Here we reassemble the string, and add value stored in
38
+ # a flag in the front. The value is also added back intto the
39
+ # front of the original array
40
+
41
+ def reassemble_the args, flag=nil
42
+ assembled = ""
43
+ args.unshift flag if flag
44
+ args.each { |a| assembled += " #{a}" }
45
+ assembled.strip!
46
+ end
47
+
48
+ private
49
+
50
+ # TODO: escape regex characters ., (), etc.
51
+ def match_to_regex match, type=:fuzzy
52
+ match.downcase!
53
+ if type == :exact
54
+ /^#{match}$/
55
+ else
56
+ /#{match}/
57
+ end
58
+ end
59
+
60
+ def fuzzy_array_match haystack, matches
61
+ results = []
62
+ matches.each do |m|
63
+ reg = match_to_regex m
64
+ haystack.each do |h|
65
+ results << h if reg.match h
66
+ end
67
+ haystack = results
68
+ results = []
69
+ end
70
+ haystack
71
+ end
72
+
73
+ def model_match haystack, matches, attribute, type=:fuzzy
74
+ attribute = "@#{attribute}".to_sym
75
+ contenders = haystack.index
76
+ results = []
77
+ matches.each do |m|
78
+ reg = match_to_regex m, type
79
+ contenders.each do |c|
80
+ results << c if reg.match c.instance_variable_get(attribute).to_s.downcase
81
+ end
82
+ contenders = results
83
+ results = []
84
+ end
85
+ contenders
86
+ end
87
+
88
+ def match_project command, options, args
89
+ if options[:id]
90
+ match = @projects.find_by_id args[0]
91
+ Views::no_match_error( "projects", "id=#{args[0]}" ) if not match
92
+ else
93
+ matches = filter_projects_by_title options, args
94
+ request = reassemble_the args
95
+ match = single_match matches, request, command
96
+ end
97
+ match
98
+ end
99
+
100
+ # verify one and only one match returned in match array
101
+ # returns the single match
102
+ def single_match matches, request, command
103
+
104
+ if matches.length == 0
105
+ Views::no_match_error "projects", request
106
+ return false
107
+ elsif matches.length > 1
108
+ Views::ambiguous_project matches, command
109
+ return false
110
+ else
111
+ match = matches[0]
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,42 @@
1
+ module Tempo
2
+ module Controllers
3
+ class Checkout < Tempo::Controllers::Base
4
+ @projects = Model::Project
5
+
6
+ class << self
7
+
8
+ def add_project options, args
9
+ request = reassemble_the args, options[:add]
10
+
11
+ if @projects.include? request
12
+ Views::already_exists_error "project", request
13
+
14
+ else
15
+ project = @projects.new({ title: request, current: true })
16
+ @projects.save_to_file
17
+ Views::project_checkout project
18
+ end
19
+ end
20
+
21
+ def existing_project options, args
22
+
23
+ match = match_project :checkout, options, args
24
+
25
+ if match
26
+ if @projects.current == match
27
+ Views::project_already_current match
28
+ else
29
+ @projects.current = match
30
+ @projects.save_to_file
31
+ Views::project_checkout match
32
+ end
33
+ end
34
+ end
35
+
36
+ def assistance
37
+ Views::checkout_assistance
38
+ end
39
+ end #class << self
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ require 'chronic'
2
+
3
+ module Tempo
4
+ module Controllers
5
+ class End < Tempo::Controllers::Base
6
+ @projects = Model::Project
7
+ @time_records = Model::TimeRecord
8
+
9
+ class << self
10
+
11
+ def end_timer options, args
12
+
13
+ return Views.project_assistance if Model::Project.index.empty?
14
+
15
+ if not options[:at]
16
+ time_out = Time.new()
17
+ else
18
+ time_out = Time.parse options[:at]
19
+ end
20
+
21
+ return Views.no_match_error( "valid timeframe", options[:at], false ) if not time_out
22
+
23
+ options = { end_time: time_out }
24
+ options[:description] = reassemble_the args
25
+
26
+ @time_records.load_last_day
27
+ record = @time_records.current
28
+
29
+ return Views.no_items( "running time records", :error ) if ! record
30
+
31
+ record.end_time = time_out
32
+ record.description = options[:description] if options[:description]
33
+ @time_records.save_to_file
34
+
35
+ Views.end_time_record_view record
36
+
37
+ end
38
+
39
+ end #class << self
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,107 @@
1
+ module Tempo
2
+ module Controllers
3
+ class Projects < Tempo::Controllers::Base
4
+ @projects = Model::Project
5
+
6
+ class << self
7
+
8
+ def load
9
+ if File.exists?( File.join( ENV['HOME'], 'tempo', @projects.file ))
10
+ @projects.read_from_file
11
+ end
12
+ end
13
+
14
+ def index options, args
15
+
16
+ request = reassemble_the args
17
+
18
+ if args.empty?
19
+ Views::projects_list_view
20
+
21
+ else
22
+ matches = filter_projects_by_title options, args
23
+
24
+ if matches.empty?
25
+ Views::no_match_error "projects", request
26
+
27
+ else
28
+ Views::projects_list_view matches
29
+ end
30
+ end
31
+ end
32
+
33
+ def show_active
34
+ if @projects.index.empty?
35
+ Views::no_items "projects"
36
+ else
37
+ Views::Reporter.add_options active: true
38
+ Views::project_view @projects.current
39
+ end
40
+ end
41
+
42
+ def add options, args, tags=nil
43
+ request = reassemble_the args
44
+
45
+ if @projects.include? request
46
+ Views::already_exists_error "project", request
47
+
48
+ else
49
+ project = @projects.new({ title: request, tags: tags })
50
+
51
+ if @projects.index.length == 1
52
+ @projects.current = project
53
+ end
54
+
55
+ @projects.save_to_file
56
+
57
+ Views::project_added project
58
+ end
59
+ end
60
+
61
+ def delete options, args
62
+
63
+ reassemble_the args, options[:delete]
64
+ match = match_project :delete, options, args
65
+
66
+ if match
67
+ if match == @projects.current
68
+ return Views::ViewRecords::Message.new "cannot delete the active project", category: :error
69
+ end
70
+
71
+ if @projects.index.include?(match)
72
+ match.delete
73
+ @projects.save_to_file
74
+ Views::projects_list_view if options[:list]
75
+ Views::project_deleted match
76
+ end
77
+ end
78
+ end
79
+
80
+ # add a project with tags, or tag or untag an existing project
81
+ def tag options, args
82
+
83
+ # TODO @projects_find_by_tag if args.empty?
84
+
85
+ tags = options[:tag].split if options[:tag]
86
+ untags = options[:untag].split if options[:untag]
87
+
88
+ # add a new project
89
+ if options[:add]
90
+ add options, args, tags
91
+
92
+ else
93
+ command = options[:tag] ? "tag" : "untag"
94
+ match = match_project command, options, args
95
+
96
+ if match
97
+ match.tag tags
98
+ match.untag untags
99
+ @projects.save_to_file
100
+ Views::project_tags match
101
+ end
102
+ end
103
+ end
104
+ end #class << self
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,21 @@
1
+ module Tempo
2
+ module Controllers
3
+ class Records < Tempo::Controllers::Base
4
+ @projects = Model::Project
5
+
6
+ class << self
7
+
8
+ def initialize_from_records options, args
9
+ if File.exists?( File.join( ENV['HOME'], 'tempo' ))
10
+
11
+ Tempo::Controllers::Projects.load
12
+
13
+ else
14
+ FileRecord::Directory.create_new
15
+ end
16
+ end
17
+
18
+ end #class << self
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,55 @@
1
+ module Tempo
2
+ module Controllers
3
+ class Report < Tempo::Controllers::Base
4
+ @projects = Model::Project
5
+ @time_records = Model::TimeRecord
6
+
7
+ class << self
8
+
9
+ def report options, args
10
+
11
+ return Tempo::Views.project_assistance if Tempo::Model::Project.index.empty?
12
+
13
+ # A from flag has been supplied by the user
14
+ # and possible a to flag as well,
15
+ # so we return a period of day records
16
+ #
17
+ if options[:from] != "last record"
18
+ from = Time.parse options[:from]
19
+ return Views.no_match_error( "valid timeframe", options[:from], false ) if from.nil?
20
+
21
+ to = Time.parse options[:to]
22
+ return Views.no_match_error( "valid timeframe", options[:to], false ) if to.nil?
23
+
24
+ @time_records.load_days_records from, to
25
+
26
+ error_timeframe = " from #{from.strftime('%m/%d/%Y')} to #{to.strftime('%m/%d/%Y')}"
27
+
28
+ # no arguments or flags have been supplied, so we return the
29
+ # current day record
30
+ #
31
+ elsif args.empty?
32
+ @time_records.load_last_day
33
+
34
+ # arguments have been supplied,
35
+ # so we return the records for a single day
36
+ #
37
+ else
38
+ time = reassemble_the args
39
+
40
+ day = Time.parse time
41
+ return Views.no_match_error( "valid timeframe", time, false ) if day.nil?
42
+
43
+ @time_records.load_day_record day
44
+
45
+ error_timeframe = " on #{day.strftime('%m/%d/%Y')}"
46
+ end
47
+
48
+ return Views.no_items( "time records#{error_timeframe}", :error ) if @time_records.index.empty?
49
+
50
+ Views.report_records_view
51
+ end
52
+ end #class << self
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ require 'chronic'
2
+
3
+ module Tempo
4
+ module Controllers
5
+ class Start < Tempo::Controllers::Base
6
+ @projects = Model::Project
7
+ @time_records = Model::TimeRecord
8
+
9
+ class << self
10
+
11
+ def start_timer options, args
12
+
13
+ return Views.project_assistance if Model::Project.index.empty?
14
+
15
+ if not options[:at]
16
+ time_in = Time.new()
17
+ else
18
+ time_in = Time.parse options[:at]
19
+ end
20
+
21
+ return Views.no_match_error( "valid timeframe", options[:at], false ) if time_in.nil?
22
+
23
+ opts = { start_time: time_in }
24
+ opts[:description] = reassemble_the args
25
+
26
+ if options[:end]
27
+ time_out = Time.parse options[:end]
28
+ return Views.no_match_error( "valid timeframe", options[:end], false ) if time_out.nil?
29
+ opts[:end_time] = time_out
30
+ end
31
+ @time_records.load_last_day
32
+ record = @time_records.new(opts)
33
+ @time_records.save_to_file
34
+
35
+ Views.start_time_record_view record
36
+
37
+ end
38
+
39
+ end #class << self
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,78 @@
1
+ require 'chronic'
2
+
3
+ module Tempo
4
+ module Controllers
5
+ class Update < Tempo::Controllers::Base
6
+ @projects = Model::Project
7
+ @time_records = Model::TimeRecord
8
+
9
+ class << self
10
+
11
+ def parse options, args
12
+
13
+ reassemble_the args
14
+
15
+ return Views.project_assistance if Model::Project.index.empty?
16
+
17
+ if options[:on]
18
+ day = Time.parse options[:on]
19
+ return Views.no_match_error( "valid timeframe", options[:from], false ) if day.nil?
20
+ @time_records.load_day_record day
21
+ else
22
+ day = @time_records.load_last_day
23
+ end
24
+
25
+ if options[:id]
26
+ record = @time_records.find_by_id( options[:id], day )
27
+ return Views.no_match_error( "time record on #{day.strftime('%m/%d/%Y')}", "id = #{options[:id]}", false ) if !record
28
+ else
29
+ record = @time_records.index.last
30
+ return Views.no_items( "time records on #{day.strftime('%m/%d/%Y')}", :error ) if ! record
31
+ end
32
+
33
+
34
+ if options[:delete]
35
+ record.delete
36
+ @time_records.save_to_file
37
+ Views.delete_time_record_view record
38
+
39
+ else
40
+ if options[:start]
41
+ start_time = Time.parse options[:start]
42
+ return Views.no_match_error( "valid timeframe", options[:at], false ) if start_time.nil?
43
+
44
+ # TODO: add "today " to start time and try again if not valid
45
+ if record.valid_start_time? start_time
46
+ record.start_time = start_time
47
+ else
48
+ return Views::ViewRecords::Message.new "cannot change start time to #{start_time.strftime('%H:%M')}", category: :error
49
+ end
50
+ end
51
+
52
+ if options[:end]
53
+ end_time = Time.parse options[:end]
54
+ return Views.no_match_error( "valid timeframe", options[:at], false ) if end_time.nil?
55
+
56
+ # TODO: add "today " to end time and try again if not valid
57
+ if record.valid_end_time? end_time
58
+ record.end_time = end_time
59
+ else
60
+ return Views::ViewRecords::Message.new "cannot change end time to #{end_time.strftime('%H:%M')}", category: :error
61
+ end
62
+ end
63
+
64
+ if options[:project]
65
+ record.project = @projects.current.id
66
+ end
67
+
68
+ options[:description] = reassemble_the args
69
+ record.description = options[:description] if options[:description] && !options[:description].empty?
70
+
71
+ @time_records.save_to_file
72
+ Views.update_time_record_view record
73
+ end
74
+ end
75
+ end #class << self
76
+ end
77
+ end
78
+ end