na 1.2.86 → 1.2.87

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.
@@ -2,124 +2,152 @@
2
2
 
3
3
  class App
4
4
  extend GLI::App
5
- desc "Find actions matching a tag"
5
+ desc 'Find actions matching a tag'
6
6
  long_desc 'Finds actions with tags matching the arguments. An action is shown if it
7
7
  contains all of the tags listed. Add a + before a tag to make it required
8
8
  and others optional. You can specify values using TAG=VALUE pairs.
9
9
  Use <, >, and = for numeric comparisons, and *=, ^=, $=, or =~ (regex) for text comparisons.
10
10
  Date comparisons use natural language (`na tagged "due<=today"`) and
11
11
  are detected automatically.'
12
- arg_name "TAG[=VALUE]"
12
+ arg_name 'TAG[=VALUE]'
13
13
  command %i[tagged] do |c|
14
- c.example "na tagged maybe", desc: "Show all actions tagged @maybe"
15
- c.example 'na tagged -d 3 "feature, idea"', desc: "Show all actions tagged @feature AND @idea, recurse 3 levels"
16
- c.example 'na tagged --or "feature, idea"', desc: "Show all actions tagged @feature OR @idea"
17
- c.example 'na tagged "priority>=4"', desc: "Show actions with @priority(4) or @priority(5)"
18
- c.example 'na tagged "due<in 2 days"', desc: "Show actions with a due date coming up in the next 2 days"
19
-
20
- c.desc "Recurse to depth"
21
- c.arg_name "DEPTH"
14
+ c.example 'na tagged maybe', desc: 'Show all actions tagged @maybe'
15
+ c.example 'na tagged -d 3 "feature, idea"', desc: 'Show all actions tagged @feature AND @idea, recurse 3 levels'
16
+ c.example 'na tagged --or "feature, idea"', desc: 'Show all actions tagged @feature OR @idea'
17
+ c.example 'na tagged "priority>=4"', desc: 'Show actions with @priority(4) or @priority(5)'
18
+ c.example 'na tagged "due<in 2 days"', desc: 'Show actions with a due date coming up in the next 2 days'
19
+
20
+ c.desc 'Recurse to depth'
21
+ c.arg_name 'DEPTH'
22
22
  c.default_value 1
23
23
  c.flag %i[d depth], type: :integer, must_match: /^\d+$/
24
24
 
25
- c.desc "Show actions from a specific todo file in history. May use wildcards (* and ?)"
26
- c.arg_name "TODO_PATH"
25
+ c.desc 'Show actions from a specific todo file in history. May use wildcards (* and ?)'
26
+ c.arg_name 'TODO_PATH'
27
27
  c.flag %i[in]
28
28
 
29
- c.desc "Include notes in output"
29
+ c.desc 'Include notes in output'
30
30
  c.switch %i[notes], negatable: true, default_value: false
31
31
 
32
- c.desc "Combine tags with OR, displaying actions matching ANY of the tags"
32
+ c.desc 'Show per-action durations and total'
33
+ c.switch %i[times], negatable: false
34
+
35
+ c.desc 'Format durations in human-friendly form'
36
+ c.switch %i[human], negatable: false
37
+
38
+ c.desc 'Show only actions that have a duration (@started and @done)'
39
+ c.switch %i[only_timed], negatable: false
40
+
41
+ c.desc 'Output times as JSON object (implies --times and --done)'
42
+ c.switch %i[json_times], negatable: false
43
+
44
+ c.desc 'Output only elapsed time totals (implies --times and --done)'
45
+ c.switch %i[only_times], negatable: false
46
+
47
+ c.desc 'Combine tags with OR, displaying actions matching ANY of the tags'
33
48
  c.switch %i[o or], negatable: false
34
49
 
