na 1.2.86 → 1.2.88

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.
@@ -5,6 +5,17 @@ class App
5
5
  desc 'Find and mark an action as @done'
6
6
  arg_name 'ACTION'
7
7
  command %i[complete finish] do |c|
8
+ c.desc 'Started time (natural language or ISO)'
9
+ c.arg_name 'DATE'
10
+ c.flag %i[started], type: :date_begin
11
+
12
+ c.desc 'End/Finished time (natural language or ISO)'
13
+ c.arg_name 'DATE'
14
+ c.flag %i[end finished], type: :date_end
15
+
16
+ c.desc 'Duration (e.g. 45m, 2h, 1d2h30m, or minutes)'
17
+ c.arg_name 'DURATION'
18
+ c.flag %i[duration], type: :duration
8
19
  c.example 'na complete "An existing task"',
9
20
  desc: 'Find "An existing task" and mark @done'
10
21
  c.example 'na finish "An existing task"',
data/bin/commands/find.rb CHANGED
@@ -29,6 +29,12 @@ class App
29
29
  c.desc "Include notes in output"
30
30
  c.switch %i[notes], negatable: true, default_value: false
31
31
 
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
+
32
38
  c.desc "Include notes in search"
33
39
  c.switch %i[search_notes], negatable: true, default_value: true
34
40
 
@@ -62,6 +68,22 @@ class App
62
68
  c.desc "Output actions nested by file and project"
63
69
  c.switch %[omnifocus], negatable: false
64
70
 
71
+ c.desc 'Run a plugin on results (STDOUT only; no file writes)'
72
+ c.arg_name 'NAME'
73
+ c.flag %i[plugin]
74
+
75
+ c.desc 'Plugin input format (json|yaml|csv|text)'
76
+ c.arg_name 'TYPE'
77
+ c.flag %i[input]
78
+
79
+ c.desc 'Plugin output format (json|yaml|csv|text)'
80
+ c.arg_name 'TYPE'
81
+ c.flag %i[output]
82
+
83
+ c.desc 'Divider string for text IO'
84
+ c.arg_name 'STRING'
85
+ c.flag %i[divider]
86
+
65
87
  c.action do |global_options, options, args|
66
88
  options[:nest] = true if options[:omnifocus]
67
89
 
@@ -169,13 +191,61 @@ class App
169
191
  [tokens]
170
192
  end
171
193
 
194
+ # Plugin piping (display-only)
195
+ if options[:plugin]
196
+ NA::Plugins.ensure_plugins_home
197
+ plugin_path = options[:plugin]
198
+ unless File.exist?(plugin_path)
199
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
200
+ plugin_path = resolved if resolved
201
+ end
202
+ if plugin_path && File.exist?(plugin_path)
203
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
204
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
205
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
206
+ divider = (options[:divider] || '||')
207
+
208
+ io_actions = todo.actions.map(&:to_plugin_io_hash)
209
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
210
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
211
+ returned = Array(NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider))
212
+ index = {}
213
+ todo.actions.each { |a| index["#{a.file_path}:#{a.file_line}"] = a }
214
+ returned.each do |h|
215
+ key = "#{h['file_path']}:#{h['line'].to_i}"
216
+ a = index[key]
217
+ next unless a
218
+ new_text = h['text'].to_s
219
+ new_note = h['note'].to_s
220
+ new_tags = Array(h['tags']).map { |t| [t['name'].to_s, t['value'].to_s] }
221
+ new_text = new_text.gsub(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
222
+ unless new_tags.empty?
223
+ tag_str = new_tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
224
+ new_text = new_text.strip + (tag_str.empty? ? '' : " #{tag_str}")
225
+ end
226
+ a.action = new_text
227
+ a.note = new_note.empty? ? [] : new_note.split("\n")
228
+ a.instance_variable_set(:@tags, a.scan_tags)
229
+ parents = Array(h['parents']).map(&:to_s)
230
+ if parents.any?
231
+ new_proj = parents.first.to_s
232
+ new_chain = parents[1..] || []
233
+ a.instance_variable_set(:@project, new_proj)
234
+ a.parent = new_chain
235
+ end
236
+ end
237
+ end
238
+ end
239
+
172
240
  todo.actions.output(depth,
173
241
  { files: todo.files,
174
242
  regexes: regexes,
175
243
  notes: options[:notes],
176
244
  nest: options[:nest],
177
245
  nest_projects: options[:omnifocus],
178
- no_files: options[:no_file] })
246
+ no_files: options[:no_file],
247
+ times: options[:times],
248
+ human: options[:human] })
179
249
  end
