punched 1.0.4 → 1.3.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b31fb8d911d693ff343d9becd044d9c3378b64ba8214e6175aec5e6e042ce621
4
- data.tar.gz: aba03f814669c72f40e8cb2a56317aac1313ae3033f627fcc3ae9f83cc33ad03
3
+ metadata.gz: b9e6c733da15335832d27af80790665daed6c11c5baad9fbca2e221c094a15ca
4
+ data.tar.gz: 9d0817147f2df8b7c275ca233c45640b4fe9b2919bad621d4b3fe7cacd9847ba
5
5
  SHA512:
6
- metadata.gz: 9bd2d23729a8fbde5073d9aaa907d8585ea10c063ffeb3176e942cf6ce2fae7fdd078b038e7a520f03a39f4037c385278fe2c1a8b7e28b058bf9586ded70094c
7
- data.tar.gz: d4111205855e18c030236011ba93e02b6780771595df1c755d61095f5d6fcec0425cf22d56ca0da723c6e7bd5dabd2120ff4cc2035e80dcb62579fea8d3d15a7
6
+ metadata.gz: 7a3e6d274a7e53e9672178aaf1ff4beb283cba46f8b48b28f101f7e433af24195f016a5200f4a3b9222d51cf4daed4c10c5e1270a931730ed184f67b8fc54051
7
+ data.tar.gz: e4ad79f601795b9fa1f0afda921cec744ad853f2fc54c82122abea80faeffcf37f1d3c4c5b0b2b388c442efa933c059fc9ffbb7d51b21579090619526e290a9b
data/.gitignore CHANGED
@@ -1,2 +1,3 @@
1
1
  Gemfile.lock
2
2
  punchcard_test_data/
