na 1.2.85 → 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.
@@ -169,8 +169,9 @@ module NA
169
169
  # @param done [Boolean] Include done actions
170
170
  # @param project [String, nil] Project name
171
171
  # @param search_note [Boolean] Search notes
172
+ # @param target_line [Integer] Specific line number to target
172
173
  # @return [Array] Projects and actions
173
- def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true)
174
+ def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true, target_line: nil)
174
175
  todo = NA::Todo.new({ search: search,
175
176
  search_note: search_note,
176
177
  require_na: false,
@@ -187,7 +188,17 @@ module NA
187
188
 
188
189
  return [todo.projects, todo.actions] if todo.actions.count == 1 || all
189
190
 
190
- options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
191
+ # If target_line is specified, find the action at that specific line
192
+ if target_line
193
+ matching_action = todo.actions.find { |a| a.line == target_line }
194
+ return [todo.projects, NA::Actions.new([matching_action])] if matching_action
195
+
196
+ NA.notify("#{NA.theme[:error]}No action found at line #{target_line}", exit_code: 1)
197
+ return [todo.projects, NA::Actions.new]
198
+
199
+ end
200
+
201
+ options = todo.actions.map { |action| "#{action.file} : #{action.action}" }
191
202
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
192
203
 
193
204
  unless res&.length&.positive?
@@ -197,9 +208,14 @@ module NA
197
208
 
198
209
  selected = NA::Actions.new
199
210
  res.each do |result|
200
- idx = result.match(/^(\d+)(?= % )/)[1]
201
- action = todo.actions.select { |a| a.line == idx.to_i }.first
202
- selected.push(action)
211
+ # Extract file:line from result (e.g., "./todo.taskpaper:21 : action text")
212
+ match = result.match(/^(.+?):(\d+) : /)
213
+ next unless match
214
+
215
+ file_path = match[1]
216
+ line_num = match[2].to_i
217
+ action = todo.actions.select { |a| a.file_path == file_path && a.file_line == line_num }.first
218
+ selected.push(action) if action
203
219
  end
204
220
  [todo.projects, selected]
205
221
  end
@@ -311,7 +327,25 @@ module NA
311
327
  move: nil,
312
328
  remove_tag: [],
313
329
  replace: nil,
314
- tagged: nil)
330
+ tagged: nil,
331
+ started_at: nil,
332
+ done_at: nil,
333
+ duration_seconds: nil)
334
+ # Coerce date/time inputs if passed as strings
335
+ begin
336
+ started_at = NA::Types.parse_date_begin(started_at) if started_at && !started_at.is_a?(Time)
337
+ rescue StandardError
338
+ # leave as-is
339
+ end
340
+ begin
341
+ done_at = NA::Types.parse_date_end(done_at) if done_at && !done_at.is_a?(Time)
342
+ rescue StandardError
343
+ # leave as-is
344
+ end
345
+ NA.notify("UPDATE parsed started_at=#{started_at.inspect} done_at=#{done_at.inspect} duration=#{duration_seconds.inspect}", debug: true)
346
+ # Expand target to absolute path to avoid path resolution issues
347
+ target = File.expand_path(target) unless Pathname.new(target).absolute?
348
+
315
349
  projects = find_projects(target)
316
350
  affected_actions = []
317
351
 
@@ -336,13 +370,24 @@ module NA
336
370
  contents = target.read_file.split("\n")
337
371
 
338
372
  if add.is_a?(Action)
339
- add_tag ||= []
340
- add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
373
+ # NOTE: Edit is handled in the update command before calling update_action
374
+ # So we don't need to handle it here - the action is already edited
341
375
 
342
- # Remove the original action and its notes
343
- action_line = add.line
376
+ add_tag ||= []
377
+ NA.notify("PROCESS before add.process started_at=#{started_at.inspect} done_at=#{done_at.inspect}", debug: true)
378
+ add.process(priority: priority,
379
+ finish: finish,
380
+ add_tag: add_tag,
381
+ remove_tag: remove_tag,
382
+ started_at: started_at,
383
+ done_at: done_at,
384
+ duration_seconds: duration_seconds)
385
+ NA.notify("PROCESS after add.process action=\"#{add.action}\"", debug: true)
386
+
387
+ # Remove the original action and its notes if this is an existing action
388
+ action_line = add.file_line
344
389
  note_lines = add.note.is_a?(Array) ? add.note.count : 0
