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 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