na 1.2.81 → 1.2.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4dc73a85eb981ab0057e442baca77e7711423d6d613e2a2325c4f010edb9d9a
4
- data.tar.gz: 10c305c69642775ec7ad174a5e443738a62b13620a6c1ff5c24429253ba612b3
3
+ metadata.gz: 742c746b884aa5ac75841f771fb9f13c95ead1a161d340ca0ea362c52b584914
4
+ data.tar.gz: 9b62bb218a75b19767dc7d2ebc59b1a49d2770df1ee44c28077cc077add2e1a4
5
5
  SHA512:
6
- metadata.gz: 527ba9b2d9e437542948a258266f931eca7ee2c273120e5da3dd070e76dd6c459f83b38ed23164d78927b26704af2b52c4eff574c5b8e089a544039d0efb4cdc
7
- data.tar.gz: '04324826716a3515782f38148350f965d9e9779f9d1802d2e2b454c0fb599af1ebbbbf75606b0383093229014c408cbb875356d0b825ebd47155c5f587cda3e4'
6
+ metadata.gz: 4a57ecae0c67beb917c7b1f9986acbdd8f8cd4315c50cef6800da7dc3707a0b19919d2aff2496dd786dd7373fce45ec361feb2b36e768a04f0653545196d08f0
7
+ data.tar.gz: f4c5bf1a7d6169d8fd77eb94816469e196c626357e0c73ca25a7084e9e2e94480036c739f4880cbd98ffddae351e872ca667c31e54901473ec0bc19c92edad0a
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2025-10-25 12:09:08 UTC using RuboCop version 1.75.7.
3
+ # on 2025-10-25 14:39:51 UTC using RuboCop version 1.75.7.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -29,33 +29,33 @@ Lint/UnusedMethodArgument:
29
29
  # Offense count: 35
30
30
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
31
31
  Metrics/AbcSize:
32
- Max: 274
32
+ Max: 232
33
33
 
34
34
  # Offense count: 10
35
35
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
36
36
  # AllowedMethods: refine
37
37
  Metrics/BlockLength:
38
- Max: 141
38
+ Max: 144
39
39
 
40
40
  # Offense count: 4
41
41
  # Configuration parameters: CountComments, CountAsOne.
42
42
  Metrics/ClassLength:
43
- Max: 788
43
+ Max: 762
44
44
 
45
45
  # Offense count: 22
46
46
  # Configuration parameters: AllowedMethods, AllowedPatterns.
47
47
  Metrics/CyclomaticComplexity:
48
- Max: 73
48
+ Max: 66
49
49
 
50
50
  # Offense count: 38
51
51
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
52
52
  Metrics/MethodLength:
53
- Max: 170
53
+ Max: 146
54
54
 
55
55
  # Offense count: 2
56
56
  # Configuration parameters: CountComments, CountAsOne.
57
57
  Metrics/ModuleLength:
58
- Max: 790
58
+ Max: 764
59
59
 
60
60
  # Offense count: 4
61
61
  # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
@@ -65,7 +65,7 @@ Metrics/ParameterLists:
65
65
  # Offense count: 21
66
66
  # Configuration parameters: AllowedMethods, AllowedPatterns.
67
67
  Metrics/PerceivedComplexity:
68
- Max: 87
68
+ Max: 76
69
69
 
70
70
  # Offense count: 3
71
71
  # This cop supports unsafe autocorrection (--autocorrect-all).
@@ -110,7 +110,7 @@ Style/YAMLFileRead:
110
110
  Exclude:
111
111
  - 'lib/na/theme.rb'
112
112
 
113
- # Offense count: 19
113
+ # Offense count: 18
114
114
  # This cop supports safe autocorrection (--autocorrect).
115
115
  # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
116
116
  # URISchemes: http, https
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ### 1.2.82
2
+
3
+ 2025-10-25 09:47
4
+
5
+ #### NEW
6
+
7
+ - Multi-select menu when using `na update`
8
+
1
9
  ### 1.2.81
2
10
 