180
250
  end
181
251
  end
data/bin/commands/next.rb CHANGED
@@ -65,9 +65,40 @@ class App
65
65
  c.desc "Include notes in output"
66
66
  c.switch %i[notes], negatable: true, default_value: false
67
67
 
68
+ c.desc "Show per-action durations and total"
69
+ c.switch %i[times], negatable: false
70
+
71
+ c.desc "Format durations in human-friendly form"
72
+ c.switch %i[human], negatable: false
73
+
74
+ c.desc "Show only actions that have a duration (@started and @done)"
75
+ c.switch %i[only_timed], negatable: false
76
+
77
+ c.desc "Output times as JSON object (implies --times and --done)"
78
+ c.switch %i[json_times], negatable: false
79
+
80
+ c.desc "Output only elapsed time totals (implies --times and --done)"
81
+ c.switch %i[only_times], negatable: false
82
+
68
83
  c.desc "Include @done actions"
69
84
  c.switch %i[done]
70
85
 
86
+ c.desc "Run a plugin on results (STDOUT only; no file writes)"
87
+ c.arg_name 'NAME'
88
+ c.flag %i[plugin]
89
+
90
+ c.desc 'Plugin input format (json|yaml|csv|text)'
91
+ c.arg_name 'TYPE'
92
+ c.flag %i[input]
93
+
94
+ c.desc 'Plugin output format (json|yaml|csv|text)'
95
+ c.arg_name 'TYPE'
96
+ c.flag %i[output]
97
+
98
+ c.desc 'Divider string for text IO'
99
+ c.arg_name 'STRING'
100
+ c.flag %i[divider]
101
+
71
102
  c.desc "Output actions nested by file"
72
103
  c.switch %i[nest], negatable: false
73
104
 
@@ -189,7 +220,20 @@ class App
189
220
  end
190
221
  end
191
222
 
192
- options[:done] = true if tags.any? { |tag| tag[:tag] =~ /done/ }
223
+ if options[:json_times]
224
+ options[:times] = true
225
+ options[:done] = true
226
+ elsif options[:only_times]
227
+ options[:times] = true
228
+ options[:done] = true
229
+ elsif options[:only_timed]
230
+ options[:times] = true
231
+ options[:done] = true
232
+ elsif options[:times]
233
+ options[:done] = true
234
+ else
235
+ options[:done] = true if tags.any? { |tag| tag[:tag] =~ /done/ }
236
+ end
193
237
 
194
238
  search_tokens = nil
195
239
  if options[:exact]
@@ -234,12 +278,66 @@ class App
234
278
  Run `na todos` to see available todo files.")
235
279
  end
236
280
  NA::Pager.paginate = false if options[:omnifocus]
281
+
282
+ # If a plugin is specified, transform actions in memory for display only
283
+ if options[:plugin]
284
+ NA::Plugins.ensure_plugins_home
285
+ plugin_path = options[:plugin]
286
+ unless File.exist?(plugin_path)
287
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
288
+ plugin_path = resolved if resolved
289
+ end
290
+ if plugin_path && File.exist?(plugin_path)
291
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
292
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
293
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
294
+ divider = (options[:divider] || '||')
295
+
296
+ io_actions = todo.actions.map(&:to_plugin_io_hash)
297
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
298
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
299
+ returned = Array(NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider))
300
+ index = {}
301
+ todo.actions.each { |a| index["#{a.file_path}:#{a.file_line}"] = a }
302
+ returned.each do |h|
303
+ key = "#{h['file_path']}:#{h['line'].to_i}"
304
+ a = index[key]
305
+ next unless a
306
+ # Update for display: text, note, tags
307
+ new_text = h['text'].to_s
308
+ new_note = h['note'].to_s
309
+ new_tags = Array(h['tags']).map { |t| [t['name'].to_s, t['value'].to_s] }
310
+ # replace tags in text
311
+ new_text = new_text.gsub(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
312
+ unless new_tags.empty?
313
+ tag_str = new_tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
314
+ new_text = new_text.strip + (tag_str.empty? ? '' : " #{tag_str}")
315
+ end
316
+ a.action = new_text
317
+ a.note = new_note.empty? ? [] : new_note.split("\n")
318
+ a.instance_variable_set(:@tags, a.scan_tags)
319
+ # parents -> possibly change project and parent chain for display
320
+ parents = Array(h['parents']).map(&:to_s)
321
+ if parents.any?
322
+ new_proj = parents.first.to_s
323
+ new_chain = parents[1..] || []
324
+ a.instance_variable_set(:@project, new_proj)
325
+ a.parent = new_chain
326
+ end
327
+ end
328
+ end
329
+ end
237
330
  todo.actions.output(depth,
238
331
  { files: todo.files,
239
332
  nest: options[:nest],
240
333
  nest_projects: options[:omnifocus],
241
334
  notes: options[:notes],
242
- no_files: options[:no_file] })
335
+ no_files: options[:no_file],
336
+ times: options[:times],
337
+ human: options[:human],
338
+ only_timed: options[:only_timed],
339
+ json_times: options[:json_times],
340
+ only_times: options[:only_times] })
243
341
  end
