na 1.2.87 → 1.2.89
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/.cursor/commands/changelog.md +3 -1
- data/.rubocop_todo.yml +24 -16
- data/CHANGELOG.md +75 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +4 -1
- data/README.md +322 -45
- data/bin/commands/find.rb +62 -0
- data/bin/commands/next.rb +65 -0
- data/bin/commands/plugin.rb +289 -0
- data/bin/commands/tagged.rb +63 -0
- data/bin/commands/update.rb +169 -85
- data/bin/na +1 -0
- data/lib/na/action.rb +19 -0
- data/lib/na/actions.rb +3 -2
- data/lib/na/next_action.rb +105 -7
- data/lib/na/plugins.rb +564 -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 +240 -44
- metadata +20 -1
data/lib/na/plugins.rb
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
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 plugins_disabled_home
|
|
17
|
+
File.expand_path('~/.local/share/na/plugins_disabled')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def samples_generated_flag
|
|
21
|
+
File.expand_path('~/.local/share/na/.samples_generated')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def samples_generated?
|
|
25
|
+
File.exist?(samples_generated_flag)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mark_samples_generated
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(samples_generated_flag))
|
|
30
|
+
File.write(samples_generated_flag, Time.now.iso8601) unless File.exist?(samples_generated_flag)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ensure_plugins_home(force_samples: false)
|
|
34
|
+
dir = plugins_home
|
|
35
|
+
dis = plugins_disabled_home
|
|
36
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
37
|
+
FileUtils.mkdir_p(dis) unless File.directory?(dis)
|
|
38
|
+
|
|
39
|
+
readme = File.join(dir, 'README.md')
|
|
40
|
+
File.write(readme, default_readme_contents) unless File.exist?(readme)
|
|
41
|
+
|
|
42
|
+
return if samples_generated? || force_samples
|
|
43
|
+
|
|
44
|
+
create_sample_plugins(dis)
|
|
45
|
+
mark_samples_generated
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def generate_sample_plugins
|
|
49
|
+
dis = plugins_disabled_home
|
|
50
|
+
FileUtils.mkdir_p(dis) unless File.directory?(dis)
|
|
51
|
+
create_sample_plugins(dis, force: true)
|
|
52
|
+
mark_samples_generated
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def list_plugins
|
|
56
|
+
dir = plugins_home
|
|
57
|
+
return {} unless File.directory?(dir)
|
|
58
|
+
|
|
59
|
+
Dir.children(dir).each_with_object({}) do |entry, acc|
|
|
60
|
+
path = File.join(dir, entry)
|
|
61
|
+
next unless File.file?(path)
|
|
62
|
+
next if entry =~ /\.(md|bak)$/i
|
|
63
|
+
next unless shebang?(path)
|
|
64
|
+
|
|
65
|
+
base = File.basename(entry, File.extname(entry))
|
|
66
|
+
key = base.gsub(/[\s_]/, '')
|
|
67
|
+
acc[key.downcase] = path
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def list_plugins_disabled
|
|
72
|
+
dir = plugins_disabled_home
|
|
73
|
+
return {} unless File.directory?(dir)
|
|
74
|
+
|
|
75
|
+
Dir.children(dir).each_with_object({}) do |entry, acc|
|
|
76
|
+
path = File.join(dir, entry)
|
|
77
|
+
next unless File.file?(path)
|
|
78
|
+
next if entry =~ /\.(md|bak)$/i
|
|
79
|
+
next unless shebang?(path)
|
|
80
|
+
|
|
81
|
+
base = File.basename(entry, File.extname(entry))
|
|
82
|
+
key = base.gsub(/[\s_]/, '')
|
|
83
|
+
acc[key.downcase] = path
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def resolve_plugin(name)
|
|
88
|
+
return nil unless name && !name.to_s.strip.empty?
|
|
89
|
+
|
|
90
|
+
normalized = name.to_s.strip.gsub(/[\s_]/, '').downcase
|
|
91
|
+
candidates = list_plugins
|
|
92
|
+
return candidates[normalized] if candidates.key?(normalized)
|
|
93
|
+
|
|
94
|
+
# Fallback: try exact filename match in dir
|
|
95
|
+
path = File.join(plugins_home, name)
|
|
96
|
+
return path if File.file?(path)
|
|
97
|
+
|
|
98
|
+
# Also check disabled folder
|
|
99
|
+
path = File.join(plugins_disabled_home, name)
|
|
100
|
+
File.file?(path) ? path : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def shebang_for(file)
|
|
104
|
+
first = begin
|
|
105
|
+
File.open(file, 'r', &:readline)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
''
|
|
108
|
+
end
|
|
109
|
+
first.start_with?('#!') ? first.sub('#!', '').strip : nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def infer_shebang_for_extension(ext)
|
|
113
|
+
case ext.downcase
|
|
114
|
+
when '.rb' then '#!/usr/bin/env ruby'
|
|
115
|
+
when '.py' then '#!/usr/bin/env python3'
|
|
116
|
+
when '.zsh' then '#!/usr/bin/env zsh'
|
|
117
|
+
when '.fish' then '#!/usr/bin/env fish'
|
|
118
|
+
when '.js', '.mjs' then '#!/usr/bin/env node'
|
|
119
|
+
else '#!/usr/bin/env bash'
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_plugin_metadata(file)
|
|
124
|
+
meta = { 'input' => nil, 'output' => nil, 'name' => nil }
|
|
125
|
+
lines = File.readlines(file, chomp: true)
|
|
126
|
+
return meta if lines.empty?
|
|
127
|
+
|
|
128
|
+
# skip shebang
|
|
129
|
+
i = 0
|
|
130
|
+
i += 1 if lines[0].to_s.start_with?('#!')
|
|
131
|
+
# skip leading blanks
|
|
132
|
+
i += 1 while i < lines.length && lines[i].strip.empty?
|
|
133
|
+
while i < lines.length
|
|
134
|
+
line = lines[i]
|
|
135
|
+
break if line.strip.empty?
|
|
136
|
+
|
|
137
|
+
# strip common comment leaders
|
|
138
|
+
stripped = line.sub(%r{^\s*(#|//)}, '').strip
|
|
139
|
+
if (m = stripped.match(/^([A-Za-z]+)\s*:\s*(.+)$/))
|
|
140
|
+
key = m[1].downcase
|
|
141
|
+
val = m[2].strip
|
|
142
|
+
case key
|
|
143
|
+
when 'input', 'output'
|
|
144
|
+
meta[key] = val.downcase
|
|
145
|
+
when 'name', 'title'
|
|
146
|
+
meta['name'] = val
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
break if meta.values_at('input', 'output', 'name').compact.size == 3
|
|
150
|
+
|
|
151
|
+
i += 1
|
|
152
|
+
end
|
|
153
|
+
meta
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def run_plugin(file, stdin_str)
|
|
157
|
+
interp = shebang_for(file)
|
|
158
|
+
cmd = interp ? %(#{interp} #{Shellwords.escape(file)}) : %(sh #{Shellwords.escape(file)})
|
|
159
|
+
IO.popen(cmd, 'r+', err: %i[child out]) do |io|
|
|
160
|
+
io.write(stdin_str.to_s)
|
|
161
|
+
io.close_write
|
|
162
|
+
io.read
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def enable_plugin(name)
|
|
167
|
+
# Try by resolved path; if already enabled, return
|
|
168
|
+
path = resolve_plugin(name)
|
|
169
|
+
return path if path && File.dirname(path) == plugins_home
|
|
170
|
+
|
|
171
|
+
# Find in disabled by normalized name
|
|
172
|
+
disabled_map = Dir.exist?(plugins_disabled_home) ? Dir.children(plugins_disabled_home) : []
|
|
173
|
+
from = disabled_map.map { |e| File.join(plugins_disabled_home, e) }
|
|
174
|
+
.find { |p| File.basename(p).downcase.start_with?(name.to_s.downcase) }
|
|
175
|
+
from ||= File.join(plugins_disabled_home, name)
|
|
176
|
+
to = File.join(plugins_home, File.basename(from))
|
|
177
|
+
FileUtils.mv(from, to)
|
|
178
|
+
to
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def disable_plugin(name)
|
|
182
|
+
path = resolve_plugin(name)
|
|
183
|
+
return path if path && File.dirname(path) == plugins_disabled_home
|
|
184
|
+
|
|
185
|
+
enabled_map = Dir.exist?(plugins_home) ? Dir.children(plugins_home) : []
|
|
186
|
+
from = enabled_map.map { |e| File.join(plugins_home, e) }
|
|
187
|
+
.find { |p| File.basename(p).downcase.start_with?(name.to_s.downcase) }
|
|
188
|
+
from ||= File.join(plugins_home, name)
|
|
189
|
+
to = File.join(plugins_disabled_home, File.basename(from))
|
|
190
|
+
FileUtils.mv(from, to)
|
|
191
|
+
to
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def create_plugin(name, language: nil)
|
|
195
|
+
base = File.basename(name)
|
|
196
|
+
ext = File.extname(base)
|
|
197
|
+
if ext.empty? && language
|
|
198
|
+
ext = language.start_with?('.') ? language : ".#{language.split('/').last}"
|
|
199
|
+
end
|
|
200
|
+
ext = '.sh' if ext.empty?
|
|
201
|
+
she = language&.start_with?('/') ? language : infer_shebang_for_extension(ext)
|
|
202
|
+
file = File.join(plugins_home, base.sub(File.extname(base), '') + ext)
|
|
203
|
+
content = []
|
|
204
|
+
content << she
|
|
205
|
+
content << "# name: #{base.sub(File.extname(base), '')}"
|
|
206
|
+
content << '# input: json'
|
|
207
|
+
content << '# output: json'
|
|
208
|
+
content << '# New plugin template'
|
|
209
|
+
content << ''
|
|
210
|
+
content << '# Read STDIN and echo back unchanged'
|
|
211
|
+
content << 'if command -v python3 >/dev/null 2>&1; then'
|
|
212
|
+
content << " python3 - \"$@\" <<'PY'"
|
|
213
|
+
content << 'import sys, json'
|
|
214
|
+
content << 'data = json.load(sys.stdin)'
|
|
215
|
+
content << 'json.dump(data, sys.stdout)'
|
|
216
|
+
content << 'PY'
|
|
217
|
+
content << 'else'
|
|
218
|
+
content << ' cat'
|
|
219
|
+
content << 'fi'
|
|
220
|
+
File.write(file, content.join("\n"))
|
|
221
|
+
file
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def serialize_actions(actions, format: 'json', divider: '||')
|
|
225
|
+
case format.to_s.downcase
|
|
226
|
+
when 'json'
|
|
227
|
+
JSON.pretty_generate(actions)
|
|
228
|
+
when 'yaml', 'yml'
|
|
229
|
+
YAML.dump(actions)
|
|
230
|
+
when 'csv'
|
|
231
|
+
CSV.generate(force_quotes: true) do |csv|
|
|
232
|
+
csv << %w[action arguments file_path line parents text note tags]
|
|
233
|
+
actions.each do |a|
|
|
234
|
+
csv << [
|
|
235
|
+
(a['action'] && a['action']['action']) || 'UPDATE',
|
|
236
|
+
Array(a['action'] && a['action']['arguments']).join(','),
|
|
237
|
+
a['file_path'],
|
|
238
|
+
a['line'],
|
|
239
|
+
Array(a['parents']).join('>'),
|
|
240
|
+
a['text'] || '',
|
|
241
|
+
a['note'] || '',
|
|
242
|
+
serialize_tags(a['tags'])
|
|
243
|
+
]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
when 'text', 'txt'
|
|
247
|
+
actions.map { |a| serialize_text(a, divider: divider) }.join("\n")
|
|
248
|
+
else
|
|
249
|
+
JSON.generate(actions)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def parse_actions(str, format: 'json', divider: '||')
|
|
254
|
+
case format.to_s.downcase
|
|
255
|
+
when 'json'
|
|
256
|
+
JSON.parse(str)
|
|
257
|
+
when 'yaml', 'yml'
|
|
258
|
+
YAML.safe_load(str, permitted_classes: [Time], aliases: true)
|
|
259
|
+
when 'csv'
|
|
260
|
+
rows = CSV.parse(str.to_s, headers: true)
|
|
261
|
+
rows = CSV.parse(str.to_s) if rows.nil? || rows.empty?
|
|
262
|
+
rows.map do |row|
|
|
263
|
+
r = if row.is_a?(CSV::Row)
|
|
264
|
+
row.to_h
|
|
265
|
+
else
|
|
266
|
+
{
|
|
267
|
+
'action' => row[0], 'arguments' => row[1], 'file_path' => row[2], 'line' => row[3],
|
|
268
|
+
'parents' => row[4], 'text' => row[5], 'note' => row[6], 'tags' => row[7]
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
{
|
|
272
|
+
'file_path' => r['file_path'].to_s,
|
|
273
|
+
'line' => r['line'].to_i,
|
|
274
|
+
'parents' => (r['parents'].to_s.empty? ? [] : r['parents'].split('>').map(&:strip)),
|
|
275
|
+
'text' => r['text'].to_s,
|
|
276
|
+
'note' => r['note'].to_s,
|
|
277
|
+
'tags' => parse_tags(r['tags']),
|
|
278
|
+
'action' => normalize_action_block(r['action'], r['arguments'])
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
when 'text', 'txt'
|
|
282
|
+
str.to_s.split(/\r?\n/).reject(&:empty?).map { |line| parse_text(line, divider: divider) }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def serialize_text(action, divider: '||')
|
|
287
|
+
parts = []
|
|
288
|
+
act = action['action'] && action['action']['action']
|
|
289
|
+
args = Array(action['action'] && action['action']['arguments']).join(',')
|
|
290
|
+
parts << (act || 'UPDATE')
|
|
291
|
+
parts << args
|
|
292
|
+
parts << "#{action['file_path']}:#{action['line']}"
|
|
293
|
+
parts << Array(action['parents']).join('>')
|
|
294
|
+
parts << (action['text'] || '')
|
|
295
|
+
parts << (action['note'] || '').gsub("\n", '\\n')
|
|
296
|
+
parts << serialize_tags(action['tags'])
|
|
297
|
+
parts.join(divider)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def parse_text(line, divider: '||')
|
|
301
|
+
tokens = line.split(divider, 7)
|
|
302
|
+
action_token = tokens[0].to_s.strip
|
|
303
|
+
if action_name?(action_token)
|
|
304
|
+
act = action_token
|
|
305
|
+
args = tokens[1]
|
|
306
|
+
fileline = tokens[2]
|
|
307
|
+
parents = tokens[3]
|
|
308
|
+
text = tokens[4]
|
|
309
|
+
note = tokens[5]
|
|
310
|
+
tags = tokens[6]
|
|
311
|
+
else
|
|
312
|
+
act = 'UPDATE'
|
|
313
|
+
args = ''
|
|
314
|
+
fileline = tokens[0]
|
|
315
|
+
parents = tokens[1]
|
|
316
|
+
text = tokens[2]
|
|
317
|
+
note = tokens[3]
|
|
318
|
+
tags = tokens[4]
|
|
319
|
+
end
|
|
320
|
+
fp, ln = (fileline || '').split(':', 2)
|
|
321
|
+
{
|
|
322
|
+
'file_path' => fp.to_s,
|
|
323
|
+
'line' => ln.to_i,
|
|
324
|
+
'parents' => (parents.to_s.empty? ? [] : parents.split('>').map(&:strip)),
|
|
325
|
+
'text' => text.to_s,
|
|
326
|
+
'note' => note.to_s.gsub('\\n', "\n"),
|
|
327
|
+
'tags' => parse_tags(tags),
|
|
328
|
+
'action' => normalize_action_block(act, args)
|
|
329
|
+
}
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def serialize_tags(tags)
|
|
333
|
+
Array(tags).map { |t| t['value'].to_s.empty? ? t['name'].to_s : %(#{t['name']}(#{t['value']})) }.join(';')
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def parse_tags(str)
|
|
337
|
+
return [] if str.to_s.strip.empty?
|
|
338
|
+
|
|
339
|
+
str.split(';').map do |part|
|
|
340
|
+
if (m = part.match(/^([^()]+)\((.*)\)$/))
|
|
341
|
+
{ 'name' => m[1].strip, 'value' => m[2].to_s }
|
|
342
|
+
else
|
|
343
|
+
{ 'name' => part.strip, 'value' => '' }
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def shebang?(file)
|
|
349
|
+
first = begin
|
|
350
|
+
File.open(file, 'r', &:readline)
|
|
351
|
+
rescue StandardError
|
|
352
|
+
''
|
|
353
|
+
end
|
|
354
|
+
first.start_with?('#!')
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def action_name?(name)
|
|
358
|
+
return false if name.to_s.strip.empty?
|
|
359
|
+
|
|
360
|
+
%w[update delete complete finish restore unfinish archive add_tag delete_tag remove_tag move].include?(name.to_s.downcase)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def normalize_action_block(action_name, args)
|
|
364
|
+
name = (action_name || 'UPDATE').to_s.upcase
|
|
365
|
+
name = 'DELETE_TAG' if name == 'REMOVE_TAG'
|
|
366
|
+
name = 'COMPLETE' if name == 'FINISH'
|
|
367
|
+
name = 'RESTORE' if name == 'UNFINISH'
|
|
368
|
+
{
|
|
369
|
+
'action' => name,
|
|
370
|
+
'arguments' => args.is_a?(Array) ? args : args.to_s.split(/[,;]/).map(&:strip).reject(&:empty?)
|
|
371
|
+
}
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def default_readme_contents
|
|
375
|
+
<<~MD
|
|
376
|
+
# NA Plugins
|
|
377
|
+
|
|
378
|
+
Put your scripts in this folder. Each plugin must start with a shebang (#!) so NA knows how to execute it.
|
|
379
|
+
|
|
380
|
+
- Plugins receive input on STDIN and must write output to STDOUT
|
|
381
|
+
- Do not modify the original files; NA applies changes based on your output
|
|
382
|
+
- Do not change `file_path` or `line` in your output
|
|
383
|
+
- You may change `parents` (to move), `text`, `note`, and `tags`
|
|
384
|
+
|
|
385
|
+
## Metadata (optional)
|
|
386
|
+
Add a comment block (after the shebang) with key: value pairs to declare defaults. Keys are case-insensitive.
|
|
387
|
+
|
|
388
|
+
```
|
|
389
|
+
# input: json
|
|
390
|
+
# output: json
|
|
391
|
+
# name: My Fancy Plugin
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
CLI flags `--input/--output/--divider` override metadata when provided.
|
|
395
|
+
|
|
396
|
+
## Formats
|
|
397
|
+
Valid input/output formats: `json`, `yaml`, `csv`, `text`.
|
|
398
|
+
|
|
399
|
+
Text format line:
|
|
400
|
+
```
|
|
401
|
+
ACTION||ARGS||file_path:line||parents||text||note||tags
|
|
402
|
+
```
|
|
403
|
+
- If the first token isn’t a known ACTION, it’s treated as `file_path:line` and ACTION defaults to `UPDATE`.
|
|
404
|
+
- `parents`: `Parent>Child>Leaf`
|
|
405
|
+
- `tags`: `name(value);name;other(value)`
|
|
406
|
+
|
|
407
|
+
JSON/YAML object schema per action:
|
|
408
|
+
```json
|
|
409
|
+
{
|
|
410
|
+
"action": { "action": "UPDATE", "arguments": ["arg1"] },
|
|
411
|
+
"file_path": "/path/to/todo.taskpaper",
|
|
412
|
+
"line": 15,
|
|
413
|
+
"parents": ["Project", "Subproject"],
|
|
414
|
+
"text": "- Do something @tag(value)",
|
|
415
|
+
"note": "Notes can\nspan lines",
|
|
416
|
+
"tags": [ { "name": "tag", "value": "value" } ]
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
ACTION values (case-insensitive): `UPDATE` (default), `DELETE`, `COMPLETE`/`FINISH`, `RESTORE`/`UNFINISH`, `ARCHIVE`, `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, `MOVE`.
|
|
421
|
+
- For `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, and `MOVE`, provide arguments (e.g., tags or target project).
|
|
422
|
+
|
|
423
|
+
## Examples
|
|
424
|
+
|
|
425
|
+
JSON input example (2 actions):
|
|
426
|
+
```json
|
|
427
|
+
[
|
|
428
|
+
{
|
|
429
|
+
"file_path": "/projects/todo.taskpaper",
|
|
430
|
+
"line": 21,
|
|
431
|
+
"parents": ["Inbox"],
|
|
432
|
+
"text": "- Example action",
|
|
433
|
+
"note": "",
|
|
434
|
+
"tags": []
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
"file_path": "/projects/todo.taskpaper",
|
|
438
|
+
"line": 42,
|
|
439
|
+
"parents": ["Work", "Feature"],
|
|
440
|
+
"text": "- Add feature @na",
|
|
441
|
+
"note": "Spec TKT-123",
|
|
442
|
+
"tags": [{"name":"na","value":""}]
|
|
443
|
+
}
|
|
444
|
+
]
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Text input example (2 actions):
|
|
448
|
+
```
|
|
449
|
+
UPDATE||||/projects/todo.taskpaper:21||Inbox||- Example action||||
|
|
450
|
+
MOVE||Work:NewFeature||/projects/todo.taskpaper:42||Work>Feature||- Add feature @na||Spec TKT-123||na
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
A plugin would read from STDIN, transform, and write the same shape to STDOUT. For example, a shell plugin that adds `@bar`:
|
|
454
|
+
```bash
|
|
455
|
+
#!/usr/bin/env bash
|
|
456
|
+
# input: text
|
|
457
|
+
# output: text
|
|
458
|
+
while IFS= read -r line; do
|
|
459
|
+
[[ -z "$line" ]] && continue
|
|
460
|
+
IFS='||' read -r a1 a2 a3 a4 a5 a6 a7 <<<"$line"
|
|
461
|
+
# If first token is not an action, treat it as file:line
|
|
462
|
+
case "${a1^^}" in
|
|
463
|
+
UPDATE|DELETE|COMPLETE|FINISH|RESTORE|UNFINISH|ARCHIVE|ADD_TAG|DELETE_TAG|REMOVE_TAG|MOVE) : ;;
|
|
464
|
+
*) a7="$a6"; a6="$a5"; a5="$a4"; a4="$a3"; a3="$a2"; a2=""; a1="UPDATE";;
|
|
465
|
+
esac
|
|
466
|
+
tags="$a7"; tags=${tags:+"$tags;bar"}; tags=${tags:-bar}
|
|
467
|
+
echo "$a1||$a2||$a3||$a4||$a5||$a6||$tags"
|
|
468
|
+
done
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Python example (JSON):
|
|
472
|
+
```python
|
|
473
|
+
#!/usr/bin/env python3
|
|
474
|
+
# input: json
|
|
475
|
+
# output: json
|
|
476
|
+
import sys, json, time
|
|
477
|
+
data = json.load(sys.stdin)
|
|
478
|
+
for a in data:
|
|
479
|
+
act = a.get('action') or {'action':'UPDATE','arguments':[]}
|
|
480
|
+
a['action'] = act
|
|
481
|
+
tags = a.get('tags', [])
|
|
482
|
+
tags.append({'name':'foo','value':time.strftime('%Y-%m-%d %H:%M:%S')})
|
|
483
|
+
a['tags'] = tags
|
|
484
|
+
json.dump(data, sys.stdout)
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Tips:
|
|
488
|
+
- Always preserve `file_path` and `line`
|
|
489
|
+
- Return only actions you want changed; others can be omitted
|
|
490
|
+
- For text IO, the field divider defaults to `||` and can be overridden with `--divider`
|
|
491
|
+
MD
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def create_sample_plugins(dir, force: false)
|
|
495
|
+
py = File.join(dir, 'Add Foo.py')
|
|
496
|
+
sh = File.join(dir, 'Add Bar.sh')
|
|
497
|
+
|
|
498
|
+
if force || !File.exist?(py)
|
|
499
|
+
FileUtils.rm_f(py)
|
|
500
|
+
File.write(py, <<~PY)
|
|
501
|
+
#!/usr/bin/env python3
|
|
502
|
+
# name: Add Foo
|
|
503
|
+
# input: json
|
|
504
|
+
# output: json
|
|
505
|
+
import sys, json, time
|
|
506
|
+
data = json.load(sys.stdin)
|
|
507
|
+
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
|
508
|
+
for a in data:
|
|
509
|
+
tags = a.get('tags', [])
|
|
510
|
+
tags.append({'name':'foo','value':now})
|
|
511
|
+
a['tags'] = tags
|
|
512
|
+
json.dump(data, sys.stdout)
|
|
513
|
+
PY
|
|
514
|
+
end
|
|
515
|
+
unless File.exist?(sh)
|
|
516
|
+
File.write(sh, <<~SH)
|
|
517
|
+
#!/usr/bin/env bash
|
|
518
|
+
# name: Add Bar
|
|
519
|
+
# input: text
|
|
520
|
+
# output: text
|
|
521
|
+
while IFS= read -r line; do
|
|
522
|
+
if [[ -z "$line" ]]; then continue; fi
|
|
523
|
+
if [[ "$line" == *"||"* ]]; then
|
|
524
|
+
fileline=${line%%||*}
|
|
525
|
+
rest=${line#*||}
|
|
526
|
+
parents=${rest%%||*}; rest=${rest#*||}
|
|
527
|
+
text=${rest%%||*}; rest=${rest#*||}
|
|
528
|
+
note=${rest%%||*}; tags=${rest#*||}
|
|
529
|
+
if [[ -z "$tags" ]]; then tags="bar"; else tags="$tags;bar"; fi
|
|
530
|
+
echo "$fileline||$parents||$text||$note||$tags"
|
|
531
|
+
else
|
|
532
|
+
echo "$line"
|
|
533
|
+
fi
|
|
534
|
+
done
|
|
535
|
+
SH
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
return unless force || !File.exist?(sh)
|
|
539
|
+
|
|
540
|
+
FileUtils.rm_f(sh)
|
|
541
|
+
File.write(sh, <<~SH)
|
|
542
|
+
#!/usr/bin/env bash
|
|
543
|
+
# name: Add Bar
|
|
544
|
+
# input: text
|
|
545
|
+
# output: text
|
|
546
|
+
while IFS= read -r line; do
|
|
547
|
+
if [[ -z "$line" ]]; then continue; fi
|
|
548
|
+
if [[ "$line" == *"||"* ]]; then
|
|
549
|
+
fileline=${line%%||*}
|
|
550
|
+
rest=${line#*||}
|
|
551
|
+
parents=${rest%%||*}; rest=${rest#*||}
|
|
552
|
+
text=${rest%%||*}; rest=${rest#*||}
|
|
553
|
+
note=${rest%%||*}; tags=${rest#*||}
|
|
554
|
+
if [[ -z "$tags" ]]; then tags="bar"; else tags="$tags;bar"; fi
|
|
555
|
+
echo "$fileline||$parents||$text||$note||$tags"
|
|
556
|
+
else
|
|
557
|
+
echo "$line"
|
|
558
|
+
fi
|
|
559
|
+
done
|
|
560
|
+
SH
|
|
561
|
+
File.chmod(0o755, sh)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
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')
|