3
11
  2025-10-25 07:32
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- na (1.2.81)
4
+ na (1.2.82)
5
5
  chronic (~> 0.10, >= 0.10.2)
6
6
  git (~> 3.0.0)
7
7
  gli (~> 2.21.0)
data/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is 1.2.81.
12
+ The current version of `na` is 1.2.82.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
@@ -76,7 +76,7 @@ SYNOPSIS
76
76
  na [global options] command [command options] [arguments...]
77
77
 
78
78
  VERSION
79
- 1.2.81
79
+ 1.2.82
80
80
 
81
81
  GLOBAL OPTIONS
82
82
  -a, --add - Add a next action (deprecated, for backwards compatibility)
@@ -478,46 +478,48 @@ To perform a string comparison, you can use `*=` (contains), `^=` (starts with),
478
478
 
479
479
  ```
480
480
  NAME
481
- next - Show next actions
481
+ tagged - Find actions matching a tag
482
482
 
483
483
  SYNOPSIS
484
484
 
485
- na [global options] next [command options] [QUERY]
485
+ na [global options] tagged [command options] TAG[=VALUE]
486
486
 
487
487
  DESCRIPTION
488
- Next actions are actions which contain the next action tag (default @na), do not contain @done, and are not in the Archive project. Arguments will target a todo file from history, whether it's in the current directory or not. Todo file queries can include path components separated by / or :, and may use wildcards (`*` to match any text, `?` to match a single character). Multiple queries allowed (separate arguments or separated by comma).
488
+ Finds actions with tags matching the arguments. An action is shown if it contains all of the tags listed. Add a + before a tag to make it required and others optional. You can specify values using TAG=VALUE pairs. Use <, >, and = for numeric comparisons, and *=, ^=, $=, or =~ (regex) for text comparisons. Date comparisons use natural language (`na tagged "due<=today"`) and are detected automatically.
489
489
 
490
490
  COMMAND OPTIONS
491
- --all - Show next actions from all known todo files (in any directory)
492
- -d, --depth=DEPTH - Recurse to depth (default: none)
491
+ -d, --depth=DEPTH - Recurse to depth (default: 1)
493
492
  --[no-]done - Include @done actions
494
493
  --exact - Search query is exact text match (not tokens)
495
- --file=TODO_FILE - Display matches from specific todo file ([relative] path) (default: none)
496
- --hidden - Include hidden directories while traversing
497
- --in, --todo=TODO - Display matches from a known todo file anywhere in history (short name) (may be used more than once, default: none)
494
+ --in=TODO_PATH - Show actions from a specific todo file in history. May use wildcards (* and ?) (default: none)
498
495
  --nest - Output actions nested by file
499
496
  --no_file - No filename in output
500
497
  --[no-]notes - Include notes in output
498
+ -o, --or - Combine tags with OR, displaying actions matching ANY of the tags
501
499
  --omnifocus - Output actions nested by file and project
502
- -p, --prio, --priority=PRIORITY - Match actions with priority, allows <>= comparison (may be used more than once, default: none)
503
500
  --proj, --project=PROJECT[/SUBPROJECT] - Show actions from a specific project (default: none)
504
501
  --regex - Search query is regular expression
505
502
  --save=TITLE - Save this search for future use (default: none)
506
503
  --search, --find, --grep=QUERY - Filter results using search terms (may be used more than once, default: none)
507
504
  --[no-]search_notes - Include notes in search (default: enabled)
508
- -t, --tag=TAG - Alternate tag to search for (default: none)
509
- --tagged=TAG - Match actions containing tag. Allows value comparisons (may be used more than once, default: none)
505
+ -v, --invert - Show actions not matching tags
510
506
 
511
507
  EXAMPLES
512
508
 
513
- # display the next actions from any todo files in the current directory
514
- na next
509
+ # Show all actions tagged @maybe
510
+ na tagged maybe
515
511
 
516
- # display the next actions from the current directory, traversing 3 levels deep
517
- na next -d 3
512
+ # Show all actions tagged @feature AND @idea, recurse 3 levels
513
+ na tagged -d 3 "feature, idea"
518
514
 
519
- # display next actions for a project you visited in the past
520
- na next marked
515
+ # Show all actions tagged @feature OR @idea
516
+ na tagged --or "feature, idea"
517
+
518
+ # Show actions with @priority(4) or @priority(5)
519
+ na tagged "priority>=4"
520
+
521
+ # Show actions with a due date coming up in the next 2 days
522
+ na tagged "due<in 2 days"
521
523
  ```