345
- contents.slice!(action_line, note_lines + 1)
390
+ contents.slice!(action_line, note_lines + 1) if action_line.is_a?(Integer)
346
391
 
347
392
  # Prepare updated note
348
393
  note = note.to_s.split("\n") unless note.is_a?(Array)
@@ -362,59 +407,94 @@ module NA
362
407
  # Format note for insertion
363
408
  note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"
364
409
 
365
- # Insert at correct location: if moving, insert at start/end of target project
366
- if move && target_proj
367
- insert_line = if append
368
- # End of project
369
- target_proj.last_line + 1
370
- else
371
- # Start of project (after project header)
372
- target_proj.line + 1
373
- end
374
- contents.insert(insert_line, "#{indent}\t- #{add.action}#{note_str}")
410
+ # If delete was requested in this direct update path, do not re-insert
411
+ if delete
412
+ affected_actions << { action: add, desc: 'deleted' }
375
413
  else
376
- # Not moving, update in-place
377
- contents.insert(action_line, "#{indent}\t- #{add.action}#{note_str}")
378
- end
414
+ # Insert at correct location
415
+ if target_proj
416
+ insert_line = if append
417
+ # End of project
418
+ target_proj.last_line + 1
419
+ else
420
+ # Start of project (after project header)
421
+ target_proj.line + 1
422
+ end
423
+ # Ensure @started tag persists if provided
424
+ final_action = add.action.dup
425
+ if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
426
+ final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
427
+ final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
428
+ end
429
+ NA.notify("INSERT at #{insert_line} final_action=\"#{final_action}\"", debug: true)
430
+ contents.insert(insert_line, "#{indent}\t- #{final_action}#{note_str}")
431
+ else
432
+ # Fallback: append to end of file
433
+ final_action = add.action.dup
434
+ if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
435
+ final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
436
+ final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
437
+ end
438
+ NA.notify("APPEND final_action=\"#{final_action}\"", debug: true)
439
+ contents << "#{indent}\t- #{final_action}#{note_str}"
440
+ end
379
441
 
380
- notify(add.pretty)
442
+ notify(add.pretty)
443
+ end
381
444
 
382
445
  # Track affected action and description
383
- changes = ['updated']
384
- changes << 'finished' if finish
385
- changes << "priority=#{priority}" if priority.to_i.positive?
386
- changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
387
- changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
388
- changes << 'note updated' unless note.nil? || note.empty?
389
- changes << "moved to #{target_proj.project}" if move && target_proj
390
- affected_actions << { action: add, desc: changes.join(', ') }
446
+ unless delete
447
+ changes = ['updated']
448
+ changes << 'finished' if finish
449
+ changes << "priority=#{priority}" if priority.to_i.positive?
450
+ changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
451
+ changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
452
+ changes << 'note updated' unless note.nil? || note.empty?
453
+ changes << "moved to #{target_proj.project}" if move && target_proj
454
+ affected_actions << { action: add, desc: changes.join(', ') }
455
+ end
391
456
  else
457
+ # Check if search is actually target_line
458
+ target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
392
459
  _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
393
- search_note: search_note)
460
+ search_note: search_note, target_line: target_line)
394
461
 
395
462
  return if actions.nil?
396
463
 
397
- actions.sort_by(&:line).reverse.each do |action|
398
- contents.slice!(action.line, action.note.count + 1)
464
+ # Handle edit (single or multi-action)
465
+ if edit
466
+ editor_content = Editor.format_multi_action_input(actions)
467
+ edited_content = Editor.fork_editor(editor_content)
468
+ edited_actions = Editor.parse_multi_action_output(edited_content)
469
+
470
+ # Map edited content back to actions
471
+ actions.each do |action|
472
+ # Use file_path:file_line as the key
473
+ key = "#{action.file_path}:#{action.file_line}"
474
+ action.action, action.note = edited_actions[key] if edited_actions[key]
475
+ end
476
+ end
477
+
478
+ actions.sort_by(&:file_line).reverse.each do |action|
479
+ contents.slice!(action.file_line, action.note.count + 1)
399
480
  if delete
400
481
  # Track deletion before skipping re-insert
401
482
  affected_actions << { action: action, desc: 'deleted' }
402
483
  next
403
484
  end
