tempo-cli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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