35
- c.desc "Show actions from a specific project"
36
- c.arg_name "PROJECT[/SUBPROJECT]"
50
+ c.desc 'Show actions from a specific project'
51
+ c.arg_name 'PROJECT[/SUBPROJECT]'
37
52
  c.flag %i[proj project]
38
53
 
39
- c.desc "Filter results using search terms"
40
- c.arg_name "QUERY"
54
+ c.desc 'Filter results using search terms'
55
+ c.arg_name 'QUERY'
41
56
  c.flag %i[search find grep], multiple: true
42
57
 
43
- c.desc "Include notes in search"
58
+ c.desc 'Include notes in search'
44
59
  c.switch %i[search_notes], negatable: true, default_value: true
45
60
 
46
- c.desc "Search query is regular expression"
61
+ c.desc 'Search query is regular expression'
47
62
  c.switch %i[regex], negatable: false
48
63
 
49
- c.desc "Search query is exact text match (not tokens)"
64
+ c.desc 'Search query is exact text match (not tokens)'
50
65
  c.switch %i[exact], negatable: false
51
66
 
52
- c.desc "Include @done actions"
67
+ c.desc 'Include @done actions'
53
68
  c.switch %i[done]
54
69
 
55
- c.desc "Show actions not matching tags"
70
+ c.desc 'Show actions not matching tags'
56
71
  c.switch %i[v invert], negatable: false
57
72
 
58
- c.desc "Save this search for future use"
59
- c.arg_name "TITLE"
73
+ c.desc 'Save this search for future use'
74
+ c.arg_name 'TITLE'
60
75
  c.flag %i[save]
61
76
 
62
- c.desc "Output actions nested by file"
77
+ c.desc 'Output actions nested by file'
63
78
  c.switch %i[nest], negatable: false
64
79
 
65
- c.desc "No filename in output"
80
+ c.desc 'No filename in output'
66
81
  c.switch %i[no_file], negatable: false
67
82
 
68
- c.desc "Output actions nested by file and project"
83
+ c.desc 'Output actions nested by file and project'
69
84
  c.switch %i[omnifocus], negatable: false
70
85
 
71
86
  c.action do |global_options, options, args|
72
87
  options[:nest] = true if options[:omnifocus]
73
88
 
74
89
  if options[:save]
75
- title = options[:save].gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_")
76
- cmd = NA.command_line.join(" ").sub(/ --save[= ]*\S+/, "").split(" ").map { |t| %("#{t}") }.join(" ")
90
+ title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
91
+ cmd = NA.command_line.join(' ').sub(/ --save[= ]*\S+/, '').split.map { |t| %("#{t}") }.join(' ')
77
92
  NA.save_search(title, cmd)
78
93
  end
79
94
 
80
95
  depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
81
- 3
82
- else
83
- options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
84
- end
96
+ 3
97
+ else
98
+ options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
99
+ end
85
100
 
86
101
  tags = []
87
102
 
88
- all_req = args.join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
89
- args.join(",").split(/ *, */).each do |arg|
103
+ all_req = args.join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
104
+ args.join(',').split(/ *, */).each do |arg|
90
105
  m = arg.match(/^(?<req>[+!-])?(?<tag>[^ =<>$~\^]+?) *(?:(?<op>[=<>~]{1,2}|[*$\^]=) *(?<val>.*?))?$/)
91
106
  next if m.nil?
92
107
 
93
108
  tags.push({
94
- tag: m["tag"].sub(/^@/, "").wildcard_to_rx,
95
- comp: m["op"],
96
- value: m["val"],
97
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
98
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
109
+ tag: m['tag'].sub(/^@/, '').wildcard_to_rx,
110
+ comp: m['op'],
111
+ value: m['val'],
112
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
113
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
99
114
  })
100
115
  end
101
116
 
102
117
  search_for_done = false
103
118
  tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
