na 1.2.78 → 1.2.80

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f9de8bad22cccef320d35bec3e5dd840de73bd58e9288f3a079479135594333
4
- data.tar.gz: 3bb87af83e65f8ceab63188502bc3018302d2755f6cc2dfde34d603bb37d0563
3
+ metadata.gz: 65f46379443596b7f52385686e09c6b558c2f6eaaab614a34cd55432921fc1f7
4
+ data.tar.gz: 5050ee855d909fb050ecde79cae733fcfa5d411b6f28d48e88661569f0ed9989
5
5
  SHA512:
6
- metadata.gz: 12aa91ea7c15385478dd0ff8e351c180de3ada85ab818561302b258f058dada8b9fcc5e30ddc43a3c28ef2084df6fd47e53e0085494863984a6e536a6e2e0952
7
- data.tar.gz: 62389ffca74e95eb4e91da17690c1f6d4d78546c38a8aae5b20c246805946ab5ed2041e3aeac0a7c9bd1abbba0a36dc6027d66a3384f3529b739ecffb96d7d4a
6
+ metadata.gz: 2aa83793e127693a7481e4f0294ff98067f7b32ad288445d6b23ce2744f2f975ecc4b854c48eb8faee91f2e5fc720e05732001003eb04a0ccede62b48bf3c685
7
+ data.tar.gz: 21560b1ae71aa1559070ad8768d0ddacaa3e60a6287bff35bc07a0c3ffb6208c23d83da39baee6ffc5e470d76d2c4e6793163ee08c2e2a7fd6ae6763af1900fc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,58 @@
1
+ ### 1.2.80
2
+
3
+ 2025-10-23 05:26
4
+
5
+ #### CHANGED
6
+
7
+ - Default behavior: git integration now opt-in with --repo-top flag
8
+ - Pager execution: use spawn for better performance than fork+exec
9
+ - Color template processing: cache compiled templates and colors hash
10
+ - Action processing: batch regex highlighting instead of per-action processing
11
+
12
+ #### NEW
13
+
14
+ - Add comprehensive benchmarking system with NA_BENCHMARK=1
15
+ - Add template caching in Color.template to avoid repeated regex processing
16
+ - Add colors hash caching to eliminate repeated hash creation
17
+ - Add smart pagination that skips pager for small outputs (<2000 chars, <50 lines)
18
+ - Add --repo-top flag to make git integration opt-in instead of default
19
+
20
+ #### IMPROVED
21
+
22
+ - Optimize performance
23
+ - Theme loading now uses cached NA.theme instead of loading on every Action.pretty call
24
+ - Action.pretty method with conditional processing and optimized string operations
25
+ - Actions.output with batch regex processing - compile all output first, then apply regexes once
26
+ - Pager performance using spawn instead of fork+exec and removing IO.select delays
27
+ - Lazy loading of heavy gems (git, chronic, mdless) only when needed
28
+ - String operations in Action.pretty with pre-computed template parts
29
+
30
+ #### FIXED
31
+
32
+ - Performance variability caused by git system calls running by default
33
+ - Pager overhead causing 300-479ms delays on small outputs
34
+ - Repeated regex compilation in color template processing
35
+ - Theme loading bottleneck in Action.pretty method
36
+
37
+ ### 1.2.79
38
+
39
+ 2025-09-29 06:51
40
+
41
+ #### NEW
42
+
43
+ - Track affected actions in `update_action` and output per-action summaries
44
+
45
+ #### IMPROVED
46
+
47
+ - Prompt to select a project when multiple suffix matches are found
48
+ - Distinguish summaries: Task deleted vs Task updated/added
49
+ - Display affected actions using `action.to_s_pretty` with colored change descriptions
50
+
51
+ #### FIXED
52
+
53
+ - Resolve project matching for `na add --to Ideas` by supporting unique suffix matches (e.g. `rnkd:Ideas`)
54
+ - Validate `na update` requires at least one actionable option; error with No action specified, see `na help update`
55
+
1
56
  ### 1.2.78
2
57
 
3
58
  2025-06-02 10:07
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- na (1.2.78)
4
+ na (1.2.80)
5
5
  chronic (~> 0.10, >= 0.10.2)
6
6
  git (~> 3.0.0)
7
7
  gli (~> 2.21.0)
@@ -31,7 +31,7 @@ GEM
31
31
  public_suffix (>= 2.0.2, < 7.0)
32
32
  base64 (0.3.0)
33
33
  benchmark (0.4.1)
34
- bigdecimal (3.2.1)
34
+ bigdecimal (3.2.3)
35
35
  chronic (0.10.2)
36
36
  concurrent-ruby (1.3.5)
37
37
  connection_pool (2.5.3)
data/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 1.2.78.
12
+ The current version of `na` is 1.2.80.
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
 
@@ -76,7 +76,7 @@ SYNOPSIS
76
76
  na [global options] command [command options] [arguments...]
77
77
 
78
78
  VERSION
79
- 1.2.78
79
+ 1.2.80
80
80
 
81
81
  GLOBAL OPTIONS
82
82
  -a, --add - Add a next action (deprecated, for backwards compatibility)
@@ -93,7 +93,7 @@ GLOBAL OPTIONS
93
93
  -p, --priority=PRIORITY - Set a priority 0-5 (deprecated, for backwards compatibility) (default: none)
94
94
  --[no-]pager - Enable pagination (default: enabled)
95
95
  -r, --[no-]recurse - Recurse 3 directories deep (deprecated, for backwards compatability)
96
- --[no-]repo - Use a taskpaper file named after the git repository (default: enabled)
96
+ --[no-]repo-top - Use a taskpaper file named after the git repository (enables git integration)
97
97
  -t, --na_tag=TAG - Tag to consider a next action (default: na)
