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