104
- tags.push({ tag: "done", value: nil, negate: true }) unless search_for_done || options[:done]
105
- options[:done] = true if search_for_done
119
+ if options[:json_times]
120
+ options[:times] = true
121
+ options[:done] = true
122
+ elsif options[:only_times]
123
+ options[:times] = true
124
+ options[:done] = true
125
+ elsif options[:only_timed]
126
+ options[:times] = true
127
+ options[:done] = true
128
+ elsif options[:times]
129
+ options[:done] = true
130
+ else
131
+ tags.push({ tag: 'done', value: nil, negate: true }) unless search_for_done || options[:done]
132
+ options[:done] = true if search_for_done
133
+ end
106
134
 
107
135
  tokens = nil
108
136
  if options[:search]
109
137
  if options[:exact]
110
- tokens = options[:search].join(" ")
138
+ tokens = options[:search].join(' ')
111
139
  elsif options[:regex]
112
- tokens = Regexp.new(options[:search].join(" "), Regexp::IGNORECASE)
140
+ tokens = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
113
141
  else
114
142
  tokens = []
115
- all_req = options[:search].join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
143
+ all_req = options[:search].join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
116
144
 
117
- options[:search].join(" ").split(/ /).each do |arg|
145
+ options[:search].join(' ').split(/ /).each do |arg|
118
146
  m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
119
147
  tokens.push({
120
- token: m["tok"],
121
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
122
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
148
+ token: m['tok'],
149
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
150
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
123
151
  })
124
152
  end
125
153
  end
@@ -132,9 +160,9 @@ class App
132
160
  options[:in].split(/ *, */).each do |a|
133
161
  m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
134
162
  todos.push({
135
- token: m["tok"],
136
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
137
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
163
+ token: m['tok'],
164
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
165
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
138
166
  })
139
167
  end
140
168
  end
@@ -152,17 +180,22 @@ class App
152
180
  require_na: false })
153
181
 
154
182
  regexes = if tokens.is_a?(Array)
155
- tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
156
- else
157
- [tokens]
158
- end
183
+ tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
184
+ else
185
+ [tokens]
186
+ end
159
187
  todo.actions.output(depth,
160
188
  { files: todo.files,
161
189
  regexes: regexes,
162
190
  notes: options[:notes],
163
191
  nest: options[:nest],
164
192
  nest_projects: options[:omnifocus],
165
- no_files: options[:no_file] })
193
+ no_files: options[:no_file],
194
+ times: options[:times],
195
+ human: options[:human],
196
+ only_timed: options[:only_timed],
197
+ json_times: options[:json_times],
198
+ only_times: options[:only_times] })
166
199
  end
167
200
  end
168
201
  end
@@ -9,6 +9,17 @@ class App
9
9
  allow you to pick which file to act on.'
10
10
  arg_name 'ACTION'
11
11
  command %i[update] do |c|
12
+ c.desc 'Started time (natural language or ISO)'
13
+ c.arg_name 'DATE'
14
+ c.flag %i[started], type: :date_begin
15
+
16
+ c.desc 'End/Finished time (natural language or ISO)'
17
+ c.arg_name 'DATE'
18
+ c.flag %i[end finished], type: :date_end
19
+
20
+ c.desc 'Duration (e.g. 45m, 2h, 1d2h30m, or minutes)'
21
+ c.arg_name 'DURATION'
22
+ c.flag %i[duration], type: :duration
12
23
  c.example 'na update --remove na "An existing task"',
13
24
  desc: 'Find "An existing task" action and remove the @na tag from it'
14
25
  c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
@@ -299,7 +310,10 @@ class App
299
310
  options[:archive],
300
311
  options[:restore],
301
312
  options[:delete],
302
- options[:edit]
313
+ options[:edit],
314
+ options[:started],
315
+ (options[:end] || options[:finished]),
316
+ options[:duration]
303
317
  ].any?
304
318
  unless actionable
305
319
  # Interactive menu for actions
@@ -361,6 +375,21 @@ class App
361
375
  options[:delete] = true
362
376
  when :finish
