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.
@@ -3,6 +3,98 @@
3
3
  # Next Action methods
4
4
  module NA
5
5
  class << self
6
+ # Select actions across files using existing search pipeline
7
+ # @return [Array<NA::Action>]
8
+ def select_actions(file: nil, depth: 1, search: [], tagged: [], include_done: false)
9
+ files = if file
10
+ [file]
11
+ else
12
+ find_files(depth: depth)
13
+ end
14
+ out = []
15
+ files.each do |f|
16
+ _projects, actions = find_actions(f, search, tagged, done: include_done, all: true)
17
+ out.concat(actions) if actions
18
+ end
19
+ out
20
+ end
21
+
22
+ # Apply a plugin result hash back to the underlying file
23
+ # - Move if parents changed (project path differs)
24
+ # - Update text/note/tags
25
+ def apply_plugin_result(io_hash)
26
+ file = io_hash['file_path']
27
+ line = io_hash['line'].to_i
28
+ parents = Array(io_hash['parents']).map(&:to_s)
29
+ text = io_hash['text'].to_s
30
+ note = io_hash['note'].to_s
31
+ tags = Array(io_hash['tags']).to_h { |t| [t['name'].to_s, t['value'].to_s] }
32
+ action_block = io_hash['action'] || { 'action' => 'UPDATE', 'arguments' => [] }
33
+ action_name = action_block['action'].to_s.upcase
34
+ action_args = Array(action_block['arguments'])
35
+
36
+ # Load current action
37
+ _projects, actions = find_actions(file, nil, nil, all: true, done: true, project: nil, search_note: true, target_line: line)
38
+ action = actions&.first
39
+ return unless action
40
+
41
+ # Determine new project path from parents array
42
+ new_project = ''
43
+ new_parent_chain = []
44
+ if parents.any?
45
+ new_project = parents.first.to_s
46
+ new_parent_chain = parents[1..] || []
47
+ end
48
+
49
+ case action_name
50
+ when 'DELETE'
51
+ update_action(file, { target_line: line }, delete: true, all: true)
52
+ return
53
+ when 'COMPLETE'
54
+ update_action(file, { target_line: line }, finish: true, all: true)
55
+ return
56
+ when 'RESTORE'
57
+ update_action(file, { target_line: line }, restore: true, all: true)
58
+ return
59
+ when 'ARCHIVE'
60
+ update_action(file, { target_line: line }, finish: true, move: 'Archive', all: true)
61
+ return
62
+ when 'ADD_TAG'
63
+ add_tags = action_args.map { |t| t.sub(/^@/, '') }
64
+ update_action(file, { target_line: line }, add: action, add_tag: add_tags, all: true)
65
+ return
66
+ when 'DELETE_TAG', 'REMOVE_TAG'
67
+ remove_tags = action_args.map { |t| t.sub(/^@/, '') }
68
+ update_action(file, { target_line: line }, add: action, remove_tag: remove_tags, all: true)
69
+ return
70
+ when 'MOVE'
71
+ move_to = action_args.first.to_s
72
+ update_action(file, { target_line: line }, add: action, move: move_to, all: true)
73
+ return
74
+ end
75
+
76
+ # Replace content on the existing action then write back in-place
77
+ original_line = action.file_line
78
+ action.action = text
79
+ action.note = note.to_s.split("\n")
80
+ action.action.gsub!(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
81
+ unless tags.empty?
82
+ tag_str = tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
83
+ action.action = action.action.strip + (tag_str.empty? ? "" : " #{tag_str}")
84
+ end
85
+ # Ensure we update this exact action in-place
86
+ update_action(file, { target_line: original_line }, add: action, all: true)
87
+
88
+ # If parents changed, set move target
89
+ move_to = nil
90
+ move_to = ([new_project] + new_parent_chain).join(':') if new_project.to_s.strip != action.project || new_parent_chain != action.parent
91
+
92
+ update_action(file, nil,
93
+ add: action,
94
+ project: action.project,
95
+ overwrite: true,
96
+ move: move_to)
97
+ end
6
98
  include NA::Editor
7
99
 
8
100
  attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
@@ -327,7 +419,22 @@ module NA
327
419
  move: nil,
328
420
  remove_tag: [],
329
421
  replace: nil,
330
- tagged: nil)
422
+ tagged: nil,
423
+ started_at: nil,
424
+ done_at: nil,
425
+ duration_seconds: nil)
426
+ # Coerce date/time inputs if passed as strings
427
+ begin
428
+ started_at = NA::Types.parse_date_begin(started_at) if started_at && !started_at.is_a?(Time)
429
+ rescue StandardError
430
+ # leave as-is
431
+ end
432
+ begin
433
+ done_at = NA::Types.parse_date_end(done_at) if done_at && !done_at.is_a?(Time)
434
+ rescue StandardError
435
+ # leave as-is
436
+ end
437
+ NA.notify("UPDATE parsed started_at=#{started_at.inspect} done_at=#{done_at.inspect} duration=#{duration_seconds.inspect}", debug: true)
331
438
  # Expand target to absolute path to avoid path resolution issues
