punched 1.0.4 → 1.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.
- checksums.yaml +4 -4
- data/.travis.yml +1 -0
- data/README.md +2 -0
- data/bin/punched +49 -29
- data/lib/punchcard.rb +77 -77
- data/punched.gemspec +6 -8
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3d977bf420412857af8b4b497b7eea7bcf68a102c3ce82bc6d7bac251ffb3753
|
4
|
+
data.tar.gz: 0c2e123a93f7dc608eb3feaf63501b1f2de35f43f61110b5cc98db314083aa2a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9656f8be00bd6cb0768982eb9671f58544d12260453d943fcbdfa5f097215599b6281f0bb2984f450ed258c3af7a9aae470d0fb228fac21418c4aaa243c7cba5
|
7
|
+
data.tar.gz: 22f015763eb98da0de64fcf742a7f1907d81ab66a8b2a4c82092697e568c6728eafb38d5eaf2827c845685264c5960fca1540539738b22dc91d53adf667a72c6
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -3,6 +3,8 @@
|
|
3
3
|
|
4
4
|
[](https://travis-ci.org/pstaender/punched)
|
5
5
|
|
6
|
+
[](https://asciinema.org/a/222572)
|
7
|
+
|
6
8
|
### Requirements
|
7
9
|
|
8
10
|
* ruby 2.1+
|
data/bin/punched
CHANGED
@@ -1,10 +1,13 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
|
2
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
3
3
|
|
4
|
-
require 'punchcard'
|
4
|
+
require 'punchcard.rb'
|
5
5
|
require 'csv'
|
6
6
|
require 'markdown-tables'
|
7
7
|
require 'date'
|
8
|
+
require 'json'
|
9
|
+
|
10
|
+
class UnknownActionError < StandardError; end
|
8
11
|
|
9
12
|
#
|
10
13
|
# CLI Wrapper
|
@@ -14,11 +17,11 @@ def available_actions
|
|
14
17
|
PunchCard.new(nil).public_methods(false).reject { |item| item.to_s.end_with?('=') || item.to_s == 'project' }.concat([:all]).sort
|
15
18
|
end
|
16
19
|
|
17
|
-
def action_available?
|
20
|
+
def action_available?(action)
|
18
21
|
available_actions.include? action.to_sym
|
19
22
|
end
|
20
23
|
|
21
|
-
def exit_with_error!
|
24
|
+
def exit_with_error!(msg)
|
22
25
|
STDERR.puts msg
|
23
26
|
exit 1
|
24
27
|
end
|
@@ -27,22 +30,21 @@ def usage
|
|
27
30
|
"Usage: #{File.basename(__FILE__)} #{available_actions.join('|')} 'Name of my project'"
|
28
31
|
end
|
29
32
|
|
30
|
-
def all
|
31
|
-
available_formats = %w
|
33
|
+
def all(action)
|
34
|
+
available_formats = %w[csv plain md]
|
32
35
|
unless available_formats.include?(action)
|
33
36
|
raise "Format #{action} is not supported. Possible formats are: #{available_formats.join(',')}"
|
34
37
|
end
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
+
|
39
|
+
labels = ['project', 'status', 'last active on', 'total duration', 'hourly rate', 'earnings']
|
40
|
+
data = Dir[PunchCard::SETTINGS_DIR + '/*'].map do |file|
|
41
|
+
data = CSV.parse(call_punchcard('csv', File.basename(file)))[0]
|
38
42
|
last_activity = !data[2].empty? ? DateTime.parse(data[2]).to_time.to_i : 0
|
39
43
|
data.push(last_activity)
|
40
44
|
data
|
41
|
-
|
42
|
-
a.last <=> b.last
|
43
|
-
}.reverse.map { |row|
|
45
|
+
end.sort_by(&:last).reverse.map do |row|
|
44
46
|
row[0...-1]
|
45
|
-
|
47
|
+
end
|
46
48
|
|
47
49
|
return puts('No record(s) so far') if data.empty?
|
48
50
|
|
@@ -58,27 +60,18 @@ def all action
|
|
58
60
|
end
|
59
61
|
end
|
60
62
|
|
61
|
-
|
62
|
-
all ARGV[1] || 'plain'
|
63
|
-
exit
|
64
|
-
elsif ARGV.first && ['-h', '--help', 'help'].include?(ARGV.first)
|
65
|
-
puts usage
|
66
|
-
exit
|
67
|
-
end
|
68
|
-
|
69
|
-
selected_action = ARGV[0]
|
70
|
-
project_name = ARGV[1]
|
71
|
-
|
72
|
-
if selected_action
|
63
|
+
def call_punchcard(selected_action, project_name)
|
73
64
|
if action_available?(selected_action)
|
74
|
-
|
65
|
+
if !project_name && selected_action != 'list'
|
66
|
+
exit_with_error!("2nd argument has to be the project name, e.g.:\n#{usage}")
|
67
|
+
end
|
75
68
|
punch_card = PunchCard.new project_name
|
76
69
|
begin
|
77
70
|
arguments = ARGV.drop(2)
|
78
|
-
if arguments.
|
79
|
-
|
71
|
+
if !arguments.empty?
|
72
|
+
return punch_card.send(selected_action.to_s, *arguments)
|
80
73
|
else
|
81
|
-
|
74
|
+
return punch_card.send(selected_action.to_s)
|
82
75
|
end
|
83
76
|
rescue PunchCardError => e
|
84
77
|
exit_with_error! "Error: #{e.message}"
|
@@ -87,3 +80,30 @@ if selected_action
|
|
87
80
|
exit_with_error! "Unrecognized action '#{selected_action || ''}'\n#{usage}"
|
88
81
|
end
|
89
82
|
end
|
83
|
+
|
84
|
+
if ['-h', '--help', 'help'].include?(ARGV.first)
|
85
|
+
puts(usage)
|
86
|
+
exit
|
87
|
+
end
|
88
|
+
|
89
|
+
selected_action = ARGV[0]
|
90
|
+
project_name = ARGV[1]
|
91
|
+
|
92
|
+
if selected_action
|
93
|
+
begin
|
94
|
+
if selected_action == 'all'
|
95
|
+
all(ARGV[1] || 'plain')
|
96
|
+
else
|
97
|
+
result = call_punchcard(selected_action, project_name)
|
98
|
+
if result.is_a?(Hash)
|
99
|
+
puts result.to_json
|
100
|
+
else
|
101
|
+
puts result
|
102
|
+
end
|
103
|
+
end
|
104
|
+
rescue PunchCardError => e
|
105
|
+
exit_with_error! "Error: #{e.message}"
|
106
|
+
rescue UnknownActionError => e
|
107
|
+
exit_with_error! "Unrecognized action '#{selected_action || ''}'\n#{usage}"
|
108
|
+
end
|
109
|
+
end
|
data/lib/punchcard.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# (c) 2017-
|
1
|
+
# (c) 2017-2019 by Philipp Staender
|
2
2
|
|
3
3
|
require 'date'
|
4
4
|
require 'time'
|
@@ -7,31 +7,30 @@ class PunchCardError < StandardError
|
|
7
7
|
end
|
8
8
|
|
9
9
|
class PunchCard
|
10
|
-
|
11
10
|
SETTINGS_DIR = ENV['PUNCHCARD_DIR'] || File.expand_path('~/.punchcard')
|
12
|
-
HOURLY_RATE_PATTERN = /^\s*(\d+)([^\d]+)*\s*/i
|
13
|
-
TIME_POINT_PATTERN = /^((\d+|.+?\s[\+\-]\d{4}?\s*)(\-)*(\d+|\s.+\d?)*)
|
14
|
-
META_KEY_PATTERN = /^([a-zA-Z0-9]+)\:\s*(.*)
|
15
|
-
VERSION = '1.0.
|
11
|
+
HOURLY_RATE_PATTERN = /^\s*(\d+)([^\d]+)*\s*/i.freeze
|
12
|
+
TIME_POINT_PATTERN = /^((\d+|.+?\s[\+\-]\d{4}?\s*)(\-)*(\d+|\s.+\d?)*)$/.freeze
|
13
|
+
META_KEY_PATTERN = /^([a-zA-Z0-9]+)\:\s*(.*)$/.freeze
|
14
|
+
VERSION = '1.1.0'.freeze
|
16
15
|
|
17
16
|
attr_accessor :project
|
18
17
|
|
19
|
-
def initialize
|
18
|
+
def initialize(project_name)
|
20
19
|
@wilcard_for_filename = ''
|
21
20
|
@meta_data = {}
|
22
21
|
find_or_make_settings_dir
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
22
|
+
return unless project_name
|
23
|
+
|
24
|
+
self.project = project_name
|
25
|
+
find_or_make_file
|
26
|
+
read_project_data
|
28
27
|
end
|
29
28
|
|
30
29
|
def start
|
31
30
|
output = []
|
32
31
|
if start_time && !end_time
|
33
32
|
output << "'#{project}' already started (#{humanized_total} total)"
|
34
|
-
output <<
|
33
|
+
output << duration(start_time, timestamp).to_s
|
35
34
|
else
|
36
35
|
output << "'#{project}' started (#{humanized_total} total)"
|
37
36
|
self.start_time = timestamp
|
@@ -47,7 +46,7 @@ class PunchCard
|
|
47
46
|
output << "'#{@project}' stopped (#{humanized_total} total)"
|
48
47
|
self.end_time = timestamp
|
49
48
|
else
|
50
|
-
output <<
|
49
|
+
output << 'Nothing to stop'
|
51
50
|
end
|
52
51
|
output.join("\n")
|
53
52
|
end
|
@@ -64,7 +63,7 @@ class PunchCard
|
|
64
63
|
project_exists_or_stop!
|
65
64
|
find_or_make_file
|
66
65
|
output = []
|
67
|
-
output << (project+" (#{running_status})\n")
|
66
|
+
output << (project + " (#{running_status})\n")
|
68
67
|
output << humanized_total
|
69
68
|
output.join("\n")
|
70
69
|
end
|
@@ -73,14 +72,18 @@ class PunchCard
|
|
73
72
|
project_exists_or_stop!
|
74
73
|
find_or_make_file
|
75
74
|
output = []
|
76
|
-
|
77
|
-
|
75
|
+
data = project_data
|
76
|
+
data[0] = "#{data[0]} (#{running_status})"
|
77
|
+
data.map do |line|
|
78
78
|
points = line_to_time_points(line)
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
output << duration(starttime, endtime) + "\t" + self.class.format_time(Time.at(starttime)) + " - " + self.class.format_time(Time.at(endtime))
|
79
|
+
unless points
|
80
|
+
output << line + "\n"
|
81
|
+
next
|
83
82
|
end
|
83
|
+
|
84
|
+
starttime = points[0]
|
85
|
+
endtime = points[1] || timestamp
|
86
|
+
output << duration(starttime, endtime) + "\t" + self.class.format_time(Time.at(starttime)) + ' - ' + self.class.format_time(Time.at(endtime))
|
84
87
|
end
|
85
88
|
output << "========\n#{humanized_total}\t(total)"
|
86
89
|
output.join("\n")
|
@@ -93,31 +96,31 @@ class PunchCard
|
|
93
96
|
last_activity = nil
|
94
97
|
project_data.map do |line|
|
95
98
|
points = line_to_time_points(line)
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
99
|
+
next unless points
|
100
|
+
|
101
|
+
starttime = points[0]
|
102
|
+
endtime = points[1] || timestamp
|
103
|
+
last_activity = points[1] || points[0]
|
104
|
+
durations.push duration(starttime, endtime)
|
102
105
|
end
|
103
|
-
'"'+[
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
106
|
+
'"' + [
|
107
|
+
@project,
|
108
|
+
running_status,
|
109
|
+
last_activity ? self.class.format_time(Time.at(last_activity).to_datetime) : '',
|
110
|
+
humanized_total,
|
111
|
+
hourly_rate ? hourly_rate[:hourlyRate].to_s + " #{hourly_rate[:currency]}" : '',
|
112
|
+
hourly_rate ? (hourly_rate[:hourlyRate] * total / 3600.0).round(2).to_s + " #{hourly_rate[:currency]}" : ''
|
110
113
|
].join('","') + '"'
|
111
114
|
end
|
112
115
|
|
113
116
|
def remove
|
114
|
-
if File.
|
117
|
+
if File.exist?(project_file)
|
115
118
|
File.delete(project_file)
|
116
119
|
"Deleted #{project_file}"
|
117
120
|
end
|
118
121
|
end
|
119
122
|
|
120
|
-
def rename
|
123
|
+
def rename(new_project_name)
|
121
124
|
old_filename = project_filename
|
122
125
|
data = project_data
|
123
126
|
data[0] = new_project_name
|
@@ -126,11 +129,11 @@ class PunchCard
|
|
126
129
|
File.rename(old_filename, project_filename) && "#{old_filename} -> #{project_filename}"
|
127
130
|
end
|
128
131
|
|
129
|
-
def project=
|
132
|
+
def project=(project_name)
|
130
133
|
@project = project_name
|
131
134
|
if @project.end_with?('*')
|
132
|
-
@wilcard_for_filename =
|
133
|
-
@project = @project.chomp(
|
135
|
+
@wilcard_for_filename = '*'
|
136
|
+
@project = @project.chomp('*')
|
134
137
|
end
|
135
138
|
@project.strip
|
136
139
|
end
|
@@ -139,8 +142,9 @@ class PunchCard
|
|
139
142
|
@project.strip
|
140
143
|
end
|
141
144
|
|
142
|
-
def set
|
143
|
-
raise PunchCardError
|
145
|
+
def set(key, value)
|
146
|
+
raise PunchCardError, "Key '#{key}' can only be alphanumeric" unless key =~ /^[a-zA-Z0-9]+$/
|
147
|
+
|
144
148
|
@meta_data[key.to_sym] = value
|
145
149
|
write_to_project_file!
|
146
150
|
@meta_data
|
@@ -150,16 +154,16 @@ class PunchCard
|
|
150
154
|
total = 0
|
151
155
|
project_data.map do |line|
|
152
156
|
points = line_to_time_points(line)
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
157
|
+
next unless points
|
158
|
+
|
159
|
+
starttime = points[0]
|
160
|
+
endtime = points[1] || timestamp
|
161
|
+
total += endtime - starttime
|
158
162
|
end
|
159
163
|
total
|
160
164
|
end
|
161
165
|
|
162
|
-
def self.format_time
|
166
|
+
def self.format_time(datetime)
|
163
167
|
datetime.strftime('%F %T')
|
164
168
|
end
|
165
169
|
|
@@ -167,18 +171,16 @@ class PunchCard
|
|
167
171
|
|
168
172
|
def hourly_rate
|
169
173
|
hourly_rate_found = @meta_data[:hourlyRate] && @meta_data[:hourlyRate].match(HOURLY_RATE_PATTERN)
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
nil
|
177
|
-
end
|
174
|
+
return unless hourly_rate_found
|
175
|
+
|
176
|
+
{
|
177
|
+
hourlyRate: hourly_rate_found[1].to_f,
|
178
|
+
currency: hourly_rate_found[2] ? hourly_rate_found[2].strip : ''
|
179
|
+
}
|
178
180
|
end
|
179
181
|
|
180
182
|
def project_exists_or_stop!
|
181
|
-
raise PunchCardError
|
183
|
+
raise PunchCardError, "'#{@project}' does not exists" unless project_exist?
|
182
184
|
end
|
183
185
|
|
184
186
|
def active?
|
@@ -193,7 +195,7 @@ class PunchCard
|
|
193
195
|
humanize_duration total
|
194
196
|
end
|
195
197
|
|
196
|
-
def duration
|
198
|
+
def duration(starttime, endtime)
|
197
199
|
if starttime
|
198
200
|
humanize_duration endtime - starttime
|
199
201
|
else
|
@@ -201,14 +203,14 @@ class PunchCard
|
|
201
203
|
end
|
202
204
|
end
|
203
205
|
|
204
|
-
def humanize_duration
|
206
|
+
def humanize_duration(duration)
|
205
207
|
hours = duration / (60 * 60)
|
206
208
|
minutes = (duration / 60) % 60
|
207
209
|
seconds = duration % 60
|
208
210
|
"#{decimal_digits(hours)}:#{decimal_digits(minutes)}:#{decimal_digits(seconds)}"
|
209
211
|
end
|
210
212
|
|
211
|
-
def decimal_digits
|
213
|
+
def decimal_digits(digit)
|
212
214
|
if digit.to_i < 10
|
213
215
|
"0#{digit}"
|
214
216
|
else
|
@@ -220,11 +222,11 @@ class PunchCard
|
|
220
222
|
time_points ? time_points[0] : nil
|
221
223
|
end
|
222
224
|
|
223
|
-
def start_time=
|
225
|
+
def start_time=(time)
|
224
226
|
append_new_line timestamp_to_time(time)
|
225
227
|
end
|
226
228
|
|
227
|
-
def end_time=
|
229
|
+
def end_time=(time)
|
228
230
|
replace_last_line "#{timestamp_to_time(start_time)} - #{timestamp_to_time(time)}"
|
229
231
|
end
|
230
232
|
|
@@ -236,20 +238,20 @@ class PunchCard
|
|
236
238
|
line_to_time_points last_entry
|
237
239
|
end
|
238
240
|
|
239
|
-
def line_to_time_points
|
241
|
+
def line_to_time_points(line)
|
240
242
|
matches = line.match(TIME_POINT_PATTERN)
|
241
|
-
|
243
|
+
|
242
244
|
time_points = matches ? [string_to_timestamp(matches[2]), string_to_timestamp(matches[4])] : nil
|
243
245
|
if time_points && time_points.reject(&:nil?).empty?
|
244
246
|
nil
|
245
247
|
else
|
246
248
|
time_points
|
247
249
|
end
|
248
|
-
|
249
250
|
end
|
250
251
|
|
251
252
|
def string_to_timestamp(str)
|
252
253
|
return str if str.nil?
|
254
|
+
|
253
255
|
str.strip!
|
254
256
|
# This is some legacy... previous versions stored timestamp,
|
255
257
|
# but now punched stores date-time strings for better readability.
|
@@ -271,10 +273,9 @@ class PunchCard
|
|
271
273
|
|
272
274
|
def read_project_data
|
273
275
|
title = nil
|
274
|
-
meta_data = []
|
275
276
|
timestamps = []
|
276
277
|
i = 0
|
277
|
-
File.open(project_file,
|
278
|
+
File.open(project_file, 'r').each_line do |line|
|
278
279
|
line.strip!
|
279
280
|
if i.zero?
|
280
281
|
title = line
|
@@ -290,10 +291,10 @@ class PunchCard
|
|
290
291
|
end
|
291
292
|
|
292
293
|
def project_data
|
293
|
-
File.open(project_file).each_line.map
|
294
|
+
File.open(project_file).each_line.map(&:strip)
|
294
295
|
end
|
295
296
|
|
296
|
-
def write_string_to_project_file!
|
297
|
+
def write_string_to_project_file!(string)
|
297
298
|
File.open(project_file, 'w') { |f| f.write(string) }
|
298
299
|
end
|
299
300
|
|
@@ -303,11 +304,11 @@ class PunchCard
|
|
303
304
|
write_string_to_project_file! [@project, meta_data_lines.join("\n"), timestamps].reject(&:empty?).join("\n")
|
304
305
|
end
|
305
306
|
|
306
|
-
def append_new_line
|
307
|
-
open(project_file, 'a') { |f| f.puts("\n"+line.to_s.strip) }
|
307
|
+
def append_new_line(line)
|
308
|
+
open(project_file, 'a') { |f| f.puts("\n" + line.to_s.strip) }
|
308
309
|
end
|
309
310
|
|
310
|
-
def replace_last_line
|
311
|
+
def replace_last_line(line)
|
311
312
|
data = project_data
|
312
313
|
data[-1] = line
|
313
314
|
write_string_to_project_file! data.join("\n")
|
@@ -322,20 +323,19 @@ class PunchCard
|
|
322
323
|
end
|
323
324
|
|
324
325
|
def project_exist?
|
325
|
-
File.
|
326
|
+
File.exist?(project_file)
|
326
327
|
end
|
327
328
|
|
328
329
|
def find_or_make_file
|
329
|
-
write_string_to_project_file!(@project+"\n") unless project_exist?
|
330
|
+
write_string_to_project_file!(@project + "\n") unless project_exist?
|
330
331
|
@project = project_data.first
|
331
332
|
end
|
332
333
|
|
333
334
|
def find_or_make_settings_dir
|
334
|
-
Dir.mkdir(SETTINGS_DIR) unless File.
|
335
|
+
Dir.mkdir(SETTINGS_DIR) unless File.exist?(SETTINGS_DIR)
|
335
336
|
end
|
336
337
|
|
337
|
-
def sanitize_filename
|
338
|
-
name.downcase.gsub(
|
338
|
+
def sanitize_filename(name)
|
339
|
+
name.downcase.gsub(%r{(\\|/)}, '').gsub(/[^0-9a-z.\-]/, '_')
|
339
340
|
end
|
340
|
-
|
341
341
|
end
|
data/punched.gemspec
CHANGED
@@ -1,22 +1,20 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
require "punchcard"
|
1
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
2
|
+
require 'punchcard'
|
4
3
|
|
5
4
|
Gem::Specification.new do |s|
|
6
5
|
s.name = 'punched'
|
7
6
|
s.version = PunchCard::VERSION
|
8
|
-
s.authors = [
|
9
|
-
s.email = [
|
7
|
+
s.authors = ['Philipp Staender']
|
8
|
+
s.email = ['pstaender@mailbox.org']
|
10
9
|
s.homepage = 'https://github.com/pstaender/punchcard'
|
11
10
|
s.summary = 'Punchcard Timetracker'
|
12
11
|
s.description = 'Minimal time tracking tool for cli'
|
13
12
|
s.license = 'GPL-3.0'
|
14
13
|
s.executables = ['punched']
|
15
|
-
s.default_executable = 'punched'
|
16
14
|
s.rubyforge_project = 'punched'
|
17
|
-
s.require_paths = [
|
15
|
+
s.require_paths = ['lib']
|
18
16
|
s.required_ruby_version = '>= 2.1'
|
19
17
|
s.files = `git ls-files`.split("\n")
|
20
|
-
|
18
|
+
|
21
19
|
s.add_dependency 'markdown-tables', '~> 1.0.2'
|
22
20
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: punched
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Philipp Staender
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-02-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: markdown-tables
|
@@ -26,7 +26,7 @@ dependencies:
|
|
26
26
|
version: 1.0.2
|
27
27
|
description: Minimal time tracking tool for cli
|
28
28
|
email:
|
29
|
-
-
|
29
|
+
- pstaender@mailbox.org
|
30
30
|
executables:
|
31
31
|
- punched
|
32
32
|
extensions: []
|