522
524
 
523
525
  ##### todos
@@ -553,6 +555,8 @@ If more than one file is matched, a menu will be presented, multiple selections
553
555
 
554
556
  Any time an update action is carried out, a backup of the file before modification will be made in the same directory with a `.` prepended and `.bak` appended (e.g. `marked.taskpaper` is copied to `.marked.taskpaper.bak`). Only one undo step is available, but if something goes wrong (and this feature is still experimental, so be wary), you can just copy the ".bak" file back to the original.
555
557
 
558
+ > **Note:** When using the `update` command, if you have [fzf](https://github.com/junegunn/fzf) installed, menus for selecting files or actions will support multi-select (tab to mark multiple, return to confirm). If [gum](https://github.com/charmbracelet/gum) is installed, multi-select is also supported (use j/k/x to navigate and mark). If neither is available, a simple prompt is used. This makes it easy to apply updates to multiple actions at once.
559
+
556
560
  ###### Marking a task as complete
557
561
 
558
562
  You can mark an action complete using `--finish`, which will add a dated @done tag to the action. You can also mark it @done and immediately move it to the Archive project using `--archive`.
@@ -104,6 +104,49 @@ class App
104
104
  c.switch %i[x exact], negatable: false
105
105
 
106
106
  c.action do |global_options, options, args|
107
+ # Ensure all variables used in update loop are declared
108
+ target_proj = if options[:move]
109
+ options[:move]
110
+ elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
111
+ NA.cwd
112
+ end
113
+
114
+ priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
115
+ remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').respond_to?(:wildcard_to_rx) ? t.wildcard_to_rx : t } : []
116
+ remove_tags << 'done' if options[:restore]
117
+
118
+ stdin_note = NA.respond_to?(:stdin) && NA.stdin ? NA.stdin.split("\n") : []
119
+ line_note = if options[:note] && $stdin.isatty
120
+ puts stdin_note unless stdin_note.nil?
121
+ if TTY::Which.exist?('gum')
122
+ args = ['--placeholder "Enter a note, CTRL-d to save"']
123
+ args << '--char-limit 0'
124
+ args << '--width $(tput cols)'
125
+ gum = TTY::Which.which('gum')
126
+ `#{gum} write #{args.join(' ')}`.strip.split("\n")
127
+ else
128
+ NA.notify("#{NA.theme[:prompt]}Enter a note, {bw}CTRL-d#{NA.theme[:prompt]} to end editing:#{NA.theme[:action]}")
129
+ reader.read_multiline
130
+ end
131
+ end
132
+ note = stdin_note.empty? ? [] : stdin_note
133
+ note.concat(line_note) unless line_note.nil? || line_note.empty?
134
+
135
+ append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
136
+ add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').respond_to?(:wildcard_to_rx) ? t.wildcard_to_rx : t } : []
137
+ # Build tags array from options[:tagged]
138
+ all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
139
+ tags = []
140
+ options[:tagged].join(',').split(/ *, */).each do |arg|
141
+ m = arg.match(/^(?<req>[+!-])?(?<tag>[^ =<>$~\^]+?) *(?:(?<op>[=<>~]{1,2}|[*$\^]=) *(?<val>.*?))?$/)
142
+ tags.push({
143
+ tag: m['tag'].respond_to?(:wildcard_to_rx) ? m['tag'].wildcard_to_rx : m['tag'],
144
+ comp: m['op'],
145
+ value: m['val'],
146
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
147
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
148
+ })
149
+ end
107
150
  reader = TTY::Reader.new
108
151
 
109
152
  args.concat(options[:search]) unless options[:search].nil?
@@ -121,13 +164,13 @@ class App
121
164
 
122
165
  options[:exact] = true unless options[:replace].nil?
123
166
 
124
- action = if args.count.positive?
125
- args.join(' ').strip
126
- else
127
- NA.request_input(options, prompt: 'Enter a task to search for')
128
- end
129
- if action
130
- tokens = nil
167
+ if args.count.positive?
168
+ action = args.join(' ').strip
169
+ else
170
+ action = nil
171
+ end
172
+ tokens = nil
173
+ if action && !action.empty?
131
174
  if options[:exact]
132
175
  tokens = action
133
176
  elsif options[:regex]
@@ -135,20 +178,254 @@ class App
135
178
  else
136
179
  tokens = []
137
180
  all_req = action !~ /[+!-]/ && !options[:or]
138
-
139
181
  action.split(/ /).each do |arg|
140
182
  m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
141
183
  tokens.push({
142
- token: m['tok'],
143
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
144
- negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
145
- })
184
+ token: m['tok'],
185
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
186
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
187
+ })
146
188
  end