244
342
  end
245
343
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ class App
4
+ extend GLI::App
5
+
6
+ desc 'Run a plugin on selected actions'
7
+ arg_name 'NAME'
8
+ command %i[plugin] do |c|
9
+ c.desc 'Input format (json|yaml|text)'
10
+ c.arg_name 'TYPE'
11
+ c.flag %i[input]
12
+
13
+ c.desc 'Output format (json|yaml|text)'
14
+ c.arg_name 'TYPE'
15
+ c.flag %i[output]
16
+
17
+ c.desc 'Text divider when using --input/--output text'
18
+ c.arg_name 'STRING'
19
+ c.flag %i[divider]
20
+
21
+ c.desc 'Specify the file to search for the task'
22
+ c.arg_name 'PATH'
23
+ c.flag %i[file in]
24
+
25
+ c.desc 'Search for files X directories deep'
26
+ c.arg_name 'DEPTH'
27
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
28
+
29
+ c.desc 'Filter results using search terms'
30
+ c.arg_name 'QUERY'
31
+ c.flag %i[search find grep], multiple: true
32
+
33
+ c.desc 'Include @done actions'
34
+ c.switch %i[done]
35
+
36
+ c.desc 'Match actions containing tag. Allows value comparisons'
37
+ c.arg_name 'TAG'
38
+ c.flag %i[tagged], multiple: true
39
+
40
+ c.action do |_global, options, args|
41
+ plugin_name = args.first
42
+ NA.notify("#{NA.theme[:error]}Plugin name required", exit_code: 1) unless plugin_name
43
+
44
+ NA::Plugins.ensure_plugins_home
45
+ path = NA::Plugins.resolve_plugin(plugin_name)
46
+ NA.notify("#{NA.theme[:error]}Plugin not found: #{plugin_name}", exit_code: 1) unless path
47
+
48
+ meta = NA::Plugins.parse_plugin_metadata(path)
49
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
50
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
51
+ divider = (options[:divider] || '||')
52
+
53
+ # Build selection using the same plumbing as update/find
54
+ actions = NA.select_actions(
55
+ file: options[:file],
56
+ depth: options[:depth],
57
+ search: options[:search],
58
+ tagged: options[:tagged],
59
+ include_done: options[:done]
60
+ )
61
+
62
+ io_actions = actions.map(&:to_plugin_io_hash)
63
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
64
+ stdout = NA::Plugins.run_plugin(path, stdin_str)
65
+ returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
66
+
67
+ # Apply updates
68
+ returned.each do |h|
69
+ NA.apply_plugin_result(h)
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+
@@ -2,124 +2,168 @@
2
2
 
3
3
  class App
4
4
  extend GLI::App
5
- desc "Find actions matching a tag"
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 "TAG[=VALUE]"
12
+ arg_name 'TAG[=VALUE]'
13
13
  command %i[tagged] do |c|
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"
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 "Show actions from a specific todo file in history. May use wildcards (* and ?)"
26
- c.arg_name "TODO_PATH"
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 "Include notes in output"
29
+ c.desc 'Include notes in output'
30
30
  c.switch %i[notes], negatable: true, default_value: false
