na 1.2.88 → 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.
@@ -69,12 +69,16 @@ module NA
69
69
  return
70
70
  when 'MOVE'
71
71
  move_to = action_args.first.to_s
72
- update_action(file, { target_line: line }, add: action, move: move_to, all: true)
72
+ update_action(file, { target_line: line }, add: action, move: move_to, all: true, suppress_prompt: true)
73
73
  return
74
74
  end
75
75
 
76
76
  # Replace content on the existing action then write back in-place
77
77
  original_line = action.file_line
78
+ original_project = action.project
79
+ original_parent_chain = action.parent.dup
80
+
81
+ # Update action content
78
82
  action.action = text
79
83
  action.note = note.to_s.split("\n")
80
84
  action.action.gsub!(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
@@ -82,18 +86,13 @@ module NA
82
86
  tag_str = tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
83
87
  action.action = action.action.strip + (tag_str.empty? ? "" : " #{tag_str}")
84
88
  end
85
- # Ensure we update this exact action in-place
86
- update_action(file, { target_line: original_line }, add: action, all: true)
87
89
 
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
90
+ # Check if parents changed
91
+ parents_changed = new_project.to_s.strip != original_project || new_parent_chain != original_parent_chain
92
+ move_to = parents_changed ? ([new_project] + new_parent_chain).join(':') : nil
91
93
 
92
- update_action(file, nil,
93
- add: action,
94
- project: action.project,
95
- overwrite: true,
96
- move: move_to)
94
+ # Update in-place (with move if parents changed)
95
+ update_action(file, { target_line: original_line }, add: action, move: move_to, all: true, suppress_prompt: true)
97
96
  end
98
97
  include NA::Editor
99
98
 
@@ -143,6 +142,7 @@ module NA
143
142
  # @return [Boolean] result
144
143
  #
145
144
  def yn(prompt, default: true)
145
+ return default if ENV['NA_TEST'] == '1'
146
146
  return default unless $stdout.isatty
147
147
 
148
148
  tty_state = `stty -g`
@@ -422,7 +422,8 @@ module NA
422
422
  tagged: nil,
423
423
  started_at: nil,
424
424
  done_at: nil,
425
- duration_seconds: nil)
425
+ duration_seconds: nil,
426
+ suppress_prompt: false)
426
427
  # Coerce date/time inputs if passed as strings
427
428
  begin
428
429
  started_at = NA::Types.parse_date_begin(started_at) if started_at && !started_at.is_a?(Time)
@@ -447,14 +448,19 @@ module NA
447
448
  move = move.sub(/:$/, '')
448
449
  target_proj = projects.select { |pr| pr.project =~ /#{move.gsub(':', '.*?:.*?')}/i }.first
449
450
  if target_proj.nil?
450
- res = NA.yn(
451
- NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{move}#{NA.theme[:warning]} doesn't exist, add it"), default: true
452
- )
453
- if res
454
- target_proj = insert_project(target, move, projects)
451
+ if suppress_prompt || !$stdout.isatty
452
+ target_proj = insert_project(target, move)
455
453
  projects << target_proj
456
454
  else
457
- NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
455
+ res = NA.yn(
456
+ NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{move}#{NA.theme[:warning]} doesn't exist, add it"), default: true
457
+ )
458
+ if res
459
+ target_proj = insert_project(target, move)
460
+ projects << target_proj
461
+ else
462
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
463
+ end
458
464
  end
459
465
  end
460
466
  end
data/lib/na/plugins.rb CHANGED
@@ -13,14 +13,43 @@ module NA
13
13
  File.expand_path('~/.local/share/na/plugins')
14
14
  end
15
15
 
16
- def ensure_plugins_home
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)
17
34
  dir = plugins_home
18
- return if File.directory?(dir)
35
+ dis = plugins_disabled_home
36
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
37
+ FileUtils.mkdir_p(dis) unless File.directory?(dis)
19
38
 
20
- FileUtils.mkdir_p(dir)
21
39
  readme = File.join(dir, 'README.md')
22
40
  File.write(readme, default_readme_contents) unless File.exist?(readme)
23
- create_sample_plugins(dir)
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
24
53
  end
