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.
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 = 0
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
- if (length + uncolored.length + 1) <= width
202
+ candidate = length + 1 + uncolored.length
203
+ if candidate <= width
202
204
  line << word
203
- length += uncolored.length + 1
205
+ length = candidate
204
206
  else
205
207
  output << line.join(' ')
206
208
  line = [word]
207
- length = uncolored.length + 1
209
+ length = uncolored.length
208
210
  end
209
211
  end
210
212
  output << line.join(' ')
data/lib/na/version.rb CHANGED
@@ -5,5 +5,5 @@
5
5
  module Na
6
6
  ##
7
7
  # Current version of the na gem.
8
- VERSION = '1.2.87'
8
+ VERSION = '1.2.89'
9
9
  end
data/lib/na.rb CHANGED
@@ -35,3 +35,4 @@ require 'na/types'
35
35
  require 'na/editor'
36
36
  require 'na/next_action'
37
37
  require 'na/prompt'
38
+ require 'na/plugins'
@@ -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')