363
377
  options[:finish] = true
378
+ # Timed finish? Prompt user for optional start/date inputs
379
+ if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
380
+ # Ask for start date expression
381
+ start_expr = nil
382
+ if TTY::Which.exist?('gum')
383
+ gum = TTY::Which.which('gum')
384
+ prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
385
+ start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
386
+ else
387
+ print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
388
+ start_expr = (STDIN.gets || '').strip
389
+ end
390
+ start_time = NA::Types.parse_date_begin(start_expr)
391
+ options[:started] = start_time if start_time
392
+ end
364
393
  when :edit
365
394
  # Just set the flag - multi-action editor will handle it below
366
395
  options[:edit] = true
@@ -477,8 +506,8 @@ class App
477
506
  end
478
507
  end
479
508
 
480
- # Update each action
481
- action_list.each do |action_obj|
509
+ # Update each action (process from bottom to top to avoid line shifts)
510
+ action_list.sort_by(&:file_line).reverse.each do |action_obj|
482
511
  NA.update_action(file, nil,
483
512
  add: action_obj,
484
513
  add_tag: add_tags,
@@ -639,7 +668,10 @@ class App
639
668
  remove_tag: remove_tags,
640
669
  replace: options[:replace],
641
670
  search_note: options[:search_notes],
642
- tagged: tags)
671
+ tagged: tags,
672
+ started_at: options[:started],
673
+ done_at: (options[:end] || options[:finished]),
674
+ duration_seconds: options[:duration])
643
675
  end
644
676
  end
645
677
  end
data/bin/na CHANGED
@@ -195,6 +195,12 @@ class App
195
195
  true
196
196
  end
197
197
  end
198
+
199
+ # Register custom GLI types for natural language dates and durations
200
+ # Return original string if parsing fails so commands can handle fallback parsing
201
+ accept(:date_begin) { |v| NA::Types.parse_date_begin(v) || v }
202
+ accept(:date_end) { |v| NA::Types.parse_date_end(v) || v }
203
+ accept(:duration) { |v| NA::Types.parse_duration_seconds(v) }
198
204
  end
199
205
 
200
206
  NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
data/lib/na/action.rb CHANGED
@@ -63,8 +63,8 @@ module NA
63
63
  # @param note [Array<String>] Notes to set
64
64
  # @return [void]
65
65
  # @example
66
- # action.process(priority: 5, finish: true, add_tag: ['urgent'], remove_tag: ['waiting'], note: ['Call Bob'])
67
- def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
66
+ # action.process(priority: 5, finish: true, add_tag: ["urgent"], remove_tag: ["waiting"], note: ["Call Bob"], started_at: Time.now, done_at: Time.now)
67
+ def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [], started_at: nil, done_at: nil, duration_seconds: nil)
68
68
  string = @action.dup
69
69
 
70
70
  if priority&.positive?
@@ -84,9 +84,32 @@ module NA
84
84
  string += " @#{tag}"
85
85
  end
86
86
 
87
- string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /(?<=\A| )@done/
87
+ # Compute started/done from duration if provided
88
+ if duration_seconds && (done_at || finish)
89
+ done_time = done_at || Time.now
90
+ started_at ||= done_time - duration_seconds.to_i
91
+ elsif duration_seconds && started_at
92
+ done_at ||= started_at + duration_seconds.to_i
93
+ end
94
+
95
+ # Insert @started if provided
96
+ if started_at
97
+ string.gsub!(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '')
98
+ string.strip!
99
+ string += " @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
100
+ end
101
+
102
+ # Insert @done if provided or finishing
103
+ if done_at
104
+ string.gsub!(/(?<=\A| )@done\(.*?\)/i, '')
105
+ string.strip!
106
+ string += " @done(#{done_at.strftime('%Y-%m-%d %H:%M')})"
107
+ elsif finish && string !~ /(?<=\A| )@done/
108
+ string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})"
109
+ end
88
110
 