404
485
 
405
- projects = shift_index_after(projects, action.line, action.note.count + 1)
406
-
407
- if edit
408
- editor_content = "#{action.action}\n#{action.note.join("\n")}"
409
- new_action, new_note = Editor.format_input(Editor.fork_editor(editor_content))
410
- action.action = new_action
411
- action.note = new_note
412
- end
486
+ projects = shift_index_after(projects, action.file_line, action.note.count + 1)
413
487
 
414
488
  # If replace is defined, use search to search and replace text in action
415
489
  action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace
416
490
 
417
- action.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
491
+ action.process(priority: priority,
492
+ finish: finish,
493
+ add_tag: add_tag,
494
+ remove_tag: remove_tag,
495
+ started_at: started_at,
496
+ done_at: done_at,
497
+ duration_seconds: duration_seconds)
418
498
 
419
499
  target_proj = if target_proj
420
500
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
@@ -501,7 +581,7 @@ module NA
501
581
  # @param finish [Boolean] Mark as finished
502
582
  # @param append [Boolean] Append to project
503
583
  # @return [void]
504
- def add_action(file, project, action, note = [], priority: 0, finish: false, append: false)
584
+ def add_action(file, project, action, note = [], priority: 0, finish: false, append: false, started_at: nil, done_at: nil, duration_seconds: nil)
505
585
  parent = project.split(%r{[:/]})
506
586
 
507
587
  if NA.global_file
@@ -514,8 +594,16 @@ module NA
514
594
 
515
595
  action = Action.new(file, project, parent, action, nil, note)
516
596
 
517
- update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish,
518
- append: append)
597
+ update_action(file, nil,
598
+ add: action,
599
+ project: project,
600
+ add_tag: add_tag,
601
+ priority: priority,
602
+ finish: finish,
603
+ append: append,
604
+ started_at: started_at,
605
+ done_at: done_at,
606
+ duration_seconds: duration_seconds)
519
607
  end
520
608
 
521
609
  # Build a nested hash representing project hierarchy from actions
data/lib/na/string.rb CHANGED
@@ -149,13 +149,17 @@ class ::String
149
149
  # @param color [String] The highlight color template
150
150
  # @param last_color [String] Color to restore after highlight
151
151
  def highlight_search(regexes, color: NA.theme[:search_highlight], last_color: NA.theme[:action])
152
+ # Skip if string already contains ANSI codes - applying regex to colored text
153
+ # will break escape sequences (e.g., searching for "3" will match "3" in "38;2;236;204;135m")
154
+ return self if include?("\e")
155
+
156
+ # Original simple approach for strings without ANSI codes
152
157
  string = dup
153
158
  color = NA::Color.template(color.dup)
154
159
  regexes.each do |rx|
155
160
  next if rx.nil?
156
161
 
157
162
  rx = Regexp.new(rx, Regexp::IGNORECASE) if rx.is_a?(String)
158
-
159
163
  string.gsub!(rx) do
160
164
  m = Regexp.last_match
161
165
  last = m.pre_match.last_color
@@ -185,14 +189,14 @@ class ::String
185
189
  # @param indent [Integer] Number of spaces to indent each line
186
190
  # @return [String] Wrapped string
187
191
  def wrap(width, indent)
188
- return "\n#{self}" if width <= 80
192
+ return to_s if width.nil? || width <= 0
189
193
 
190
194
  output = []
191
195
  line = []
192
196
  length = 0
