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.
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +56 -0
- data/README.md +326 -0
- data/Rakefile +65 -0
- data/bin/tempo +477 -0
- data/features/arrange.feature +43 -0
- data/features/checkout.feature +63 -0
- data/features/end.feature +65 -0
- data/features/project.feature +246 -0
- data/features/report.feature +62 -0
- data/features/start.feature +87 -0
- data/features/step_definitions/tempo_steps.rb +138 -0
- data/features/support/env.rb +26 -0
- data/features/tempo.feature +13 -0
- data/features/update.feature +69 -0
- data/lib/file_record/directory.rb +11 -0
- data/lib/file_record/directory_structure/tempo/README.txt +4 -0
- data/lib/file_record/directory_structure/tempo/tempo_projects.yaml +6 -0
- data/lib/file_record/record.rb +120 -0
- data/lib/tempo/controllers/arrange_controller.rb +52 -0
- data/lib/tempo/controllers/base.rb +117 -0
- data/lib/tempo/controllers/checkout_controller.rb +42 -0
- data/lib/tempo/controllers/end_controller.rb +42 -0
- data/lib/tempo/controllers/projects_controller.rb +107 -0
- data/lib/tempo/controllers/records_controller.rb +21 -0
- data/lib/tempo/controllers/report_controller.rb +55 -0
- data/lib/tempo/controllers/start_controller.rb +42 -0
- data/lib/tempo/controllers/update_controller.rb +78 -0
- data/lib/tempo/models/base.rb +176 -0
- data/lib/tempo/models/composite.rb +71 -0
- data/lib/tempo/models/log.rb +194 -0
- data/lib/tempo/models/project.rb +73 -0
- data/lib/tempo/models/time_record.rb +235 -0
- data/lib/tempo/version.rb +3 -0
- data/lib/tempo/views/arrange_view.rb +27 -0
- data/lib/tempo/views/base.rb +82 -0
- data/lib/tempo/views/formatters/base.rb +30 -0
- data/lib/tempo/views/formatters/screen.rb +86 -0
- data/lib/tempo/views/projects_view.rb +82 -0
- data/lib/tempo/views/report_view.rb +26 -0
- data/lib/tempo/views/reporter.rb +70 -0
- data/lib/tempo/views/time_record_view.rb +30 -0
- data/lib/tempo/views/view_records/base.rb +117 -0
- data/lib/tempo/views/view_records/composite.rb +40 -0
- data/lib/tempo/views/view_records/log.rb +28 -0
- data/lib/tempo/views/view_records/project.rb +32 -0
- data/lib/tempo/views/view_records/time_record.rb +48 -0
- data/lib/tempo.rb +26 -0
- data/lib/time_utilities.rb +30 -0
- data/tempo-cli.gemspec +26 -0
- data/test/lib/file_record/directory_test.rb +30 -0
- data/test/lib/file_record/record_test.rb +106 -0
- data/test/lib/tempo/controllers/base_controller_test.rb +60 -0
- data/test/lib/tempo/controllers/project_controller_test.rb +24 -0
- data/test/lib/tempo/models/base_test.rb +173 -0
- data/test/lib/tempo/models/composite_test.rb +76 -0
- data/test/lib/tempo/models/log_test.rb +171 -0
- data/test/lib/tempo/models/project_test.rb +105 -0
- data/test/lib/tempo/models/time_record_test.rb +212 -0
- data/test/lib/tempo/views/base_test.rb +31 -0
- data/test/lib/tempo/views/formatters/base_test.rb +13 -0
- data/test/lib/tempo/views/formatters/screen_test.rb +94 -0
- data/test/lib/tempo/views/reporter_test.rb +40 -0
- data/test/lib/tempo/views/view_records/base_test.rb +77 -0
- data/test/lib/tempo/views/view_records/composite_test.rb +57 -0
- data/test/lib/tempo/views/view_records/log_test.rb +28 -0
- data/test/lib/tempo/views/view_records/project_test.rb +0 -0
- data/test/lib/tempo/views/view_records/time_record_test.rb +0 -0
- data/test/support/factories.rb +177 -0
- data/test/support/helpers.rb +69 -0
- data/test/test_helper.rb +31 -0
- metadata +230 -0
@@ -0,0 +1,176 @@
|
|
1
|
+
module Tempo
|
2
|
+
module Model
|
3
|
+
|
4
|
+
class IdentityConflictError < Exception
|
5
|
+
end
|
6
|
+
|
7
|
+
class Base
|
8
|
+
attr_reader :id
|
9
|
+
|
10
|
+
class << self
|
11
|
+
|
12
|
+
# Maintain an array of unique ids for the class.
|
13
|
+
# Initialize new members with the next numberical id
|
14
|
+
# Ids can be assigned on init (for the purpose of reading
|
15
|
+
# in records of previous instances). An error will
|
16
|
+
# be raised if there is already an instance with that
|
17
|
+
# id.
|
18
|
+
def id_counter
|
19
|
+
@id_counter ||= 1
|
20
|
+
end
|
21
|
+
|
22
|
+
def ids
|
23
|
+
@ids ||= []
|
24
|
+
end
|
25
|
+
|
26
|
+
def index
|
27
|
+
@index ||= []
|
28
|
+
end
|
29
|
+
|
30
|
+
# example: Tempo::Model::Animal -> tempo_animals.yaml
|
31
|
+
def file
|
32
|
+
FileRecord::Record.model_filename self
|
33
|
+
end
|
34
|
+
|
35
|
+
def save_to_file
|
36
|
+
FileRecord::Record.save_model self
|
37
|
+
end
|
38
|
+
|
39
|
+
def read_from_file
|
40
|
+
FileRecord::Record.read_model self
|
41
|
+
end
|
42
|
+
|
43
|
+
def method_missing meth, *args, &block
|
44
|
+
|
45
|
+
if meth.to_s =~ /^find_by_(.+)$/
|
46
|
+
run_find_by_method($1, *args, &block)
|
47
|
+
|
48
|
+
elsif meth.to_s =~ /^sort_by_(.+)$/
|
49
|
+
run_sort_by_method($1, *args, &block)
|
50
|
+
else
|
51
|
+
super
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def run_sort_by_method attribute, args=@index.clone, &block
|
56
|
+
attr = "@#{attribute}".to_sym
|
57
|
+
args.sort! { |a,b| a.instance_variable_get( attr ) <=> b.instance_variable_get( attr ) }
|
58
|
+
return args unless block
|
59
|
+
block.call args
|
60
|
+
end
|
61
|
+
|
62
|
+
def run_find_by_method attrs, *args, &block
|
63
|
+
# Make an array of attribute names
|
64
|
+
attrs = attrs.split('_and_')
|
65
|
+
|
66
|
+
attrs_with_args = [attrs, args].transpose
|
67
|
+
|
68
|
+
filtered = index.clone
|
69
|
+
attrs_with_args.each do | kv |
|
70
|
+
matches = find kv[0], kv[1]
|
71
|
+
|
72
|
+
return matches if matches.empty?
|
73
|
+
matches.each do |match|
|
74
|
+
matches.delete match unless filtered.include? match
|
75
|
+
filtered = matches
|
76
|
+
end
|
77
|
+
end
|
78
|
+
filtered
|
79
|
+
end
|
80
|
+
|
81
|
+
# find by id should be exact, so we remove the array wrapper
|
82
|
+
def find_by_id id
|
83
|
+
matches = find "id", id
|
84
|
+
match = matches[0]
|
85
|
+
end
|
86
|
+
|
87
|
+
# example: Tempo::Model.find("id", 1)
|
88
|
+
#
|
89
|
+
def find key, value
|
90
|
+
key = "@#{key}".to_sym
|
91
|
+
matches = []
|
92
|
+
index.each do |i|
|
93
|
+
stored_value = i.instance_variable_get( key )
|
94
|
+
|
95
|
+
if stored_value.kind_of? String
|
96
|
+
if value.kind_of? Regexp
|
97
|
+
matches << i if value.match stored_value
|
98
|
+
else
|
99
|
+
matches << i if stored_value.downcase.include? value.to_s.downcase
|
100
|
+
end
|
101
|
+
|
102
|
+
elsif stored_value.kind_of? Integer
|
103
|
+
matches << i if stored_value == value.to_i
|
104
|
+
end
|
105
|
+
end
|
106
|
+
matches
|
107
|
+
end
|
108
|
+
|
109
|
+
def delete instance
|
110
|
+
id = instance.id
|
111
|
+
index.delete( instance )
|
112
|
+
ids.delete( id )
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def initialize options={}
|
117
|
+
id_candidate = options[:id]
|
118
|
+
if !id_candidate
|
119
|
+
@id = self.class.next_id
|
120
|
+
elsif self.class.ids.include? id_candidate
|
121
|
+
raise IdentityConflictError, "Id #{id_candidate} already exists"
|
122
|
+
else
|
123
|
+
@id = id_candidate
|
124
|
+
end
|
125
|
+
self.class.add_id @id
|
126
|
+
self.class.add_to_index self
|
127
|
+
end
|
128
|
+
|
129
|
+
# record the state of all instance variables as a hash
|
130
|
+
def freeze_dry
|
131
|
+
record = {}
|
132
|
+
state = instance_variables
|
133
|
+
state.each do |attr|
|
134
|
+
key = attr[1..-1].to_sym
|
135
|
+
val = instance_variable_get attr
|
136
|
+
|
137
|
+
#val = val.to_s if val.kind_of? Time
|
138
|
+
|
139
|
+
record[key] = val
|
140
|
+
end
|
141
|
+
record
|
142
|
+
end
|
143
|
+
|
144
|
+
def delete
|
145
|
+
self.class.delete self
|
146
|
+
end
|
147
|
+
|
148
|
+
protected
|
149
|
+
|
150
|
+
def self.add_to_index member
|
151
|
+
@index ||= []
|
152
|
+
@index << member
|
153
|
+
@index.sort! { |a,b| a.id <=> b.id }
|
154
|
+
end
|
155
|
+
|
156
|
+
def self.add_id id
|
157
|
+
@ids ||=[]
|
158
|
+
@ids << id
|
159
|
+
@ids.sort!
|
160
|
+
end
|
161
|
+
|
162
|
+
def self.increase_id_counter
|
163
|
+
@id_counter ||= 0
|
164
|
+
@id_counter = @id_counter.next
|
165
|
+
end
|
166
|
+
|
167
|
+
def self.next_id
|
168
|
+
while ids.include? id_counter
|
169
|
+
increase_id_counter
|
170
|
+
end
|
171
|
+
id_counter
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# Composite Model extends base to accomodate tree structures
|
2
|
+
# Each instance can be a root instance, or a child of another
|
3
|
+
# instance, and each instance can have any number of children.
|
4
|
+
# report_trees is a utility method for testing the validity of the
|
5
|
+
# model, and cam be used as a template for creating tree reports.
|
6
|
+
|
7
|
+
module Tempo
|
8
|
+
module Model
|
9
|
+
class Composite < Tempo::Model::Base
|
10
|
+
attr_accessor :parent, :children
|
11
|
+
|
12
|
+
class << self
|
13
|
+
|
14
|
+
def report_trees
|
15
|
+
report_array = "["
|
16
|
+
@index.each do |member|
|
17
|
+
if member.parent == :root
|
18
|
+
report_array += "["
|
19
|
+
report_array += member.report_branches
|
20
|
+
report_array += "],"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
if report_array[-1] == ","
|
24
|
+
report_array = report_array[0..-2]
|
25
|
+
end
|
26
|
+
report_array += "]"
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete instance
|
30
|
+
instance.children.each do |child_id|
|
31
|
+
child = find_by_id child_id
|
32
|
+
instance.remove_child child
|
33
|
+
end
|
34
|
+
super instance
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def initialize(options={})
|
39
|
+
super options
|
40
|
+
@parent = options.fetch(:parent, :root)
|
41
|
+
@children = options.fetch(:children, [])
|
42
|
+
end
|
43
|
+
|
44
|
+
def << child
|
45
|
+
@children << child.id unless @children.include? child.id
|
46
|
+
@children.sort!
|
47
|
+
child.parent = self.id
|
48
|
+
end
|
49
|
+
|
50
|
+
def remove_child( child )
|
51
|
+
@children.delete child.id
|
52
|
+
child.parent = :root
|
53
|
+
end
|
54
|
+
|
55
|
+
def report_branches
|
56
|
+
report = self.id.to_s
|
57
|
+
child_report = ",["
|
58
|
+
@children.each do |c|
|
59
|
+
child = self.class.find_by_id c
|
60
|
+
child_report += "#{child.report_branches},"
|
61
|
+
end
|
62
|
+
if child_report == ",["
|
63
|
+
child_report = ""
|
64
|
+
else
|
65
|
+
child_report = child_report[0..-2] + "]"
|
66
|
+
end
|
67
|
+
report += child_report
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
module Tempo
|
2
|
+
module Model
|
3
|
+
class Log < Tempo::Model::Base
|
4
|
+
attr_accessor :start_time
|
5
|
+
attr_reader :d_id
|
6
|
+
|
7
|
+
class << self
|
8
|
+
|
9
|
+
# Maintain arrays of unique ids for each day.
|
10
|
+
# days are represented as symbols in the hash,
|
11
|
+
# for example Jan 1, 2013 would be :"130101"
|
12
|
+
# id counter is managed through the private methods
|
13
|
+
# increase_id_counter and next_id below
|
14
|
+
def id_counter time
|
15
|
+
dsym = date_symbol time
|
16
|
+
@id_counter = {} unless @id_counter.kind_of? Hash
|
17
|
+
@id_counter[ dsym ] ||= 1
|
18
|
+
end
|
19
|
+
|
20
|
+
def ids time
|
21
|
+
dsym = date_symbol time
|
22
|
+
@ids = {} unless @ids.kind_of? Hash
|
23
|
+
@ids[dsym] ||= []
|
24
|
+
end
|
25
|
+
|
26
|
+
# all instances are saved in the index inherited from base.
|
27
|
+
# Additionally, the days index organizes all instances into
|
28
|
+
# arrays by day. This is used for saving to file.
|
29
|
+
def days_index
|
30
|
+
@days_index = {} unless @days_index.kind_of? Hash
|
31
|
+
@days_index
|
32
|
+
end
|
33
|
+
|
34
|
+
def file time
|
35
|
+
FileRecord::Record.log_filename( self, time )
|
36
|
+
end
|
37
|
+
|
38
|
+
def dir
|
39
|
+
FileRecord::Record.log_dirname( self )
|
40
|
+
end
|
41
|
+
|
42
|
+
def records
|
43
|
+
path = FileRecord::Record.log_dir( self )
|
44
|
+
Dir[path + "/*.yaml"]
|
45
|
+
end
|
46
|
+
|
47
|
+
def save_to_file
|
48
|
+
FileRecord::Record.save_log( self )
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
def read_from_file time
|
53
|
+
dsym = date_symbol time
|
54
|
+
@days_index[ dsym ] = [] if not days_index.has_key? dsym
|
55
|
+
FileRecord::Record.read_log( self, time )
|
56
|
+
end
|
57
|
+
|
58
|
+
# load all the records for a single day
|
59
|
+
def load_day_record time
|
60
|
+
dsym = date_symbol time
|
61
|
+
if not days_index.has_key? dsym
|
62
|
+
@days_index[ dsym ] = []
|
63
|
+
read_from_file time
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# load the records for each day from time 1 to time 2
|
68
|
+
def load_days_records time_1, time_2
|
69
|
+
|
70
|
+
return if time_1.nil? || time_2.nil?
|
71
|
+
|
72
|
+
days = ( time_2.to_date - time_1.to_date ).to_i
|
73
|
+
return if days < 0
|
74
|
+
|
75
|
+
(days + 1).times { |i| load_day_record( time_1.add_days( i ))}
|
76
|
+
end
|
77
|
+
|
78
|
+
# load the records for the most recently recorded day
|
79
|
+
def load_last_day
|
80
|
+
reg = /(\d+)\.yaml/
|
81
|
+
if records.last
|
82
|
+
d_id = reg.match(records.last)[1] if records.last
|
83
|
+
time = day_id_to_time d_id if d_id
|
84
|
+
load_day_record time
|
85
|
+
return time
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# takes and integer, and time or day_id
|
90
|
+
# and returns the instance that matches both
|
91
|
+
# the id and d_id
|
92
|
+
def find_by_id id, time
|
93
|
+
time = day_id time
|
94
|
+
ids = find "id", id
|
95
|
+
d_ids = find "d_id", time
|
96
|
+
|
97
|
+
#return the first and only match in the union
|
98
|
+
#of the arrays
|
99
|
+
(ids & d_ids)[0]
|
100
|
+
end
|
101
|
+
|
102
|
+
# day_ids can be run through without change
|
103
|
+
# Time will be converted into "YYYYmmdd"
|
104
|
+
# ex: 1-1-2014 => "20140101"
|
105
|
+
def day_id time
|
106
|
+
if time.kind_of? String
|
107
|
+
return time if time =~ /^\d{8}$/
|
108
|
+
end
|
109
|
+
raise ArgumentError, "Invalid Time" if not time.kind_of? Time
|
110
|
+
time.strftime("%Y%m%d")
|
111
|
+
end
|
112
|
+
|
113
|
+
def day_id_to_time d_id
|
114
|
+
time = Time.new(d_id[0..3].to_i, d_id[4..5].to_i, d_id[6..7].to_i)
|
115
|
+
end
|
116
|
+
|
117
|
+
def delete instance
|
118
|
+
id = instance.id
|
119
|
+
dsym = date_symbol instance.d_id
|
120
|
+
|
121
|
+
index.delete instance
|
122
|
+
days_index[dsym].delete instance
|
123
|
+
@ids[dsym].delete id
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def initialize( options={} )
|
128
|
+
@start_time = options.fetch(:start_time, Time.now )
|
129
|
+
@start_time = Time.new(@start_time) if @start_time.kind_of? String
|
130
|
+
|
131
|
+
self.class.load_day_record(@start_time)
|
132
|
+
@d_id = self.class.day_id @start_time
|
133
|
+
|
134
|
+
id_candidate = options[:id]
|
135
|
+
if !id_candidate
|
136
|
+
@id = self.class.next_id @start_time
|
137
|
+
elsif self.class.ids( @start_time ).include? id_candidate
|
138
|
+
raise IdentityConflictError, "Id #{id_candidate} already exists"
|
139
|
+
else
|
140
|
+
@id = id_candidate
|
141
|
+
end
|
142
|
+
|
143
|
+
self.class.add_id @start_time, @id
|
144
|
+
self.class.add_to_index self
|
145
|
+
self.class.add_to_days_index self
|
146
|
+
end
|
147
|
+
|
148
|
+
def freeze_dry
|
149
|
+
record = super
|
150
|
+
record.delete(:d_id)
|
151
|
+
record
|
152
|
+
end
|
153
|
+
|
154
|
+
protected
|
155
|
+
|
156
|
+
class << self
|
157
|
+
|
158
|
+
def add_to_days_index member
|
159
|
+
@days_index = {} unless @days_index.kind_of? Hash
|
160
|
+
dsym = date_symbol member.start_time
|
161
|
+
@days_index[dsym] ||= []
|
162
|
+
@days_index[dsym] << member
|
163
|
+
@days_index[dsym].sort! { |a,b| a.start_time <=> b.start_time }
|
164
|
+
end
|
165
|
+
|
166
|
+
def add_id time, id
|
167
|
+
dsym = date_symbol time
|
168
|
+
@ids = {} unless @ids.kind_of? Hash
|
169
|
+
@ids[dsym] ||= []
|
170
|
+
@ids[dsym] << id
|
171
|
+
@ids[dsym].sort!
|
172
|
+
end
|
173
|
+
|
174
|
+
def date_symbol time
|
175
|
+
day_id( time ).to_sym
|
176
|
+
end
|
177
|
+
|
178
|
+
def increase_id_counter time
|
179
|
+
dsym = date_symbol time
|
180
|
+
@id_counter = {} unless @id_counter.kind_of? Hash
|
181
|
+
@id_counter[ dsym ] ||= 0
|
182
|
+
@id_counter[ dsym ] = @id_counter[ dsym ].next
|
183
|
+
end
|
184
|
+
|
185
|
+
def next_id time
|
186
|
+
while ids(time).include? id_counter time
|
187
|
+
increase_id_counter time
|
188
|
+
end
|
189
|
+
id_counter time
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module Tempo
|
2
|
+
module Model
|
3
|
+
class Project < Tempo::Model::Composite
|
4
|
+
attr_accessor :title
|
5
|
+
attr_reader :tags
|
6
|
+
@current = 0
|
7
|
+
|
8
|
+
class << self
|
9
|
+
|
10
|
+
def current( instance=nil )
|
11
|
+
@current
|
12
|
+
end
|
13
|
+
|
14
|
+
def current=( instance )
|
15
|
+
if instance.class == self
|
16
|
+
@current = instance
|
17
|
+
else
|
18
|
+
raise ArgumentError
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def include?( title )
|
23
|
+
matches = find_by_title( title )
|
24
|
+
return false if matches.empty?
|
25
|
+
matches.each do |match|
|
26
|
+
return true if match.title == title
|
27
|
+
end
|
28
|
+
false
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(options={})
|
33
|
+
super options
|
34
|
+
@title = options.fetch(:title, "new project")
|
35
|
+
@tags = []
|
36
|
+
tag options.fetch(:tags, [])
|
37
|
+
current = options.fetch(:current, false)
|
38
|
+
self.class.current = self if current
|
39
|
+
end
|
40
|
+
|
41
|
+
def current?
|
42
|
+
self.class.current == self
|
43
|
+
end
|
44
|
+
|
45
|
+
def freeze_dry
|
46
|
+
record = super
|
47
|
+
if self.class.current == self
|
48
|
+
record[:current] = true
|
49
|
+
end
|
50
|
+
record
|
51
|
+
end
|
52
|
+
|
53
|
+
def tag( tags )
|
54
|
+
return unless tags and tags.kind_of? Array
|
55
|
+
tags.each do |tag|
|
56
|
+
tag.split.each {|t| @tags << t if ! @tags.include? t }
|
57
|
+
end
|
58
|
+
@tags.sort!
|
59
|
+
end
|
60
|
+
|
61
|
+
def untag( tags )
|
62
|
+
return unless tags and tags.kind_of? Array
|
63
|
+
tags.each do |tag|
|
64
|
+
tag.split.each {|t| @tags.delete t }
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_s
|
69
|
+
puts "id: #{id}, title: #{title}, tags: #{tags}"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|