89
111
  @action = string.expand_date_tags
112
+ @tags = scan_tags
90
113
  @note = note unless note.empty?
91
114
  end
92
115
 
data/lib/na/actions.rb CHANGED
@@ -26,12 +26,26 @@ module NA
26
26
  notes: false,
27
27
  nest: false,
28
28
  nest_projects: false,
29
- no_files: false
29
+ no_files: false,
30
+ times: false,
31
+ human: false,
32
+ only_timed: false,
33
+ json_times: false
30
34
  }
31
35
  config = defaults.merge(config)
32
36
 
33
37
  return if config[:files].nil?
34
38
 
39
+ # Optionally filter to only actions with a computable duration (@started and @done)
40
+ filtered_actions = if config[:only_timed]
41
+ self.select do |a|
42
+ t = a.tags
43
+ (t['started'] || t['start']) && t['done']
44
+ end
45
+ else
46
+ self
47
+ end
48
+
35
49
  if config[:nest]
36
50
  template = NA.theme[:templates][:default]
37
51
  template = NA.theme[:templates][:no_file] if config[:no_files]
@@ -40,7 +54,7 @@ module NA
40
54
  out = []
41
55
 
42
56
  if config[:nest_projects]
43
- each do |action|
57
+ filtered_actions.each do |action|
44
58
  parent_files[action.file] ||= []
45
59
  parent_files[action.file].push(action)
46
60
  end
@@ -54,7 +68,7 @@ module NA
54
68
  template = NA.theme[:templates][:default]
55
69
  template = NA.theme[:templates][:no_file] if config[:no_files]
56
70
 
57
- each do |action|
71
+ filtered_actions.each do |action|
58
72
  parent_files[action.file] ||= []
59
73
  parent_files[action.file].push(action)
60
74
  end
@@ -94,14 +108,71 @@ module NA
94
108
 
95
109
  # Optimize output generation - compile all output first, then apply regexes
96
110
  output = String.new
111
+ total_seconds = 0
112
+ totals_by_tag = Hash.new(0)
113
+ timed_items = []
97
114
  NA::Benchmark.measure('Generate action strings') do
98
- each_with_index do |action, idx|
115
+ filtered_actions.each_with_index do |action, idx|
99
116
  # Generate raw output without regex processing
100
- output << action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
101
- output << "\n" unless idx == size - 1
117
+ line = action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
118
+
119
+ if config[:times]
120
+ # compute duration from @started/@done
121
+ tags = action.tags
122
+ begun = tags['started'] || tags['start']
123
+ finished = tags['done']
124
+ if begun && finished
125
+ begin
126
+ start_t = Time.parse(begun)
127
+ end_t = Time.parse(finished)
128
+ secs = [end_t - start_t, 0].max.to_i
129
+ total_seconds += secs
130
+ dur_color = NA.theme[:duration] || '{y}'
131
+ line << NA::Color.template(" #{dur_color}[#{format_duration(secs, human: config[:human])}]{x}")
132
+
133
+ # collect for JSON output
134
+ timed_items << {
135
+ action: NA::Color.uncolor(action.action),
136
+ started: start_t.iso8601,
137
+ ended: end_t.iso8601,
138
+ duration: secs
139
+ }
140
+
141
+ # accumulate per-tag durations (exclude time-control tags)
142
+ tags.each_key do |k|
143
+ next if k =~ /^(start|started|done)$/i
144
+
145
+ totals_by_tag[k.sub(/^@/, '')] += secs
146
+ end
147
+ rescue StandardError
148
+ # ignore parse errors
149
+ end
150
+ end
151
+ end
152
+
153
+ unless config[:only_times]
154
+ output << line
155
+ output << "\n" unless idx == filtered_actions.size - 1
156
+ end
102
157
  end
103
158
  end
104
159
 