332
439
  target = File.expand_path(target) unless Pathname.new(target).absolute?
333
440
 
@@ -359,12 +466,20 @@ module NA
359
466
  # So we don't need to handle it here - the action is already edited
360
467
 
361
468
  add_tag ||= []
362
- add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
363
-
364
- # Remove the original action and its notes
469
+ NA.notify("PROCESS before add.process started_at=#{started_at.inspect} done_at=#{done_at.inspect}", debug: true)
470
+ add.process(priority: priority,
471
+ finish: finish,
472
+ add_tag: add_tag,
473
+ remove_tag: remove_tag,
474
+ started_at: started_at,
475
+ done_at: done_at,
476
+ duration_seconds: duration_seconds)
477
+ NA.notify("PROCESS after add.process action=\"#{add.action}\"", debug: true)
478
+
479
+ # Remove the original action and its notes if this is an existing action
365
480
  action_line = add.file_line
366
481
  note_lines = add.note.is_a?(Array) ? add.note.count : 0
367
- contents.slice!(action_line, note_lines + 1)
482
+ contents.slice!(action_line, note_lines + 1) if action_line.is_a?(Integer)
368
483
 
369
484
  # Prepare updated note
370
485
  note = note.to_s.split("\n") unless note.is_a?(Array)
@@ -384,32 +499,52 @@ module NA
384
499
  # Format note for insertion
385
500
  note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"
386
501
 
387
- # Insert at correct location: if moving, insert at start/end of target project
388
- if move && target_proj
389
- insert_line = if append
390
- # End of project
391
- target_proj.last_line + 1
392
- else
393
- # Start of project (after project header)
394
- target_proj.line + 1
395
- end
396
- contents.insert(insert_line, "#{indent}\t- #{add.action}#{note_str}")
502
+ # If delete was requested in this direct update path, do not re-insert
503
+ if delete
504
+ affected_actions << { action: add, desc: 'deleted' }
397
505
  else
398
- # Not moving, update in-place
399
- contents.insert(action_line, "#{indent}\t- #{add.action}#{note_str}")
400
- end
506
+ # Insert at correct location
507
+ if target_proj
508
+ insert_line = if append
509
+ # End of project
510
+ target_proj.last_line + 1
511
+ else
512
+ # Start of project (after project header)
513
+ target_proj.line + 1
514
+ end
515
+ # Ensure @started tag persists if provided
516
+ final_action = add.action.dup
517
+ if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
518
+ final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
519
+ final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
520
+ end
521
+ NA.notify("INSERT at #{insert_line} final_action=\"#{final_action}\"", debug: true)
522
+ contents.insert(insert_line, "#{indent}\t- #{final_action}#{note_str}")
523
+ else
524
+ # Fallback: append to end of file
525
+ final_action = add.action.dup
526
+ if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
527
+ final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
528
+ final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
529
+ end
530
+ NA.notify("APPEND final_action=\"#{final_action}\"", debug: true)
531
+ contents << "#{indent}\t- #{final_action}#{note_str}"
532
+ end
401
533
 