98
98
  --template=arg - Provide a template for new/blank todo files, use initconfig to make permanent (default: none)
99
99
  --version - Display the program version
@@ -189,6 +189,23 @@ class App
189
189
  note = stdin_note.empty? ? [] : stdin_note
190
190
  note.concat(line_note) unless line_note.nil? || line_note.empty?
191
191
 
192
+ # Require at least one actionable option to be provided
193
+ actionable = [
194
+ options[:note],
195
+ (options[:priority].to_i if options[:priority]).to_i > 0,
196
+ !options[:move].to_s.empty?,
197
+ !(options[:tag].nil? || options[:tag].empty?),
198
+ !(options[:remove].nil? || options[:remove].empty?),
199
+ !options[:replace].to_s.empty?,
200
+ options[:finish],
201
+ options[:archive],
202
+ options[:restore],
203
+ options[:delete],
204
+ options[:edit]
205
+ ].any?
206
+
207
+ NA.notify("#{NA.theme[:error]}No action specified, see `na help update`", exit_code: 1) unless actionable
208
+
192
209
  target_proj = if options[:move]
193
210
  options[:move]
194
211
  elsif NA.cwd_is == :project
data/bin/na CHANGED
@@ -5,9 +5,13 @@ $LOAD_PATH.unshift File.join(__dir__, '..', 'lib')
5
5
  require 'gli'
6
6
  require 'na/help_monkey_patch'
7
7
  require 'na'
8
+ require 'na/benchmark'
8
9
  require 'fcntl'
9
10
  require 'tempfile'
10
11
 
12
+ NA::Benchmark.init
13
+ NA::Benchmark.measure('Gem loading') { nil } # Measures time up to this point
14
+
11
15
  # Search for XDG compliant config first. Default to ~/.na.rc for compatibility
12
16
  def self.find_config_file
13
17
  home = ENV['HOME']
@@ -75,9 +79,9 @@ class App
75
79
  arg_name 'PATH'
76
80
  flag %i[f file]
77
81
 
78
- desc 'Use a taskpaper file named after the git repository'
79
- arg_name 'REPO'
80
- switch %i[repo], negatable: true, default_value: true
82
+ desc 'Use a taskpaper file named after the git repository (enables git integration)'
83
+ arg_name 'REPO_TOP'
84
+ switch %i[repo-top], default_value: false
81
85
 
82
86
  desc 'Provide a template for new/blank todo files, use initconfig to make permanent'
83
87
  flag %[template]
@@ -125,8 +129,8 @@ class App
125
129
  end
126
130
 
127
131
  # start of git repo addition ==================================
128
- # defaut to git repo if in a git managed directory
129
- if global[:repo]
132
+ # Use git repo if --repo-top flag is specified
133
+ if global[:repo_top]
130
134
  begin
131
135
  require 'git'
132
136
 
@@ -209,4 +213,6 @@ ARGV.each do |arg|
209
213
  end
210
214
  NA.command = NA.command_line[0]
211
215
 
212
- exit App.run(ARGV)
216
+ exit_code = App.run(ARGV)
217
+ NA::Benchmark.report
218
+ exit exit_code
data/lib/na/action.rb CHANGED
@@ -53,6 +53,15 @@ module NA
53
53
  "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}#{note}"
54
54
  end
55
55
 
56
+ def to_s_pretty
57
+ note = if @note.count.positive?
58
+ "\n#{@note.join("\n")}"
59
+ else
60
+ ''
61
+ end
62
+ "#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}#{NA.theme[:bracket]}[#{NA.theme[:project]}#{@project}:#{@parent.join(">")}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}#{NA.theme[:note]}#{note}"
63
+ end
64
+
56
65
  def inspect
57
66
  <<~EOINSPECT
58
67
  @file: #{@file}
@@ -75,65 +84,92 @@ module NA
75
84
  ## @param notes [Boolean] Include notes
76
85
  ##
77
86
  def pretty(extension: 'taskpaper', template: {}, regexes: [], notes: false, detect_width: true)