25
54
 
26
55
  def list_plugins
@@ -39,6 +68,22 @@ module NA
39
68
  end
40
69
  end
41
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
+
42
87
  def resolve_plugin(name)
43
88
  return nil unless name && !name.to_s.strip.empty?
44
89
 
@@ -48,6 +93,10 @@ module NA
48
93
 
49
94
  # Fallback: try exact filename match in dir
50
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)
51
100
  File.file?(path) ? path : nil
52
101
  end
53
102
 
@@ -60,6 +109,17 @@ module NA
60
109
  first.start_with?('#!') ? first.sub('#!', '').strip : nil
61
110
  end
62
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
+
63
123
  def parse_plugin_metadata(file)
64
124
  meta = { 'input' => nil, 'output' => nil, 'name' => nil }
65
125
  lines = File.readlines(file, chomp: true)
@@ -103,6 +163,64 @@ module NA
103
163
  end
104
164
  end
105
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
+
106
224
  def serialize_actions(actions, format: 'json', divider: '||')
107
225
  case format.to_s.downcase
108
226
  when 'json'
@@ -373,10 +491,12 @@ module NA
373
491
  MD
374
492
  end
375
493
 
376
- def create_sample_plugins(dir)
494
+ def create_sample_plugins(dir, force: false)
377
495
  py = File.join(dir, 'Add Foo.py')
378
496
  sh = File.join(dir, 'Add Bar.sh')
379
- unless File.exist?(py)
497
+
498
+ if force || !File.exist?(py)
499
+ FileUtils.rm_f(py)
380
500
  File.write(py, <<~PY)
381
501
  #!/usr/bin/env python3
382
502
  # name: Add Foo
@@ -392,8 +512,32 @@ module NA
392
512
  json.dump(data, sys.stdout)
393
513
  PY
394
514
  end
395
- return if File.exist?(sh)
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)
396
539
 
540
+ FileUtils.rm_f(sh)
397
541
  File.write(sh, <<~SH)
398
542
  #!/usr/bin/env bash
399
543
  # name: Add Bar
@@ -414,6 +558,7 @@ module NA
414
558
  fi
415
559
  done
416
560
  SH
561
+ File.chmod(0o755, sh)
417
562
  end
418
563
  end
419
564
  end
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.88'
8
+ VERSION = '1.2.89'
9
9
  end