3
+ /*.gem
@@ -1,10 +1,7 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 2.1
4
- - 2.2
5
- - 2.3
6
- - 2.4
7
- - 2.5
3
+ - 2.6
4
+ - 2.7
8
5
  - ruby-head
9
6
  script: bundle exec rspec
10
7
  matrix:
data/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  [![Build Status](https://img.shields.io/travis/pstaender/punched.svg?branch=v1.0.0&style=flat-square)](https://travis-ci.org/pstaender/punched)
5
5
 
6
+ [![asciicast](https://asciinema.org/a/222572.svg)](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 start "Punchcard (programming)"
23
+ $ punched toggle punchcard_programming
24
+ 'punchcard_programming' started (00:00:00 total)
22
25
  ```
23
26
 
24
- #### Wildcard
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 start "Punch*"
30
+ $ punched toggle punchcard_programming
31
+ 'punchcard_programming' stopped (00:01:25 total)
30
32
  ```
31
33
 
32
- #### Stop Project
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
- #### Toggle
36
+ #### Wildcard
39
37
 
40
- Toggle between start and stop:
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 "Punch*"
41
+ $ punched toggle 'punched*'
44
42
  ```
45
43
 
46
44
  #### Status
47
45
 
48
46
  ```sh
49
- $ punched status "Punch*"
47
+ $ punched status punched_programming
50
48
 
51
- Punchcard (programming)
49
+ punched_programming
52
50
  01:10:09
53
51
  ```
54
52
 
55
53
  #### List details
56
54
 
57
55
  ```sh
58
- $ punched details "Punch*"
56
+ $ punched details punched_programming
59
57
 
60
- Punchcard (programming) (stopped)
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 "Punch*" hourlyRate 250€
80
+ $ punched set punched_programming hourlyRate 250€
81
+ {"hourlyRate":"250€"}
73
82
  ```
74
83
 
75
- #### Total time in seconds
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 "Punch*"
89
+ $ punched total punched_programming
90
+ 13505
79
91
  ```
80
92
 
81
- #### Rename and delete Project
93
+ `totalsum` calculates human readable spended time on project(s) (wildcard is used by default):
82
94
 
83
95
  ```sh
84
- $ punched rename "Old Title" "New Title"
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 remove "Punchcard (programming)"
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
- | Website | stopped | 2017-05-07 15:50:00 | 00:04:40 | 95.0 € | 380.00 € |
124
+ | website | stopped | 2017-05-07 15:50:00 | 00:04:40 | 95.0 € | 380.00 € |
109
125
  |----------------------------------------|---------|---------------------|----------------|-------------|----------|
110
- | Punchcard (programming) | stopped | 2017-07-11 12:47:42 | 01:10:04 | | |
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:
@@ -1,24 +1,39 @@
1
1
  #!/usr/bin/env ruby
2
- $:.push File.expand_path("../lib", __FILE__)
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 { |item| item.to_s.end_with?('=') || item.to_s == 'project' }.concat([:all]).sort
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? action
32
+ def action_available?(action)
18
33
  available_actions.include? action.to_sym
19
34
  end
20
35
 
21
- def exit_with_error! msg
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 all action
31
- available_formats = %w(csv plain md)
32
- unless available_formats.include?(action)
33
- raise "Format #{action} is not supported. Possible formats are: #{available_formats.join(',')}"
34
- end
35
- labels = ["project","status","last active on","total duration","hourly rate","earnings"]
36
- data = Dir[PunchCard::SETTINGS_DIR+'/*'].map { |file|
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
- case action
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
- puts MarkdownTables.make_table(labels, data, is_rows: true, align: ['l'])
63
+ MarkdownTables.make_table(labels, data, is_rows: true, align: ['l'])
52
64
  when 'csv'
53
- puts labels.to_csv
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
- puts MarkdownTables.plain_text(table)
68
+ MarkdownTables.plain_text(table)
58
69
  end
59
70
  end
60
71
 
61
- if ARGV.first == 'all'
62
- all ARGV[1] || 'plain'
63
- exit
64
- elsif ARGV.first && ['-h', '--help', 'help'].include?(ARGV.first)
65
- puts usage
66
- exit
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 = ARGV[0]
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
- exit_with_error!("2nd argument has to be the project name, e.g.:\n#{usage}") if !project_name && selected_action != 'list'
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 = ARGV.drop(2)
78
- if arguments.size > 0
79
- puts punch_card.send(selected_action.to_s, *arguments)
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
- puts punch_card.send(selected_action.to_s)
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
@@ -1,4 +1,6 @@
1
- # (c) 2017-2018 by philipp staender
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.0.4'
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 :project
18
+ attr_accessor :title
18
19
 
19
- def initialize project_name
20
+ def initialize(project_name)
20
21
  @wilcard_for_filename = ''
21
22
  @meta_data = {}
22
23
  find_or_make_settings_dir
23
- if project_name
24
- self.project = project_name
25
- find_or_make_file
26
- read_project_data
27
- end
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 << "'#{project}' already started (#{humanized_total} total)"
34
- output << "#{duration(start_time, timestamp)}"
34
+ output << "'#{title_or_project}' already started (#{humanized_total} total)"
35
+ output << duration(start_time, timestamp).to_s
35
36
  else
36
- output << "'#{project}' started (#{humanized_total} total)"
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 << "'#{@project}' already stopped (#{humanized_total} total)"
46
+ output << "'#{title_or_project}' already stopped (#{humanized_total} total)"
46
47
  elsif start_time
47
- output << "'#{@project}' stopped (#{humanized_total} total)"
48
+ output << "'#{title_or_project}' stopped (#{humanized_total} total)"
48
49
  self.end_time = timestamp
49
50
  else
50
- output << "Nothing to stop"
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 << (project+" (#{running_status})\n")
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
- output << project+" (#{running_status})\n\n"
77
- project_data.map do |line|
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
- if points
80
- starttime = points[0]
81
- endtime = points[1] || timestamp
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
- if points
97
- starttime = points[0]
98
- endtime = points[1] || timestamp
99
- last_activity = points[1] || points[0]
100
- durations.push duration(starttime, endtime)
101
- end
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
- @project,
105
- running_status,
106
- last_activity ? self.class.format_time(Time.at(last_activity).to_datetime) : '',
107
- humanized_total,
108
- hourly_rate ? hourly_rate[:hourlyRate].to_s + " #{hourly_rate[:currency]}" : '',
109
- hourly_rate ? (hourly_rate[:hourlyRate] * total / 3600.0).round(2).to_s + " #{hourly_rate[:currency]}" : '',
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.exists?(project_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 new_project_name
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= project_name
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 key, value
143
- raise PunchCardError.new("Key '#{key}' can only be alphanumeric") unless key.match(/^[a-zA-Z0-9]+$/)
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
- if points
154
- starttime = points[0]
155
- endtime = points[1] || timestamp
156
- total += endtime - starttime
157
- end
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 datetime
201
+ def self.format_time(datetime)
163
202
  datetime.strftime('%F %T')
164
203
  end
165
204
 
166
- private
205
+ def self.humanize_duration(duration)
206
+ return nil unless duration
167
207
 
168
- def hourly_rate
169
- hourly_rate_found = @meta_data[:hourlyRate] && @meta_data[:hourlyRate].match(HOURLY_RATE_PATTERN)
170
- if hourly_rate_found
171
- {
172
- hourlyRate: hourly_rate_found[1].to_f,
173
- currency: hourly_rate_found[2] ? hourly_rate_found[2].strip : '',
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
- nil
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.new("'#{@project}' does not exists") unless project_exist?
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 starttime, endtime
197
- if starttime
198
- humanize_duration endtime - starttime
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= time
262
+ def start_time=(time)
224
263
  append_new_line timestamp_to_time(time)
225
264
  end
226
265
 
227
- def end_time= 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 line
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 && time_points.reject(&:nil?).empty?
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
- # This is some legacy... previous versions stored timestamp,
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 here
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, "r").each_line do |line|
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 = title if title
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 { |line| line.strip }
332
+ File.open(project_file).each_line.map(&:strip)
294
333
  end
295
334
 
296
- def write_string_to_project_file! string
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 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 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.exists?(project_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
- @project = project_data.first
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.exists?(SETTINGS_DIR)
373
+ Dir.mkdir(SETTINGS_DIR) unless File.exist?(SETTINGS_DIR)
335
374
  end
336
375
 
337
- def sanitize_filename name
338
- name.downcase.gsub(/(\\|\/)/, '').gsub(/[^0-9a-z.\-]/, '_')
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
@@ -1,22 +1,19 @@
1
- # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
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 = ["Philipp Staender"]
9
- s.email = ["philipp.staender@gmail.com"]
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
- s.rubyforge_project = 'punched'
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
@@ -1,6 +1,7 @@
1
- $:.push File.expand_path("../lib", __FILE__)
1
+ # frozen_string_literal: true
2
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
2
3
 
3
- require "punchcard"
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
- describe PunchCard do
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 filename = 'my_project'
29
- File.open("#{example_settings_dir}/#{filename}", "r").read
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 be_between 1, 3
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 read and write utf8 names' do
84
- PunchCard.new "Playing Motörhead"
85
- expect(my_project_file('playing_mot_rhead').strip).to eq("Playing Motörhead")
86
- project = PunchCard.new "Playing*"
87
- expect(project.project).to eq("Playing Motörhead")
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', "1000 €"
93
- expect(my_project_file.lines[1].strip).to eq("hourlyRate: 1000 €")
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', "1000EURO"
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 "My random*"
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 "My random*"
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", "r").read.strip).to eq(content.strip.sub(/My Project/, 'Renamed Project'))
137
- expect(File.exists?("#{example_settings_dir}/my_project")).to be_falsey
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", "r").read.strip
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", "r").read.strip).to eq(content.strip.sub(/Renamed Project/, 'Other Project'))
145
- expect(File.exists?("#{example_settings_dir}/renamed_project")).to be_falsey
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.exists?("#{example_settings_dir}/my_project")).to be_truthy
161
+ expect(File.exist?("#{example_settings_dir}/my_project")).to be_truthy
151
162
  project.remove
152
- expect(File.exists?("#{example_settings_dir}/my_project")).to be_falsey
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.0.4
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: 2019-01-20 00:00:00.000000000 Z
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
- - philipp.staender@gmail.com
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.1'
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
- rubyforge_project: punched
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