78
- theme = NA::Theme.load_theme
79
- template = theme.merge(template)
80
-
81
- # Create the hierarchical parent string
82
- parents = @parent.map do |par|
83
- NA::Color.template("{x}#{template[:parent]}#{par}")
84
- end.join(NA::Color.template(template[:parent_divider]))
85
- parents = "#{NA.theme[:bracket]}[#{NA.theme[:error]}#{parents}#{NA.theme[:bracket]}]{x} "
86
-
87
- # Create the project string
88
- project = NA::Color.template("#{template[:project]}#{@project}{x} ")
89
-
90
- # Create the source filename string, substituting ~ for HOME and removing extension
91
- file = @file.sub(%r{^\./}, '').sub(/#{ENV['HOME']}/, '~')
92
- file = file.sub(/\.#{extension}$/, '') unless NA.include_ext
93
- # colorize the basename
94
- file = file.highlight_filename
95
- file_tpl = "#{template[:file]}#{file} {x}"
96
- filename = NA::Color.template(file_tpl)
97
-
98
- # colorize the action and highlight tags
99
- @action.gsub!(/\{(.*?)\}/, '\\{\1\\}')
100
- action = NA::Color.template("#{template[:action]}#{@action.sub(/ @#{NA.na_tag}\b/, '')}{x}")
101
- action = action.highlight_tags(color: template[:tags],
102
- parens: template[:value_parens],
103
- value: template[:values],
104
- last_color: template[:action])
105
-
106
- if detect_width
107
- width = TTY::Screen.columns
108
- prefix = NA::Color.uncolor(pretty(template: { templates: { output: template[:templates][:output].sub(/%action/, '').sub(/%note/, '') } }, detect_width: false))
109
- indent = prefix.length
110
-
111
- # Add notes if needed
112
- note = if notes && @note.count.positive?
113
- NA::Color.template(@note.wrap(width, indent, template[:note]))
114
- elsif !notes && @note.count.positive?
115
- action += "#{template[:note]}*"
116
- else
117
- ''
118
- end
119
-
120
- action = action.wrap(width, indent)
121
- else
122
- note = if notes && @note.count.positive?
123
- NA::Color.template("\n#{@note.map { |l| " #{template[:note]}• #{l.wrap(width, indent)}{x}" }.join("\n")}")
124
- elsif !notes && @note.count.positive?
125
- action += "#{template[:note]}*"
126
- else
127
- ''
128
- end
129
- end
87
+ NA::Benchmark.measure('Action.pretty') do
88
+ # Use cached theme instead of loading every time
89
+ theme = NA.theme
90
+ template = theme.merge(template)
91
+
92
+ # Pre-compute common template parts to avoid repeated processing
93
+ output_template = template[:templates][:output]
94
+ needs_filename = output_template.include?('%filename')
95
+ needs_parents = output_template.include?('%parents') || output_template.include?('%parent')
96
+ needs_project = output_template.include?('%project')
97
+
98
+ # Create the hierarchical parent string (optimized)
99
+ parents = if needs_parents && @parent.any?
100
+ parent_parts = @parent.map { |par| "#{template[:parent]}#{par}" }.join(template[:parent_divider])
101
+ NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}#{template[:bracket]}]{x} ")
102
+ else
103
+ ''
104
+ end
105
+
106
+ # Create the project string (optimized)
107
+ project = if needs_project && !@project.empty?
108
+ NA::Color.template("#{template[:project]}#{@project}{x} ")
109
+ else
110
+ ''
111
+ end
112
+
113
+ # Create the source filename string (optimized)
114
+ filename = if needs_filename
115
+ file = @file.sub(%r{^\./}, '').sub(/#{ENV['HOME']}/, '~')
116
+ file = file.sub(/\.#{extension}$/, '') unless NA.include_ext
117
+ file = file.highlight_filename
118
+ NA::Color.template("#{template[:filename]}#{file} {x}")
119
+ else
120
+ ''
121
+ end
122
+
123
+ # colorize the action and highlight tags (optimized)
124
+ action_text = @action.dup
125
+ action_text.gsub!(/\{(.*?)\}/, '\\{\1\\}')
126
+ action_text = action_text.sub(/ @#{NA.na_tag}\b/, '')
127
+ action = NA::Color.template("#{template[:action]}#{action_text}{x}")
128
+ action = action.highlight_tags(color: template[:tags],
129
+ parens: template[:value_parens],
130
+ value: template[:values],
131
+ last_color: template[:action])
132
+
133
+ # Handle notes and wrapping (optimized)
134
+ note = ''
135
+ if @note.any?
136
+ if notes
137
+ if detect_width
138
+ # Cache width calculation
139
+ width = @cached_width ||= TTY::Screen.columns
140
+ # Calculate indent more efficiently - avoid repeated template processing
141
+ base_template = output_template.gsub(/%action/, '').gsub(/%note/, '')
142
+ base_output = base_template.gsub(/%filename/, filename).gsub(/%project/, project).gsub(/%parents?/, parents)
143
+ indent = NA::Color.uncolor(NA::Color.template(base_output)).length
144
+ note = NA::Color.template(@note.wrap(width, indent, template[:note]))
145
+ else
146
+ note = NA::Color.template("\n#{@note.map { |l| " #{template[:note]}• #{l}{x}" }.join("\n")}")
147
+ end
148
+ else
149
+ action += "#{template[:note]}*"
150
+ end
151
+ end
152
+
153
+ # Wrap action if needed (optimized)
154
+ if detect_width && !action.empty?
155
+ width = @cached_width ||= TTY::Screen.columns
156
+ base_template = output_template.gsub(/%action/, '').gsub(/%note/, '')
157
+ base_output = base_template.gsub(/%filename/, filename).gsub(/%project/, project).gsub(/%parents?/, parents)
158
+ indent = NA::Color.uncolor(NA::Color.template(base_output)).length
159
+ action = action.wrap(width, indent)
160
+ end
161
+
162
+ # Replace variables in template string and output colorized (optimized)
163
+ final_output = output_template.dup
164
+ final_output.gsub!(/%filename/, filename)
165
+ final_output.gsub!(/%project/, project)
166
+ final_output.gsub!(/%parents?/, parents)
167
+ final_output.gsub!(/%action/, action.highlight_search(regexes))
168
+ final_output.gsub!(/%note/, note)
169
+ final_output.gsub!(/\\\{/, '{')
130
170
 
131
- # Replace variables in template string and output colorized
132
- NA::Color.template(template[:templates][:output].gsub(/%filename/, filename)
133
- .gsub(/%project/, project)
134
- .gsub(/%parents?/, parents)
135
- .gsub(/%action/, action.highlight_search(regexes))
136
- .gsub(/%note/, note)).gsub(/\\\{/, '{')
171
+ NA::Color.template(final_output)
172
+ end
137
173
  end
138
174
 
139
175
  def tags_match?(any: [], all: [], none: [])
@@ -150,8 +186,9 @@ module NA
150
186
 
151
187
  def search_matches_none(regexes, include_note: true)
152
188
  regexes.each do |rx|
153
- note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
154
- return false if @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
189
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
190
+ note_matches = include_note && @note.join(' ').match(regex)
191
+ return false if @action.match(regex) || note_matches
155
192
  end
156
193
  true
157
194
  end
@@ -160,16 +197,18 @@ module NA
160
197
  return true if regexes.empty?
161
198
 
162
199
  regexes.each do |rx|
163
- note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
164
- return true if @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
200
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
201
+ note_matches = include_note && @note.join(' ').match(regex)
202
+ return true if @action.match(regex) || note_matches
165
203
  end
166
204
  false
167
205
  end
168
206
 
169
207
  def search_matches_all(regexes, include_note: true)
170
208
  regexes.each do |rx|
171
- note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
172
- return false unless @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
209
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
210
+ note_matches = include_note && @note.join(' ').match(regex)
211
+ return false unless @action.match(regex) || note_matches
173
212
  end
174
213
  true
175
214
  end
@@ -198,7 +237,8 @@ module NA
198
237
  end
199
238
 
200
239
  def compare_tag(tag)
201
- keys = @tags.keys.delete_if { |k| k !~ Regexp.new(tag[:tag], Regexp::IGNORECASE) }
240
+ tag_regex = tag[:tag].is_a?(Regexp) ? tag[:tag] : Regexp.new(tag[:tag], Regexp::IGNORECASE)
241
+ keys = @tags.keys.delete_if { |k| k !~ tag_regex }
202
242
  return false if keys.empty?
203
243
 
204
244
  key = keys[0]
@@ -211,6 +251,7 @@ module NA
211
251
 
212
252
  begin
213
253
  tag_date = Time.parse(tag_val)
254
+ require 'chronic' unless defined?(Chronic)
214
255
  date = Chronic.parse(val)
215
256
 
216
257
  raise ArgumentError if date.nil?
data/lib/na/actions.rb CHANGED
@@ -24,15 +24,16 @@ module NA
24
24
  ## @return [String] The output string
25
25
  ##
26
26
  def output(depth, config = {})
27
- defaults = {
28
- files: nil,
29
- regexes: [],
30
- notes: false,
31
- nest: false,
32
- nest_projects: false,
33
- no_files: false,
34
- }
35
- config = defaults.merge(config)
27
+ NA::Benchmark.measure('Actions.output') do
28
+ defaults = {
29
+ files: nil,
30
+ regexes: [],
31
+ notes: false,
32
+ nest: false,
33
+ nest_projects: false,
34
+ no_files: false,
35
+ }
36
+ config = defaults.merge(config)
36
37
 
37
38
  return if config[:files].nil?
38
39
 
@@ -73,21 +74,43 @@ module NA
73
74
  end
74
75
  NA::Pager.page out.join("\n")
75
76
  else
76
- template = if config[:no_files]
77
- NA.theme[:templates][:no_file]
78
- elsif config[:files].count.positive?
79
- config[:files].count == 1 ? NA.theme[:templates][:single_file] : NA.theme[:templates][:multi_file]
80
- elsif NA.find_files(depth: depth).count > 1
81
- depth > 1 ? NA.theme[:templates][:multi_file] : NA.theme[:templates][:single_file]
82
- else
83
- NA.theme[:templates][:default]
84
- end
77
+ # Optimize template selection
78
+ template = case
79
+ when config[:no_files]
80
+ NA.theme[:templates][:no_file]
81
+ when config[:files]&.count&.positive?
82
+ config[:files].count == 1 ? NA.theme[:templates][:single_file] : NA.theme[:templates][:multi_file]
83
+ when depth > 1
84
+ NA.theme[:templates][:multi_file]
85
+ else
86
+ NA.theme[:templates][:default]
87
+ end
85
88
  template += "%note" if config[:notes]
86
89
 
87
- config[:files].map { |f| NA.notify(f, debug: true) } if config[:files]
90
+ # Skip debug output if not verbose
91
+ config[:files]&.each { |f| NA.notify(f, debug: true) } if config[:files] && NA.verbose
92
+
93
+ # Optimize output generation - compile all output first, then apply regexes
94
+ output = String.new
95
+ NA::Benchmark.measure('Generate action strings') do
96
+ each_with_index do |action, idx|
97
+ # Generate raw output without regex processing
98
+ output << action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
99
+ output << "\n" unless idx == size - 1
100
+ end
101
+ end
88
102
 
89
- output = map { |action| action.pretty(template: { templates: { output: template } }, regexes: config[:regexes], notes: config[:notes]) }
90
- NA::Pager.page(output.join("\n"))
103
+ # Apply regex highlighting to the entire output at once
104
+ if config[:regexes].any?
105
+ NA::Benchmark.measure('Apply regex highlighting') do
106
+ output = output.highlight_search(config[:regexes])
107
+ end
108
+ end
109
+
110
+ NA::Benchmark.measure('Pager.page call') do
111
+ NA::Pager.page(output)
112
+ end
113
+ end
91
114
  end
92
115
  end
93
116
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NA
4
+ module Benchmark
5
+ class << self
6
+ attr_accessor :enabled, :timings
7
+
8
+ def init
9
+ @enabled = ENV['NA_BENCHMARK'] == '1' || ENV['NA_BENCHMARK'] == 'true'
10
+ @timings = []
11
+ @start_time = Time.now
12
+ end
13
+
14
+ def measure(label)
15
+ return yield unless @enabled
16
+
17
+ start = Time.now
18
+ result = yield
19
+ duration = ((Time.now - start) * 1000).round(2)
20
+ @timings << { label: label, duration: duration, timestamp: (start - @start_time) * 1000 }
21
+ result
22
+ end
23
+
24
+ def report
25
+ return unless @enabled
26
+
27
+ total = @timings.sum { |t| t[:duration] }
28
+ $stderr.puts "\n#{NA::Color.template('{y}=== NA Performance Report ===')}"
29
+ $stderr.puts NA::Color.template("{dw}Total: {bw}#{total.round(2)}ms{x}")
30
+ $stderr.puts NA::Color.template("{dw}GC Count: {bw}#{GC.count}{x}") if defined?(GC)
31
+ $stderr.puts NA::Color.template("{dw}Memory: {bw}#{(GC.stat[:heap_live_slots] * 40 / 1024.0).round(1)}KB{x}") if defined?(GC)
32
+ $stderr.puts ""
33
+
34
+ @timings.each do |timing|
35
+ pct = total > 0 ? ((timing[:duration] / total) * 100).round(1) : 0
36
+ bar = '█' * [(pct / 2).round, 50].min
37
+ $stderr.puts NA::Color.template(
38
+ "{dw}[{y}#{bar.ljust(25)}{dw}] {bw}#{timing[:duration].to_s.rjust(7)}ms {dw}(#{pct.to_s.rjust(5)}%) {x}#{timing[:label]}"
39
+ )
40
+ end
41
+ $stderr.puts NA::Color.template("{y}#{'=' * 50}{x}\n")
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/na/colors.rb CHANGED
@@ -196,6 +196,24 @@ module NA
196
196
 
197
197
  attr_writer :coloring
198
198
 
199
+ # Cache for compiled templates to avoid repeated regex processing
200
+ def template_cache
201
+ @template_cache ||= {}
202
+ end
203
+
204
+ def clear_template_cache
205
+ @template_cache = {}
206
+ end
207
+
208
+ # Pre-computed colors hash (expensive to create, so we cache it)
209
+ def colors_hash
210
+ @colors_hash ||= { w: white, k: black, g: green, l: blue,
211
+ y: yellow, c: cyan, m: magenta, r: red,
212
+ W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
213
+ Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
214
+ d: dark, b: bold, u: underline, i: italic, x: reset }
215
+ end
216
+
199
217
  ##
200
218
  ## Enables colored output
201
219
  ##
@@ -236,23 +254,28 @@ module NA
236
254
  input = input.join(' ') if input.is_a? Array
237
255
  return input.gsub(/(?<!\\)\{#?(\w+)\}/i, '') unless NA::Color.coloring?
238
256
 
239
- input = input.gsub(/(?<!\\)\{((?:[fb]g?)?#[a-f0-9]{3,6})\}/i) do
240
- hex = Regexp.last_match(1)
241
- rgb(hex)
242
- end
257
+ # Check cache first
258
+ cache_key = input.hash
259
+ return template_cache[cache_key] if template_cache.key?(cache_key)
260
+
261
+ # Process hex colors first
262
+ processed_input = input.gsub(/(?<!\\)\{((?:[fb]g?)?#[a-f0-9]{3,6})\}/i) do
263
+ hex = Regexp.last_match(1)
264
+ rgb(hex)
265
+ end
243
266
 
244
- fmt = input.gsub(/%/, '%%')
267
+ # Convert to format string
268
+ fmt = processed_input.gsub(/%/, '%%')
245
269
  fmt = fmt.gsub(/(?<!\\)\{(\w+)\}/i) do
246
270
  Regexp.last_match(1).split('').map { |c| "%<#{c}>s" }.join('')
247
271
  end
248
272
 
249
- colors = { w: white, k: black, g: green, l: blue,
250
- y: yellow, c: cyan, m: magenta, r: red,
251
- W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
252
- Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
253
- d: dark, b: bold, u: underline, i: italic, x: reset }
273
+ # Use pre-computed colors hash
274
+ result = format(fmt, colors_hash)
254
275
 
255
- format(fmt, colors)
276
+ # Cache the result
277
+ template_cache[cache_key] = result
278
+ result
256
279
  end
257
280
  end
258
281
 
@@ -259,6 +259,7 @@ module NA
259
259
  tagged: nil)
260
260
 
261
261
  projects = find_projects(target)
262
+ affected_actions = []
262
263
 
263
264
  target_proj = nil
264
265
 
@@ -287,9 +288,22 @@ module NA
287
288
  target_proj = if target_proj
288
289
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/i }.first
289
290
  else
291
+ # First try exact full-path match
290
292
  projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/i }.first
291
293
  end
292
294
 
295
+ # If no exact match, try unique suffix match (e.g., :Ideas at end)
296
+ if target_proj.nil?
297
+ leaf = Regexp.escape(add.parent.join(':'))
298
+ suffix_matches = projects.select { |proj| proj.project =~ /(^|:)#{leaf}$/i }
299
+ if suffix_matches.count == 1
300
+ target_proj = suffix_matches.first
301
+ elsif suffix_matches.count > 1 && $stdout.isatty
302
+ choice = choose_from(suffix_matches.map(&:project), prompt: 'Select a target project: ', multiple: false)
303
+ target_proj = projects.select { |proj| proj.project == choice }.first if choice
304
+ end
305
+ end
306
+
293
307
  if target_proj.nil?
294
308
  res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{add.project}#{NA.theme[:warning]} doesn't exist, add it"), default: true)
295
309
 
@@ -336,6 +350,15 @@ module NA
336
350
  contents.insert(target_line, "#{indent}\t- #{add.action}#{note}")
337
351
 
338
352
  notify(add.pretty)
353
+
354
+ # Track affected action and description
355
+ changes = ["added"]
356
+ changes << "finished" if finish
357
+ changes << "priority=#{priority}" if priority.to_i.positive?
358
+ changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
359
+ changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
360
+ changes << "note updated" unless note.nil? || note.empty?
361
+ affected_actions << { action: add, desc: changes.join(', ') }
339
362
  else
340
363
  _, actions = find_actions(target, search, tagged, done: done, all: all, project: project, search_note: search_note)
341
364
 
@@ -343,7 +366,11 @@ module NA
343
366
 
344
367
  actions.sort_by(&:line).reverse.each do |action|
345
368
  contents.slice!(action.line, action.note.count + 1)
346
- next if delete
369
+ if delete
370
+ # Track deletion before skipping re-insert
371
+ affected_actions << { action: action, desc: 'deleted' }
372
+ next
373
+ end
347
374
 
348
375
  projects = shift_index_after(projects, action.line, action.note.count + 1)
349
376
 
@@ -395,16 +422,44 @@ module NA
395
422
  contents.insert(target_line, "#{indent}\t- #{action.action}#{note}")
396
423
 
397
424
  notify(action.pretty)
425
+
426
+ # Track affected action and description
427
+ changes = []
428
+ changes << "finished" if finish
429
+ changes << "edited" if edit
430
+ changes << "priority=#{priority}" if priority.to_i.positive?
431
+ changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
432
+ changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
433
+ changes << "text replaced" if replace
434
+ changes << "moved to #{target_proj.project}" if target_proj
435
+ changes << "note updated" unless note.nil? || note.empty?
436
+ changes = ["updated"] if changes.empty?
437
+ affected_actions << { action: action, desc: changes.join(', ') }
398
438
  end
399
439
  end
400
440
 
401
441
  backup_file(target)
402
442
  File.open(target, 'w') { |f| f.puts contents.join("\n") }
403
443
 
404
- if add
405
- notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
444
+ if affected_actions.any?
445
+ if affected_actions.all? { |e| e[:desc] =~ /^deleted/ }
446
+ notify("#{NA.theme[:success]}Task deleted in #{NA.theme[:filename]}#{target}")
447
+ elsif add
448
+ notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
449
+ else
450
+ notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
451
+ end
452
+
453
+ affected_actions.reverse.each do |entry|
454
+ action_color = delete ? NA.theme[:error] : NA.theme[:success]
455
+ notify(" #{entry[:action].to_s_pretty} — #{action_color}#{entry[:desc]}")
456
+ end
406
457
  else
407
- notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
458
+ if add
459
+ notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
460
+ else
461
+ notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
462
+ end
408
463
  end
409
464
  end
410
465
 
@@ -499,11 +554,19 @@ module NA
499
554
  ## @param depth [Number] The depth at which to search
500
555
  ##
501
556
  def find_files(depth: 1)
502
- return [NA.global_file] if NA.global_file
503
-
504
- files = `find . -maxdepth #{depth} -name "*.#{NA.extension}"`.strip.split("\n")
505
- files.each { |f| save_working_dir(File.expand_path(f)) }
506
- files
557
+ NA::Benchmark.measure("find_files (depth=#{depth})") do
558
+ return [NA.global_file] if NA.global_file
559
+
560
+ pattern = if depth == 1
561
+ "*.#{NA.extension}"
562
+ else
563
+ "{#{'*,' * (depth - 1)}*}.#{NA.extension}"
564
+ end
565
+
566
+ files = Dir.glob(pattern, File::FNM_DOTMATCH).reject { |f| f.start_with?('.') }
567
+ files.each { |f| save_working_dir(File.expand_path(f)) }
568
+ files
569
+ end
507
570
  end
508
571
 
509
572
  def find_files_matching(options = {})
@@ -616,12 +679,14 @@ module NA
616
679
  ## @param todo_file The todo file path
617
680
  ##
618
681
  def save_working_dir(todo_file)
619
- file = database_path
620
- content = File.exist?(file) ? file.read_file : ''
621
- dirs = content.split(/\n/)
622
- dirs.push(File.expand_path(todo_file))
623
- dirs.sort!.uniq!
624
- File.open(file, 'w') { |f| f.puts dirs.join("\n") }
682
+ NA::Benchmark.measure('save_working_dir') do
683
+ file = database_path
684
+ content = File.exist?(file) ? file.read_file : ''
685
+ dirs = content.split(/\n/)
686
+ dirs.push(File.expand_path(todo_file))
687
+ dirs.sort!.uniq!
688
+ File.open(file, 'w') { |f| f.puts dirs.join("\n") }
689
+ end
625
690
  end
626
691
 
627
692
  ##
data/lib/na/pager.rb CHANGED
@@ -29,38 +29,37 @@ module NA
29
29
  return
30
30
  end
31
31
 
32
+ # Skip pagination for small outputs (faster than starting a pager)
33
+ if text.length < 2000 && text.lines.count < 50
34
+ puts text
35
+ return
36
+ end
37
+
32
38
  pager = which_pager
39
+ return false unless pager
33
40
 
41
+ # Optimized pager execution - use spawn instead of fork+exec
34
42
  read_io, write_io = IO.pipe
35
43
 
36
- input = $stdin
37
-
38
- pid = Kernel.fork do
39
- write_io.close
40
- input.reopen(read_io)
41
- read_io.close
42
-
43
- # Wait until we have input before we start the pager
44
- IO.select [input]
45
-
46
- begin
47
- NA.notify("#{NA.theme[:debug]}Pager #{pager}", debug: true)
48
- exec(pager)
49
- rescue SystemCallError => e
50
- raise Errors::DoingStandardError, "Pager error, #{e}"
51
- end
52
- end
44
+ # Use spawn for better performance than fork+exec
45
+ pid = spawn(pager, in: read_io, out: $stdout, err: $stderr)
46
+ read_io.close
53
47
 
54
48
  begin
55
- read_io.close
49
+ # Write data to pager
56
50
  write_io.write(text)
57
51
  write_io.close
58
- rescue SystemCallError # => e
59
- # raise Errors::DoingStandardError, "Pager error, #{e}"
60
- end
61
52
 
62
- _, status = Process.waitpid2(pid)
63
- status.success?
53
+ # Wait for pager to complete
54
+ _, status = Process.waitpid2(pid)
55
+ status.success?
56
+ rescue SystemCallError => e
57
+ # Clean up on error
58
+ write_io.close rescue nil
59
+ Process.kill('TERM', pid) rescue nil
60
+ Process.waitpid(pid) rescue nil
61
+ false
62
+ end
64
63
  end
65
64
 
66
65
  private
@@ -89,6 +88,11 @@ module NA
89
88
  def which_pager
90
89
  @which_pager ||= find_executable(*pagers)
91
90
  end
91
+
92
+ # Clear pager cache (useful for testing)
93
+ def clear_pager_cache
94
+ @which_pager = nil
95
+ end
92
96
  end
93
97
  end
94
98
  end
data/lib/na/string.rb CHANGED
@@ -355,6 +355,7 @@ class ::String
355
355
  date_string = 'today' if date_string.match(REGEX_DAY) && now.strftime('%a') =~ /^#{Regexp.last_match(1)}/i
356
356
  date_string = "#{options[:context].to_s} #{date_string}" if date_string =~ REGEX_TIME && options[:context]
357
357
 
358
+ require 'chronic' unless defined?(Chronic)
358
359
  res = Chronic.parse(date_string, {
359
360
  guess: options.fetch(:guess, :begin),
360
361
  context: options.fetch(:future, false) ? :future : :past,
data/lib/na/theme.rb CHANGED
@@ -23,8 +23,9 @@ module NA
23
23
  end
24
24
 
25
25
  def load_theme(template: {})
26
- # Default colorization, can be overridden with full or partial template variable
27
- default_template = {
26
+ NA::Benchmark.measure('Theme.load_theme') do
27
+ # Default colorization, can be overridden with full or partial template variable
28
+ default_template = {
28
29
  parent: '{c}',
29
30
  bracket: '{dc}',
30
31
  parent_divider: '{xw}/',
@@ -58,14 +59,15 @@ module NA
58
59
  else
59
60
  {}
60
61
  end
61
- theme = default_template.deep_merge(theme)
62
+ theme = default_template.deep_merge(theme)
62
63
 
63
- File.open(theme_file, 'w') do |f|
64
- f.puts template_help.comment
65
- f.puts YAML.dump(theme)
66
- end
64
+ File.open(theme_file, 'w') do |f|
65
+ f.puts template_help.comment
66
+ f.puts YAML.dump(theme)
67
+ end
67
68
 
68
- theme.merge(template)
69
+ theme.merge(template)
70
+ end
69
71
  end
70
72
  end
71
73
  end
data/lib/na/todo.rb CHANGED
@@ -27,21 +27,22 @@ module NA
27
27
  ## @option file_path [String] file path to parse
28
28
  ##
29
29
  def parse(options)
30
- defaults = {
31
- depth: 1,
32
- done: false,
33
- file_path: nil,
34
- negate: false,
35
- project: nil,
36
- query: nil,
37
- regex: false,
38
- require_na: true,
39
- search: nil,
40
- search_note: true,
41
- tag: nil
42
- }
43
-
44
- settings = defaults.merge(options)
30
+ NA::Benchmark.measure('Todo.parse') do
31
+ defaults = {
32
+ depth: 1,
33
+ done: false,
34
+ file_path: nil,
35
+ negate: false,
36
+ project: nil,
37
+ query: nil,
38
+ regex: false,
39
+ require_na: true,
40
+ search: nil,
41
+ search_note: true,
42
+ tag: nil
43
+ }
44
+
45
+ settings = defaults.merge(options)
45
46
 
46
47
  actions = NA::Actions.new
47
48
  required = []
@@ -87,6 +88,11 @@ module NA
87
88
  end
88
89
  end
89
90
 
91
+ # Pre-compile regexes for better performance
92
+ optional = optional.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
93
+ required = required.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
94
+ negated = negated.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
95
+
90
96
  files = if !settings[:file_path].nil?
91
97
  [settings[:file_path]]
92
98
  elsif settings[:query].nil?
@@ -96,14 +102,21 @@ module NA
96
102
  end
97
103
 
98
104
  NA.notify("Files: #{files.join(', ')}", debug: true)
99
- files.each do |file|
100
- NA.save_working_dir(File.expand_path(file))
101
- content = file.read_file
102
- indent_level = 0
103
- parent = []
104
- in_yaml = false
105
- in_action = false
106
- content.split(/\n/).each.with_index do |line, idx|
105
+ # Cache project regex compilation outside the line loop for better performance
106
+ project_regex = if settings[:project]
107
+ rx = settings[:project].split(%r{[/:]}).join('.*?/')
108
+ Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
109
+ end
110
+
111
+ files.each do |file|
112
+ NA::Benchmark.measure("Parse file: #{File.basename(file)}") do
113
+ NA.save_working_dir(File.expand_path(file))
114
+ content = file.read_file
115
+ indent_level = 0
116
+ parent = []
117
+ in_yaml = false
118
+ in_action = false
119
+ content.split(/\n/).each.with_index do |line, idx|
107
120
  if in_yaml && line !~ /^(---|~~~)\s*$/
108
121
  NA.notify("YAML: #{line}", debug: true)
109
122
  elsif line =~ /^(---|~~~)\s*$/
@@ -130,20 +143,22 @@ module NA
130
143
  elsif line.action?
131
144
  in_action = false
132
145
 
133
- action = line.action
134
- new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
135
-
136
- projects[-1].last_line = idx if projects.count.positive?
137
-
146
+ # Early exits before creating Action object
138
147
  next if line.done? && !settings[:done]
139
148
 
140
149
  next if settings[:require_na] && !line.na?
141
150
 
142
- if settings[:project]
143
- rx = settings[:project].split(%r{[/:]}).join('.*?/')
144
- next unless parent.join('/') =~ Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
151
+ if project_regex
152
+ next unless parent.join('/') =~ project_regex
145
153
  end
146
154
 
155
+ # Only create Action if we passed basic filters
156
+ action = line.action
157
+ new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
158
+
159
+ projects[-1].last_line = idx if projects.count.positive?
160
+
161
+ # Tag matching
147
162
  has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
148
163
  next if has_tag && !new_action.tags_match?(any: optional_tag,
149
164
  all: required_tag,
@@ -155,19 +170,23 @@ module NA
155
170
  actions[-1].note.push(line.strip) if actions.count.positive?
156
171
  projects[-1].last_line = idx if projects.count.positive?
157
172
  end
173
+ end
174
+ projects = projects.dup
175
+ end
158
176
  end
159
- projects = projects.dup
160
- end
161
177
 
162
- actions.delete_if do |new_action|
163
- has_search = !optional.empty? || !required.empty? || !negated.empty?
164
- has_search && !new_action.search_match?(any: optional,
165
- all: required,
166
- none: negated,
167
- include_note: settings[:search_note])
168
- end
178
+ NA::Benchmark.measure('Filter actions by search') do
179
+ actions.delete_if do |new_action|
180
+ has_search = !optional.empty? || !required.empty? || !negated.empty?
181
+ has_search && !new_action.search_match?(any: optional,
182
+ all: required,
183
+ none: negated,
184
+ include_note: settings[:search_note])
185
+ end
186
+ end
169
187
 
170
- [files, actions, projects]
188
+ [files, actions, projects]
189
+ end
171
190
  end
172
191
 
173
192
  def parse_search(tag, negate)
data/lib/na/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Na
2
- VERSION = '1.2.78'
2
+ VERSION = '1.2.80'
3
3
  end
data/lib/na.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'na/benchmark'
3
4
  require 'na/version'
4
5
  require 'na/pager'
5
6
  require 'time'
6
7
  require 'fileutils'
7
8
  require 'shellwords'
8
- require 'chronic'
9
+ # Lazy load heavy gems - only load when needed
10
+ # require 'chronic' # Loaded in action.rb and string.rb when needed
9
11
  require 'tty-screen'
10
12
  require 'tty-reader'
11
13
  require 'tty-which'
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.77<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.79<!--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
 
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Simple performance test script that doesn't require bundler
5
+ require_relative 'lib/na/benchmark'
6
+
7
+ # Mock the required dependencies
8
+ module NA
9
+ module Color
10
+ def self.template(input)
11
+ input.to_s # Simple mock
12
+ end
13
+ end
14
+
15
+ module Theme
16
+ def self.load_theme
17
+ {
18
+ parent: '{c}',
19
+ bracket: '{dc}',
20
+ parent_divider: '{xw}/',
21
+ action: '{bg}',
22
+ project: '{xbk}',
23
+ templates: {
24
+ output: '%parent%action',
25
+ default: '%parent%action'
26
+ }
27
+ }
28
+ end
29
+ end
30
+
31
+ def self.theme
32
+ @theme ||= Theme.load_theme
33
+ end
34
+
35
+ def self.notify(msg, debug: false)
36
+ puts msg if debug
37
+ end
38
+ end
39
+
40
+ # Initialize benchmark
41
+ NA::Benchmark.init
42
+
43
+ # Test the optimizations
44
+ puts "Testing performance optimizations..."
45
+
46
+ # Test 1: Theme caching
47
+ NA::Benchmark.measure('Theme loading (first time)') do
48
+ theme1 = NA::Theme.load_theme
49
+ end
50
+
51
+ NA::Benchmark.measure('Theme loading (cached)') do
52
+ theme2 = NA.theme
53
+ end
54
+
55
+ # Test 2: Color template caching
56
+ NA::Benchmark.measure('Color template (first time)') do
57
+ NA::Color.template('{bg}Test action{x}')
58
+ end
59
+
60
+ NA::Benchmark.measure('Color template (cached)') do
61
+ NA::Color.template('{bg}Test action{x}')
62
+ end
63
+
64
+ # Test 3: Multiple operations
65
+ NA::Benchmark.measure('Multiple theme calls') do
66
+ 100.times do
67
+ NA.theme
68
+ end
69
+ end
70
+
71
+ NA::Benchmark.measure('Multiple color templates') do
72
+ 100.times do
73
+ NA::Color.template('{bg}Action {c}#{rand(1000)}{x}')
74
+ end
75
+ end
76
+
77
+ # Report results
78
+ NA::Benchmark.report
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.78
4
+ version: 1.2.80
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -261,6 +261,7 @@ files:
261
261
  - lib/na/action.rb
262
262
  - lib/na/actions.rb
263
263
  - lib/na/array.rb
264
+ - lib/na/benchmark.rb
264
265
  - lib/na/colors.rb
265
266
  - lib/na/editor.rb
266
267
  - lib/na/hash.rb
@@ -281,6 +282,7 @@ files:
281
282
  - src/_README.md
282
283
  - test.md
283
284
  - test2.txt
285
+ - test_performance.rb
284
286
  homepage: https://brettterpstra.com/projects/na/
285
287
  licenses:
286
288
  - MIT