147
189
  end
148
190
  end
149
191
 
192
+ # If no search query or tags, list all tasks for selection
150
193
  if (action.nil? || action.empty?) && options[:tagged].empty?
151
- NA.notify("#{NA.theme[:error]}Empty input, cancelled", exit_code: 1)
194
+ tokens = nil # No search, list all
195
+ end
196
+
197
+ # Gather all candidate actions for selection
198
+ candidate_actions = []
199
+ targets_for_selection = []
200
+ files = NA.find_files_matching({
201
+ depth: options[:depth],
202
+ done: options[:done],
203
+ project: options[:project],
204
+ regex: options[:regex],
205
+ require_na: false,
206
+ search: tokens,
207
+ tag: tags
208
+ })
209
+ files.each do |file|
210
+ safe_search = (tokens.is_a?(String) || tokens.is_a?(Array) || tokens.is_a?(Regexp)) ? tokens : nil
211
+ todo = NA::Todo.new({
212
+ search: safe_search,
213
+ search_note: options[:search_notes],
214
+ require_na: false,
215
+ file_path: file,
216
+ project: options[:project],
217
+ tag: tags,
218
+ done: options[:done]
219
+ })
220
+ todo.actions.each do |action_obj|
221
+ # Format: filename:project:parent > action
222
+ display = "#{File.basename(action_obj.file)}:#{action_obj.project}:#{action_obj.parent.join('>')} | #{action_obj.action}"
223
+ candidate_actions << display
224
+ targets_for_selection << { file: action_obj.file, line: action_obj.line, action: action_obj }
225
+ end
226
+ end
227
+
228
+ # Multi-select using fzf or gum if available
229
+ selected_indices = []
230
+ if candidate_actions.any?
231
+ selector = nil
232
+ if TTY::Which.exist?('fzf')
233
+ selector = 'fzf --multi --prompt="Select tasks> "'
234
+ elsif TTY::Which.exist?('gum')
235
+ selector = 'gum choose --no-limit'
236
+ end
237
+ if selector
238
+ require 'open3'
239
+ input = candidate_actions.join("\n")
240
+ output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
241
+ selected = output.split("\n").map(&:strip).reject(&:empty?)
242
+ selected_indices = candidate_actions.each_index.select { |i| selected.include?(candidate_actions[i]) }
243
+ else
244
+ # Fallback: select all or prompt for search string
245
+ selected_indices = (0...candidate_actions.size).to_a
246
+ end
247
+ end
248
+
249
+ # If no actions found, notify and exit
250
+ if selected_indices.empty?
251
+ NA.notify("#{NA.theme[:error]}No matching actions found for selection", exit_code: 1)
252
+ end
253
+
254
+ # Apply update to selected actions
255
+ actionable = [
256
+ options[:note],
257
+ (options[:priority].to_i if options[:priority]).to_i.positive?,
258
+ !options[:move].to_s.empty?,
259
+ !(options[:tag].nil? || options[:tag].empty?),
260
+ !(options[:remove].nil? || options[:remove].empty?),
261
+ !options[:replace].to_s.empty?,
262
+ options[:finish],
263
+ options[:archive],
264
+ options[:restore],
265
+ options[:delete],
266
+ options[:edit]
267
+ ].any?
268
+ unless actionable
269
+ # Interactive menu for actions
270
+ actions_menu = [
271
+ { key: :add_tag, label: 'Add Tag', param: 'Tag' },
272
+ { key: :remove_tag, label: 'Remove Tag', param: 'Tag' },
273
+ { key: :delete, label: 'Delete', param: nil },
274
+ { key: :finish, label: 'Finish (mark done)', param: nil },
275
+ { key: :edit, label: 'Edit', param: nil },
276
+ { key: :priority, label: 'Set Priority', param: 'Priority (1-5)' },
277
+ { key: :move, label: 'Move to Project', param: 'Project' },
278
+ { key: :restore, label: 'Restore', param: nil },
279
+ { key: :archive, label: 'Archive', param: nil },
280
+ { key: :note, label: 'Add Note', param: 'Note' }
281
+ ]
282
+ selector = nil
283
+ if TTY::Which.exist?('fzf')
284
+ selector = 'fzf --prompt="Select action> "'
285
+ elsif TTY::Which.exist?('gum')
286
+ selector = 'gum choose'
287
+ end
288
+ menu_labels = actions_menu.map { |a| a[:label] }
289
+ selected_action = nil
290
+ if selector
291
+ require 'open3'
292
+ input = menu_labels.join("\n")
293
+ output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
294
+ selected_action = output.strip
295
+ else
296
+ puts 'Select an action:'
297
+ menu_labels.each_with_index { |label, i| puts "#{i+1}. #{label}" }
298
+ idx = (STDIN.gets || '').strip.to_i - 1
299
+ selected_action = menu_labels[idx] if idx >= 0 && idx < menu_labels.size
300
+ end
301
+ action_obj = actions_menu.find { |a| a[:label] == selected_action }
302
+ if action_obj.nil?
303
+ NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1)
304
+ end
305
+ # Prompt for parameter if needed
306
+ param_value = nil
307
+ # Only prompt for param if not :move (which has custom menu logic)
308
+ if action_obj[:param] && action_obj[:key] != :move
309
+ if TTY::Which.exist?('gum')
310
+ gum = TTY::Which.which('gum')
311
+ prompt = "Enter #{action_obj[:param]}: "
312
+ param_value = `#{gum} input --placeholder "#{prompt}"`.strip
313
+ else
314
+ print "Enter #{action_obj[:param]}: "
315
+ param_value = (STDIN.gets || '').strip
316
+ end
317
+ end
318
+ # Set options for update
319
+ case action_obj[:key]
320
+ when :add_tag
321
+ options[:tag] = [param_value]
322
+ when :remove_tag
323
+ options[:remove] = [param_value]
324
+ when :delete
325
+ options[:delete] = true
326
+ when :finish
327
+ options[:finish] = true
328
+ when :edit
329
+ # Open editor for the selected action and update its content
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
335
+ options[:edit] = true
336
+ when :priority
337
+ options[:priority] = param_value
338
+ when :move
339
+ # Gather projects from the same file as the selected action
340
+ selected_file = targets_for_selection[selected_indices.first][:file]
341
+ todo = NA::Todo.new(file_path: selected_file)
342
+ project_names = todo.projects.map { |proj| proj.project }
343
+ project_menu = project_names + ['New project']
344
+ move_selector = nil
345
+ if TTY::Which.exist?('fzf')
346
+ move_selector = 'fzf --prompt="Select project> "'
347
+ elsif TTY::Which.exist?('gum')
348
+ move_selector = 'gum choose'
349
+ end
350
+ selected_project = nil
351
+ if move_selector
352
+ require 'open3'
353
+ input = project_menu.join("\n")
354
+ output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
355
+ selected_project = output.strip
356
+ else
357
+ puts 'Select a project:'
358
+ project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
359
+ idx = (STDIN.gets || '').strip.to_i - 1
360
+ selected_project = project_menu[idx] if idx >= 0 && idx < project_menu.size
361
+ end
362
+ if selected_project == 'New project'
363
+ if TTY::Which.exist?('gum')
364
+ gum = TTY::Which.which('gum')
365
+ prompt = 'Enter new project name: '
366
+ new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
367
+ else
368
+ print 'Enter new project name: '
369
+ new_proj_name = (STDIN.gets || '').strip
370
+ end
371
+ # Create the new project in the file
372
+ NA.insert_project(selected_file, new_proj_name, todo.projects)
373
+ options[:move] = new_proj_name
374
+ else
375
+ options[:move] = selected_project
376
+ end
377
+ when :restore
378
+ options[:restore] = true
379
+ when :archive
380
+ options[:archive] = true
381
+ when :note
382
+ options[:note] = true
383
+ note = [param_value]
384
+ end
385
+ end
386
+ did_direct_update = false
387
+ selected_indices.each do |idx|
388
+ # Rebuild all derived variables from options after menu-driven assignment
389
+ add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
390
+ remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
391
+ remove_tags << 'done' if options[:restore]
392
+ priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
393
+ target_proj = if options[:move]
394
+ options[:move]
395
+ elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
396
+ NA.cwd
397
+ end
398
+ note_val = note
399
+ if options[:note] && defined?(param_value) && param_value
400
+ note_val = [param_value]
401
+ end
402
+ # Pass the selected action object as 'add', set search to nil
403
+ # Pass the exact selected action object to update_action, bypassing all search/filter logic
404
+ target = targets_for_selection[idx][:file]
405
+ action_obj = targets_for_selection[idx][:action]
406
+ # Direct action mode: update only the selected action in the known file
407
+ NA.update_action(target, nil,
408
+ add: action_obj,
409
+ add_tag: add_tags,
410
+ all: true,
411
+ append: append,
412
+ delete: options[:delete],
413
+ done: options[:done],
414
+ edit: options[:edit],
415
+ finish: options[:finish],
416
+ move: target_proj,
417
+ note: note_val,
418
+ overwrite: options[:overwrite],
419
+ priority: priority,
420
+ project: options[:project],
421
+ remove_tag: remove_tags,
422
+ replace: options[:replace],
423
+ search_note: options[:search_notes],
424
+ tagged: nil)
425
+ did_direct_update = true
426
+ end
427
+ if did_direct_update
428
+ next
152
429
  end