160
+ # If JSON output requested, emit JSON and return immediately
161
+ if config[:json_times]
162
+ require 'json'
163
+ json = {
164
+ timed: timed_items,
165
+ tags: totals_by_tag.map { |k, v| { tag: k, duration: v } }.sort_by { |h| -h[:duration] },
166
+ total: {
167
+ seconds: total_seconds,
168
+ timestamp: format_duration(total_seconds, human: false),
169
+ human: format_duration(total_seconds, human: true)
170
+ }
171
+ }
172
+ puts JSON.pretty_generate(json)
173
+ return
174
+ end
175
+
105
176
  # Apply regex highlighting to the entire output at once
106
177
  if config[:regexes].any?
107
178
  NA::Benchmark.measure('Apply regex highlighting') do
@@ -109,11 +180,70 @@ module NA
109
180
  end
110
181
  end
111
182
 
183
+ if config[:times] && total_seconds.positive?
184
+ # Build Markdown table of per-tag totals
185
+ if totals_by_tag.empty?
186
+ # No tag totals, just show total line
187
+ dur_color = NA.theme[:duration] || '{y}'
188
+ output << "\n"
189
+ output << NA::Color.template("{x}#{dur_color}Total time: [#{format_duration(total_seconds, human: config[:human])}]{x}")
190
+ else
191
+ rows = totals_by_tag.sort_by { |_, v| -v }.map do |tag, secs|
192
+ disp = format_duration(secs, human: config[:human])
193
+ ["@#{tag}", disp]
194
+ end
195
+ # Pre-compute total display for width calculation
196
+ total_disp = format_duration(total_seconds, human: config[:human])
197
+ # Determine column widths, including footer labels/values
198
+ tag_header = 'Tag'
199
+ dur_header = config[:human] ? 'Duration (human)' : 'Duration'
200
+ tag_width = ([tag_header.length, 'Total'.length] + rows.map { |r| r[0].length }).max
201
+ dur_width = ([dur_header.length, total_disp.length] + rows.map { |r| r[1].length }).max
202
+
203
+ # Header
204
+ output << "\n"
205
+ output << "| #{tag_header.ljust(tag_width)} | #{dur_header.ljust(dur_width)} |\n"
206
+ # Separator for header
207
+ output << "| #{'-' * tag_width} | #{'-' * dur_width} |\n"
208
+ # Body rows
209
+ rows.each do |tag, disp|
210
+ output << "| #{tag.ljust(tag_width)} | #{disp.ljust(dur_width)} |\n"
211
+ end
212
+ # Footer separator (kramdown footer separator with '=') and footer row
213
+ output << "| #{'=' * tag_width} | #{'=' * dur_width} |\n"
214
+ output << "| #{'Total'.ljust(tag_width)} | #{total_disp.ljust(dur_width)} |\n"
215
+ end
216
+ end
217
+
112
218
  NA::Benchmark.measure('Pager.page call') do
113
219
  NA::Pager.page(output)
114
220
  end
115
221
  end
116
222
  end
117
223
  end
224
+
225
+ private
226
+
227
+ def format_duration(secs, human: false)
228
+ return '' if secs.nil?
229
+
230
+ secs = secs.to_i
231
+ days = secs / 86_400
232
+ rem = secs % 86_400
233
+ hours = rem / 3600
234
+ rem %= 3600
235
+ minutes = rem / 60
236
+ seconds = rem % 60
237
+ if human
238
+ parts = []
239
+ parts << "#{days} days" if days.positive?
240
+ parts << "#{hours} hours" if hours.positive?
241
+ parts << "#{minutes} minutes" if minutes.positive?
242
+ parts << "#{seconds} seconds" if seconds.positive? || parts.empty?
243
+ parts.join(', ')
244
+ else
245
+ format('%<d>02d:%<h>02d:%<m>02d:%<s>02d', d: days, h: hours, m: minutes, s: seconds)
246
+ end
247
+ end
118
248
  end
119
249
  end