punched 1.0.4 → 1.3.2
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/.gitignore +1 -0
- data/.travis.yml +2 -5
- data/README.md +45 -27
- data/bin/punched +141 -41
- data/lib/punchcard.rb +148 -106
- data/punched.gemspec +7 -10
- data/spec/punchcard_spec.rb +42 -26
- metadata +5 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b9e6c733da15335832d27af80790665daed6c11c5baad9fbca2e221c094a15ca
|
4
|
+
data.tar.gz: 9d0817147f2df8b7c275ca233c45640b4fe9b2919bad621d4b3fe7cacd9847ba
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7a3e6d274a7e53e9672178aaf1ff4beb283cba46f8b48b28f101f7e433af24195f016a5200f4a3b9222d51cf4daed4c10c5e1270a931730ed184f67b8fc54051
|
7
|
+
data.tar.gz: e4ad79f601795b9fa1f0afda921cec744ad853f2fc54c82122abea80faeffcf37f1d3c4c5b0b2b388c442efa933c059fc9ffbb7d51b21579090619526e290a9b
|
data/.gitignore
CHANGED
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+
|
@@ -15,49 +17,45 @@
|
|
15
17
|
|
16
18
|
### Usage
|
17
19
|
|
18
|
-
#### Start Project
|
20
|
+
#### Start and stop a Project
|
19
21
|
|
20
22
|
```sh
|
21
|
-
$ punched
|
23
|
+
$ punched toggle punchcard_programming
|
24
|
+
'punchcard_programming' started (00:00:00 total)
|
22
25
|
```
|
23
26
|
|
24
|
-
|
25
|
-
|
26
|
-
Save keystrokes by using wildcard. The first last active project, which matches the pattern (case insensitive) will be selected:
|
27
|
+
To stop:
|
27
28
|
|
28
29
|
```sh
|
29
|
-
$ punched
|
30
|
+
$ punched toggle punchcard_programming
|
31
|
+
'punchcard_programming' stopped (00:01:25 total)
|
30
32
|
```
|
31
33
|
|
32
|
-
|
33
|
-
|
34
|
-
```sh
|
35
|
-
$ punched stop "Punch*"
|
36
|
-
```
|
34
|
+
To be more explicit, you can also use `start` and `stop` instead of `toggle`.
|
37
35
|
|
38
|
-
####
|
36
|
+
#### Wildcard
|
39
37
|
|
40
|
-
|
38
|
+
Save keystrokes by using wildcard. The first last active project, which matches the (case insensitive) pattern will be selected:
|
41
39
|
|
42
40
|
```sh
|
43
|
-
$ punched toggle
|
41
|
+
$ punched toggle 'punched*'
|
44
42
|
```
|
45
43
|
|
46
44
|
#### Status
|
47
45
|
|
48
46
|
```sh
|
49
|
-
$ punched status
|
47
|
+
$ punched status punched_programming
|
50
48
|
|
51
|
-
|
49
|
+
punched_programming
|
52
50
|
01:10:09
|
53
51
|
```
|
54
52
|
|
55
53
|
#### List details
|
56
54
|
|
57
55
|
```sh
|
58
|
-
$ punched details
|
56
|
+
$ punched details punched_programming
|
59
57
|
|
60
|
-
|
58
|
+
punched_programming (stopped)
|
61
59
|
|
62
60
|
00:00:08 2017-05-07 08:16:06 - 2017-05-07 08:16:14
|
63
61
|
00:04:35 2017-05-07 08:22:02 - 2017-05-07 08:26:37
|
@@ -66,26 +64,44 @@ Toggle between start and stop:
|
|
66
64
|
01:10:04 (total)
|
67
65
|
```
|
68
66
|
|
67
|
+
#### Filtering
|
68
|
+
|
69
|
+
You can filter your spend time with `startAt` and `endAt`:
|
70
|
+
|
71
|
+
```sh
|
72
|
+
$ punched totalsum myproject --startAt=15.05.2020
|
73
|
+
$ punched totalsum myproject --startAt=15.05.2020 --endAt=30.05.2020
|
74
|
+
$ punched all plain --startAt=15.05.2020 --endAt=30.05.2020
|
75
|
+
```
|
76
|
+
|
69
77
|
#### Set Hourly Rate
|
70
78
|
|
71
79
|
```sh
|
72
|
-
$ punched set
|
80
|
+
$ punched set punched_programming hourlyRate 250€
|
81
|
+
{"hourlyRate":"250€"}
|
73
82
|
```
|
74
83
|
|
75
|
-
####
|
84
|
+
#### Sum spended time on project(s)
|
85
|
+
|
86
|
+
`total` returns the total spend time in seconds:
|
76
87
|
|
77
88
|
```sh
|
78
|
-
$ punched total
|
89
|
+
$ punched total punched_programming
|
90
|
+
13505
|
79
91
|
```
|
80
92
|
|
81
|
-
|
93
|
+
`totalsum` calculates human readable spended time on project(s) (wildcard is used by default):
|
82
94
|
|
83
95
|
```sh
|
84
|
-
$ punched
|
96
|
+
$ punched totalsum punched_programming
|
97
|
+
02:05:06
|
85
98
|
```
|
86
99
|
|
100
|
+
Use `startAt` and/or `endAt` to set a time range:
|
101
|
+
|
87
102
|
```sh
|
88
|
-
$ punched
|
103
|
+
$ punched totalsum punched_programming --startAt=2020-05-01 --endAt=2020-05-03
|
104
|
+
01:02:36
|
89
105
|
```
|
90
106
|
|
91
107
|
#### Help
|
@@ -105,14 +121,14 @@ List all available actions:
|
|
105
121
|
|========================================|=========|=====================|================|=============|==========|
|
106
122
|
| project | status | last active on | total duration | hourly rate | earnings |
|
107
123
|
|========================================|=========|=====================|================|=============|==========|
|
108
|
-
|
|
124
|
+
| website | stopped | 2017-05-07 15:50:00 | 00:04:40 | 95.0 € | 380.00 € |
|
109
125
|
|----------------------------------------|---------|---------------------|----------------|-------------|----------|
|
110
|
-
|
|
126
|
+
| punchcard_programming | stopped | 2017-07-11 12:47:42 | 01:10:04 | | |
|
111
127
|
|========================================|=========|=====================|================|=============|==========|
|
112
128
|
|
113
129
|
```
|
114
130
|
|
115
|
-
To use `md` or `csv` as output format:
|
131
|
+
To use `plain`, `md` or `csv` as output format:
|
116
132
|
|
117
133
|
```sh
|
118
134
|
$ punched all csv
|
@@ -124,6 +140,8 @@ To use `md` or `csv` as output format:
|
|
124
140
|
|
125
141
|
You can use `all` with any other action as well, e.g. `punched all stop` to stop all running projects.
|
126
142
|
|
143
|
+
Here you can also filter your spend time with `startAt` and `endAt`, respectively.
|
144
|
+
|
127
145
|
### Store projects files in a custom folder and sync them between computers
|
128
146
|
|
129
147
|
By default, PunchCard will store the data in `~/.punchcard/`. Define your custom destination with:
|
data/bin/punched
CHANGED
@@ -1,24 +1,39 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
THIS_FILE = File.symlink?(__FILE__) ? File.readlink(__FILE__) : __FILE__
|
5
|
+
begin
|
6
|
+
require File.expand_path(File.dirname(THIS_FILE) + '/../lib/punchcard.rb')
|
7
|
+
rescue LoadError
|
8
|
+
require 'punchcard'
|
9
|
+
end
|
3
10
|
|
4
|
-
require 'punchcard'
|
5
11
|
require 'csv'
|
6
12
|
require 'markdown-tables'
|
7
13
|
require 'date'
|
14
|
+
require 'json'
|
8
15
|
|
16
|
+
class UnknownActionError < StandardError; end
|
17
|
+
class InvalidAllArgument < StandardError; end
|
9
18
|
#
|
10
19
|
# CLI Wrapper
|
11
20
|
#
|
12
21
|
|
13
22
|
def available_actions
|
14
|
-
PunchCard.new(nil).public_methods(false).reject
|
23
|
+
PunchCard.new(nil).public_methods(false).reject do |item|
|
24
|
+
item.to_s.end_with?('=') || item.to_s == 'project'
|
25
|
+
end.concat(global_available_actions).sort
|
26
|
+
end
|
27
|
+
|
28
|
+
def global_available_actions
|
29
|
+
%i[all totalsum]
|
15
30
|
end
|
16
31
|
|
17
|
-
def action_available?
|
32
|
+
def action_available?(action)
|
18
33
|
available_actions.include? action.to_sym
|
19
34
|
end
|
20
35
|
|
21
|
-
def exit_with_error!
|
36
|
+
def exit_with_error!(msg)
|
22
37
|
STDERR.puts msg
|
23
38
|
exit 1
|
24
39
|
end
|
@@ -27,58 +42,58 @@ def usage
|
|
27
42
|
"Usage: #{File.basename(__FILE__)} #{available_actions.join('|')} 'Name of my project'"
|
28
43
|
end
|
29
44
|
|
30
|
-
def
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
data = CSV.parse(`ruby #{__FILE__} csv '#{File.basename(file)}'`).first
|
38
|
-
last_activity = !data[2].empty? ? DateTime.parse(data[2]).to_time.to_i : 0
|
39
|
-
data.push(last_activity)
|
40
|
-
data
|
41
|
-
}.sort { |a, b|
|
42
|
-
a.last <=> b.last
|
43
|
-
}.reverse.map { |row|
|
45
|
+
def list_all_projects(format)
|
46
|
+
data = call_punchcards_by_pattern('*', 'csv').map do |csv_string|
|
47
|
+
csv_data = CSV.parse(csv_string)[0]
|
48
|
+
last_activity = !csv_data[2].empty? ? Time.parse(csv_data[2]).to_i : 0
|
49
|
+
csv_data.push(last_activity)
|
50
|
+
csv_data
|
51
|
+
end.sort_by(&:last).reverse.map do |row|
|
44
52
|
row[0...-1]
|
45
|
-
|
46
|
-
|
53
|
+
end
|
47
54
|
return puts('No record(s) so far') if data.empty?
|
48
55
|
|
49
|
-
|
56
|
+
puts convert_project_data(format, data)
|
57
|
+
end
|
58
|
+
|
59
|
+
def convert_project_data(format, data)
|
60
|
+
labels = ['project', 'status', 'last active on', 'total duration', 'hourly rate', 'earnings']
|
61
|
+
case format
|
50
62
|
when 'md'
|
51
|
-
|
63
|
+
MarkdownTables.make_table(labels, data, is_rows: true, align: ['l'])
|
52
64
|
when 'csv'
|
53
|
-
|
54
|
-
puts data.map(&:to_csv).join
|
65
|
+
"#{labels.to_csv}\n#{data.map(&:to_csv).join}"
|
55
66
|
when 'plain'
|
56
67
|
table = MarkdownTables.make_table(labels, data, is_rows: true, align: ['l'])
|
57
|
-
|
68
|
+
MarkdownTables.plain_text(table)
|
58
69
|
end
|
59
70
|
end
|
60
71
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
72
|
+
def all(action)
|
73
|
+
available_formats = %w[csv plain md]
|
74
|
+
if available_formats.include?(action)
|
75
|
+
list_all_projects(action)
|
76
|
+
elsif action_available?(action)
|
77
|
+
puts call_punchcards_by_pattern('*', action).to_a.join("\n")
|
78
|
+
else
|
79
|
+
suppurted_arguments = (available_formats + available_actions).reject {|n| n.to_s == 'all' }.uniq
|
80
|
+
raise InvalidAllArgument, "'#{action}' is not supported\nSupported formats and actions: #{suppurted_arguments.join(',')}"
|
81
|
+
end
|
67
82
|
end
|
68
83
|
|
69
|
-
selected_action
|
70
|
-
project_name = ARGV[1]
|
71
|
-
|
72
|
-
if selected_action
|
84
|
+
def call_punchcard(selected_action:, project_name:, arguments:)
|
73
85
|
if action_available?(selected_action)
|
74
|
-
|
86
|
+
if !project_name && selected_action != 'list'
|
87
|
+
exit_with_error!("2nd argument has to be the project name, e.g.:\n#{usage}")
|
88
|
+
end
|
75
89
|
punch_card = PunchCard.new project_name
|
76
90
|
begin
|
77
|
-
arguments
|
78
|
-
|
79
|
-
|
91
|
+
if arguments.nil? || arguments.empty?
|
92
|
+
punch_card.public_send(selected_action.to_s)
|
93
|
+
elsif arguments.is_a?(Hash)
|
94
|
+
punch_card.public_send(selected_action.to_s, **arguments)
|
80
95
|
else
|
81
|
-
|
96
|
+
punch_card.public_send(selected_action.to_s, *arguments)
|
82
97
|
end
|
83
98
|
rescue PunchCardError => e
|
84
99
|
exit_with_error! "Error: #{e.message}"
|
@@ -87,3 +102,88 @@ if selected_action
|
|
87
102
|
exit_with_error! "Unrecognized action '#{selected_action || ''}'\n#{usage}"
|
88
103
|
end
|
89
104
|
end
|
105
|
+
|
106
|
+
def cli_argument_by_name(name)
|
107
|
+
value = ARGV.filter { |arg| arg.start_with?("--#{name}=") }&.first
|
108
|
+
value = value.split('=')[1] unless value.nil?
|
109
|
+
if block_given? && !value.nil? && !value.empty?
|
110
|
+
yield value
|
111
|
+
else
|
112
|
+
value
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def call_punchcards_by_pattern(pattern, action)
|
117
|
+
path_patterns = [
|
118
|
+
"#{PunchCard::SETTINGS_DIR}/#{pattern}*",
|
119
|
+
"#{PunchCard::SETTINGS_DIR}/*#{pattern}*"
|
120
|
+
]
|
121
|
+
arguments = {}
|
122
|
+
if %w(csv plain md total).include?(action)
|
123
|
+
arguments = {
|
124
|
+
start_at: cli_argument_by_name('startAt') { |v| Date.parse(v) },
|
125
|
+
end_at: cli_argument_by_name('endAt') { |v| Date.parse(v) }
|
126
|
+
}
|
127
|
+
end
|
128
|
+
found = []
|
129
|
+
path_patterns.each do |path_pattern|
|
130
|
+
found = Dir[path_pattern].map do |file|
|
131
|
+
project_name = File.basename(file)
|
132
|
+
call_punchcard(
|
133
|
+
selected_action: action,
|
134
|
+
project_name: project_name,
|
135
|
+
arguments: arguments
|
136
|
+
)
|
137
|
+
end
|
138
|
+
break if found.any?
|
139
|
+
end
|
140
|
+
found
|
141
|
+
end
|
142
|
+
|
143
|
+
def validate_project_name_and_stop_if_invalid(project_name)
|
144
|
+
if project_name.strip.strip.start_with?('.')
|
145
|
+
STDERR.puts "Error: project name's are not allowed to start with '.'"
|
146
|
+
exit 1
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
if ['-h', '--help', 'help'].include?(ARGV.first)
|
151
|
+
puts(usage)
|
152
|
+
exit
|
153
|
+
end
|
154
|
+
|
155
|
+
selected_action = ARGV[0]
|
156
|
+
project_name = ARGV[1]
|
157
|
+
|
158
|
+
if selected_action
|
159
|
+
begin
|
160
|
+
raise UnknownActionError.new(selected_action) unless action_available?(selected_action)
|
161
|
+
if selected_action == 'all'
|
162
|
+
all(ARGV[1] || 'plain')
|
163
|
+
elsif selected_action == 'totalsum'
|
164
|
+
pattern_argument = ARGV.slice(1)
|
165
|
+
pattern = !pattern_argument || pattern_argument.empty? || pattern_argument.start_with?('--') ? '*' : pattern_argument
|
166
|
+
puts PunchCard.humanize_duration(
|
167
|
+
call_punchcards_by_pattern(pattern, 'total').reduce(&:+)
|
168
|
+
)
|
169
|
+
else
|
170
|
+
validate_project_name_and_stop_if_invalid(project_name)
|
171
|
+
result = call_punchcard(
|
172
|
+
selected_action: selected_action,
|
173
|
+
project_name: project_name,
|
174
|
+
arguments: ARGV.drop(2)
|
175
|
+
)
|
176
|
+
if result.is_a?(Hash)
|
177
|
+
puts result.to_json
|
178
|
+
else
|
179
|
+
puts result
|
180
|
+
end
|
181
|
+
end
|
182
|
+
rescue InvalidAllArgument => e
|
183
|
+
exit_with_error! e.message
|
184
|
+
rescue PunchCardError => e
|
185
|
+
exit_with_error! "Error: #{e.message}"
|
186
|
+
rescue UnknownActionError => e
|
187
|
+
exit_with_error! "Unknown action '#{e.message}'\n#{usage}"
|
188
|
+
end
|
189
|
+
end
|
data/lib/punchcard.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# (c) 2017-2019 by Philipp Staender
|
2
4
|
|
3
5
|
require 'date'
|
4
6
|
require 'time'
|
@@ -7,33 +9,32 @@ class PunchCardError < StandardError
|
|
7
9
|
end
|
8
10
|
|
9
11
|
class PunchCard
|
10
|
-
|
11
12
|
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.
|
13
|
+
HOURLY_RATE_PATTERN = /^\s*(\d+)([^\d]+)*\s*/i.freeze
|
14
|
+
TIME_POINT_PATTERN = /^((\d+|.+?\s[\+\-]\d{4}?\s*)(\-)*(\d+|\s.+\d?)*)$/.freeze
|
15
|
+
META_KEY_PATTERN = /^([a-zA-Z0-9]+)\:\s*(.*)$/.freeze
|
16
|
+
VERSION = '1.3.2'
|
16
17
|
|
17
|
-
attr_accessor :
|
18
|
+
attr_accessor :title
|
18
19
|
|
19
|
-
def initialize
|
20
|
+
def initialize(project_name)
|
20
21
|
@wilcard_for_filename = ''
|
21
22
|
@meta_data = {}
|
22
23
|
find_or_make_settings_dir
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
24
|
+
return unless project_name
|
25
|
+
|
26
|
+
self.project = project_name
|
27
|
+
find_or_make_file
|
28
|
+
read_project_data
|
28
29
|
end
|
29
30
|
|
30
31
|
def start
|
31
32
|
output = []
|
32
33
|
if start_time && !end_time
|
33
|
-
output << "'#{
|
34
|
-
output <<
|
34
|
+
output << "'#{title_or_project}' already started (#{humanized_total} total)"
|
35
|
+
output << duration(start_time, timestamp).to_s
|
35
36
|
else
|
36
|
-
output << "'#{
|
37
|
+
output << "'#{title_or_project}' started (#{humanized_total} total)"
|
37
38
|
self.start_time = timestamp
|
38
39
|
end
|
39
40
|
output.join("\n")
|
@@ -42,12 +43,12 @@ class PunchCard
|
|
42
43
|
def stop
|
43
44
|
output = []
|
44
45
|
if end_time
|
45
|
-
output << "'#{
|
46
|
+
output << "'#{title_or_project}' already stopped (#{humanized_total} total)"
|
46
47
|
elsif start_time
|
47
|
-
output << "'#{
|
48
|
+
output << "'#{title_or_project}' stopped (#{humanized_total} total)"
|
48
49
|
self.end_time = timestamp
|
49
50
|
else
|
50
|
-
output <<
|
51
|
+
output << 'Nothing to stop'
|
51
52
|
end
|
52
53
|
output.join("\n")
|
53
54
|
end
|
@@ -60,11 +61,23 @@ class PunchCard
|
|
60
61
|
end
|
61
62
|
end
|
62
63
|
|
64
|
+
def title_or_project
|
65
|
+
title || project
|
66
|
+
end
|
67
|
+
|
68
|
+
def title_and_project
|
69
|
+
if title != project
|
70
|
+
"#{title} [#{project}]"
|
71
|
+
else
|
72
|
+
project
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
63
76
|
def status
|
64
77
|
project_exists_or_stop!
|
65
78
|
find_or_make_file
|
66
79
|
output = []
|
67
|
-
output << (
|
80
|
+
output << (title_or_project + " (#{running_status})\n")
|
68
81
|
output << humanized_total
|
69
82
|
output.join("\n")
|
70
83
|
end
|
@@ -73,51 +86,66 @@ class PunchCard
|
|
73
86
|
project_exists_or_stop!
|
74
87
|
find_or_make_file
|
75
88
|
output = []
|
76
|
-
|
77
|
-
|
89
|
+
data = project_data
|
90
|
+
data[0] = "#{data[0]} (#{running_status})"
|
91
|
+
data.map do |line|
|
78
92
|
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))
|
93
|
+
unless points
|
94
|
+
output << line + "\n"
|
95
|
+
next
|
83
96
|
end
|
97
|
+
|
98
|
+
starttime = points[0]
|
99
|
+
endtime = points[1] || timestamp
|
100
|
+
output << duration(starttime, endtime) + "\t" + self.class.format_time(Time.at(starttime)) + ' - ' + self.class.format_time(Time.at(endtime))
|
84
101
|
end
|
85
102
|
output << "========\n#{humanized_total}\t(total)"
|
86
103
|
output.join("\n")
|
87
104
|
end
|
88
105
|
|
89
|
-
def csv
|
106
|
+
def csv(start_at: nil, end_at: nil)
|
90
107
|
project_exists_or_stop!
|
91
108
|
find_or_make_file
|
92
109
|
durations = []
|
93
110
|
last_activity = nil
|
94
111
|
project_data.map do |line|
|
95
112
|
points = line_to_time_points(line)
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
113
|
+
next unless points
|
114
|
+
|
115
|
+
start_time = points[0]
|
116
|
+
end_time = points[1] || timestamp
|
117
|
+
|
118
|
+
next if time_range_is_excluded_by_filter?(
|
119
|
+
start_at: start_at,
|
120
|
+
end_at: end_at,
|
121
|
+
start_time: start_time,
|
122
|
+
end_time: end_time
|
123
|
+
)
|
124
|
+
|
125
|
+
last_activity = points[1] || points[0]
|
126
|
+
durations.push end_time - start_time
|
102
127
|
end
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
128
|
+
total_duration = self.class.humanize_duration(
|
129
|
+
durations.reduce(&:+) || 0
|
130
|
+
)
|
131
|
+
'"' + [
|
132
|
+
title_and_project,
|
133
|
+
running_status,
|
134
|
+
last_activity ? self.class.format_time(Time.at(last_activity).to_datetime) : '',
|
135
|
+
total_duration,
|
136
|
+
hourly_rate ? hourly_rate[:hourlyRate].to_s + " #{hourly_rate[:currency]}" : '',
|
137
|
+
hourly_rate ? (hourly_rate[:hourlyRate] * total / 3600.0).round(2).to_s + " #{hourly_rate[:currency]}" : ''
|
110
138
|
].join('","') + '"'
|
111
139
|
end
|
112
140
|
|
113
141
|
def remove
|
114
|
-
if File.
|
142
|
+
if File.exist?(project_file)
|
115
143
|
File.delete(project_file)
|
116
144
|
"Deleted #{project_file}"
|
117
145
|
end
|
118
146
|
end
|
119
147
|
|
120
|
-
def rename
|
148
|
+
def rename(new_project_name)
|
121
149
|
old_filename = project_filename
|
122
150
|
data = project_data
|
123
151
|
data[0] = new_project_name
|
@@ -126,11 +154,11 @@ class PunchCard
|
|
126
154
|
File.rename(old_filename, project_filename) && "#{old_filename} -> #{project_filename}"
|
127
155
|
end
|
128
156
|
|
129
|
-
def project=
|
157
|
+
def project=(project_name)
|
130
158
|
@project = project_name
|
131
159
|
if @project.end_with?('*')
|
132
|
-
@wilcard_for_filename =
|
133
|
-
@project = @project.chomp(
|
160
|
+
@wilcard_for_filename = '*'
|
161
|
+
@project = @project.chomp('*')
|
134
162
|
end
|
135
163
|
@project.strip
|
136
164
|
end
|
@@ -139,46 +167,72 @@ class PunchCard
|
|
139
167
|
@project.strip
|
140
168
|
end
|
141
169
|
|
142
|
-
def set
|
143
|
-
|
170
|
+
def set(key, value)
|
171
|
+
unless key =~ /^[a-zA-Z0-9]+$/
|
172
|
+
raise PunchCardError, "Key '#{key}' can only be alphanumeric"
|
173
|
+
end
|
174
|
+
|
144
175
|
@meta_data[key.to_sym] = value
|
145
176
|
write_to_project_file!
|
146
177
|
@meta_data
|
147
178
|
end
|
148
179
|
|
149
|
-
def total
|
180
|
+
def total(start_at: nil, end_at: nil)
|
150
181
|
total = 0
|
151
182
|
project_data.map do |line|
|
152
183
|
points = line_to_time_points(line)
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
184
|
+
next unless points
|
185
|
+
|
186
|
+
start_time = points[0]
|
187
|
+
end_time = points[1] || timestamp
|
188
|
+
|
189
|
+
next if time_range_is_excluded_by_filter?(
|
190
|
+
start_at: start_at,
|
191
|
+
end_at: end_at,
|
192
|
+
start_time: start_time,
|
193
|
+
end_time: end_time
|
194
|
+
)
|
195
|
+
|
196
|
+
total += end_time - start_time
|
158
197
|
end
|
159
198
|
total
|
160
199
|
end
|
161
200
|
|
162
|
-
def self.format_time
|
201
|
+
def self.format_time(datetime)
|
163
202
|
datetime.strftime('%F %T')
|
164
203
|
end
|
165
204
|
|
166
|
-
|
205
|
+
def self.humanize_duration(duration)
|
206
|
+
return nil unless duration
|
167
207
|
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
208
|
+
hours = duration / (60 * 60)
|
209
|
+
minutes = (duration / 60) % 60
|
210
|
+
seconds = duration % 60
|
211
|
+
"#{decimal_digits(hours)}:#{decimal_digits(minutes)}:#{decimal_digits(seconds)}"
|
212
|
+
end
|
213
|
+
|
214
|
+
def self.decimal_digits(digit)
|
215
|
+
if digit.to_i < 10
|
216
|
+
"0#{digit}"
|
175
217
|
else
|
176
|
-
|
218
|
+
digit.to_s
|
177
219
|
end
|
178
220
|
end
|
179
221
|
|
222
|
+
private
|
223
|
+
|
224
|
+
def hourly_rate
|
225
|
+
hourly_rate_found = @meta_data[:hourlyRate]&.match(HOURLY_RATE_PATTERN)
|
226
|
+
return unless hourly_rate_found
|
227
|
+
|
228
|
+
{
|
229
|
+
hourlyRate: hourly_rate_found[1].to_f,
|
230
|
+
currency: hourly_rate_found[2] ? hourly_rate_found[2].strip : ''
|
231
|
+
}
|
232
|
+
end
|
233
|
+
|
180
234
|
def project_exists_or_stop!
|
181
|
-
raise PunchCardError
|
235
|
+
raise PunchCardError, "'#{@project}' does not exists" unless project_exist?
|
182
236
|
end
|
183
237
|
|
184
238
|
def active?
|
@@ -190,29 +244,14 @@ class PunchCard
|
|
190
244
|
end
|
191
245
|
|
192
246
|
def humanized_total
|
193
|
-
humanize_duration total
|
247
|
+
self.class.humanize_duration total
|
194
248
|
end
|
195
249
|
|
196
|
-
def duration
|
197
|
-
if
|
198
|
-
humanize_duration
|
250
|
+
def duration(start_time, end_time)
|
251
|
+
if start_time
|
252
|
+
self.class.humanize_duration end_time - start_time
|
199
253
|
else
|
200
|
-
humanize_duration 0
|
201
|
-
end
|
202
|
-
end
|
203
|
-
|
204
|
-
def humanize_duration duration
|
205
|
-
hours = duration / (60 * 60)
|
206
|
-
minutes = (duration / 60) % 60
|
207
|
-
seconds = duration % 60
|
208
|
-
"#{decimal_digits(hours)}:#{decimal_digits(minutes)}:#{decimal_digits(seconds)}"
|
209
|
-
end
|
210
|
-
|
211
|
-
def decimal_digits digit
|
212
|
-
if digit.to_i < 10
|
213
|
-
"0#{digit}"
|
214
|
-
else
|
215
|
-
digit.to_s
|
254
|
+
self.class.humanize_duration 0
|
216
255
|
end
|
217
256
|
end
|
218
257
|
|
@@ -220,11 +259,11 @@ class PunchCard
|
|
220
259
|
time_points ? time_points[0] : nil
|
221
260
|
end
|
222
261
|
|
223
|
-
def start_time=
|
262
|
+
def start_time=(time)
|
224
263
|
append_new_line timestamp_to_time(time)
|
225
264
|
end
|
226
265
|
|
227
|
-
def end_time=
|
266
|
+
def end_time=(time)
|
228
267
|
replace_last_line "#{timestamp_to_time(start_time)} - #{timestamp_to_time(time)}"
|
229
268
|
end
|
230
269
|
|
@@ -236,24 +275,24 @@ class PunchCard
|
|
236
275
|
line_to_time_points last_entry
|
237
276
|
end
|
238
277
|
|
239
|
-
def line_to_time_points
|
278
|
+
def line_to_time_points(line)
|
240
279
|
matches = line.match(TIME_POINT_PATTERN)
|
241
|
-
|
280
|
+
|
242
281
|
time_points = matches ? [string_to_timestamp(matches[2]), string_to_timestamp(matches[4])] : nil
|
243
|
-
if time_points
|
282
|
+
if time_points&.reject(&:nil?)&.empty?
|
244
283
|
nil
|
245
284
|
else
|
246
285
|
time_points
|
247
286
|
end
|
248
|
-
|
249
287
|
end
|
250
288
|
|
251
289
|
def string_to_timestamp(str)
|
252
290
|
return str if str.nil?
|
291
|
+
|
253
292
|
str.strip!
|
254
|
-
#
|
293
|
+
# here some legacy… previous versions stored timestamp,
|
255
294
|
# but now punched stores date-time strings for better readability.
|
256
|
-
# So we have to convert timestamp and date-time format into timestamp
|
295
|
+
# So we have to convert timestamp and date-time format into timestamp
|
257
296
|
str =~ /^\d+$/ ? str.to_i : (str =~ /^\d{4}\-\d/ ? Time.parse(str).to_i : nil)
|
258
297
|
end
|
259
298
|
|
@@ -271,10 +310,9 @@ class PunchCard
|
|
271
310
|
|
272
311
|
def read_project_data
|
273
312
|
title = nil
|
274
|
-
meta_data = []
|
275
313
|
timestamps = []
|
276
314
|
i = 0
|
277
|
-
File.open(project_file,
|
315
|
+
File.open(project_file, 'r').each_line do |line|
|
278
316
|
line.strip!
|
279
317
|
if i.zero?
|
280
318
|
title = line
|
@@ -285,15 +323,16 @@ class PunchCard
|
|
285
323
|
end
|
286
324
|
i += 1
|
287
325
|
end
|
288
|
-
@project =
|
326
|
+
@project = File.basename(project_file)
|
327
|
+
self.title = title
|
289
328
|
timestamps
|
290
329
|
end
|
291
330
|
|
292
331
|
def project_data
|
293
|
-
File.open(project_file).each_line.map
|
332
|
+
File.open(project_file).each_line.map(&:strip)
|
294
333
|
end
|
295
334
|
|
296
|
-
def write_string_to_project_file!
|
335
|
+
def write_string_to_project_file!(string)
|
297
336
|
File.open(project_file, 'w') { |f| f.write(string) }
|
298
337
|
end
|
299
338
|
|
@@ -303,11 +342,11 @@ class PunchCard
|
|
303
342
|
write_string_to_project_file! [@project, meta_data_lines.join("\n"), timestamps].reject(&:empty?).join("\n")
|
304
343
|
end
|
305
344
|
|
306
|
-
def append_new_line
|
307
|
-
open(project_file, 'a') { |f| f.puts("\n"+line.to_s.strip) }
|
345
|
+
def append_new_line(line)
|
346
|
+
open(project_file, 'a') { |f| f.puts("\n" + line.to_s.strip) }
|
308
347
|
end
|
309
348
|
|
310
|
-
def replace_last_line
|
349
|
+
def replace_last_line(line)
|
311
350
|
data = project_data
|
312
351
|
data[-1] = line
|
313
352
|
write_string_to_project_file! data.join("\n")
|
@@ -322,20 +361,23 @@ class PunchCard
|
|
322
361
|
end
|
323
362
|
|
324
363
|
def project_exist?
|
325
|
-
File.
|
364
|
+
File.exist?(project_file)
|
326
365
|
end
|
327
366
|
|
328
367
|
def find_or_make_file
|
329
|
-
write_string_to_project_file!(@project+"\n") unless project_exist?
|
330
|
-
|
368
|
+
write_string_to_project_file!(@project + "\n") unless project_exist?
|
369
|
+
self.title ||= project_data.first
|
331
370
|
end
|
332
371
|
|
333
372
|
def find_or_make_settings_dir
|
334
|
-
Dir.mkdir(SETTINGS_DIR) unless File.
|
373
|
+
Dir.mkdir(SETTINGS_DIR) unless File.exist?(SETTINGS_DIR)
|
335
374
|
end
|
336
375
|
|
337
|
-
def sanitize_filename
|
338
|
-
name.downcase.gsub(
|
376
|
+
def sanitize_filename(name)
|
377
|
+
name.downcase.gsub(%r{(\\|/)}, '').gsub(/[^0-9a-z.\-]/, '_')
|
339
378
|
end
|
340
379
|
|
380
|
+
def time_range_is_excluded_by_filter?(start_time:, end_time:, start_at: nil, end_at: nil)
|
381
|
+
start_at && start_at.to_time.to_i >= start_time || end_at && end_at.to_time.to_i <= end_time
|
382
|
+
end
|
341
383
|
end
|
data/punched.gemspec
CHANGED
@@ -1,22 +1,19 @@
|
|
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.
|
16
|
-
s.
|
17
|
-
s.require_paths = ["lib"]
|
18
|
-
s.required_ruby_version = '>= 2.1'
|
14
|
+
s.require_paths = ['lib']
|
15
|
+
s.required_ruby_version = '>= 2.6'
|
19
16
|
s.files = `git ls-files`.split("\n")
|
20
|
-
|
17
|
+
|
21
18
|
s.add_dependency 'markdown-tables', '~> 1.0.2'
|
22
19
|
end
|
data/spec/punchcard_spec.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
2
3
|
|
3
|
-
require
|
4
|
+
require 'punchcard'
|
4
5
|
require 'securerandom'
|
5
6
|
|
6
7
|
def example_settings_dir
|
@@ -8,13 +9,16 @@ def example_settings_dir
|
|
8
9
|
end
|
9
10
|
|
10
11
|
def setup_example_settings_dir
|
11
|
-
Dir.glob(example_settings_dir+'/*').each { |file| File.delete(file) }
|
12
|
+
Dir.glob(example_settings_dir + '/*').each { |file| File.delete(file) }
|
12
13
|
PunchCard.send(:remove_const, :SETTINGS_DIR)
|
13
14
|
PunchCard.const_set(:SETTINGS_DIR, example_settings_dir)
|
14
15
|
end
|
15
16
|
|
16
|
-
|
17
|
+
def punched_bin
|
18
|
+
"PUNCHCARD_DIR=#{example_settings_dir} #{Dir.pwd}/bin/punched"
|
19
|
+
end
|
17
20
|
|
21
|
+
describe PunchCard do
|
18
22
|
before do
|
19
23
|
setup_example_settings_dir
|
20
24
|
end
|
@@ -25,8 +29,8 @@ describe PunchCard do
|
|
25
29
|
PunchCard.new("My random Project #{SecureRandom.hex}")
|
26
30
|
end
|
27
31
|
|
28
|
-
def my_project_file
|
29
|
-
File.open("#{example_settings_dir}/#{filename}",
|
32
|
+
def my_project_file(filename = 'my_project')
|
33
|
+
File.open("#{example_settings_dir}/#{filename}", 'r').read
|
30
34
|
end
|
31
35
|
|
32
36
|
def start_and_stop
|
@@ -63,7 +67,7 @@ describe PunchCard do
|
|
63
67
|
it 'should calculate tracked total time' do
|
64
68
|
project = two_seconds_tracking
|
65
69
|
tracked_time = project.details.lines.last.match(/^\d{2}\:\d{2}\:(\d{2}).*total/)[1].to_i
|
66
|
-
expect(tracked_time).to
|
70
|
+
expect(tracked_time).to be_between 1, 3
|
67
71
|
project = two_seconds_tracking
|
68
72
|
tracked_time = project.details.lines.last.match(/^\d{2}\:\d{2}\:(\d{2}).*total/)[1].to_i
|
69
73
|
expect(tracked_time).to be_between 3, 5
|
@@ -80,29 +84,36 @@ describe PunchCard do
|
|
80
84
|
expect(my_project_file.lines[-2]).to match(/^\d+/)
|
81
85
|
end
|
82
86
|
|
83
|
-
it 'should
|
84
|
-
PunchCard.new
|
85
|
-
expect(my_project_file('playing_mot_rhead').strip).to eq(
|
86
|
-
project = PunchCard.new
|
87
|
-
expect(project.project).to eq(
|
87
|
+
it 'should convert names to underscore with special characters' do
|
88
|
+
PunchCard.new 'Playing Motörhead'
|
89
|
+
expect(my_project_file('playing_mot_rhead').strip).to eq('Playing Motörhead')
|
90
|
+
project = PunchCard.new 'Playing*'
|
91
|
+
expect(project.project).to eq('playing_mot_rhead')
|
92
|
+
end
|
93
|
+
|
94
|
+
xit 'should read and write utf8 names' do
|
95
|
+
PunchCard.new 'Playing Motörhead'
|
96
|
+
expect(my_project_file('playing_mot_rhead').strip).to eq('Playing Motörhead')
|
97
|
+
project = PunchCard.new 'Playing*'
|
98
|
+
expect(project.project).to eq('Playing Motörhead')
|
88
99
|
end
|
89
100
|
|
90
101
|
it 'should set hourlyRate' do
|
91
102
|
project = start_and_stop
|
92
|
-
project.set 'hourlyRate',
|
93
|
-
expect(my_project_file.lines[1].strip).to eq(
|
103
|
+
project.set 'hourlyRate', '1000 €'
|
104
|
+
expect(my_project_file.lines[1].strip).to eq('hourlyRate: 1000 €')
|
94
105
|
end
|
95
106
|
|
96
107
|
it 'should calculate earnings' do
|
97
108
|
project = start_and_stop
|
98
|
-
project.set 'hourlyRate',
|
109
|
+
project.set 'hourlyRate', '1000EURO'
|
99
110
|
project.toggle
|
100
111
|
sleep 2
|
101
112
|
project.toggle
|
102
113
|
project.toggle
|
103
114
|
sleep 2
|
104
115
|
project.toggle
|
105
|
-
expect(project.csv).to match /^"My Project","stopped","[0-9\-\s\:]+?","[0-9\:]+?","1000.0 EURO","1\.\d+ EURO"$/
|
116
|
+
expect(project.csv).to match /^"My Project \[my_project\]","stopped","[0-9\-\s\:]+?","[0-9\:]+?","1000.0 EURO","1\.\d+ EURO"$/
|
106
117
|
end
|
107
118
|
|
108
119
|
it 'should track different projects simultanously' do
|
@@ -115,16 +126,16 @@ describe PunchCard do
|
|
115
126
|
project_a.stop
|
116
127
|
sleep 2
|
117
128
|
project_b.stop
|
118
|
-
expect(project_b.total.to_i - project_a.total.to_i).to be_between(2,4)
|
129
|
+
expect(project_b.total.to_i - project_a.total.to_i).to be_between(2, 4)
|
119
130
|
end
|
120
131
|
|
121
132
|
it 'should load latest project by wildcard' do
|
122
133
|
project_a = random_project
|
123
|
-
project = PunchCard.new
|
134
|
+
project = PunchCard.new 'My random*'
|
124
135
|
expect(project.project).to eq(project_a.project)
|
125
136
|
sleep 1
|
126
137
|
project_b = random_project
|
127
|
-
project = PunchCard.new
|
138
|
+
project = PunchCard.new 'My random*'
|
128
139
|
expect(project.project).to eq(project_b.project)
|
129
140
|
expect(project.project).not_to eq(project_a.project)
|
130
141
|
end
|
@@ -133,23 +144,28 @@ describe PunchCard do
|
|
133
144
|
project = example_project
|
134
145
|
content = my_project_file
|
135
146
|
project.rename 'Renamed Project'
|
136
|
-
expect(File.open("#{example_settings_dir}/renamed_project",
|
137
|
-
expect(File.
|
147
|
+
expect(File.open("#{example_settings_dir}/renamed_project", 'r').read.strip).to eq(content.strip.sub(/My Project/, 'Renamed Project'))
|
148
|
+
expect(File.exist?("#{example_settings_dir}/my_project")).to be_falsey
|
138
149
|
expect(project.project).to eq('Renamed Project')
|
139
150
|
project.start
|
140
151
|
sleep 0.1
|
141
152
|
project.stop
|
142
|
-
content = File.open("#{example_settings_dir}/renamed_project",
|
153
|
+
content = File.open("#{example_settings_dir}/renamed_project", 'r').read.strip
|
143
154
|
project.rename 'Other Project'
|
144
|
-
expect(File.open("#{example_settings_dir}/other_project",
|
145
|
-
expect(File.
|
155
|
+
expect(File.open("#{example_settings_dir}/other_project", 'r').read.strip).to eq(content.strip.sub(/Renamed Project/, 'Other Project'))
|
156
|
+
expect(File.exist?("#{example_settings_dir}/renamed_project")).to be_falsey
|
146
157
|
end
|
147
158
|
|
148
159
|
it 'should remove' do
|
149
160
|
project = example_project
|
150
|
-
expect(File.
|
161
|
+
expect(File.exist?("#{example_settings_dir}/my_project")).to be_truthy
|
151
162
|
project.remove
|
152
|
-
expect(File.
|
163
|
+
expect(File.exist?("#{example_settings_dir}/my_project")).to be_falsey
|
153
164
|
end
|
154
165
|
|
166
|
+
it 'should call punched all' do
|
167
|
+
two_seconds_tracking
|
168
|
+
result = `#{punched_bin} all`
|
169
|
+
expect(result).to match('My Project')
|
170
|
+
end
|
155
171
|
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.
|
4
|
+
version: 1.3.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Philipp Staender
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2020-09-12 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: []
|
@@ -53,15 +53,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
53
53
|
requirements:
|
54
54
|
- - ">="
|
55
55
|
- !ruby/object:Gem::Version
|
56
|
-
version: '2.
|
56
|
+
version: '2.6'
|
57
57
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
59
|
- - ">="
|
60
60
|
- !ruby/object:Gem::Version
|
61
61
|
version: '0'
|
62
62
|
requirements: []
|
63
|
-
|
64
|
-
rubygems_version: 2.7.8
|
63
|
+
rubygems_version: 3.1.2
|
65
64
|
signing_key:
|
66
65
|
specification_version: 4
|
67
66
|
summary: Punchcard Timetracker
|