na 1.2.87 → 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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +17 -9
- data/2025-10-29-one-more-na-update.md +142 -0
- data/CHANGELOG.md +39 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +4 -1
- data/README.md +128 -2
- data/bin/commands/find.rb +62 -0
- data/bin/commands/next.rb +65 -0
- data/bin/commands/plugin.rb +75 -0
- data/bin/commands/tagged.rb +63 -0
- data/bin/commands/update.rb +54 -1
- data/bin/na +1 -0
- data/lib/na/action.rb +13 -0
- data/lib/na/next_action.rb +92 -0
- data/lib/na/plugins.rb +419 -0
- data/lib/na/string.rb +6 -4
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +1 -0
- data/na/Test.todo.markdown +32 -0
- data/na/test.md +21 -0
- data/na.gemspec +1 -0
- data/plugins.md +38 -0
- data/src/_README.md +110 -1
- metadata +21 -1
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
|
data/lib/na/string.rb
CHANGED
|
@@ -193,18 +193,20 @@ class ::String
|
|
|
193
193
|
|
|
194
194
|
output = []
|
|
195
195
|
line = []
|
|
196
|
-
length
|
|
196
|
+
# Track visible length of current line (exclude the separating space before first word)
|
|
197
|
+
length = -1
|
|
197
198
|
text = gsub(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
|
|
198
199
|
|
|
199
200
|
text.split.each do |word|
|
|
200
201
|
uncolored = NA::Color.uncolor(word)
|
|
201
|
-
|
|
202
|
+
candidate = length + 1 + uncolored.length
|
|
203
|
+
if candidate <= width
|
|
202
204
|
line << word
|
|
203
|
-
length
|
|
205
|
+
length = candidate
|
|
204
206
|
else
|
|
205
207
|
output << line.join(' ')
|
|
206
208
|
line = [word]
|
|
207
|
-
length = uncolored.length
|
|
209
|
+
length = uncolored.length
|
|
208
210
|
end
|
|
209
211
|
end
|
|
210
212
|
output << line.join(' ')
|
data/lib/na/version.rb
CHANGED
data/lib/na.rb
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
comment: 2023-09-03
|
|
3
|
+
keywords:
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Project3:
|
|
7
|
+
Project0:
|
|
8
|
+
- This is another task @na
|
|
9
|
+
- How about this one? @na
|
|
10
|
+
Subproject:
|
|
11
|
+
- Bollocks @na
|
|
12
|
+
Subsub:
|
|
13
|
+
- Hey, I think it's all working @na
|
|
14
|
+
- Is this at the end? @na
|
|
15
|
+
- This better work @na
|
|
16
|
+
2023-09-08:
|
|
17
|
+
Project2:
|
|
18
|
+
- new_task @na
|
|
19
|
+
- new_task @na
|
|
20
|
+
- test task @na
|
|
21
|
+
Project0:
|
|
22
|
+
- other task @na
|
|
23
|
+
- other task @na
|
|
24
|
+
- There, that's better @na
|
|
25
|
+
Subproject:
|
|
26
|
+
- new_task 2 @na
|
|
27
|
+
- new_task @na
|
|
28
|
+
- new_task 2 @na
|
|
29
|
+
Project1:
|
|
30
|
+
- Test4
|
|
31
|
+
- Test5
|
|
32
|
+
- Test6
|
data/na/test.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
comment: 2023-09-03
|
|
3
|
+
keywords:
|
|
4
|
+
---
|
|
5
|
+
Other New Project:
|
|
6
|
+
- testing @na @butter
|
|
7
|
+
Brand New Project:
|
|
8
|
+
- testing @na
|
|
9
|
+
A multi line (multiline) note
|
|
10
|
+
with a line break
|
|
11
|
+
- testing @na
|
|
12
|
+
Project0:
|
|
13
|
+
|
|
14
|
+
- Test1
|
|
15
|
+
|
|
16
|
+
- Test2
|
|
17
|
+
|
|
18
|
+
Project1:
|
|
19
|
+
- Test4
|
|
20
|
+
- Test5
|
|
21
|
+
- Test6
|
data/na.gemspec
CHANGED
|
@@ -24,6 +24,7 @@ spec = Gem::Specification.new do |s|
|
|
|
24
24
|
s.add_development_dependency('minitest', '~> 5.14')
|
|
25
25
|
s.add_development_dependency('rdoc', '~> 4.3')
|
|
26
26
|
s.add_runtime_dependency('chronic', '~> 0.10', '>= 0.10.2')
|
|
27
|
+
s.add_runtime_dependency('csv', '>= 3.2')
|
|
27
28
|
s.add_runtime_dependency('git', '~> 3.0.0')
|
|
28
29
|
s.add_runtime_dependency('gli','~> 2.21.0')
|
|
29
30
|
s.add_runtime_dependency('mdless', '~> 1.0', '>= 1.0.32')
|
data/plugins.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
I would like to add a plugin architecture to na_gem. It should allow the user to add plugins to ~/.local/share/na/plugins/. These plugins can be any shell script (with a shebang). They can be run with `na plugin NAME`, which accepts the plugin filename with or without an extension, and with or without spaces (so that `plugin AddFoo` will run `Add Foo.sh` if found, but the user can also use `plugin "Add Foo"`).
|
|
2
|
+
|
|
3
|
+
A plugin will be a shell script that takes input on STDIN. The input should be an action as a JSON object, with the file path, line number, action text, note, and array of tags/values (`tags: [{ name: "done", value: "2025-10-29 03:00"}, { name: "na", value: ""}]`). That should be the default.
|
|
4
|
+
|
|
5
|
+
The `plugin` command should accept a `--input TYPE` flag that accepts `json`, `yaml` or `text`. The YAML should be the same as the JSON (but as YAML), and the text should just be the file_path:line_number, action text, and note, split with "||" (newlines in the note replaced with \n, and filename and line number are combined with : not the divider), with no colorization. One action per line. The "||" in `--input text` should also be a flag `--divider "STRING"` that defaults to "||", but allows the user to specify a different string to split the parts on.
|
|
6
|
+
|
|
7
|
+
The plugin will need to return output (on STDOUT) in the same format as the input (yaml, json, or text with specified divider), unless `--output FORMAT` is specified with a different type. The `plugin` command will execute the script for every command passed to it, and update the actions based on the returned output.
|
|
8
|
+
|
|
9
|
+
The `plugin` command should accept all the same filter flags as `finish` or other actions that update commands.
|
|
10
|
+
|
|
11
|
+
For the `update` command, it should accept a `--plugin NAME` flag, and if it's using interactive menus, a list of plugin names (basename minus extension) should be added to the list of available operations.
|
|
12
|
+
|
|
13
|
+
Also add a `--plugin NAME`, `--input TYPE`, and `--output TYPE` flag to all search and display commands (next, grep, tagged, etc.). That way the user can filter output with any command and run the result through the plugin.
|
|
14
|
+
|
|
15
|
+
In lieu of the `--input` and `--output` commands, the plugin itself can have a comment block after the shebang with `key: value` pairs. When reading a plugin, check for a comment block with `input: JSON` `output: YAML` (case insensitive). The user can also define a `name` or `title` (interchangeable) in this block, which will be used instead of the base name if provided. We need to ignore leading characters when scanning for this comment block (e.g. # or //). The block can have blank lines before it. The only keys we read are input, output, and name/title. Parsing stops at the first blank line or after all three keys are populated. Other keys might exist, like `author` or `description`, which should be ignored.
|
|
16
|
+
|
|
17
|
+
The plugins shouldn't need to be executable, the hashbang should be read and used to execute the script.
|
|
18
|
+
|
|
19
|
+
When `na` runs, it should check for the existence of the `plugins` directory, creating it if it's missing, and adding a `~/.local/share/na/plugsin/README.md` file with plugin instructions if one doesn't exist. Any `.md` or `.bak` file in the plugins directory should be ignored. In fact, let's have a helper validate the files in the directory by checking for a shebang and ignoring if none exists, and also ignoring any '.bak' or '.md' files.
|
|
20
|
+
|
|
21
|
+
Have NA also create 2 sample plugins in the `~/.local/share/na/plugins` folder when creating it (do not create plugins if the folder already exists). Have the sample plugins be a Python script and a Bash script. The sample plugins should just do something benign like add a tag with a dynamic value to the passed actions. In the README.md note that the user can delete the sample plugins. Give the sample plugins names "Add Foo.py" and "Add Bar.sh" and have them add @foo and @bar, respectively.
|
|
22
|
+
|
|
23
|
+
### Summary ###
|
|
24
|
+
|
|
25
|
+
- plugins are script files in ~/.local/share/na/plugins
|
|
26
|
+
- plugins require a shebang, which is used to execute them
|
|
27
|
+
- plugin base names (without extension) becomes the command name (spaces are handled)
|
|
28
|
+
- Ignore
|
|
29
|
+
- `plugin` subcommand
|
|
30
|
+
- accepts plugin name as argument
|
|
31
|
+
- has a `--input TYPE` flag that determines the input type (yaml, json, or text)
|
|
32
|
+
- has a `--output TYPE` (yaml, json, or text)
|
|
33
|
+
- has a `--divider` flag that determines the divider when `--input text` is used
|
|
34
|
+
- `update` subcommand
|
|
35
|
+
- accepts a `--plugin NAME` flag
|
|
36
|
+
- Adds plugin names to interactive menu when no action is specified
|
|
37
|
+
- main script parses the output of the plugin, stripping whitespace and reading it as YAML, JSON, or text split on the divider (based on `--output` and defaulting to the value of `--input`), then updates each action in the result. Line numbers should be passed on both input and output and used to update the specific actions.
|
|
38
|
+
- Generate README and scripts
|
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.87<!--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
|
|
|
@@ -375,6 +375,115 @@ If you're using a single global file, you'll need `--cwd_as` to be `tag` or `pro
|
|
|
375
375
|
|
|
376
376
|
After installing a hook, you'll need to close your terminal and start a new session to initialize the new commands.
|
|
377
377
|
|
|
378
|
+
### Plugins
|
|
379
|
+
|
|
380
|
+
NA supports a plugin system that allows you to run external scripts to transform or process actions. Plugins are stored in `~/.local/share/na/plugins` and can be written in any language with a shebang.
|
|
381
|
+
|
|
382
|
+
#### Getting Started
|
|
383
|
+
|
|
384
|
+
The first time NA runs, it will create the plugins directory with a README and two sample plugins:
|
|
385
|
+
- `Add Foo.py` - Adds a `@foo` tag with a timestamp
|
|
386
|
+
- `Add Bar.sh` - Adds a `@bar` tag
|
|
387
|
+
|
|
388
|
+
You can delete or modify these sample plugins as needed.
|
|
389
|
+
|
|
390
|
+
#### Running Plugins
|
|
391
|
+
|
|
392
|
+
Run a plugin with:
|
|
393
|
+
```bash
|
|
394
|
+
na plugin PLUGIN_NAME
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Or use plugins through the `update` command's interactive menu, or pipe actions through plugins on display commands:
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
na update --plugin PLUGIN_NAME # Run plugin on selected actions
|
|
401
|
+
na next --plugin PLUGIN_NAME # Transform output only (no file writes)
|
|
402
|
+
na tagged bug --plugin PLUGIN_NAME # Filter and transform
|
|
403
|
+
na find "search term" --plugin PLUGIN_NAME
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
#### Plugin Metadata
|
|
407
|
+
|
|
408
|
+
Plugins can specify their behavior in a metadata block after the shebang:
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
#!/usr/bin/env python3
|
|
412
|
+
# name: My Plugin
|
|
413
|
+
# input: json
|
|
414
|
+
# output: json
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Available metadata keys (case-insensitive):
|
|
418
|
+
- `input`: Input format (`json`, `yaml`, `csv`, `text`)
|
|
419
|
+
- `output`: Output format
|
|
420
|
+
- `name` or `title`: Display name (defaults to filename)
|
|
421
|
+
|
|
422
|
+
#### Input/Output Formats
|
|
423
|
+
|
|
424
|
+
Plugins accept and return action data. Use `--input` and `--output` flags to override metadata:
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
na plugin MY_PLUGIN --input text --output json --divider "||"
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**JSON/YAML Schema:**
|
|
431
|
+
```json
|
|
432
|
+
[
|
|
433
|
+
{
|
|
434
|
+
"file_path": "todo.taskpaper",
|
|
435
|
+
"line": 15,
|
|
436
|
+
"parents": ["Project", "Subproject"],
|
|
437
|
+
"text": "- Action text @tag(value)",
|
|
438
|
+
"note": "Note content",
|
|
439
|
+
"tags": [
|
|
440
|
+
{ "name": "tag", "value": "value" }
|
|
441
|
+
]
|
|
442
|
+
}
|
|
443
|
+
]
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Text Format:**
|
|
447
|
+
```
|
|
448
|
+
ACTION||ARGS||file_path:line||parents||text||note||tags
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Default divider is `||` (configurable with `--divider`).
|
|
452
|
+
- `parents`: `Parent>Child>Leaf`
|
|
453
|
+
- `tags`: `name(value);name;other(value)`
|
|
454
|
+
|
|
455
|
+
If the first token isn’t a known action, it’s treated as `file_path:line` and the action defaults to UPDATE.
|
|
456
|
+
|
|
457
|
+
#### Actions
|
|
458
|
+
|
|
459
|
+
Plugins may return an optional ACTION with arguments. Supported (case-insensitive):
|
|
460
|
+
- UPDATE (default; replace text/note/tags/parents)
|
|
461
|
+
- DELETE
|
|
462
|
+
- COMPLETE/FINISH
|
|
463
|
+
- RESTORE/UNFINISH
|
|
464
|
+
- ARCHIVE
|
|
465
|
+
- ADD_TAG (args: one or more tags)
|
|
466
|
+
- DELETE_TAG/REMOVE_TAG (args: one or more tags)
|
|
467
|
+
- MOVE (args: target project path)
|
|
468
|
+
|
|
469
|
+
#### Plugin Behavior
|
|
470
|
+
|
|
471
|
+
**On `update` or `plugin` command:**
|
|
472
|
+
- Plugins can modify text, notes, tags, and parents
|
|
473
|
+
- Changing `parents` will move the action to the new project location
|
|
474
|
+
- `file_path` and `line` cannot be changed
|
|
475
|
+
|
|
476
|
+
**On display commands (`next`, `tagged`, `find`):**
|
|
477
|
+
- Plugins only transform STDOUT (no file writes)
|
|
478
|
+
- Use returned text/note/tags/parents for rendering
|
|
479
|
+
- Parent changes affect display but not file structure
|
|
480
|
+
|
|
481
|
+
#### Override Formats
|
|
482
|
+
|
|
483
|
+
You can override plugin defaults with flags on any command that supports `--plugin`:
|
|
484
|
+
```bash
|
|
485
|
+
na next --plugin FOO --input csv --output text
|
|
486
|
+
```
|
|
378
487
|
|
|
379
488
|
[fzf]: https://github.com/junegunn/fzf
|
|
380
489
|
[gum]: https://github.com/charmbracelet/gum
|