153
430
 
154
431
  all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
data/lib/na/action.rb CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  module NA
4
4
  class Action < Hash
5
- attr_reader :file, :project, :parent, :tags, :line
6
-
7
- attr_accessor :action, :note
5
+ attr_reader :file, :project, :tags, :line
6
+ attr_accessor :parent, :action, :note
8
7
 
9
8
  def initialize(file, project, parent, action, idx, note = [])
10
9
  super()
@@ -117,10 +117,15 @@ module NA
117
117
  # @return [String, Array<String>] Selected file(s)
118
118
  def select_file(files, multiple: false)
119
119
  res = choose_from(files, prompt: multiple ? 'Select files' : 'Select a file', multiple: multiple)
120
-
121
- notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1) unless res&.length&.positive?
122
-
123
- res
120
+ if res.nil? || res == false || (res.respond_to?(:length) && res.empty?)
121
+ notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1)
122
+ return nil
123
+ end
124
+ if multiple
125
+ res
126
+ else
127
+ res.is_a?(Array) ? res.first : res
128
+ end
124
129
  end
125
130
 
126
131
  def shift_index_after(projects, idx, length = 1)
@@ -321,86 +326,54 @@ module NA
321
326
  add_tag ||= []
322
327
  add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
323
328
 
324
- projects = find_projects(target)
325
-
326
- target_proj = if target_proj
327
- projects.select { |proj| proj.project =~ /^#{target_proj.project}$/i }.first
328
- else
329
- # First try exact full-path match
330
- projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/i }.first
331
- end
332
-
333
- # If no exact match, try unique suffix match (e.g., :Ideas at end)
334
- if target_proj.nil?
335
- leaf = Regexp.escape(add.parent.join(':'))
336
- suffix_matches = projects.select { |proj| proj.project =~ /(^|:)#{leaf}$/i }
337
- if suffix_matches.count == 1
338
- target_proj = suffix_matches.first
339
- elsif suffix_matches.count > 1 && $stdout.isatty
340
- choice = choose_from(suffix_matches.map(&:project), prompt: 'Select a target project: ', multiple: false)
341
- target_proj = projects.select { |proj| proj.project == choice }.first if choice
342
- end
343
- end
344
-
345
- if target_proj.nil?
346
- res = NA.yn(
347
- NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{add.project}#{NA.theme[:warning]} doesn't exist, add it"), default: true
348
- )
349
-
350
- if res
351
- target_proj = insert_project(target, project, projects)
352
- projects << target_proj
353
- else
354
- NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
355
- end
356
-
357
- if target_proj.nil?
358
- NA.notify("#{NA.theme[:error]}Error parsing project #{NA.theme[:filename]}#{target}",
359
- exit_code: 1)
360
- end
329
+ # Remove the original action and its notes
330
+ action_line = add.line
331
+ note_lines = add.note.is_a?(Array) ? add.note.count : 0
332
+ contents.slice!(action_line, note_lines + 1)
361
333
 
362
- projects = find_projects(target)
363
- contents = target.read_file.split("\n")
364
- end
334
+ # Prepare updated note
335
+ note = note.to_s.split("\n") unless note.is_a?(Array)
336
+ updated_note = if note.empty?
337
+ add.note
338
+ else
339
+ overwrite ? note : add.note.concat(note)
340
+ end
365
341
 
366
- indent = "\t" * target_proj.indent
367
- note = note.split("\n") unless note.is_a?(Array)
368
- note = if note.empty?
369
- add.note
370
- else
371
- overwrite ? note : add.note.concat(note)
372
- end
373
-
374
- note = note.empty? ? '' : "\n#{indent}\t\t#{note.join("\n#{indent}\t\t").strip}"
375
-
376
- if append
377
- this_idx = 0
378
- projects.each_with_index do |proj, idx|
379
- if proj.line == target_proj.line
380
- this_idx = idx
381
- break
382
- end
383
- end
384
- target_line = if this_idx == projects.length - 1
385
- contents.count
342
+ # Prepare indentation
343
+ projects = find_projects(target)
344
+ # If move is set, update add.parent to the target project
345
+ add.parent = target_proj.project.split(':') if move && target_proj
346
+ target_proj = projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/i }.first
347
+ indent = target_proj ? ("\t" * target_proj.indent) : ''
348
+
349
+ # Format note for insertion
350
+ note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"
351
+
352
+ # Insert at correct location: if moving, insert at start/end of target project
353
+ if move && target_proj
354
+ insert_line = if append
355
+ # End of project
356
+ target_proj.last_line + 1
386
357
  else
