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/bin/commands/tagged.rb
CHANGED
|
@@ -2,124 +2,152 @@
|
|
|
2
2
|
|
|
3
3
|
class App
|
|
4
4
|
extend GLI::App
|
|
5
|
-
desc
|
|
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
|
|
12
|
+
arg_name 'TAG[=VALUE]'
|
|
13
13
|
command %i[tagged] do |c|
|
|
14
|
-
c.example
|
|
15
|
-
c.example 'na tagged -d 3 "feature, idea"', desc:
|
|
16
|
-
c.example 'na tagged --or "feature, idea"', desc:
|
|
17
|
-
c.example 'na tagged "priority>=4"', desc:
|
|
18
|
-
c.example 'na tagged "due<in 2 days"', desc:
|
|
19
|
-
|
|
20
|
-
c.desc
|
|
21
|
-
c.arg_name
|
|
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
|
|
26
|
-
c.arg_name
|
|
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
|
|
29
|
+
c.desc 'Include notes in output'
|
|
30
30
|
c.switch %i[notes], negatable: true, default_value: false
|
|
31
31
|
|
|
32
|
-
c.desc
|
|
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
|
|
36
|
-
c.arg_name
|
|
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
|
|
40
|
-
c.arg_name
|
|
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
|
|
58
|
+
c.desc 'Include notes in search'
|
|
44
59
|
c.switch %i[search_notes], negatable: true, default_value: true
|
|
45
60
|
|
|
46
|
-
c.desc
|
|
61
|
+
c.desc 'Search query is regular expression'
|
|
47
62
|
c.switch %i[regex], negatable: false
|
|
48
63
|
|
|
49
|
-
c.desc
|
|
64
|
+
c.desc 'Search query is exact text match (not tokens)'
|
|
50
65
|
c.switch %i[exact], negatable: false
|
|
51
66
|
|
|
52
|
-
c.desc
|
|
67
|
+
c.desc 'Include @done actions'
|
|
53
68
|
c.switch %i[done]
|
|
54
69
|
|
|
55
|
-
c.desc
|
|
70
|
+
c.desc 'Show actions not matching tags'
|
|
56
71
|
c.switch %i[v invert], negatable: false
|
|
57
72
|
|
|
58
|
-
c.desc
|
|
59
|
-
c.arg_name
|
|
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
|
|
77
|
+
c.desc 'Output actions nested by file'
|
|
63
78
|
c.switch %i[nest], negatable: false
|
|
64
79
|
|
|
65
|
-
c.desc
|
|
80
|
+
c.desc 'No filename in output'
|
|
66
81
|
c.switch %i[no_file], negatable: false
|
|
67
82
|
|
|
68
|
-
c.desc
|
|
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]/,
|
|
76
|
-
cmd = NA.command_line.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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
89
|
-
args.join(
|
|
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[
|
|
95
|
-
comp: m[
|
|
96
|
-
value: m[
|
|
97
|
-
required: all_req || (!m[
|
|
98
|
-
negate: !m[
|
|
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
|
-
|
|
105
|
-
|
|
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(
|
|
140
|
+
tokens = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
|
|
113
141
|
else
|
|
114
142
|
tokens = []
|
|
115
|
-
all_req = options[:search].join(
|
|
143
|
+
all_req = options[:search].join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
|
|
116
144
|
|
|
117
|
-
options[:search].join(
|
|
145
|
+
options[:search].join(' ').split(/ /).each do |arg|
|
|
118
146
|
m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
|
|
119
147
|
tokens.push({
|
|
120
|
-
token: m[
|
|
121
|
-
required: all_req || (!m[
|
|
122
|
-
negate: !m[
|
|
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[
|
|
136
|
-
required: all_req || (!m[
|
|
137
|
-
negate: !m[
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
data/bin/commands/update.rb
CHANGED
|
@@ -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',
|
|
@@ -164,13 +175,33 @@ class App
|
|
|
164
175
|
|
|
165
176
|
options[:exact] = true unless options[:replace].nil?
|
|
166
177
|
|
|
178
|
+
# Check for PATH:LINE format in arguments
|
|
179
|
+
target_file = nil
|
|
180
|
+
target_line = nil
|
|
181
|
+
if args.count.positive?
|
|
182
|
+
pathline_match = args.join(' ').strip.match(/^(.+):(\d+)$/)
|
|
183
|
+
if pathline_match
|
|
184
|
+
target_file = pathline_match[1]
|
|
185
|
+
target_line = pathline_match[2].to_i
|
|
186
|
+
|
|
187
|
+
# Verify file exists
|
|
188
|
+
if File.exist?(target_file)
|
|
189
|
+
options[:file] = target_file
|
|
190
|
+
options[:target_line] = target_line
|
|
191
|
+
action = nil # Skip search processing
|
|
192
|
+
else
|
|
193
|
+
NA.notify("#{NA.theme[:error]}File not found: #{target_file}", exit_code: 1)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
167
198
|
if args.count.positive?
|
|
168
199
|
action = args.join(' ').strip
|
|
169
200
|
else
|
|
170
201
|
action = nil
|
|
171
202
|
end
|
|
172
203
|
tokens = nil
|
|
173
|
-
if action && !action.empty?
|
|
204
|
+
if action && !action.empty? && !target_line
|
|
174
205
|
if options[:exact]
|
|
175
206
|
tokens = action
|
|
176
207
|
elsif options[:regex]
|
|
@@ -218,10 +249,11 @@ class App
|
|
|
218
249
|
done: options[:done]
|
|
219
250
|
})
|
|
220
251
|
todo.actions.each do |action_obj|
|
|
221
|
-
# Format: filename:
|
|
222
|
-
display
|
|
252
|
+
# Format: filename:LINENUM:parent > action
|
|
253
|
+
# Include line number in display for unique matching
|
|
254
|
+
display = "#{File.basename(action_obj.file_path)}:#{action_obj.file_line}:#{action_obj.parent.join('>')} | #{action_obj.action}"
|
|
223
255
|
candidate_actions << display
|
|
224
|
-
targets_for_selection << { file: action_obj.
|
|
256
|
+
targets_for_selection << { file: action_obj.file_path, line: action_obj.file_line, action: action_obj }
|
|
225
257
|
end
|
|
226
258
|
end
|
|
227
259
|
|
|
@@ -237,9 +269,24 @@ class App
|
|
|
237
269
|
if selector
|
|
238
270
|
require 'open3'
|
|
239
271
|
input = candidate_actions.join("\n")
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
272
|
+
|
|
273
|
+
# Use popen3 to properly handle stdin for fzf
|
|
274
|
+
Open3.popen3(selector) do |stdin, stdout, stderr, wait_thr|
|
|
275
|
+
stdin.write(input)
|
|
276
|
+
stdin.close
|
|
277
|
+
|
|
278
|
+
output = stdout.read
|
|
279
|
+
|
|
280
|
+
selected = output.split("\n").map(&:strip).reject(&:empty?)
|
|
281
|
+
|
|
282
|
+
# Track which candidates have been matched to avoid duplicates
|
|
283
|
+
selected_indices = []
|
|
284
|
+
candidate_actions.each_index do |i|
|
|
285
|
+
if selected.include?(candidate_actions[i])
|
|
286
|
+
selected_indices << i unless selected_indices.include?(i)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
243
290
|
else
|
|
244
291
|
# Fallback: select all or prompt for search string
|
|
245
292
|
selected_indices = (0...candidate_actions.size).to_a
|
|
@@ -263,7 +310,10 @@ class App
|
|
|
263
310
|
options[:archive],
|
|
264
311
|
options[:restore],
|
|
265
312
|
options[:delete],
|
|
266
|
-
options[:edit]
|
|
313
|
+
options[:edit],
|
|
314
|
+
options[:started],
|
|
315
|
+
(options[:end] || options[:finished]),
|
|
316
|
+
options[:duration]
|
|
267
317
|
].any?
|
|
268
318
|
unless actionable
|
|
269
319
|
# Interactive menu for actions
|
|
@@ -325,13 +375,23 @@ class App
|
|
|
325
375
|
options[:delete] = true
|
|
326
376
|
when :finish
|
|
327
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
|
|
328
393
|
when :edit
|
|
329
|
-
#
|
|
330
|
-
edit_action = targets_for_selection[selected_indices.first][:action]
|
|
331
|
-
editor_content = "#{edit_action.action}\n#{edit_action.note.join("\n")}"
|
|
332
|
-
new_action, new_note = NA::Editor.format_input(NA::Editor.fork_editor(editor_content))
|
|
333
|
-
edit_action.action = new_action
|
|
334
|
-
edit_action.note = new_note
|
|
394
|
+
# Just set the flag - multi-action editor will handle it below
|
|
335
395
|
options[:edit] = true
|
|
336
396
|
when :priority
|
|
337
397
|
options[:priority] = param_value
|
|
@@ -384,7 +444,17 @@ class App
|
|
|
384
444
|
end
|
|
385
445
|
end
|
|
386
446
|
did_direct_update = false
|
|
447
|
+
|
|
448
|
+
# Group selected actions by file for batch processing
|
|
449
|
+
actions_by_file = {}
|
|
387
450
|
selected_indices.each do |idx|
|
|
451
|
+
file = targets_for_selection[idx][:file]
|
|
452
|
+
actions_by_file[file] ||= []
|
|
453
|
+
actions_by_file[file] << targets_for_selection[idx][:action]
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Process each file's actions
|
|
457
|
+
actions_by_file.each do |file, action_list|
|
|
388
458
|
# Rebuild all derived variables from options after menu-driven assignment
|
|
389
459
|
add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
|
|
390
460
|
remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
|
|
@@ -399,29 +469,64 @@ class App
|
|
|
399
469
|
if options[:note] && defined?(param_value) && param_value
|
|
400
470
|
note_val = [param_value]
|
|
401
471
|
end
|
|
402
|
-
|
|
403
|
-
#
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
472
|
+
|
|
473
|
+
# Handle edit with multiple actions
|
|
474
|
+
if options[:edit]
|
|
475
|
+
# Open editor once with all actions for this file
|
|
476
|
+
editor_content = NA::Editor.format_multi_action_input(action_list)
|
|
477
|
+
edited_content = NA::Editor.fork_editor(editor_content)
|
|
478
|
+
edited_actions = NA::Editor.parse_multi_action_output(edited_content)
|
|
479
|
+
|
|
480
|
+
# If markers were removed but we have the same number of actions, match by position
|
|
481
|
+
if edited_actions.empty? && action_list.size > 0
|
|
482
|
+
# Parse content line by line, skipping comments and blanks
|
|
483
|
+
non_comment_lines = edited_content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
|
|
484
|
+
|
|
485
|
+
# Match each non-comment line to an action by position
|
|
486
|
+
action_list.each_with_index do |action_obj, idx|
|
|
487
|
+
if non_comment_lines[idx]
|
|
488
|
+
# Split into action and notes
|
|
489
|
+
lines = non_comment_lines[idx..-1]
|
|
490
|
+
action_text = lines[0]
|
|
491
|
+
note_lines = lines[1..-1] || []
|
|
492
|
+
|
|
493
|
+
# Store by file:line key
|
|
494
|
+
key = "#{action_obj.file_path}:#{action_obj.file_line}"
|
|
495
|
+
edited_actions[key] = [action_text, note_lines]
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Update each action with edited content
|
|
501
|
+
action_list.each do |action_obj|
|
|
502
|
+
key = "#{action_obj.file_path}:#{action_obj.file_line}"
|
|
503
|
+
if edited_actions[key]
|
|
504
|
+
action_obj.action, action_obj.note = edited_actions[key]
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
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|
|
|
511
|
+
NA.update_action(file, nil,
|
|
512
|
+
add: action_obj,
|
|
513
|
+
add_tag: add_tags,
|
|
514
|
+
all: true,
|
|
515
|
+
append: append,
|
|
516
|
+
delete: options[:delete],
|
|
517
|
+
done: options[:done],
|
|
518
|
+
edit: false, # Already handled above
|
|
519
|
+
finish: options[:finish],
|
|
520
|
+
move: target_proj,
|
|
521
|
+
note: note_val,
|
|
522
|
+
overwrite: options[:overwrite],
|
|
523
|
+
priority: priority,
|
|
524
|
+
project: options[:project],
|
|
525
|
+
remove_tag: remove_tags,
|
|
526
|
+
replace: options[:replace],
|
|
527
|
+
search_note: options[:search_notes],
|
|
528
|
+
tagged: nil)
|
|
529
|
+
end
|
|
425
530
|
did_direct_update = true
|
|
426
531
|
end
|
|
427
532
|
if did_direct_update
|
|
@@ -539,8 +644,15 @@ class App
|
|
|
539
644
|
|
|
540
645
|
NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
|
|
541
646
|
|
|
647
|
+
# Handle target_line if provided (from PATH:LINE format)
|
|
648
|
+
search_tokens = if options[:target_line]
|
|
649
|
+
{ target_line: options[:target_line] }
|
|
650
|
+
else
|
|
651
|
+
tokens
|
|
652
|
+
end
|
|
653
|
+
|
|
542
654
|
targets.each do |target|
|
|
543
|
-
NA.update_action(target,
|
|
655
|
+
NA.update_action(target, search_tokens,
|
|
544
656
|
add_tag: add_tags,
|
|
545
657
|
all: options[:all],
|
|
546
658
|
append: append,
|
|
@@ -556,7 +668,10 @@ class App
|
|
|
556
668
|
remove_tag: remove_tags,
|
|
557
669
|
replace: options[:replace],
|
|
558
670
|
search_note: options[:search_notes],
|
|
559
|
-
tagged: tags
|
|
671
|
+
tagged: tags,
|
|
672
|
+
started_at: options[:started],
|
|
673
|
+
done_at: (options[:end] || options[:finished]),
|
|
674
|
+
duration_seconds: options[:duration])
|
|
560
675
|
end
|
|
561
676
|
end
|
|
562
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?
|