193
- gsub!(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
197
+ text = gsub(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
194
198
 
195
- split(' ').each do |word|
199
+ text.split.each do |word|
196
200
  uncolored = NA::Color.uncolor(word)
197
201
  if (length + uncolored.length + 1) <= width
198
202
  line << word
@@ -306,7 +310,14 @@ class ::String
306
310
  m = Regexp.last_match
307
311
  t = m['tag']
308
312
  d = m['date']
309
- future = t =~ /^(done|complete)/ ? false : true
313
+ # Determine whether to bias toward future or past parsing
314
+ # Non-done tags usually bias to future, except explicit past phrases like "ago", "yesterday", or "last ..."
315
+ explicit_past = d =~ /(\bago\b|yesterday|\blast\b)/i
316
+ future = if t =~ /^(done|complete)/
317
+ false
318
+ else
319
+ explicit_past ? false : true
320
+ end
310
321
  parsed_date = d =~ iso_rx ? Time.parse(d) : d.chronify(guess: :begin, future: future)
311
322
  parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
312
323
  end
data/lib/na/theme.rb CHANGED
@@ -37,51 +37,61 @@ module NA
37
37
  # @example
38
38
  # NA::Theme.load_theme(template: { action: '{r}' })
39
39
  def load_theme(template: {})
40
- NA::Benchmark.measure('Theme.load_theme') do
41
- # Default colorization, can be overridden with full or partial template variable
42
- default_template = {
43
- parent: '{c}',
44
- bracket: '{dc}',
45
- parent_divider: '{xw}/',
46
- action: '{bg}',
47
- project: '{xbk}',
48
- tags: '{m}',
49
- value_parens: '{m}',
50
- values: '{c}',
51
- search_highlight: '{y}',
52
- note: '{dw}',
53
- dirname: '{xdw}',
54
- filename: '{xb}{#eccc87}',
55
- prompt: '{m}',
56
- success: '{bg}',
57
- error: '{b}{#b61d2a}',
58
- warning: '{by}',
59
- debug: '{dw}',
60
- templates: {
61
- output: '%filename%parents| %action',
62
- default: '%parent%action',
63
- single_file: '%parent%action',
64
- multi_file: '%filename%parent%action',
65
- no_file: '%parent%action'
66
- }
67
- }
40
+ if defined?(NA::Benchmark) && NA::Benchmark
41
+ NA::Benchmark.measure('Theme.load_theme') do
42
+ load_theme_internal(template: template)
43
+ end
44
+ else
45
+ load_theme_internal(template: template)
46
+ end
47
+ end
68
48
 
69
- # Load custom theme
70
- theme_file = NA.database_path(file: 'theme.yaml')
71
- theme = if File.exist?(theme_file)
72
- YAML.load(File.read(theme_file)) || {}
73
- else
74
- {}
75
- end
76
- theme = default_template.deep_merge(theme)
49
+ def load_theme_internal(template: {})
50
+ # Default colorization, can be overridden with full or partial template variable
51
+ default_template = {
52
+ parent: '{c}',
53
+ bracket: '{dc}',
54
+ parent_divider: '{xw}/',
55
+ action: '{bg}',
56
+ project: '{xbk}',
57
+ tags: '{m}',
58
+ value_parens: '{m}',
59
+ values: '{c}',
60
+ duration: '{y}',
61
+ search_highlight: '{y}',
62
+ note: '{dw}',
63
+ dirname: '{xdw}',
64
+ filename: '{xb}{#eccc87}',
65
+ line: '{dw}',
66
+ prompt: '{m}',
67
+ success: '{bg}',
68
+ error: '{b}{#b61d2a}',
69
+ warning: '{by}',
70
+ debug: '{dw}',
71
+ templates: {
72
+ output: '%filename%line%parents| %action',
73
+ default: '%parents %line %action',
74
+ single_file: '%parents %line %action',
75
+ multi_file: '%filename%line%parents %action',
76
+ no_file: '%parents %line %action'
77
+ }
78
+ }
77
79
 
78
- File.open(theme_file, 'w') do |f|
79
- f.puts template_help.comment
80
- f.puts YAML.dump(theme)
81
- end
80
+ # Load custom theme
81
+ theme_file = NA.database_path(file: 'theme.yaml')
82
+ theme = if File.exist?(theme_file)
83
+ YAML.load(File.read(theme_file)) || {}
84
+ else
85
+ {}
86
+ end
87
+ theme = default_template.deep_merge(theme)
82
88
 
83
- theme.merge(template)
89
+ File.open(theme_file, 'w') do |f|
90
+ f.puts template_help.comment
91
+ f.puts YAML.dump(theme)
84
92
  end
93
+
94
+ theme.merge(template)
85
95
  end
86
96
  end
87
97
  end
data/lib/na/types.rb ADDED
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'na/string'
4
+
5
+ module NA
6
+ # Custom types for GLI
7
+ # Provides natural language date/time and duration parsing
8
+ # Uses chronify gem for parsing
9
+ module Types
10
+ module_function
11
+
12
+ # Normalize shorthand relative durations to phrases Chronic can parse.
13
+ # Examples:
14
+ # - "30m ago" => "30 minutes ago"
15
+ # - "-30m" => "30 minutes ago"
16
+ # - "2h30m" => "2 hours 30 minutes ago" (when default_past)
17
+ # - "2h 30m ago" => "2 hours 30 minutes ago"
18
+ # - "2:30 ago" => "2 hours 30 minutes ago"
19
+ # - "-2:30" => "2 hours 30 minutes ago"
20
+ # Accepts d,h,m units; hours:minutes pattern; optional leading '-'; optional 'ago'.
21
+ # @param value [String] the duration string to normalize
22
+ # @param default_past [Boolean] whether to default to past tense
23
+ # @return [String] the normalized duration string
24
+ def normalize_relative_duration(value, default_past: false)
25
+ return value if value.nil?
26
+
27
+ s = value.to_s.strip
28
+ return s if s.empty?
29
+
30
+ has_ago = s =~ /\bago\b/i
31
+ negative = s.start_with?('-')
32
+
33
+ text = s.sub(/^[-+]/, '')
34
+
35
+ # hours:minutes pattern (e.g., 2:30, 02:30)
36
+ if (m = text.match(/^(\d{1,2}):(\d{1,2})(?:\s*ago)?$/i))
37
+ hours = m[1].to_i
38
+ minutes = m[2].to_i
39
+ parts = []
40
+ parts << "#{hours} hours" if hours.positive?
41
+ parts << "#{minutes} minutes" if minutes.positive?
42
+ return "#{parts.join(' ')} ago"
43
+ end
44
+
45
+ # Compound d/h/m (order independent, allow spaces): e.g., 1d2h30m, 2h 30m, 30m
46
+ days = hours = minutes = 0
47
+ found = false
48
+ if (dm = text.match(/(?:(\d+)\s*d)/i))
49
+ days = dm[1].to_i
50
+ found = true
51
+ end
52
+ if (hm = text.match(/(?:(\d+)\s*h)/i))
53
+ hours = hm[1].to_i
54
+ found = true
55
+ end
56
+ if (mm = text.match(/(?:(\d+)\s*m)/i))
57
+ minutes = mm[1].to_i
58
+ found = true
59
+ end
60
+
61
+ if found
62
+ parts = []
63
+ parts << "#{days} days" if days.positive?
64
+ parts << "#{hours} hours" if hours.positive?
65
+ parts << "#{minutes} minutes" if minutes.positive?
66
+ # Determine if we should make it past-tense
67
+ return "#{parts.join(' ')} ago" if negative || has_ago || default_past
68
+
69
+ return parts.join(' ')
70
+
71
+ end
72
+
73
+ # Fall through: not a shorthand we handle
74
+ s
75
+ end
76
+
77
+ # Parse a natural-language/iso date string for a start time
78
+ # @param value [String] the date string to parse
79
+ # @return [Time] the parsed date, or nil if parsing fails
80
+ def parse_date_begin(value)
81
+ return nil if value.nil? || value.to_s.strip.empty?
82
+
83
+ # Prefer explicit ISO first (only if the value looks ISO-like)
84
+ iso_rx = /\A\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\z/
85
+ if value.to_s.strip =~ iso_rx
86
+ begin
87
+ return Time.parse(value)
88
+ rescue StandardError
89
+ # fall through to chronify
90
+ end
91
+ end
92
+
93
+ # Fallback to chronify with guess begin
94
+ begin
95
+ # Normalize shorthand (e.g., 2h30m, -2:30, 30m ago)
96
+ txt = normalize_relative_duration(value.to_s, default_past: true)
97
+ # Bias to past for expressions like "ago", "yesterday", or "last ..."
98
+ future = txt !~ /(\bago\b|yesterday|\blast\b)/i
99
+ result = txt.chronify(guess: :begin, future: future)
100
+ NA.notify("Parsed '#{value}' as #{result}", debug: true) if result
101
+ result
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ end
106
+
107
+ # Parse a natural-language/iso date string for an end time
108
+ # @param value [String] the date string to parse
109
+ # @return [Time] the parsed date, or nil if parsing fails
110
+ def parse_date_end(value)
111
+ return nil if value.nil? || value.to_s.strip.empty?
112
+
113
+ # Prefer explicit ISO first (only if the value looks ISO-like)
114
+ iso_rx = /\A\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\z/
115
+ if value.to_s.strip =~ iso_rx
116
+ begin
117
+ return Time.parse(value)
118
+ rescue StandardError
119
+ # fall through to chronify
120
+ end
121
+ end
122
+
123
+ # Fallback to chronify with guess end
124
+ value.to_s.chronify(guess: :end, future: false)
125
+ end
126
+
127
+ # Convert duration expressions to seconds
128
+ # Supports: "90" (minutes), "45m", "2h", "1d2h30m", with optional leading '-' or trailing 'ago'
129
+ # Also supports "2:30", "2:30 ago", and word forms like "2 hours 30 minutes (ago)"
130
+ # @param value [String] the duration string to parse
131
+ # @return [Integer] the duration in seconds, or nil if parsing fails
132
+ def parse_duration_seconds(value)
133
+ return nil if value.nil?
134
+
135
+ s = value.to_s.strip
136
+ return nil if s.empty?
137
+
138
+ # Strip leading sign and optional 'ago'
139
+ s = s.sub(/^[-+]/, '')
140
+ s = s.sub(/\bago\b/i, '').strip
141
+
142
+ # H:MM pattern
143
+ m = s.match(/^(\d{1,2}):(\d{1,2})$/)
144
+ if m
145
+ hours = m[1].to_i
146
+ minutes = m[2].to_i
147
+ return (hours * 3600) + (minutes * 60)
148
+ end
149
+
150
+ # d/h/m compact with letters, order independent (e.g., 1d2h30m, 2h 30m, 30m)
151
+ m = s.match(/^(?:(?<day>\d+)\s*d)?\s*(?:(?<hour>\d+)\s*h)?\s*(?:(?<min>\d+)\s*m)?$/i)
152
+ if m && !m[0].strip.empty? && (m['day'] || m['hour'] || m['min'])
153
+ return [[m['day'], 86_400], [m['hour'], 3600], [m['min'], 60]].map { |q, mult| q ? q.to_i * mult : 0 }.sum
154
+ end
155
+
156
+ # Word forms: e.g., "2 hours 30 minutes", "1 day 2 hours", etc.
157
+ days = 0
158
+ hours = 0
159
+ minutes = 0
160
+ found_word = false
161
+ if (dm = s.match(/(\d+)\s*(?:day|days)\b/i))
162
+ days = dm[1].to_i
163
+ found_word = true
164
+ end
165
+ if (hm = s.match(/(\d+)\s*(?:hour|hours|hr|hrs)\b/i))
166
+ hours = hm[1].to_i
167
+ found_word = true
168
+ end
169
+ if (mm = s.match(/(\d+)\s*(?:minute|minutes|min|mins)\b/i))
170
+ minutes = mm[1].to_i
171
+ found_word = true
172
+ end
173
+ return (days * 86_400) + (hours * 3600) + (minutes * 60) if found_word
174
+
175
+ # Plain number => minutes
176
+ return s.to_i * 60 if s =~ /^\d+$/
177
+
178
+ # Last resort: try chronify two points and take delta
179
+ begin
180
+ start = Time.now
181
+ finish = s.chronify(context: 'now', guess: :end, future: false)
182
+ return (finish - start).abs.to_i if finish
183
+ rescue StandardError
184
+ # ignore
185
+ end
186
+
187
+ nil
188
+ end
189
+ end
190
+ end
data/lib/na/version.rb CHANGED
@@ -5,5 +5,5 @@
5
5
  module Na
6
6
  ##
7
7
  # Current version of the na gem.
8
- VERSION = '1.2.85'
8
+ VERSION = '1.2.87'
9
9
  end
data/lib/na.rb CHANGED
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'na/benchmark'
3
+ require 'na/benchmark' if ENV['NA_BENCHMARK']
4
+ # Define a dummy Benchmark if not available for tests
5
+ unless defined?(NA::Benchmark)
6
+ module NA
7
+ module Benchmark
8
+ def self.measure(_label)
9
+ yield
10
+ end
11
+ end
12
+ end
13
+ end
4
14
  require 'na/version'
5
15
  require 'na/pager'
6
16
  require 'time'
@@ -21,6 +31,7 @@ require 'na/todo'
21
31
  require 'na/actions'
22
32
  require 'na/project'
23
33
  require 'na/action'
34
+ require 'na/types'
24
35
  require 'na/editor'
25
36
  require 'na/next_action'
26
37
  require 'na/prompt'