402
- notify(add.pretty)
534
+ notify(add.pretty)
535
+ end
403
536
 
404
537
  # Track affected action and description
405
- changes = ['updated']
406
- changes << 'finished' if finish
407
- changes << "priority=#{priority}" if priority.to_i.positive?
408
- changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
409
- changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
410
- changes << 'note updated' unless note.nil? || note.empty?
411
- changes << "moved to #{target_proj.project}" if move && target_proj
412
- affected_actions << { action: add, desc: changes.join(', ') }
538
+ unless delete
539
+ changes = ['updated']
540
+ changes << 'finished' if finish
541
+ changes << "priority=#{priority}" if priority.to_i.positive?
542
+ changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
543
+ changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
544
+ changes << 'note updated' unless note.nil? || note.empty?
545
+ changes << "moved to #{target_proj.project}" if move && target_proj
546
+ affected_actions << { action: add, desc: changes.join(', ') }
547
+ end
413
548
  else
414
549
  # Check if search is actually target_line
415
550
  target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
@@ -445,7 +580,13 @@ module NA
445
580
  # If replace is defined, use search to search and replace text in action
446
581
  action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace
447
582
 
448
- action.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
583
+ action.process(priority: priority,
584
+ finish: finish,
585
+ add_tag: add_tag,
586
+ remove_tag: remove_tag,
587
+ started_at: started_at,
588
+ done_at: done_at,
589
+ duration_seconds: duration_seconds)
449
590
 
450
591
  target_proj = if target_proj
451
592
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
@@ -532,7 +673,7 @@ module NA
532
673
  # @param finish [Boolean] Mark as finished
533
674
  # @param append [Boolean] Append to project
534
675
  # @return [void]
535
- def add_action(file, project, action, note = [], priority: 0, finish: false, append: false)
676
+ def add_action(file, project, action, note = [], priority: 0, finish: false, append: false, started_at: nil, done_at: nil, duration_seconds: nil)
536
677
  parent = project.split(%r{[:/]})
537
678
 
538
679
  if NA.global_file
@@ -545,8 +686,16 @@ module NA
545
686
 
546
687
  action = Action.new(file, project, parent, action, nil, note)
547
688
 
548
- update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish,
549
- append: append)
689
+ update_action(file, nil,
690
+ add: action,
691
+ project: project,
692
+ add_tag: add_tag,
693
+ priority: priority,
694
+ finish: finish,
695
+ append: append,
696
+ started_at: started_at,
697
+ done_at: done_at,
698
+ duration_seconds: duration_seconds)
550
699
  end
551
700
 
552
701
  # Build a nested hash representing project hierarchy from actions