data/src/_README.md CHANGED
@@ -9,7 +9,50 @@
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.87<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.88<!--END VER-->.
13
+
14
+ <!--GITHUB-->
15
+ ### Table of contents
16
+
17
+ - [Installation](#installation)
18
+ - [Optional Dependencies](#optional-dependencies)
19
+ - [Features](#features)
20
+ - [Easy matching](#easy-matching)
21
+ - [Recursion](#recursion)
22
+ - [Adding todos](#adding-todos)
23
+ - [Updating todos](#updating-todos)
24
+ - [Terminology](#terminology)
25
+ - [Usage](#usage)
26
+ - [Commands](#commands)
27
+ - [add](#add)
28
+ - [edit](#edit)
29
+ - [find](#find)
30
+ - [init, create](#init-create)
31
+ - [move](#move)
32
+ - [next, show](#next-show)
33
+ - [plugin](#plugin)
34
+ - [projects](#projects)
35
+ - [saved](#saved)
36
+ - [scan](#scan)
37
+ - [tagged](#tagged)
38
+ - [todos](#todos)
39
+ - [update](#update)
40
+ - [changelog](#changelog)
41
+ - [complete](#complete)
42
+ - [archive](#archive)
43
+ - [tag](#tag)
44
+ - [undo](#undo)
45
+ - [Configuration](#configuration)
46
+ - [Working with a single global file](#working-with-a-single-global-file)
47
+ - [Add tasks at the end of a project](#add-tasks-at-the-end-of-a-project)
48
+ - [Prompt Hooks](#prompt-hooks)
49
+ - [Time tracking](#time-tracking)
50
+ - [Plugins](#plugins)
51
+ - [Changelog](#changelog)
52
+ <!--END GITHUB--><!--JEKYLL
53
+ - Table of Contents
54
+ {:.toc}
55
+ -->
13
56
 
14
57
  `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
58
 
@@ -140,6 +183,54 @@ To see all next actions across all known todos, use `na next "*"`. You can combi
140
183
  @cli(bundle exec bin/na help next)
141
184
  ```
142
185
 
186
+ ##### plugin
187
+
188
+ Manage and run external plugins. See also the Plugins section below.
189
+
190
+ ```
191
+ @cli(bundle exec bin/na help plugin)
192
+ ```
193
+
194
+ ###### plugin new
195
+
196
+ Create a new plugin script (aliases: `n`). Infers shebang by extension or `--language`.
197
+
198
+ ```
199
+ @cli(bundle exec bin/na help plugin new)
200
+ ```
201
+
202
+ ###### plugin edit
203
+
204
+ Open an existing plugin in your default editor. Prompts if no name is given.
205
+
206
+ ```
207
+ @cli(bundle exec bin/na help plugin edit)
208
+ ```
209
+
210
+ ###### plugin run
211
+
212
+ Run a plugin on selected actions (aliases: `x`). Supports input/output format flags and filters.
213
+
214
+ ```
215
+ @cli(bundle exec bin/na help plugin run)
216
+ ```
217
+
218
+ ###### plugin enable
219
+
220
+ Move a plugin from `plugins_disabled` to `plugins` (alias: `e`).
221
+
222
+ ```
223
+ @cli(bundle exec bin/na help plugin enable)
224
+ ```
225
+
226
+ ###### plugin disable
227
+
228
+ Move a plugin from `plugins` to `plugins_disabled` (alias: `d`).
229
+
230
+ ```
231
+ @cli(bundle exec bin/na help plugin disable)
232
+ ```
233
+
143
234
  ##### projects
144
235
 
145
236
  List all projects in a file. If arguments are provided, they're used to match a todo file from history, otherwise the todo file(s) in the current directory will be used.
@@ -235,49 +326,6 @@ See the help output for a list of all available actions.
235
326
  @cli(bundle exec bin/na help update)
236
327
  ```
237
328
 
238
- #### Time tracking
239
-
240
- `na` supports tracking elapsed time between a start and finish for actions using `@started(YYYY-MM-DD HH:MM)` and `@done(YYYY-MM-DD HH:MM)` tags. Durations are not stored; they are calculated on the fly from these tags.
241
-
242
- - Add/Finish/Update flags:
243
- - `--started TIME` set a start time when creating or finishing an item
244
- - `--end TIME` (alias `--finished`) set a done time
245
- - `--duration DURATION` backfill start time from the provided end time
246
- - All flags accept natural language (via Chronic) and shorthand: `30m ago`, `-2h`, `2h30m`, `2:30 ago`, `yesterday 5pm`
247
-
248
- Examples:
249
-
250
- ```bash
251
- na add --started "30 minutes ago" "Investigate bug"
252
- na complete --finished now --duration 2h30m "Investigate bug"
253
- na update --started "yesterday 3pm" --end "yesterday 5:15pm" "Investigate bug"
254
- ```
255
-
256
- - Display flags (next/tagged):
257
- - `--times` show per‑action durations and a grand total (implies `--done`)
258
- - `--human` format durations as human‑readable text instead of `DD:HH:MM:SS`
259
- - `--only_timed` show only actions that have both `@started` and `@done` (implies `--times --done`)
260
- - `--only_times` output only the totals section (no action lines; implies `--times --done`)
261
- - `--json_times` output a JSON object with timed items, per‑tag totals, and overall total (implies `--times --done`)
262
-
263
- Example outputs:
264
-
265
- ```bash
266
- # Per‑action durations appended and totals table
267
- na next --times --human
268
-
269
- # Only totals table (Markdown), no action lines
270
- na tagged "tag*=bug" --only_times
271
-
272
- # JSON for scripting
273
- na next --json_times > times.json
274
- ```
275
-
276
- Notes:
277
-
278
- - Any newly added or edited action text is scanned for natural‑language values in `@started(...)`/`@done(...)` and normalized to `YYYY‑MM‑DD HH:MM`.
279
- - The color of durations in output is configurable via the theme key `duration` (defaults to `{y}`).
280
-
281
329
  ##### changelog
282
330
 
283
331
  View recent changes with `na changelog` or `na changes`.
@@ -375,6 +423,49 @@ If you're using a single global file, you'll need `--cwd_as` to be `tag` or `pro
375
423
 
376
424
  After installing a hook, you'll need to close your terminal and start a new session to initialize the new commands.
377
425
 
426
+ ### Time tracking
427
+
428
+ `na` supports tracking elapsed time between a start and finish for actions using `@started(YYYY-MM-DD HH:MM)` and `@done(YYYY-MM-DD HH:MM)` tags. Durations are not stored; they are calculated on the fly from these tags.
429
+
430
+ - Add/Finish/Update flags:
431
+ - `--started TIME` set a start time when creating or finishing an item
432
+ - `--end TIME` (alias `--finished`) set a done time
433
+ - `--duration DURATION` backfill start time from the provided end time
434
+ - All flags accept natural language (via Chronic) and shorthand: `30m ago`, `-2h`, `2h30m`, `2:30 ago`, `yesterday 5pm`
435
+
436
+ Examples:
437
+
438
+ ```bash
439
+ na add --started "30 minutes ago" "Investigate bug"
440
+ na complete --finished now --duration 2h30m "Investigate bug"
441
+ na update --started "yesterday 3pm" --end "yesterday 5:15pm" "Investigate bug"
442
+ ```
443
+
444
+ - Display flags (next/tagged):
445
+ - `--times` show per‑action durations and a grand total (implies `--done`)
446
+ - `--human` format durations as human‑readable text instead of `DD:HH:MM:SS`
447
+ - `--only_timed` show only actions that have both `@started` and `@done` (implies `--times --done`)
448
+ - `--only_times` output only the totals section (no action lines; implies `--times --done`)
449
+ - `--json_times` output a JSON object with timed items, per‑tag totals, and overall total (implies `--times --done`)
450
+
451
+ Example outputs:
452
+
453
+ ```bash
454
+ # Per‑action durations appended and totals table
455
+ na next --times --human
456
+
457
+ # Only totals table (Markdown), no action lines
458
+ na tagged "tag*=bug" --only_times
459
+
460
+ # JSON for scripting
461
+ na next --json_times > times.json
462
+ ```
463
+
464
+ Notes:
465
+
466
+ - Any newly added or edited action text is scanned for natural‑language values in `@started(...)`/`@done(...)` and normalized to `YYYY‑MM‑DD HH:MM`.
467
+ - The color of durations in output is configurable via the theme key `duration` (defaults to `{y}`).
468
+
378
469
  ### Plugins
379
470
 
380
471
  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.
@@ -389,19 +480,15 @@ You can delete or modify these sample plugins as needed.
389
480
 
390
481
  #### Running Plugins
391
482
 
392
- Run a plugin with:
393
- ```bash
394
- na plugin PLUGIN_NAME
395
- ```
483
+ You can manage and run plugins using subcommands under `na plugin`:
396
484
 
397
- Or use plugins through the `update` command's interactive menu, or pipe actions through plugins on display commands:
485
+ - `new`/`n`: scaffold a new plugin script
486
+ - `edit`: open an existing plugin
487
+ - `run`/`x`: run a plugin against selected actions
488
+ - `enable`/`e`: move from disabled to enabled
489
+ - `disable`/`d`: move from enabled to disabled
398
490
 
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
- ```
491
+ Plugins are executed with actions on STDIN and must return actions on STDOUT. Display commands can still pipe through plugins via `--plugin`, which only affects STDOUT (no writes).
405
492
 
406
493
  #### Plugin Metadata
407
494
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: na
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.88
4
+ version: 1.2.89
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -264,7 +264,6 @@ files:
264
264
  - ".rubocop.yml"
265
265
  - ".rubocop_todo.yml"
266
266
  - ".travis.yml"
267
- - 2025-10-29-one-more-na-update.md
268
267
  - CHANGELOG.md
269
268
  - Gemfile
270
269
  - Gemfile.lock