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.
- checksums.yaml +4 -4
- data/.cursor/commands/changelog.md +4 -0
- data/.cursor/commands/priority35m36m335m32m.md +0 -0
- data/.rubocop_todo.yml +38 -33
- data/CHANGELOG.md +61 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +45 -1
- data/README.md +66 -2
- data/Rakefile +78 -78
- data/bin/commands/add.rb +31 -1
- data/bin/commands/changes.rb +1 -0
- data/bin/commands/complete.rb +11 -0
- data/bin/commands/find.rb +9 -1
- data/bin/commands/next.rb +35 -2
- data/bin/commands/tagged.rb +91 -58
- data/bin/commands/update.rb +154 -39
- data/bin/na +6 -0
- data/lib/na/action.rb +90 -17
- data/lib/na/actions.rb +136 -6
- data/lib/na/editor.rb +80 -1
- data/lib/na/next_action.rb +136 -48
- data/lib/na/string.rb +16 -5
- data/lib/na/theme.rb +51 -41
- data/lib/na/types.rb +190 -0
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +12 -1
- data/src/_README.md +44 -1
- metadata +4 -1
data/lib/na/next_action.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
#
|
|
366
|
-
if
|
|
367
|
-
|
|
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
|
-
#
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
442
|
+
notify(add.pretty)
|
|
443
|
+
end
|
|
381
444
|
|
|
382
445
|
# Track affected action and description
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
518
|
-
|
|
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
|
|
192
|
+
return to_s if width.nil? || width <= 0
|
|
189
193
|
|
|
190
194
|
output = []
|
|
191
195
|
line = []
|
|
192
196
|
length = 0
|
|
193
|
-
gsub
|
|
197
|
+
text = gsub(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
|
|
194
198
|
|
|
195
|
-
split
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
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'
|