data/lib/na/plugins.rb ADDED
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require 'csv'
6
+
7
+ module NA
8
+ # Plugins module for NA
9
+ module Plugins
10
+ module_function
11
+
12
+ def plugins_home
13
+ File.expand_path('~/.local/share/na/plugins')
14
+ end
15
+
16
+ def ensure_plugins_home
17
+ dir = plugins_home
18
+ return if File.directory?(dir)
19
+
20
+ FileUtils.mkdir_p(dir)
21
+ readme = File.join(dir, 'README.md')
22
+ File.write(readme, default_readme_contents) unless File.exist?(readme)
23
+ create_sample_plugins(dir)
24
+ end
25
+
26
+ def list_plugins
27
+ dir = plugins_home
28
+ return {} unless File.directory?(dir)
29
+
30
+ Dir.children(dir).each_with_object({}) do |entry, acc|
31
+ path = File.join(dir, entry)
32
+ next unless File.file?(path)
33
+ next if entry =~ /\.(md|bak)$/i
34
+ next unless shebang?(path)
35
+
36
+ base = File.basename(entry, File.extname(entry))
37
+ key = base.gsub(/[\s_]/, '')
38
+ acc[key.downcase] = path
39
+ end
40
+ end
41
+
42
+ def resolve_plugin(name)
43
+ return nil unless name && !name.to_s.strip.empty?
44
+
45
+ normalized = name.to_s.strip.gsub(/[\s_]/, '').downcase
46
+ candidates = list_plugins
47
+ return candidates[normalized] if candidates.key?(normalized)
48
+
49
+ # Fallback: try exact filename match in dir
50
+ path = File.join(plugins_home, name)
51
+ File.file?(path) ? path : nil
52
+ end
53
+
54
+ def shebang_for(file)
55
+ first = begin
56
+ File.open(file, 'r', &:readline)
57
+ rescue StandardError
58
+ ''
59
+ end
60
+ first.start_with?('#!') ? first.sub('#!', '').strip : nil
61
+ end
62
+
63
+ def parse_plugin_metadata(file)
64
+ meta = { 'input' => nil, 'output' => nil, 'name' => nil }
65
+ lines = File.readlines(file, chomp: true)
66
+ return meta if lines.empty?
67
+
68
+ # skip shebang
69
+ i = 0
70
+ i += 1 if lines[0].to_s.start_with?('#!')
71
+ # skip leading blanks
72
+ i += 1 while i < lines.length && lines[i].strip.empty?
73
+ while i < lines.length
74
+ line = lines[i]
75
+ break if line.strip.empty?
76
+
77
+ # strip common comment leaders
78
+ stripped = line.sub(%r{^\s*(#|//)}, '').strip
79
+ if (m = stripped.match(/^([A-Za-z]+)\s*:\s*(.+)$/))
80
+ key = m[1].downcase
81
+ val = m[2].strip
82
+ case key
83
+ when 'input', 'output'
84
+ meta[key] = val.downcase
85
+ when 'name', 'title'
86
+ meta['name'] = val
87
+ end
88
+ end
89
+ break if meta.values_at('input', 'output', 'name').compact.size == 3
90
+
91
+ i += 1
92
+ end
93
+ meta
94
+ end
95
+
96
+ def run_plugin(file, stdin_str)
97
+ interp = shebang_for(file)
98
+ cmd = interp ? %(#{interp} #{Shellwords.escape(file)}) : %(sh #{Shellwords.escape(file)})
99
+ IO.popen(cmd, 'r+', err: %i[child out]) do |io|
100
+ io.write(stdin_str.to_s)
101
+ io.close_write
102
+ io.read
103
+ end
104
+ end
105
+
106
+ def serialize_actions(actions, format: 'json', divider: '||')
107
+ case format.to_s.downcase
108
+ when 'json'
109
+ JSON.pretty_generate(actions)
110
+ when 'yaml', 'yml'
111
+ YAML.dump(actions)
112
+ when 'csv'
113
+ CSV.generate(force_quotes: true) do |csv|
114
+ csv << %w[action arguments file_path line parents text note tags]
115
+ actions.each do |a|
116
+ csv << [
117
+ (a['action'] && a['action']['action']) || 'UPDATE',
118
+ Array(a['action'] && a['action']['arguments']).join(','),
119
+ a['file_path'],
120
+ a['line'],
121
+ Array(a['parents']).join('>'),
122
+ a['text'] || '',
123
+ a['note'] || '',
124
+ serialize_tags(a['tags'])
125
+ ]
126
+ end
127
+ end
128
+ when 'text', 'txt'
129
+ actions.map { |a| serialize_text(a, divider: divider) }.join("\n")
130
+ else
131
+ JSON.generate(actions)
132
+ end
133
+ end
134
+
135
+ def parse_actions(str, format: 'json', divider: '||')
136
+ case format.to_s.downcase
137
+ when 'json'
138
+ JSON.parse(str)
139
+ when 'yaml', 'yml'
140
+ YAML.safe_load(str, permitted_classes: [Time], aliases: true)
141
+ when 'csv'
142
+ rows = CSV.parse(str.to_s, headers: true)
143
+ rows = CSV.parse(str.to_s) if rows.nil? || rows.empty?
144
+ rows.map do |row|
145
+ r = if row.is_a?(CSV::Row)
146
+ row.to_h
147
+ else
148
+ {
149
+ 'action' => row[0], 'arguments' => row[1], 'file_path' => row[2], 'line' => row[3],
150
+ 'parents' => row[4], 'text' => row[5], 'note' => row[6], 'tags' => row[7]
151
+ }
152
+ end
153
+ {
154
+ 'file_path' => r['file_path'].to_s,
155
+ 'line' => r['line'].to_i,
156
+ 'parents' => (r['parents'].to_s.empty? ? [] : r['parents'].split('>').map(&:strip)),
157
+ 'text' => r['text'].to_s,
158
+ 'note' => r['note'].to_s,
159
+ 'tags' => parse_tags(r['tags']),
160
+ 'action' => normalize_action_block(r['action'], r['arguments'])
161
+ }
162
+ end
163
+ when 'text', 'txt'
164
+ str.to_s.split(/\r?\n/).reject(&:empty?).map { |line| parse_text(line, divider: divider) }
165
+ end
166
+ end
167
+
168
+ def serialize_text(action, divider: '||')
169
+ parts = []
170
+ act = action['action'] && action['action']['action']
171
+ args = Array(action['action'] && action['action']['arguments']).join(',')
172
+ parts << (act || 'UPDATE')
173
+ parts << args
174
+ parts << "#{action['file_path']}:#{action['line']}"
175
+ parts << Array(action['parents']).join('>')
176
+ parts << (action['text'] || '')
177
+ parts << (action['note'] || '').gsub("\n", '\\n')
178
+ parts << serialize_tags(action['tags'])
179
+ parts.join(divider)
180
+ end
181
+
182
+ def parse_text(line, divider: '||')
183
+ tokens = line.split(divider, 7)
184
+ action_token = tokens[0].to_s.strip
185
+ if action_name?(action_token)
186
+ act = action_token
187
+ args = tokens[1]
188
+ fileline = tokens[2]
189
+ parents = tokens[3]
190
+ text = tokens[4]
191
+ note = tokens[5]
192
+ tags = tokens[6]
193
+ else
194
+ act = 'UPDATE'
195
+ args = ''
196
+ fileline = tokens[0]
197
+ parents = tokens[1]
198
+ text = tokens[2]
199
+ note = tokens[3]
200
+ tags = tokens[4]
201
+ end
202
+ fp, ln = (fileline || '').split(':', 2)
203
+ {
204
+ 'file_path' => fp.to_s,
205
+ 'line' => ln.to_i,
206
+ 'parents' => (parents.to_s.empty? ? [] : parents.split('>').map(&:strip)),
207
+ 'text' => text.to_s,
208
+ 'note' => note.to_s.gsub('\\n', "\n"),
209
+ 'tags' => parse_tags(tags),
210
+ 'action' => normalize_action_block(act, args)
211
+ }
212
+ end
213
+
214
+ def serialize_tags(tags)
215
+ Array(tags).map { |t| t['value'].to_s.empty? ? t['name'].to_s : %(#{t['name']}(#{t['value']})) }.join(';')
216
+ end
217
+
218
+ def parse_tags(str)
219
+ return [] if str.to_s.strip.empty?
220
+
221
+ str.split(';').map do |part|
222
+ if (m = part.match(/^([^()]+)\((.*)\)$/))
223
+ { 'name' => m[1].strip, 'value' => m[2].to_s }
224
+ else
225
+ { 'name' => part.strip, 'value' => '' }
226
+ end
227
+ end
228
+ end
229
+
230
+ def shebang?(file)
231
+ first = begin
232
+ File.open(file, 'r', &:readline)
233
+ rescue StandardError
234
+ ''
235
+ end
236
+ first.start_with?('#!')
237
+ end
238
+
239
+ def action_name?(name)
240
+ return false if name.to_s.strip.empty?
241
+
242
+ %w[update delete complete finish restore unfinish archive add_tag delete_tag remove_tag move].include?(name.to_s.downcase)
243
+ end
244
+
245
+ def normalize_action_block(action_name, args)
246
+ name = (action_name || 'UPDATE').to_s.upcase
247
+ name = 'DELETE_TAG' if name == 'REMOVE_TAG'
248
+ name = 'COMPLETE' if name == 'FINISH'
249
+ name = 'RESTORE' if name == 'UNFINISH'
250
+ {
251
+ 'action' => name,
252
+ 'arguments' => args.is_a?(Array) ? args : args.to_s.split(/[,;]/).map(&:strip).reject(&:empty?)
253
+ }
254
+ end
255
+
256
+ def default_readme_contents
257
+ <<~MD
258
+ # NA Plugins
259
+
260
+ Put your scripts in this folder. Each plugin must start with a shebang (#!) so NA knows how to execute it.
261
+
262
+ - Plugins receive input on STDIN and must write output to STDOUT
263
+ - Do not modify the original files; NA applies changes based on your output
264
+ - Do not change `file_path` or `line` in your output
265
+ - You may change `parents` (to move), `text`, `note`, and `tags`
266
+
267
+ ## Metadata (optional)
268
+ Add a comment block (after the shebang) with key: value pairs to declare defaults. Keys are case-insensitive.
269
+
270
+ ```
271
+ # input: json
272
+ # output: json
273
+ # name: My Fancy Plugin
274
+ ```
275
+
276
+ CLI flags `--input/--output/--divider` override metadata when provided.
277
+
278
+ ## Formats
279
+ Valid input/output formats: `json`, `yaml`, `csv`, `text`.
280
+
281
+ Text format line:
282
+ ```
283
+ ACTION||ARGS||file_path:line||parents||text||note||tags
284
+ ```
285
+ - If the first token isn’t a known ACTION, it’s treated as `file_path:line` and ACTION defaults to `UPDATE`.
286
+ - `parents`: `Parent>Child>Leaf`
287
+ - `tags`: `name(value);name;other(value)`
288
+
289
+ JSON/YAML object schema per action:
290
+ ```json
291
+ {
292
+ "action": { "action": "UPDATE", "arguments": ["arg1"] },
293
+ "file_path": "/path/to/todo.taskpaper",
294
+ "line": 15,
295
+ "parents": ["Project", "Subproject"],
296
+ "text": "- Do something @tag(value)",
297
+ "note": "Notes can\nspan lines",
298
+ "tags": [ { "name": "tag", "value": "value" } ]
299
+ }
300
+ ```
301
+
302
+ ACTION values (case-insensitive): `UPDATE` (default), `DELETE`, `COMPLETE`/`FINISH`, `RESTORE`/`UNFINISH`, `ARCHIVE`, `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, `MOVE`.
303
+ - For `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, and `MOVE`, provide arguments (e.g., tags or target project).
304
+
305
+ ## Examples
306
+
307
+ JSON input example (2 actions):
308
+ ```json
309
+ [
310
+ {
311
+ "file_path": "/projects/todo.taskpaper",
312
+ "line": 21,
313
+ "parents": ["Inbox"],
314
+ "text": "- Example action",
315
+ "note": "",
316
+ "tags": []
317
+ },
318
+ {
319
+ "file_path": "/projects/todo.taskpaper",
320
+ "line": 42,
321
+ "parents": ["Work", "Feature"],
322
+ "text": "- Add feature @na",
323
+ "note": "Spec TKT-123",
324
+ "tags": [{"name":"na","value":""}]
325
+ }
326
+ ]
327
+ ```
328
+
329
+ Text input example (2 actions):
330
+ ```
331
+ UPDATE||||/projects/todo.taskpaper:21||Inbox||- Example action||||
332
+ MOVE||Work:NewFeature||/projects/todo.taskpaper:42||Work>Feature||- Add feature @na||Spec TKT-123||na
333
+ ```
334
+
335
+ A plugin would read from STDIN, transform, and write the same shape to STDOUT. For example, a shell plugin that adds `@bar`:
336
+ ```bash
337
+ #!/usr/bin/env bash
338
+ # input: text
339
+ # output: text
340
+ while IFS= read -r line; do
341
+ [[ -z "$line" ]] && continue
342
+ IFS='||' read -r a1 a2 a3 a4 a5 a6 a7 <<<"$line"
343
+ # If first token is not an action, treat it as file:line
344
+ case "${a1^^}" in
345
+ UPDATE|DELETE|COMPLETE|FINISH|RESTORE|UNFINISH|ARCHIVE|ADD_TAG|DELETE_TAG|REMOVE_TAG|MOVE) : ;;
346
+ *) a7="$a6"; a6="$a5"; a5="$a4"; a4="$a3"; a3="$a2"; a2=""; a1="UPDATE";;
347
+ esac
348
+ tags="$a7"; tags=${tags:+"$tags;bar"}; tags=${tags:-bar}
349
+ echo "$a1||$a2||$a3||$a4||$a5||$a6||$tags"
350
+ done
351
+ ```
352
+
353
+ Python example (JSON):
354
+ ```python
355
+ #!/usr/bin/env python3
356
+ # input: json
357
+ # output: json
358
+ import sys, json, time
359
+ data = json.load(sys.stdin)
360
+ for a in data:
361
+ act = a.get('action') or {'action':'UPDATE','arguments':[]}
362
+ a['action'] = act
363
+ tags = a.get('tags', [])
364
+ tags.append({'name':'foo','value':time.strftime('%Y-%m-%d %H:%M:%S')})
365
+ a['tags'] = tags
366
+ json.dump(data, sys.stdout)
367
+ ```
368
+
369
+ Tips:
370
+ - Always preserve `file_path` and `line`
371
+ - Return only actions you want changed; others can be omitted
372
+ - For text IO, the field divider defaults to `||` and can be overridden with `--divider`
373
+ MD
374
+ end
375
+
376
+ def create_sample_plugins(dir)
377
+ py = File.join(dir, 'Add Foo.py')
378
+ sh = File.join(dir, 'Add Bar.sh')
379
+ unless File.exist?(py)
380
+ File.write(py, <<~PY)
381
+ #!/usr/bin/env python3
382
+ # name: Add Foo
383
+ # input: json
384
+ # output: json
385
+ import sys, json, time
386
+ data = json.load(sys.stdin)
387
+ now = time.strftime('%Y-%m-%d %H:%M:%S')
388
+ for a in data:
389
+ tags = a.get('tags', [])
390
+ tags.append({'name':'foo','value':now})
391
+ a['tags'] = tags
392
+ json.dump(data, sys.stdout)
393
+ PY
394
+ end
395
+ return if File.exist?(sh)
396
+
397
+ File.write(sh, <<~SH)
398
+ #!/usr/bin/env bash
399
+ # name: Add Bar
400
+ # input: text
401
+ # output: text
402
+ while IFS= read -r line; do
403
+ if [[ -z "$line" ]]; then continue; fi
404
+ if [[ "$line" == *"||"* ]]; then
405
+ fileline=${line%%||*}
406
+ rest=${line#*||}
407
+ parents=${rest%%||*}; rest=${rest#*||}
408
+ text=${rest%%||*}; rest=${rest#*||}
409
+ note=${rest%%||*}; tags=${rest#*||}
410
+ if [[ -z "$tags" ]]; then tags="bar"; else tags="$tags;bar"; fi
411
+ echo "$fileline||$parents||$text||$note||$tags"
412
+ else
413
+ echo "$line"
414
+ fi
415
+ done
416
+ SH
417
+ end
418
+ end
419
+ end