387
- projects[this_idx].last_line + 1
358
+ # Start of project (after project header)
359
+ target_proj.line + 1
388
360
  end
361
+ contents.insert(insert_line, "#{indent}\t- #{add.action}#{note_str}")
389
362
  else
390
- target_line = target_proj.line + 1
363
+ # Not moving, update in-place
364
+ contents.insert(action_line, "#{indent}\t- #{add.action}#{note_str}")
391
365
  end
392
366
 
393
- contents.insert(target_line, "#{indent}\t- #{add.action}#{note}")
394
-
395
367
  notify(add.pretty)
396
368
 
397
369
  # Track affected action and description
398
- changes = ['added']
370
+ changes = ['updated']
399
371
  changes << 'finished' if finish
400
372
  changes << "priority=#{priority}" if priority.to_i.positive?
401
373
  changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
402
374
  changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
403
375
  changes << 'note updated' unless note.nil? || note.empty?
376
+ changes << "moved to #{target_proj.project}" if move && target_proj
404
377
  affected_actions << { action: add, desc: changes.join(', ') }
405
378
  else
406
379
  _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
data/lib/na/string.rb CHANGED
@@ -153,22 +153,23 @@ class ::String
153
153
 
154
154
  output = []
155
155
  line = []
156
- length = indent
156
+ length = 0
157
157
  gsub!(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
158
158
 
159
159
  split(' ').each do |word|
160
160
  uncolored = NA::Color.uncolor(word)
161
- if (length + uncolored.length + 1) < width
161
+ if (length + uncolored.length + 1) <= width
162
162
  line << word
163
163
  length += uncolored.length + 1
164
164
  else
165
165
  output << line.join(' ')
166
166
  line = [word]
167
- length = indent + uncolored.length + 1
167
+ length = uncolored.length + 1
168
168
  end
169
169
  end
170
170
  output << line.join(' ')
171
- output.join("\n#{' ' * (indent + 2)}").gsub(/†/, ' ')
171
+ # Indent all lines after the first
172
+ output.each_with_index.map { |l, i| i.zero? ? l : (' ' * indent) + l }.join("\n").gsub(/†/, ' ')
172
173
  end
173
174
 
174
175
  # Returns the last escape sequence from a string.
data/lib/na/todo.rb CHANGED
@@ -44,6 +44,10 @@ module NA
44
44
  }