31
31
 
32
- c.desc "Combine tags with OR, displaying actions matching ANY of the tags"
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 "Show actions from a specific project"
36
- c.arg_name "PROJECT[/SUBPROJECT]"
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 "Filter results using search terms"
40
- c.arg_name "QUERY"
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 "Include notes in search"
58
+ c.desc 'Include notes in search'
44
59
  c.switch %i[search_notes], negatable: true, default_value: true
45
60
 
46
- c.desc "Search query is regular expression"
61
+ c.desc 'Search query is regular expression'
47
62
  c.switch %i[regex], negatable: false
48
63
 
49
- c.desc "Search query is exact text match (not tokens)"
64
+ c.desc 'Search query is exact text match (not tokens)'
50
65
  c.switch %i[exact], negatable: false
51
66
 
52
- c.desc "Include @done actions"
67
+ c.desc 'Include @done actions'
53
68
  c.switch %i[done]
54
69
 
55
- c.desc "Show actions not matching tags"
70
+ c.desc 'Show actions not matching tags'
56
71
  c.switch %i[v invert], negatable: false
57
72
 
58
- c.desc "Save this search for future use"
59
- c.arg_name "TITLE"
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 "Output actions nested by file"
77
+ c.desc 'Output actions nested by file'
63
78
  c.switch %i[nest], negatable: false
64
79
 
65
- c.desc "No filename in output"
80
+ c.desc 'No filename in output'
66
81
  c.switch %i[no_file], negatable: false
67
82
 
68
- c.desc "Output actions nested by file and project"
83
+ c.desc 'Output actions nested by file and project'
69
84
  c.switch %i[omnifocus], negatable: false
70
85
 
86
+ c.desc 'Run a plugin on results (STDOUT only; no file writes)'
87
+ c.arg_name 'NAME'
88
+ c.flag %i[plugin]
89
+
90
+ c.desc 'Plugin input format (json|yaml|csv|text)'
91
+ c.arg_name 'TYPE'
92
+ c.flag %i[input]
93
+
94
+ c.desc 'Plugin output format (json|yaml|csv|text)'
95
+ c.arg_name 'TYPE'
96
+ c.flag %i[output]
97
+
98
+ c.desc 'Divider string for text IO'
99
+ c.arg_name 'STRING'
100
+ c.flag %i[divider]
101
+
71
102
  c.action do |global_options, options, args|
72
103
  options[:nest] = true if options[:omnifocus]
73
104
 
74
105
  if options[:save]
75
- title = options[:save].gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_")
76
- cmd = NA.command_line.join(" ").sub(/ --save[= ]*\S+/, "").split(" ").map { |t| %("#{t}") }.join(" ")
106
+ title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
107
+ cmd = NA.command_line.join(' ').sub(/ --save[= ]*\S+/, '').split.map { |t| %("#{t}") }.join(' ')
77
108
  NA.save_search(title, cmd)
78
109
  end
79
110
 
80
111
  depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
81
- 3
82
- else
83
- options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
84
- end
112
+ 3
113
+ else
114
+ options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
115
+ end
85
116
 
86
117
  tags = []
87
118
 
88
- all_req = args.join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
89
- args.join(",").split(/ *, */).each do |arg|
119
+ all_req = args.join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
120
+ args.join(',').split(/ *, */).each do |arg|
90
121
  m = arg.match(/^(?<req>[+!-])?(?<tag>[^ =<>$~\^]+?) *(?:(?<op>[=<>~]{1,2}|[*$\^]=) *(?<val>.*?))?$/)
91
122
  next if m.nil?
92
123
 
93
124
  tags.push({
94
- tag: m["tag"].sub(/^@/, "").wildcard_to_rx,
95
- comp: m["op"],
96
- value: m["val"],
97
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
98
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
125
+ tag: m['tag'].sub(/^@/, '').wildcard_to_rx,
126
+ comp: m['op'],
127
+ value: m['val'],
128
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
129
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
99
130
  })
100
131
  end
101
132
 
102
133
  search_for_done = false
103
134
  tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
