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 +4 -4
- data/.rubocop_todo.yml +9 -9
- data/CHANGELOG.md +8 -0
- data/Gemfile.lock +1 -1
- data/README.md +23 -19
- data/bin/commands/update.rb +290 -13
- data/lib/na/action.rb +2 -3
- data/lib/na/next_action.rb +42 -69
- data/lib/na/string.rb +5 -4
- data/lib/na/todo.rb +5 -2
- data/lib/na/version.rb +1 -1
- data/src/_README.md +4 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 742c746b884aa5ac75841f771fb9f13c95ead1a161d340ca0ea362c52b584914
|
|
4
|
+
data.tar.gz: 9b62bb218a75b19767dc7d2ebc59b1a49d2770df1ee44c28077cc077add2e1a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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:
|
|
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:
|
|
38
|
+
Max: 144
|
|
39
39
|
|
|
40
40
|
# Offense count: 4
|
|
41
41
|
# Configuration parameters: CountComments, CountAsOne.
|
|
42
42
|
Metrics/ClassLength:
|
|
43
|
-
Max:
|
|
43
|
+
Max: 762
|
|
44
44
|
|
|
45
45
|
# Offense count: 22
|
|
46
46
|
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
|
47
47
|
Metrics/CyclomaticComplexity:
|
|
48
|
-
Max:
|
|
48
|
+
Max: 66
|
|
49
49
|
|
|
50
50
|
# Offense count: 38
|
|
51
51
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
|
52
52
|
Metrics/MethodLength:
|
|
53
|
-
Max:
|
|
53
|
+
Max: 146
|
|
54
54
|
|
|
55
55
|
# Offense count: 2
|
|
56
56
|
# Configuration parameters: CountComments, CountAsOne.
|
|
57
57
|
Metrics/ModuleLength:
|
|
58
|
-
Max:
|
|
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:
|
|
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:
|
|
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
data/Gemfile.lock
CHANGED
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.
|
|
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.
|
|
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
|
-
|
|
481
|
+
tagged - Find actions matching a tag
|
|
482
482
|
|
|
483
483
|
SYNOPSIS
|
|
484
484
|
|
|
485
|
-
na [global options]
|
|
485
|
+
na [global options] tagged [command options] TAG[=VALUE]
|
|
486
486
|
|
|
487
487
|
DESCRIPTION
|
|
488
|
-
|
|
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
|
-
--
|
|
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
|
-
--
|
|
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
|
-
-
|
|
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
|
-
#
|
|
514
|
-
na
|
|
509
|
+
# Show all actions tagged @maybe
|
|
510
|
+
na tagged maybe
|
|
515
511
|
|
|
516
|
-
#
|
|
517
|
-
na
|
|
512
|
+
# Show all actions tagged @feature AND @idea, recurse 3 levels
|
|
513
|
+
na tagged -d 3 "feature, idea"
|
|
518
514
|
|
|
519
|
-
#
|
|
520
|
-
na
|
|
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`.
|
data/bin/commands/update.rb
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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, :
|
|
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()
|
data/lib/na/next_action.rb
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = ['
|
|
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 =
|
|
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)
|
|
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 =
|
|
167
|
+
length = uncolored.length + 1
|
|
168
168
|
end
|
|
169
169
|
end
|
|
170
170
|
output << line.join(' ')
|
|
171
|
-
|
|
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
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.
|
|
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
|
|
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`.
|