45
45
 
46
46
  settings = defaults.merge(options)
47
+ # Coerce settings[:search] to a string or nil if it's an integer
48
+ if settings[:search].is_a?(Integer)
49
+ settings[:search] = settings[:search] <= 0 ? nil : settings[:search].to_s
50
+ end
47
51
  # Ensure tag is always an Array
48
52
  if settings[:tag].nil?
49
53
  settings[:tag] = []
@@ -82,8 +86,7 @@ module NA
82
86
  optional_tag.push({ tag: t })
83
87
  end
84
88
  end
85
-
86
- unless settings[:search].nil? || settings[:search].empty?
89
+ unless settings[:search].nil? || (settings[:search].respond_to?(:empty?) && settings[:search].empty?)
87
90
  if settings[:regex] || settings[:search].is_a?(String)
88
91
  if settings[:negate]
89
92
  negated.push(settings[:search])
data/lib/na/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Na
4
- VERSION = '1.2.81'
4
+ VERSION = '1.2.82'
5
5
  end
data/src/_README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is <!--VER-->1.2.80<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.81<!--END VER-->.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
@@ -184,7 +184,7 @@ You can also perform value comparisons on tags. A value in a TaskPaper tag is ad
184
184
  To perform a string comparison, you can use `*=` (contains), `^=` (starts with), `$=` (ends with), or `=` (matches). E.g. `na tagged "note*=video"`.