104
- tags.push({ tag: "done", value: nil, negate: true }) unless search_for_done || options[:done]
105
- options[:done] = true if search_for_done
135
+ if options[:json_times]
136
+ options[:times] = true
137
+ options[:done] = true
138
+ elsif options[:only_times]
139
+ options[:times] = true
140
+ options[:done] = true
141
+ elsif options[:only_timed]
142
+ options[:times] = true
143
+ options[:done] = true
144
+ elsif options[:times]
145
+ options[:done] = true
146
+ else
147
+ tags.push({ tag: 'done', value: nil, negate: true }) unless search_for_done || options[:done]
148
+ options[:done] = true if search_for_done
149
+ end
106
150
 
107
151
  tokens = nil
108
152
  if options[:search]
109
153
  if options[:exact]
110
- tokens = options[:search].join(" ")
154
+ tokens = options[:search].join(' ')
111
155
  elsif options[:regex]
112
- tokens = Regexp.new(options[:search].join(" "), Regexp::IGNORECASE)
156
+ tokens = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
113
157
  else
114
158
  tokens = []
115
- all_req = options[:search].join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
159
+ all_req = options[:search].join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
116
160
 
117
- options[:search].join(" ").split(/ /).each do |arg|
161
+ options[:search].join(' ').split(/ /).each do |arg|
118
162
  m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
119
163
  tokens.push({
120
- token: m["tok"],
121
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
122
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
164
+ token: m['tok'],
165
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
166
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
123
167
  })
124
168
  end
125
169
  end
@@ -132,9 +176,9 @@ class App
132
176
  options[:in].split(/ *, */).each do |a|
133
177
  m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
134
178
  todos.push({
135
- token: m["tok"],
136
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
137
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
179
+ token: m['tok'],
180
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
181
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
138
182
  })
139
183
  end
140
184
  end
@@ -152,17 +196,69 @@ class App
152
196
  require_na: false })
153
197
 
154
198
  regexes = if tokens.is_a?(Array)
155
- tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
156
- else
157
- [tokens]
199
+ tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
200
+ else
201
+ [tokens]
202
+ end
203
+
204
+ # Plugin piping (display only)
205
+ if options[:plugin]
206
+ NA::Plugins.ensure_plugins_home
207
+ plugin_path = options[:plugin]
208
+ unless File.exist?(plugin_path)
209
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
210
+ plugin_path = resolved if resolved
158
211
  end
212
+ if plugin_path && File.exist?(plugin_path)
213
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
214
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
215
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
216
+ divider = (options[:divider] || '||')
217
+
218
+ io_actions = todo.actions.map(&:to_plugin_io_hash)
219
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
220
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
221
+ returned = Array(NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider))
222
+ index = {}
223
+ todo.actions.each { |a| index["#{a.file_path}:#{a.file_line}"] = a }
224
+ returned.each do |h|
225
+ key = "#{h['file_path']}:#{h['line'].to_i}"
226
+ a = index[key]
227
+ next unless a
228
+ new_text = h['text'].to_s
229
+ new_note = h['note'].to_s
230
+ new_tags = Array(h['tags']).map { |t| [t['name'].to_s, t['value'].to_s] }
231
+ new_text = new_text.gsub(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
232
+ unless new_tags.empty?
233
+ tag_str = new_tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
234
+ new_text = new_text.strip + (tag_str.empty? ? '' : " #{tag_str}")
235
+ end
236
+ a.action = new_text
237
+ a.note = new_note.empty? ? [] : new_note.split("\n")
238
+ a.instance_variable_set(:@tags, a.scan_tags)
239
+ parents = Array(h['parents']).map(&:to_s)
240
+ if parents.any?
241
+ new_proj = parents.first.to_s
242
+ new_chain = parents[1..] || []
243
+ a.instance_variable_set(:@project, new_proj)
244
+ a.parent = new_chain
245
+ end
246
+ end
247
+ end
248
+ end
249
+
159
250
  todo.actions.output(depth,
160
251
  { files: todo.files,
161
252
  regexes: regexes,
162
253
  notes: options[:notes],
163
254
  nest: options[:nest],
164
255
  nest_projects: options[:omnifocus],
165
- no_files: options[:no_file] })
256
+ no_files: options[:no_file],
257
+ times: options[:times],
258
+ human: options[:human],
259
+ only_timed: options[:only_timed],
260
+ json_times: options[:json_times],
261
+ only_times: options[:only_times] })
166
262
  end
167
263
  end
168
264
  end