185
185
 
186
186
  ```
187
- @cli(bundle exec bin/na help show)
187
+ @cli(bundle exec bin/na help tagged)
188
188
  ```
189
189
 
190
190
  ##### todos
@@ -209,6 +209,8 @@ If more than one file is matched, a menu will be presented, multiple selections
209
209
 
210
210
  Any time an update action is carried out, a backup of the file before modification will be made in the same directory with a `.` prepended and `.bak` appended (e.g. `marked.taskpaper` is copied to `.marked.taskpaper.bak`). Only one undo step is available, but if something goes wrong (and this feature is still experimental, so be wary), you can just copy the ".bak" file back to the original.
211
211
 
212
+ > **Note:** When using the `update` command, if you have [fzf](https://github.com/junegunn/fzf) installed, menus for selecting files or actions will support multi-select (tab to mark multiple, return to confirm). If [gum](https://github.com/charmbracelet/gum) is installed, multi-select is also supported (use j/k/x to navigate and mark). If neither is available, a simple prompt is used. This makes it easy to apply updates to multiple actions at once.
213
+
212
214
  ###### Marking a task as complete
213
215
 
214
216
  You can mark an action complete using `--finish`, which will add a dated @done tag to the action. You can also mark it @done and immediately move it to the Archive project using `--archive`.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: na
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.81
4
+ version: 1.2.82
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra