na 1.2.84 → 1.2.86
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/priority35m36m335m32m.md +0 -0
 - data/.rubocop_todo.yml +30 -35
 - data/CHANGELOG.md +18 -0
 - data/Gemfile +2 -0
 - data/Gemfile.lock +9 -1
 - data/README.md +2 -2
 - data/bin/commands/update.rb +120 -37
 - data/lib/na/action.rb +74 -14
 - data/lib/na/array.rb +9 -0
 - data/lib/na/benchmark.rb +8 -0
 - data/lib/na/colors.rb +11 -4
 - data/lib/na/editor.rb +89 -1
 - data/lib/na/hash.rb +9 -0
 - data/lib/na/help_monkey_patch.rb +10 -1
 - data/lib/na/next_action.rb +93 -19
 - data/lib/na/project.rb +8 -0
 - data/lib/na/string.rb +51 -12
 - data/lib/na/theme.rb +58 -41
 - data/lib/na/todo.rb +8 -0
 - data/lib/na/version.rb +5 -1
 - data/lib/na.rb +11 -1
 - data/src/_README.md +1 -1
 - metadata +2 -1
 
    
        data/lib/na/benchmark.rb
    CHANGED
    
    | 
         @@ -1,6 +1,10 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            # frozen_string_literal: true
         
     | 
| 
       2 
2 
     | 
    
         | 
| 
       3 
3 
     | 
    
         
             
            module NA
         
     | 
| 
      
 4 
     | 
    
         
            +
              # Provides benchmarking utilities for measuring code execution time.
         
     | 
| 
      
 5 
     | 
    
         
            +
              #
         
     | 
| 
      
 6 
     | 
    
         
            +
              # @example Measure a block of code
         
     | 
| 
      
 7 
     | 
    
         
            +
              #   NA::Benchmark.measure('sleep') { sleep(1) }
         
     | 
| 
       4 
8 
     | 
    
         
             
              module Benchmark
         
     | 
| 
       5 
9 
     | 
    
         
             
                class << self
         
     | 
| 
       6 
10 
     | 
    
         
             
                  attr_accessor :enabled, :timings
         
     | 
| 
         @@ -18,6 +22,8 @@ module NA 
     | 
|
| 
       18 
22 
     | 
    
         
             
                  #
         
     | 
| 
       19 
23 
     | 
    
         
             
                  # @param label [String] Label for the measurement
         
     | 
| 
       20 
24 
     | 
    
         
             
                  # @return [Object] Result of the block
         
     | 
| 
      
 25 
     | 
    
         
            +
                  # @example
         
     | 
| 
      
 26 
     | 
    
         
            +
                  #   NA::Benchmark.measure('sleep') { sleep(1) }
         
     | 
| 
       21 
27 
     | 
    
         
             
                  def measure(label)
         
     | 
| 
       22 
28 
     | 
    
         
             
                    return yield unless @enabled
         
     | 
| 
       23 
29 
     | 
    
         | 
| 
         @@ -31,6 +37,8 @@ module NA 
     | 
|
| 
       31 
37 
     | 
    
         
             
                  # Output a performance report to STDERR
         
     | 
| 
       32 
38 
     | 
    
         
             
                  #
         
     | 
| 
       33 
39 
     | 
    
         
             
                  # @return [void]
         
     | 
| 
      
 40 
     | 
    
         
            +
                  # @example
         
     | 
| 
      
 41 
     | 
    
         
            +
                  #   NA::Benchmark.report
         
     | 
| 
       34 
42 
     | 
    
         
             
                  def report
         
     | 
| 
       35 
43 
     | 
    
         
             
                    return unless @enabled
         
     | 
| 
       36 
44 
     | 
    
         | 
    
        data/lib/na/colors.rb
    CHANGED
    
    | 
         @@ -3,6 +3,9 @@ 
     | 
|
| 
       3 
3 
     | 
    
         
             
            # Cribbed from <https://github.com/flori/term-ansicolor>
         
     | 
| 
       4 
4 
     | 
    
         
             
            module NA
         
     | 
| 
       5 
5 
     | 
    
         
             
              # Terminal output color functions.
         
     | 
| 
      
 6 
     | 
    
         
            +
              #
         
     | 
| 
      
 7 
     | 
    
         
            +
              # @example Clear the color template cache
         
     | 
| 
      
 8 
     | 
    
         
            +
              #   NA::Color.clear_template_cache
         
     | 
| 
       6 
9 
     | 
    
         
             
              module Color
         
     | 
| 
       7 
10 
     | 
    
         
             
                # Regexp to match excape sequences
         
     | 
| 
       8 
11 
     | 
    
         
             
                ESCAPE_REGEX = /(?<=\[)(?:(?:(?:[349]|10)[0-9]|[0-9])?;?)+(?=m)/.freeze
         
     | 
| 
         @@ -199,6 +202,10 @@ module NA 
     | 
|
| 
       199 
202 
     | 
    
         
             
                    @template_cache ||= {}
         
     | 
| 
       200 
203 
     | 
    
         
             
                  end
         
     | 
| 
       201 
204 
     | 
    
         | 
| 
      
 205 
     | 
    
         
            +
                  # Clears the cached compiled color templates.
         
     | 
| 
      
 206 
     | 
    
         
            +
                  # @return [void]
         
     | 
| 
      
 207 
     | 
    
         
            +
                  # @example
         
     | 
| 
      
 208 
     | 
    
         
            +
                  #   NA::Color.clear_template_cache
         
     | 
| 
       202 
209 
     | 
    
         
             
                  def clear_template_cache
         
     | 
| 
       203 
210 
     | 
    
         
             
                    @template_cache = {}
         
     | 
| 
       204 
211 
     | 
    
         
             
                  end
         
     | 
| 
         @@ -231,15 +238,15 @@ module NA 
     | 
|
| 
       231 
238 
     | 
    
         
             
                  # i: italic, x: reset (remove background, color,
         
     | 
| 
       232 
239 
     | 
    
         
             
                  # emphasis)
         
     | 
| 
       233 
240 
     | 
    
         
             
                  #
         
     | 
| 
       234 
     | 
    
         
            -
                  # Also accepts {#RGB} and {#RRGGBB} strings. Put a b
         
     | 
| 
      
 241 
     | 
    
         
            +
                  # Also accepts `{#RGB}` and `{#RRGGBB}` strings. Put a b
         
     | 
| 
       235 
242 
     | 
    
         
             
                  # before the hash to make it a background color
         
     | 
| 
       236 
243 
     | 
    
         
             
                  #
         
     | 
| 
       237 
244 
     | 
    
         
             
                  # @example    Convert a templated string
         
     | 
| 
       238 
     | 
    
         
            -
                  # Color.template('{Rwb}Warning 
     | 
| 
       239 
     | 
    
         
            -
                  # little {g}ill{x}')
         
     | 
| 
      
 245 
     | 
    
         
            +
                  # Color.template('\\{Rwb\\}Warning:\\{x\\} \\{w\}\you look a
         
     | 
| 
      
 246 
     | 
    
         
            +
                  # little \\{g\\}ill\\{x\\}')
         
     | 
| 
       240 
247 
     | 
    
         
             
                  #
         
     | 
| 
       241 
248 
     | 
    
         
             
                  # @example    Convert using RGB colors
         
     | 
| 
       242 
     | 
    
         
            -
                  # Color.template('{#f0a}This is an RGB color')
         
     | 
| 
      
 249 
     | 
    
         
            +
                  # Color.template('\\{#f0a\\}This is an RGB color')
         
     | 
| 
       243 
250 
     | 
    
         
             
                  #
         
     | 
| 
       244 
251 
     | 
    
         
             
                  # @param      input  [String, Array] The template
         
     | 
| 
       245 
252 
     | 
    
         
             
                  #                    string. If this is an array, the
         
     | 
    
        data/lib/na/editor.rb
    CHANGED
    
    | 
         @@ -3,8 +3,12 @@ 
     | 
|
| 
       3 
3 
     | 
    
         
             
            require 'English'
         
     | 
| 
       4 
4 
     | 
    
         | 
| 
       5 
5 
     | 
    
         
             
            module NA
         
     | 
| 
      
 6 
     | 
    
         
            +
              # Provides editor selection and argument helpers for launching text editors.
         
     | 
| 
       6 
7 
     | 
    
         
             
              module Editor
         
     | 
| 
       7 
8 
     | 
    
         
             
                class << self
         
     | 
| 
      
 9 
     | 
    
         
            +
                  # Returns the default editor command, checking environment variables and available editors.
         
     | 
| 
      
 10 
     | 
    
         
            +
                  # @param prefer_git_editor [Boolean] Prefer GIT_EDITOR over EDITOR
         
     | 
| 
      
 11 
     | 
    
         
            +
                  # @return [String, nil] Editor command or nil if not found
         
     | 
| 
       8 
12 
     | 
    
         
             
                  def default_editor(prefer_git_editor: true)
         
     | 
| 
       9 
13 
     | 
    
         
             
                    editor ||= if prefer_git_editor
         
     | 
| 
       10 
14 
     | 
    
         
             
                                 ENV['NA_EDITOR'] || ENV['GIT_EDITOR'] || ENV.fetch('EDITOR', nil)
         
     | 
| 
         @@ -29,10 +33,15 @@ module NA 
     | 
|
| 
       29 
33 
     | 
    
         
             
                    nil
         
     | 
| 
       30 
34 
     | 
    
         
             
                  end
         
     | 
| 
       31 
35 
     | 
    
         | 
| 
      
 36 
     | 
    
         
            +
                  # Returns the default editor command with its arguments.
         
     | 
| 
      
 37 
     | 
    
         
            +
                  # @return [String] Editor command with arguments
         
     | 
| 
       32 
38 
     | 
    
         
             
                  def editor_with_args
         
     | 
| 
       33 
39 
     | 
    
         
             
                    args_for_editor(default_editor)
         
     | 
| 
       34 
40 
     | 
    
         
             
                  end
         
     | 
| 
       35 
41 
     | 
    
         | 
| 
      
 42 
     | 
    
         
            +
                  # Returns the editor command with appropriate arguments for file opening.
         
     | 
| 
      
 43 
     | 
    
         
            +
                  # @param editor [String] Editor command
         
     | 
| 
      
 44 
     | 
    
         
            +
                  # @return [String] Editor command with arguments
         
     | 
| 
       36 
45 
     | 
    
         
             
                  def args_for_editor(editor)
         
     | 
| 
       37 
46 
     | 
    
         
             
                    return editor if editor =~ /-\S/
         
     | 
| 
       38 
47 
     | 
    
         | 
| 
         @@ -91,7 +100,12 @@ module NA 
     | 
|
| 
       91 
100 
     | 
    
         
             
                      tmpfile.unlink
         
     | 
| 
       92 
101 
     | 
    
         
             
                    end
         
     | 
| 
       93 
102 
     | 
    
         | 
| 
       94 
     | 
    
         
            -
                     
     | 
| 
      
 103 
     | 
    
         
            +
                    # Don't strip comments if this looks like multi-action format (has # ------ markers)
         
     | 
| 
      
 104 
     | 
    
         
            +
                    if input.include?('# ------ ')
         
     | 
| 
      
 105 
     | 
    
         
            +
                      input
         
     | 
| 
      
 106 
     | 
    
         
            +
                    else
         
     | 
| 
      
 107 
     | 
    
         
            +
                      input.split("\n").delete_if(&:ignore?).join("\n")
         
     | 
| 
      
 108 
     | 
    
         
            +
                    end
         
     | 
| 
       95 
109 
     | 
    
         
             
                  end
         
     | 
| 
       96 
110 
     | 
    
         | 
| 
       97 
111 
     | 
    
         
             
                  # Takes a multi-line string and formats it as an entry
         
     | 
| 
         @@ -120,6 +134,80 @@ module NA 
     | 
|
| 
       120 
134 
     | 
    
         | 
| 
       121 
135 
     | 
    
         
             
                    [title, note]
         
     | 
| 
       122 
136 
     | 
    
         
             
                  end
         
     | 
| 
      
 137 
     | 
    
         
            +
             
     | 
| 
      
 138 
     | 
    
         
            +
                  # Format multiple actions for multi-edit
         
     | 
| 
      
 139 
     | 
    
         
            +
                  # @param actions [Array<Action>] Actions to edit
         
     | 
| 
      
 140 
     | 
    
         
            +
                  # @return [String] Formatted editor content
         
     | 
| 
      
 141 
     | 
    
         
            +
                  def format_multi_action_input(actions)
         
     | 
| 
      
 142 
     | 
    
         
            +
                    header = <<~EOF
         
     | 
| 
      
 143 
     | 
    
         
            +
                      # Instructions:
         
     | 
| 
      
 144 
     | 
    
         
            +
                      # - Edit the action text (the lines WITHOUT # comment markers)
         
     | 
| 
      
 145 
     | 
    
         
            +
                      # - DO NOT remove or edit the lines starting with "# ------"
         
     | 
| 
      
 146 
     | 
    
         
            +
                      # - Add notes on new lines after the action
         
     | 
| 
      
 147 
     | 
    
         
            +
                      # - Blank lines are ignored
         
     | 
| 
      
 148 
     | 
    
         
            +
                      #
         
     | 
| 
      
 149 
     | 
    
         
            +
             
     | 
| 
      
 150 
     | 
    
         
            +
                    EOF
         
     | 
| 
      
 151 
     | 
    
         
            +
             
     | 
| 
      
 152 
     | 
    
         
            +
                    # Use + to create a mutable string
         
     | 
| 
      
 153 
     | 
    
         
            +
                    content = +header
         
     | 
| 
      
 154 
     | 
    
         
            +
             
     | 
| 
      
 155 
     | 
    
         
            +
                    actions.each do |action|
         
     | 
| 
      
 156 
     | 
    
         
            +
                      # Use file_path to get the path and file_line to get the line number
         
     | 
| 
      
 157 
     | 
    
         
            +
                      content << "# ------ #{action.file_path}:#{action.file_line}\n"
         
     | 
| 
      
 158 
     | 
    
         
            +
                      content << "#{action.action}\n"
         
     | 
| 
      
 159 
     | 
    
         
            +
                      content << "#{action.note.join("\n")}\n" if action.note.any?
         
     | 
| 
      
 160 
     | 
    
         
            +
                      content << "\n" # Blank line separator
         
     | 
| 
      
 161 
     | 
    
         
            +
                    end
         
     | 
| 
      
 162 
     | 
    
         
            +
             
     | 
| 
      
 163 
     | 
    
         
            +
                    content
         
     | 
| 
      
 164 
     | 
    
         
            +
                  end
         
     | 
| 
      
 165 
     | 
    
         
            +
             
     | 
| 
      
 166 
     | 
    
         
            +
                  # Parse multi-action editor output
         
     | 
| 
      
 167 
     | 
    
         
            +
                  # @param content [String] Editor output
         
     | 
| 
      
 168 
     | 
    
         
            +
                  # @return [Hash] Hash mapping file:line to [action, note]
         
     | 
| 
      
 169 
     | 
    
         
            +
                  def parse_multi_action_output(content)
         
     | 
| 
      
 170 
     | 
    
         
            +
                    results = {}
         
     | 
| 
      
 171 
     | 
    
         
            +
                    current_file = nil
         
     | 
| 
      
 172 
     | 
    
         
            +
                    current_action = nil
         
     | 
| 
      
 173 
     | 
    
         
            +
                    current_note = []
         
     | 
| 
      
 174 
     | 
    
         
            +
             
     | 
| 
      
 175 
     | 
    
         
            +
                    content.lines.each do |line|
         
     | 
| 
      
 176 
     | 
    
         
            +
                      stripped = line.strip
         
     | 
| 
      
 177 
     | 
    
         
            +
             
     | 
| 
      
 178 
     | 
    
         
            +
                      # Check for file marker: # ------ path:line
         
     | 
| 
      
 179 
     | 
    
         
            +
                      match = stripped.match(/^# ------ (.+?):(\d+)$/)
         
     | 
| 
      
 180 
     | 
    
         
            +
                      if match
         
     | 
| 
      
 181 
     | 
    
         
            +
                        # Save previous action if exists
         
     | 
| 
      
 182 
     | 
    
         
            +
                        results[current_file] = [current_action, current_note] if current_file && current_action
         
     | 
| 
      
 183 
     | 
    
         
            +
             
     | 
| 
      
 184 
     | 
    
         
            +
                        # Start new action
         
     | 
| 
      
 185 
     | 
    
         
            +
                        current_file = "#{match[1]}:#{match[2]}"
         
     | 
| 
      
 186 
     | 
    
         
            +
                        current_action = nil
         
     | 
| 
      
 187 
     | 
    
         
            +
                        current_note = []
         
     | 
| 
      
 188 
     | 
    
         
            +
                        next
         
     | 
| 
      
 189 
     | 
    
         
            +
                      end
         
     | 
| 
      
 190 
     | 
    
         
            +
             
     | 
| 
      
 191 
     | 
    
         
            +
                      # Skip other comment lines
         
     | 
| 
      
 192 
     | 
    
         
            +
                      next if stripped.start_with?('#')
         
     | 
| 
      
 193 
     | 
    
         
            +
             
     | 
| 
      
 194 
     | 
    
         
            +
                      # Skip blank lines
         
     | 
| 
      
 195 
     | 
    
         
            +
                      next if stripped.empty?
         
     | 
| 
      
 196 
     | 
    
         
            +
             
     | 
| 
      
 197 
     | 
    
         
            +
                      # Store as action or note based on what we've seen so far
         
     | 
| 
      
 198 
     | 
    
         
            +
                      if current_action.nil?
         
     | 
| 
      
 199 
     | 
    
         
            +
                        current_action = stripped
         
     | 
| 
      
 200 
     | 
    
         
            +
                      else
         
     | 
| 
      
 201 
     | 
    
         
            +
                        # Subsequent lines are notes
         
     | 
| 
      
 202 
     | 
    
         
            +
                        current_note << stripped
         
     | 
| 
      
 203 
     | 
    
         
            +
                      end
         
     | 
| 
      
 204 
     | 
    
         
            +
                    end
         
     | 
| 
      
 205 
     | 
    
         
            +
             
     | 
| 
      
 206 
     | 
    
         
            +
                    # Save last action
         
     | 
| 
      
 207 
     | 
    
         
            +
                    results[current_file] = [current_action, current_note] if current_file && current_action
         
     | 
| 
      
 208 
     | 
    
         
            +
             
     | 
| 
      
 209 
     | 
    
         
            +
                    results
         
     | 
| 
      
 210 
     | 
    
         
            +
                  end
         
     | 
| 
       123 
211 
     | 
    
         
             
                end
         
     | 
| 
       124 
212 
     | 
    
         
             
              end
         
     | 
| 
       125 
213 
     | 
    
         
             
            end
         
     | 
    
        data/lib/na/hash.rb
    CHANGED
    
    | 
         @@ -1,9 +1,16 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            # frozen_string_literal: true
         
     | 
| 
       2 
2 
     | 
    
         | 
| 
      
 3 
     | 
    
         
            +
            ##
         
     | 
| 
      
 4 
     | 
    
         
            +
            # Extensions to Ruby's Hash class for symbolizing keys and deep freezing values.
         
     | 
| 
      
 5 
     | 
    
         
            +
            #
         
     | 
| 
      
 6 
     | 
    
         
            +
            # @example Symbolize all keys in a hash
         
     | 
| 
      
 7 
     | 
    
         
            +
            #   { 'foo' => 1, 'bar' => { 'baz' => 2 } }.symbolize_keys #=> { :foo => 1, :bar => { :baz => 2 } }
         
     | 
| 
       3 
8 
     | 
    
         
             
            class ::Hash
         
     | 
| 
       4 
9 
     | 
    
         
             
              # Convert all keys in the hash to symbols recursively
         
     | 
| 
       5 
10 
     | 
    
         
             
              #
         
     | 
| 
       6 
11 
     | 
    
         
             
              # @return [Hash] Hash with symbolized keys
         
     | 
| 
      
 12 
     | 
    
         
            +
              # @example
         
     | 
| 
      
 13 
     | 
    
         
            +
              #   { 'foo' => 1, 'bar' => { 'baz' => 2 } }.symbolize_keys #=> { :foo => 1, :bar => { :baz => 2 } }
         
     | 
| 
       7 
14 
     | 
    
         
             
              def symbolize_keys
         
     | 
| 
       8 
15 
     | 
    
         
             
                each_with_object({}) { |(k, v), hsh| hsh[k.to_sym] = v.is_a?(Hash) ? v.symbolize_keys : v }
         
     | 
| 
       9 
16 
     | 
    
         
             
              end
         
     | 
| 
         @@ -12,6 +19,8 @@ class ::Hash 
     | 
|
| 
       12 
19 
     | 
    
         
             
              # Freeze all values in a hash
         
     | 
| 
       13 
20 
     | 
    
         
             
              #
         
     | 
| 
       14 
21 
     | 
    
         
             
              # @return     Hash with all values frozen
         
     | 
| 
      
 22 
     | 
    
         
            +
              # @example
         
     | 
| 
      
 23 
     | 
    
         
            +
              #   { foo: { bar: 'baz' } }.deep_freeze
         
     | 
| 
       15 
24 
     | 
    
         
             
              def deep_freeze
         
     | 
| 
       16 
25 
     | 
    
         
             
                chilled = {}
         
     | 
| 
       17 
26 
     | 
    
         
             
                each do |k, v|
         
     | 
    
        data/lib/na/help_monkey_patch.rb
    CHANGED
    
    | 
         @@ -1,17 +1,26 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            # frozen_string_literal: true
         
     | 
| 
       2 
2 
     | 
    
         | 
| 
      
 3 
     | 
    
         
            +
            ##
         
     | 
| 
      
 4 
     | 
    
         
            +
            # Monkeypatches for GLI CLI framework to support paginated help output.
         
     | 
| 
      
 5 
     | 
    
         
            +
            #
         
     | 
| 
      
 6 
     | 
    
         
            +
            # @example Show help for a command
         
     | 
| 
      
 7 
     | 
    
         
            +
            #   GLI::Commands::Help.new.show_help({}, {}, [], $stdout, $stderr)
         
     | 
| 
       3 
8 
     | 
    
         
             
            module GLI
         
     | 
| 
      
 9 
     | 
    
         
            +
              ##
         
     | 
| 
      
 10 
     | 
    
         
            +
              # Command extensions for GLI CLI framework.
         
     | 
| 
       4 
11 
     | 
    
         
             
              module Commands
         
     | 
| 
       5 
12 
     | 
    
         
             
                # Help Command Monkeypatch for paginated output
         
     | 
| 
       6 
13 
     | 
    
         
             
                class Help < Command
         
     | 
| 
       7 
14 
     | 
    
         
             
                  # Show help output for GLI commands with paginated output
         
     | 
| 
       8 
15 
     | 
    
         
             
                  #
         
     | 
| 
       9 
     | 
    
         
            -
                  # @param  
     | 
| 
      
 16 
     | 
    
         
            +
                  # @param _global_options [Hash] Global CLI options
         
     | 
| 
       10 
17 
     | 
    
         
             
                  # @param options [Hash] Command-specific options
         
     | 
| 
       11 
18 
     | 
    
         
             
                  # @param arguments [Array] Command arguments
         
     | 
| 
       12 
19 
     | 
    
         
             
                  # @param out [IO] Output stream
         
     | 
| 
       13 
20 
     | 
    
         
             
                  # @param error [IO] Error stream
         
     | 
| 
       14 
21 
     | 
    
         
             
                  # @return [void]
         
     | 
| 
      
 22 
     | 
    
         
            +
                  # @example
         
     | 
| 
      
 23 
     | 
    
         
            +
                  #   GLI::Commands::Help.new.show_help({}, {}, [], $stdout, $stderr)
         
     | 
| 
       15 
24 
     | 
    
         
             
                  def show_help(_global_options, options, arguments, out, error)
         
     | 
| 
       16 
25 
     | 
    
         
             
                    NA::Pager.paginate = true
         
     | 
| 
       17 
26 
     | 
    
         | 
    
        data/lib/na/next_action.rb
    CHANGED
    
    | 
         @@ -8,10 +8,17 @@ module NA 
     | 
|
| 
       8 
8 
     | 
    
         
             
                attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
         
     | 
| 
       9 
9 
     | 
    
         
             
                              :cwd_is, :cwd, :stdin, :show_cwd_indicator
         
     | 
| 
       10 
10 
     | 
    
         | 
| 
      
 11 
     | 
    
         
            +
                # Returns the current theme hash for color and style settings.
         
     | 
| 
      
 12 
     | 
    
         
            +
                # @return [Hash] The theme settings
         
     | 
| 
       11 
13 
     | 
    
         
             
                def theme
         
     | 
| 
       12 
14 
     | 
    
         
             
                  @theme ||= NA::Theme.load_theme
         
     | 
| 
       13 
15 
     | 
    
         
             
                end
         
     | 
| 
       14 
16 
     | 
    
         | 
| 
      
 17 
     | 
    
         
            +
                # Print a message to stderr, optionally exit or debug.
         
     | 
| 
      
 18 
     | 
    
         
            +
                # @param msg [String] The message to print
         
     | 
| 
      
 19 
     | 
    
         
            +
                # @param exit_code [Integer, Boolean] Exit code or false for no exit
         
     | 
| 
      
 20 
     | 
    
         
            +
                # @param debug [Boolean] Only print if verbose
         
     | 
| 
      
 21 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       15 
22 
     | 
    
         
             
                def notify(msg, exit_code: false, debug: false)
         
     | 
| 
       16 
23 
     | 
    
         
             
                  return if debug && !NA.verbose
         
     | 
| 
       17 
24 
     | 
    
         | 
| 
         @@ -23,6 +30,8 @@ module NA 
     | 
|
| 
       23 
30 
     | 
    
         
             
                  Process.exit exit_code if exit_code
         
     | 
| 
       24 
31 
     | 
    
         
             
                end
         
     | 
| 
       25 
32 
     | 
    
         | 
| 
      
 33 
     | 
    
         
            +
                # Returns a map of priority levels to numeric values.
         
     | 
| 
      
 34 
     | 
    
         
            +
                # @return [Hash{String=>Integer}] Priority mapping
         
     | 
| 
       26 
35 
     | 
    
         
             
                def priority_map
         
     | 
| 
       27 
36 
     | 
    
         
             
                  {
         
     | 
| 
       28 
37 
     | 
    
         
             
                    'h' => 5,
         
     | 
| 
         @@ -128,6 +137,11 @@ module NA 
     | 
|
| 
       128 
137 
     | 
    
         
             
                  end
         
     | 
| 
       129 
138 
     | 
    
         
             
                end
         
     | 
| 
       130 
139 
     | 
    
         | 
| 
      
 140 
     | 
    
         
            +
                # Shift project indices after a given index by a length.
         
     | 
| 
      
 141 
     | 
    
         
            +
                # @param projects [Array<NA::Project>] Projects to shift
         
     | 
| 
      
 142 
     | 
    
         
            +
                # @param idx [Integer] Index after which to shift
         
     | 
| 
      
 143 
     | 
    
         
            +
                # @param length [Integer] Amount to shift
         
     | 
| 
      
 144 
     | 
    
         
            +
                # @return [Array<NA::Project>] Shifted projects
         
     | 
| 
       131 
145 
     | 
    
         
             
                def shift_index_after(projects, idx, length = 1)
         
     | 
| 
       132 
146 
     | 
    
         
             
                  projects.map do |proj|
         
     | 
| 
       133 
147 
     | 
    
         
             
                    proj.line = proj.line - length if proj.line > idx
         
     | 
| 
         @@ -155,8 +169,9 @@ module NA 
     | 
|
| 
       155 
169 
     | 
    
         
             
                # @param done [Boolean] Include done actions
         
     | 
| 
       156 
170 
     | 
    
         
             
                # @param project [String, nil] Project name
         
     | 
| 
       157 
171 
     | 
    
         
             
                # @param search_note [Boolean] Search notes
         
     | 
| 
      
 172 
     | 
    
         
            +
                # @param target_line [Integer] Specific line number to target
         
     | 
| 
       158 
173 
     | 
    
         
             
                # @return [Array] Projects and actions
         
     | 
| 
       159 
     | 
    
         
            -
                def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true)
         
     | 
| 
      
 174 
     | 
    
         
            +
                def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true, target_line: nil)
         
     | 
| 
       160 
175 
     | 
    
         
             
                  todo = NA::Todo.new({ search: search,
         
     | 
| 
       161 
176 
     | 
    
         
             
                                        search_note: search_note,
         
     | 
| 
       162 
177 
     | 
    
         
             
                                        require_na: false,
         
     | 
| 
         @@ -173,7 +188,17 @@ module NA 
     | 
|
| 
       173 
188 
     | 
    
         | 
| 
       174 
189 
     | 
    
         
             
                  return [todo.projects, todo.actions] if todo.actions.count == 1 || all
         
     | 
| 
       175 
190 
     | 
    
         | 
| 
       176 
     | 
    
         
            -
                   
     | 
| 
      
 191 
     | 
    
         
            +
                  # If target_line is specified, find the action at that specific line
         
     | 
| 
      
 192 
     | 
    
         
            +
                  if target_line
         
     | 
| 
      
 193 
     | 
    
         
            +
                    matching_action = todo.actions.find { |a| a.line == target_line }
         
     | 
| 
      
 194 
     | 
    
         
            +
                    return [todo.projects, NA::Actions.new([matching_action])] if matching_action
         
     | 
| 
      
 195 
     | 
    
         
            +
             
     | 
| 
      
 196 
     | 
    
         
            +
                    NA.notify("#{NA.theme[:error]}No action found at line #{target_line}", exit_code: 1)
         
     | 
| 
      
 197 
     | 
    
         
            +
                    return [todo.projects, NA::Actions.new]
         
     | 
| 
      
 198 
     | 
    
         
            +
             
     | 
| 
      
 199 
     | 
    
         
            +
                  end
         
     | 
| 
      
 200 
     | 
    
         
            +
             
     | 
| 
      
 201 
     | 
    
         
            +
                  options = todo.actions.map { |action| "#{action.file} : #{action.action}" }
         
     | 
| 
       177 
202 
     | 
    
         
             
                  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
         
     | 
| 
       178 
203 
     | 
    
         | 
| 
       179 
204 
     | 
    
         
             
                  unless res&.length&.positive?
         
     | 
| 
         @@ -183,9 +208,14 @@ module NA 
     | 
|
| 
       183 
208 
     | 
    
         | 
| 
       184 
209 
     | 
    
         
             
                  selected = NA::Actions.new
         
     | 
| 
       185 
210 
     | 
    
         
             
                  res.each do |result|
         
     | 
| 
       186 
     | 
    
         
            -
                     
     | 
| 
       187 
     | 
    
         
            -
                     
     | 
| 
       188 
     | 
    
         
            -
                     
     | 
| 
      
 211 
     | 
    
         
            +
                    # Extract file:line from result (e.g., "./todo.taskpaper:21 : action text")
         
     | 
| 
      
 212 
     | 
    
         
            +
                    match = result.match(/^(.+?):(\d+) : /)
         
     | 
| 
      
 213 
     | 
    
         
            +
                    next unless match
         
     | 
| 
      
 214 
     | 
    
         
            +
             
     | 
| 
      
 215 
     | 
    
         
            +
                    file_path = match[1]
         
     | 
| 
      
 216 
     | 
    
         
            +
                    line_num = match[2].to_i
         
     | 
| 
      
 217 
     | 
    
         
            +
                    action = todo.actions.select { |a| a.file_path == file_path && a.file_line == line_num }.first
         
     | 
| 
      
 218 
     | 
    
         
            +
                    selected.push(action) if action
         
     | 
| 
       189 
219 
     | 
    
         
             
                  end
         
     | 
| 
       190 
220 
     | 
    
         
             
                  [todo.projects, selected]
         
     | 
| 
       191 
221 
     | 
    
         
             
                end
         
     | 
| 
         @@ -194,9 +224,8 @@ module NA 
     | 
|
| 
       194 
224 
     | 
    
         
             
                #
         
     | 
| 
       195 
225 
     | 
    
         
             
                # @param target [String] Path to the todo file
         
     | 
| 
       196 
226 
     | 
    
         
             
                # @param project [String] Project name
         
     | 
| 
       197 
     | 
    
         
            -
                # @param projects [Array<NA::Project>] Existing projects
         
     | 
| 
       198 
227 
     | 
    
         
             
                # @return [NA::Project] The new project
         
     | 
| 
       199 
     | 
    
         
            -
                def insert_project(target, project 
     | 
| 
      
 228 
     | 
    
         
            +
                def insert_project(target, project)
         
     | 
| 
       200 
229 
     | 
    
         
             
                  path = project.split(%r{[:/]})
         
     | 
| 
       201 
230 
     | 
    
         
             
                  todo = NA::Todo.new(file_path: target)
         
     | 
| 
       202 
231 
     | 
    
         
             
                  built = []
         
     | 
| 
         @@ -299,6 +328,9 @@ module NA 
     | 
|
| 
       299 
328 
     | 
    
         
             
                                  remove_tag: [],
         
     | 
| 
       300 
329 
     | 
    
         
             
                                  replace: nil,
         
     | 
| 
       301 
330 
     | 
    
         
             
                                  tagged: nil)
         
     | 
| 
      
 331 
     | 
    
         
            +
                  # Expand target to absolute path to avoid path resolution issues
         
     | 
| 
      
 332 
     | 
    
         
            +
                  target = File.expand_path(target) unless Pathname.new(target).absolute?
         
     | 
| 
      
 333 
     | 
    
         
            +
             
     | 
| 
       302 
334 
     | 
    
         
             
                  projects = find_projects(target)
         
     | 
| 
       303 
335 
     | 
    
         
             
                  affected_actions = []
         
     | 
| 
       304 
336 
     | 
    
         | 
| 
         @@ -323,11 +355,14 @@ module NA 
     | 
|
| 
       323 
355 
     | 
    
         
             
                  contents = target.read_file.split("\n")
         
     | 
| 
       324 
356 
     | 
    
         | 
| 
       325 
357 
     | 
    
         
             
                  if add.is_a?(Action)
         
     | 
| 
      
 358 
     | 
    
         
            +
                    # NOTE: Edit is handled in the update command before calling update_action
         
     | 
| 
      
 359 
     | 
    
         
            +
                    # So we don't need to handle it here - the action is already edited
         
     | 
| 
      
 360 
     | 
    
         
            +
             
     | 
| 
       326 
361 
     | 
    
         
             
                    add_tag ||= []
         
     | 
| 
       327 
362 
     | 
    
         
             
                    add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
         
     | 
| 
       328 
363 
     | 
    
         | 
| 
       329 
364 
     | 
    
         
             
                    # Remove the original action and its notes
         
     | 
| 
       330 
     | 
    
         
            -
                    action_line = add. 
     | 
| 
      
 365 
     | 
    
         
            +
                    action_line = add.file_line
         
     | 
| 
       331 
366 
     | 
    
         
             
                    note_lines = add.note.is_a?(Array) ? add.note.count : 0
         
     | 
| 
       332 
367 
     | 
    
         
             
                    contents.slice!(action_line, note_lines + 1)
         
     | 
| 
       333 
368 
     | 
    
         | 
| 
         @@ -376,27 +411,36 @@ module NA 
     | 
|
| 
       376 
411 
     | 
    
         
             
                    changes << "moved to #{target_proj.project}" if move && target_proj
         
     | 
| 
       377 
412 
     | 
    
         
             
                    affected_actions << { action: add, desc: changes.join(', ') }
         
     | 
| 
       378 
413 
     | 
    
         
             
                  else
         
     | 
| 
      
 414 
     | 
    
         
            +
                    # Check if search is actually target_line
         
     | 
| 
      
 415 
     | 
    
         
            +
                    target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
         
     | 
| 
       379 
416 
     | 
    
         
             
                    _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
         
     | 
| 
       380 
     | 
    
         
            -
                                                                      search_note: search_note)
         
     | 
| 
      
 417 
     | 
    
         
            +
                                                                      search_note: search_note, target_line: target_line)
         
     | 
| 
       381 
418 
     | 
    
         | 
| 
       382 
419 
     | 
    
         
             
                    return if actions.nil?
         
     | 
| 
       383 
420 
     | 
    
         | 
| 
       384 
     | 
    
         
            -
                     
     | 
| 
       385 
     | 
    
         
            -
             
     | 
| 
      
 421 
     | 
    
         
            +
                    # Handle edit (single or multi-action)
         
     | 
| 
      
 422 
     | 
    
         
            +
                    if edit
         
     | 
| 
      
 423 
     | 
    
         
            +
                      editor_content = Editor.format_multi_action_input(actions)
         
     | 
| 
      
 424 
     | 
    
         
            +
                      edited_content = Editor.fork_editor(editor_content)
         
     | 
| 
      
 425 
     | 
    
         
            +
                      edited_actions = Editor.parse_multi_action_output(edited_content)
         
     | 
| 
      
 426 
     | 
    
         
            +
             
     | 
| 
      
 427 
     | 
    
         
            +
                      # Map edited content back to actions
         
     | 
| 
      
 428 
     | 
    
         
            +
                      actions.each do |action|
         
     | 
| 
      
 429 
     | 
    
         
            +
                        # Use file_path:file_line as the key
         
     | 
| 
      
 430 
     | 
    
         
            +
                        key = "#{action.file_path}:#{action.file_line}"
         
     | 
| 
      
 431 
     | 
    
         
            +
                        action.action, action.note = edited_actions[key] if edited_actions[key]
         
     | 
| 
      
 432 
     | 
    
         
            +
                      end
         
     | 
| 
      
 433 
     | 
    
         
            +
                    end
         
     | 
| 
      
 434 
     | 
    
         
            +
             
     | 
| 
      
 435 
     | 
    
         
            +
                    actions.sort_by(&:file_line).reverse.each do |action|
         
     | 
| 
      
 436 
     | 
    
         
            +
                      contents.slice!(action.file_line, action.note.count + 1)
         
     | 
| 
       386 
437 
     | 
    
         
             
                      if delete
         
     | 
| 
       387 
438 
     | 
    
         
             
                        # Track deletion before skipping re-insert
         
     | 
| 
       388 
439 
     | 
    
         
             
                        affected_actions << { action: action, desc: 'deleted' }
         
     | 
| 
       389 
440 
     | 
    
         
             
                        next
         
     | 
| 
       390 
441 
     | 
    
         
             
                      end
         
     | 
| 
       391 
442 
     | 
    
         | 
| 
       392 
     | 
    
         
            -
                      projects = shift_index_after(projects, action. 
     | 
| 
       393 
     | 
    
         
            -
             
     | 
| 
       394 
     | 
    
         
            -
                      if edit
         
     | 
| 
       395 
     | 
    
         
            -
                        editor_content = "#{action.action}\n#{action.note.join("\n")}"
         
     | 
| 
       396 
     | 
    
         
            -
                        new_action, new_note = Editor.format_input(Editor.fork_editor(editor_content))
         
     | 
| 
       397 
     | 
    
         
            -
                        action.action = new_action
         
     | 
| 
       398 
     | 
    
         
            -
                        action.note = new_note
         
     | 
| 
       399 
     | 
    
         
            -
                      end
         
     | 
| 
      
 443 
     | 
    
         
            +
                      projects = shift_index_after(projects, action.file_line, action.note.count + 1)
         
     | 
| 
       400 
444 
     | 
    
         | 
| 
       401 
445 
     | 
    
         
             
                      # If replace is defined, use search to search and replace text in action
         
     | 
| 
       402 
446 
     | 
    
         
             
                      action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace
         
     | 
| 
         @@ -602,6 +646,19 @@ module NA 
     | 
|
| 
       602 
646 
     | 
    
         
             
                  end
         
     | 
| 
       603 
647 
     | 
    
         
             
                end
         
     | 
| 
       604 
648 
     | 
    
         | 
| 
      
 649 
     | 
    
         
            +
                # Find files matching criteria and containing actions.
         
     | 
| 
      
 650 
     | 
    
         
            +
                # @param options [Hash] Options for file search
         
     | 
| 
      
 651 
     | 
    
         
            +
                # @option options [Integer] :depth Search depth
         
     | 
| 
      
 652 
     | 
    
         
            +
                # @option options [Boolean] :done Include done actions
         
     | 
| 
      
 653 
     | 
    
         
            +
                # @option options [String] :file_path File path
         
     | 
| 
      
 654 
     | 
    
         
            +
                # @option options [Boolean] :negate Negate search
         
     | 
| 
      
 655 
     | 
    
         
            +
                # @option options [Boolean] :hidden Include hidden files
         
     | 
| 
      
 656 
     | 
    
         
            +
                # @option options [String] :project Project name
         
     | 
| 
      
 657 
     | 
    
         
            +
                # @option options [String] :query Query string
         
     | 
| 
      
 658 
     | 
    
         
            +
                # @option options [Boolean] :regex Use regex
         
     | 
| 
      
 659 
     | 
    
         
            +
                # @option options [String] :search Search string
         
     | 
| 
      
 660 
     | 
    
         
            +
                # @option options [String] :tag Tag to filter
         
     | 
| 
      
 661 
     | 
    
         
            +
                # @return [Array<String>] Matching files
         
     | 
| 
       605 
662 
     | 
    
         
             
                def find_files_matching(options = {})
         
     | 
| 
       606 
663 
     | 
    
         
             
                  defaults = {
         
     | 
| 
       607 
664 
     | 
    
         
             
                    depth: 1,
         
     | 
| 
         @@ -690,6 +747,10 @@ module NA 
     | 
|
| 
       690 
747 
     | 
    
         
             
                  end
         
     | 
| 
       691 
748 
     | 
    
         
             
                end
         
     | 
| 
       692 
749 
     | 
    
         | 
| 
      
 750 
     | 
    
         
            +
                # Find a directory with an exact match from a list.
         
     | 
| 
      
 751 
     | 
    
         
            +
                # @param dirs [Array<String>] Directories to search
         
     | 
| 
      
 752 
     | 
    
         
            +
                # @param search [Array<Hash>] Search tokens
         
     | 
| 
      
 753 
     | 
    
         
            +
                # @return [Array<String>] Matching directories
         
     | 
| 
       693 
754 
     | 
    
         
             
                def find_exact_dir(dirs, search)
         
     | 
| 
       694 
755 
     | 
    
         
             
                  terms = search.filter { |s| !s[:negate] }.map { |t| t[:token] }.join(' ')
         
     | 
| 
       695 
756 
     | 
    
         
             
                  out = dirs
         
     | 
| 
         @@ -878,6 +939,12 @@ module NA 
     | 
|
| 
       878 
939 
     | 
    
         
             
                  File.open(file, 'w') { |f| f.puts dirs.join("\n") }
         
     | 
| 
       879 
940 
     | 
    
         
             
                end
         
     | 
| 
       880 
941 
     | 
    
         | 
| 
      
 942 
     | 
    
         
            +
                # List projects in a todo file or matching query.
         
     | 
| 
      
 943 
     | 
    
         
            +
                # @param query [Array] Query tokens
         
     | 
| 
      
 944 
     | 
    
         
            +
                # @param file_path [String, nil] File path
         
     | 
| 
      
 945 
     | 
    
         
            +
                # @param depth [Integer] Search depth
         
     | 
| 
      
 946 
     | 
    
         
            +
                # @param paths [Boolean] Show full paths
         
     | 
| 
      
 947 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       881 
948 
     | 
    
         
             
                def list_projects(query: [], file_path: nil, depth: 1, paths: true)
         
     | 
| 
       882 
949 
     | 
    
         
             
                  files = if NA.global_file
         
     | 
| 
       883 
950 
     | 
    
         
             
                            [NA.global_file]
         
     | 
| 
         @@ -906,6 +973,9 @@ module NA 
     | 
|
| 
       906 
973 
     | 
    
         
             
                  end
         
     | 
| 
       907 
974 
     | 
    
         
             
                end
         
     | 
| 
       908 
975 
     | 
    
         | 
| 
      
 976 
     | 
    
         
            +
                # List todo files matching a query.
         
     | 
| 
      
 977 
     | 
    
         
            +
                # @param query [Array] Query tokens
         
     | 
| 
      
 978 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       909 
979 
     | 
    
         
             
                def list_todos(query: [])
         
     | 
| 
       910 
980 
     | 
    
         
             
                  dirs = if query
         
     | 
| 
       911 
981 
     | 
    
         
             
                           match_working_dir(query, distance: 2, require_last: false)
         
     | 
| 
         @@ -922,6 +992,10 @@ module NA 
     | 
|
| 
       922 
992 
     | 
    
         
             
                  puts NA::Color.template(dirs.join("\n"))
         
     | 
| 
       923 
993 
     | 
    
         
             
                end
         
     | 
| 
       924 
994 
     | 
    
         | 
| 
      
 995 
     | 
    
         
            +
                # Save a search definition to the database.
         
     | 
| 
      
 996 
     | 
    
         
            +
                # @param title [String] The search title
         
     | 
| 
      
 997 
     | 
    
         
            +
                # @param search [String] The search string
         
     | 
| 
      
 998 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       925 
999 
     | 
    
         
             
                def save_search(title, search)
         
     | 
| 
       926 
1000 
     | 
    
         
             
                  file = database_path(file: 'saved_searches.yml')
         
     | 
| 
       927 
1001 
     | 
    
         
             
                  searches = load_searches
         
     | 
    
        data/lib/na/project.rb
    CHANGED
    
    | 
         @@ -1,6 +1,10 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            # frozen_string_literal: true
         
     | 
| 
       2 
2 
     | 
    
         | 
| 
       3 
3 
     | 
    
         
             
            module NA
         
     | 
| 
      
 4 
     | 
    
         
            +
              # Represents a project section in a todo file, with indentation and line tracking.
         
     | 
| 
      
 5 
     | 
    
         
            +
              #
         
     | 
| 
      
 6 
     | 
    
         
            +
              # @example Create a new project
         
     | 
| 
      
 7 
     | 
    
         
            +
              #   project = NA::Project.new('Inbox', 0, 1, 5)
         
     | 
| 
       4 
8 
     | 
    
         
             
              class Project < Hash
         
     | 
| 
       5 
9 
     | 
    
         
             
                attr_accessor :project, :indent, :line, :last_line
         
     | 
| 
       6 
10 
     | 
    
         | 
| 
         @@ -11,6 +15,8 @@ module NA 
     | 
|
| 
       11 
15 
     | 
    
         
             
                # @param line [Integer] Starting line number
         
     | 
| 
       12 
16 
     | 
    
         
             
                # @param last_line [Integer] Ending line number
         
     | 
| 
       13 
17 
     | 
    
         
             
                # @return [void]
         
     | 
| 
      
 18 
     | 
    
         
            +
                # @example
         
     | 
| 
      
 19 
     | 
    
         
            +
                #   project = NA::Project.new('Inbox', 0, 1, 5)
         
     | 
| 
       14 
20 
     | 
    
         
             
                def initialize(project, indent = 0, line = 0, last_line = 0)
         
     | 
| 
       15 
21 
     | 
    
         
             
                  super()
         
     | 
| 
       16 
22 
     | 
    
         
             
                  @project = project
         
     | 
| 
         @@ -22,6 +28,8 @@ module NA 
     | 
|
| 
       22 
28 
     | 
    
         
             
                # String representation of the project
         
     | 
| 
       23 
29 
     | 
    
         
             
                #
         
     | 
| 
       24 
30 
     | 
    
         
             
                # @return [String]
         
     | 
| 
      
 31 
     | 
    
         
            +
                # @example
         
     | 
| 
      
 32 
     | 
    
         
            +
                #   project.to_s #=> "{ project: 'Inbox', ... }"
         
     | 
| 
       25 
33 
     | 
    
         
             
                def to_s
         
     | 
| 
       26 
34 
     | 
    
         
             
                  { project: @project, indent: @indent, line: @line, last_line: @last_line }.to_s
         
     | 
| 
       27 
35 
     | 
    
         
             
                end
         
     | 
    
        data/lib/na/string.rb
    CHANGED
    
    | 
         @@ -1,15 +1,39 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            # frozen_string_literal: true
         
     | 
| 
       2 
2 
     | 
    
         | 
| 
      
 3 
     | 
    
         
            +
            # Special handling for nil strings
         
     | 
| 
      
 4 
     | 
    
         
            +
            class ::NilClass
         
     | 
| 
      
 5 
     | 
    
         
            +
              # Always returns an empty string for nil.
         
     | 
| 
      
 6 
     | 
    
         
            +
              # @return [String]
         
     | 
| 
      
 7 
     | 
    
         
            +
              def highlight_filename
         
     | 
| 
      
 8 
     | 
    
         
            +
                ''
         
     | 
| 
      
 9 
     | 
    
         
            +
              end
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
              # Always returns an empty string for nil.
         
     | 
| 
      
 12 
     | 
    
         
            +
              # @param max [Integer] Maximum allowed length
         
     | 
| 
      
 13 
     | 
    
         
            +
              # @return [String]
         
     | 
| 
      
 14 
     | 
    
         
            +
              def trunc_middle(max)
         
     | 
| 
      
 15 
     | 
    
         
            +
                ''
         
     | 
| 
      
 16 
     | 
    
         
            +
              end
         
     | 
| 
      
 17 
     | 
    
         
            +
            end
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
            # Matches day names (e.g., mon, tue, wednesday)
         
     | 
| 
      
 20 
     | 
    
         
            +
            # @return [Regexp]
         
     | 
| 
       3 
21 
     | 
    
         
             
            REGEX_DAY = /^(mon|tue|wed|thur?|fri|sat|sun)(\w+(day)?)?$/i.freeze
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
            # Matches clock times (e.g., 12:30 pm, midnight)
         
     | 
| 
      
 24 
     | 
    
         
            +
            # @return [String]
         
     | 
| 
       4 
25 
     | 
    
         
             
            REGEX_CLOCK = '(?:\d{1,2}+(?::\d{1,2}+)?(?: *(?:am|pm))?|midnight|noon)'
         
     | 
| 
      
 26 
     | 
    
         
            +
             
     | 
| 
      
 27 
     | 
    
         
            +
            # Matches time strings using REGEX_CLOCK
         
     | 
| 
      
 28 
     | 
    
         
            +
            # @return [Regexp]
         
     | 
| 
       5 
29 
     | 
    
         
             
            REGEX_TIME = /^#{REGEX_CLOCK}$/i.freeze
         
     | 
| 
       6 
30 
     | 
    
         | 
| 
       7 
31 
     | 
    
         
             
            # String helpers
         
     | 
| 
       8 
32 
     | 
    
         
             
            class ::String
         
     | 
| 
       9 
33 
     | 
    
         
             
              # Insert a comment character at the start of every line
         
     | 
| 
       10 
34 
     | 
    
         
             
              # @param char [String] The character to insert (default #)
         
     | 
| 
       11 
     | 
    
         
            -
              def comment( 
     | 
| 
       12 
     | 
    
         
            -
                split("\n").map { |l| "# #{l}" }.join("\n")
         
     | 
| 
      
 35 
     | 
    
         
            +
              def comment(char = '#')
         
     | 
| 
      
 36 
     | 
    
         
            +
                split("\n").map { |l| "#{char} #{l}" }.join("\n")
         
     | 
| 
       13 
37 
     | 
    
         
             
              end
         
     | 
| 
       14 
38 
     | 
    
         | 
| 
       15 
39 
     | 
    
         
             
              # Tests if object is nil or empty
         
     | 
| 
         @@ -25,6 +49,10 @@ class ::String 
     | 
|
| 
       25 
49 
     | 
    
         
             
                line =~ /^#/ || line.strip.empty?
         
     | 
| 
       26 
50 
     | 
    
         
             
              end
         
     | 
| 
       27 
51 
     | 
    
         | 
| 
      
 52 
     | 
    
         
            +
              # Returns the contents of the file, or raises if missing.
         
     | 
| 
      
 53 
     | 
    
         
            +
              # Handles directories and NA extension.
         
     | 
| 
      
 54 
     | 
    
         
            +
              # @return [String] Contents of the file
         
     | 
| 
      
 55 
     | 
    
         
            +
              # @raise [RuntimeError] if the file does not exist
         
     | 
| 
       28 
56 
     | 
    
         
             
              def read_file
         
     | 
| 
       29 
57 
     | 
    
         
             
                file = File.expand_path(self)
         
     | 
| 
       30 
58 
     | 
    
         
             
                raise "Missing file #{file}" unless File.exist?(file)
         
     | 
| 
         @@ -64,11 +92,15 @@ class ::String 
     | 
|
| 
       64 
92 
     | 
    
         
             
                !action? && self =~ /:( +@\S+(\([^)]*\))?)*$/
         
     | 
| 
       65 
93 
     | 
    
         
             
              end
         
     | 
| 
       66 
94 
     | 
    
         | 
| 
      
 95 
     | 
    
         
            +
              # Returns the project name if matched, otherwise nil.
         
     | 
| 
      
 96 
     | 
    
         
            +
              # @return [String, nil]
         
     | 
| 
       67 
97 
     | 
    
         
             
              def project
         
     | 
| 
       68 
98 
     | 
    
         
             
                m = match(/^([ \t]*)([^\-][^@:]*?): *(@\S+ *)*$/)
         
     | 
| 
       69 
99 
     | 
    
         
             
                m ? m[2] : nil
         
     | 
| 
       70 
100 
     | 
    
         
             
              end
         
     | 
| 
       71 
101 
     | 
    
         | 
| 
      
 102 
     | 
    
         
            +
              # Returns the action text with leading dash and whitespace removed.
         
     | 
| 
      
 103 
     | 
    
         
            +
              # @return [String]
         
     | 
| 
       72 
104 
     | 
    
         
             
              def action
         
     | 
| 
       73 
105 
     | 
    
         
             
                sub(/^[ \t]*- /, '')
         
     | 
| 
       74 
106 
     | 
    
         
             
              end
         
     | 
| 
         @@ -84,6 +116,8 @@ class ::String 
     | 
|
| 
       84 
116 
     | 
    
         
             
              # Colorize the dirname and filename of a path
         
     | 
| 
       85 
117 
     | 
    
         
             
              # @return [String] Colorized string
         
     | 
| 
       86 
118 
     | 
    
         
             
              def highlight_filename
         
     | 
| 
      
 119 
     | 
    
         
            +
                return '' if nil?
         
     | 
| 
      
 120 
     | 
    
         
            +
             
     | 
| 
       87 
121 
     | 
    
         
             
                dir = File.dirname(self).shorten_path.trunc_middle(TTY::Screen.columns / 3)
         
     | 
| 
       88 
122 
     | 
    
         
             
                file = NA.include_ext ? File.basename(self) : File.basename(self, ".#{NA.extension}")
         
     | 
| 
       89 
123 
     | 
    
         
             
                "#{NA.theme[:dirname]}#{dir}/#{NA.theme[:filename]}#{file}{x}"
         
     | 
| 
         @@ -115,13 +149,17 @@ class ::String 
     | 
|
| 
       115 
149 
     | 
    
         
             
              # @param color [String] The highlight color template
         
     | 
| 
       116 
150 
     | 
    
         
             
              # @param last_color [String] Color to restore after highlight
         
     | 
| 
       117 
151 
     | 
    
         
             
              def highlight_search(regexes, color: NA.theme[:search_highlight], last_color: NA.theme[:action])
         
     | 
| 
      
 152 
     | 
    
         
            +
                # Skip if string already contains ANSI codes - applying regex to colored text
         
     | 
| 
      
 153 
     | 
    
         
            +
                # will break escape sequences (e.g., searching for "3" will match "3" in "38;2;236;204;135m")
         
     | 
| 
      
 154 
     | 
    
         
            +
                return self if include?("\e")
         
     | 
| 
      
 155 
     | 
    
         
            +
             
     | 
| 
      
 156 
     | 
    
         
            +
                # Original simple approach for strings without ANSI codes
         
     | 
| 
       118 
157 
     | 
    
         
             
                string = dup
         
     | 
| 
       119 
158 
     | 
    
         
             
                color = NA::Color.template(color.dup)
         
     | 
| 
       120 
159 
     | 
    
         
             
                regexes.each do |rx|
         
     | 
| 
       121 
160 
     | 
    
         
             
                  next if rx.nil?
         
     | 
| 
       122 
161 
     | 
    
         | 
| 
       123 
162 
     | 
    
         
             
                  rx = Regexp.new(rx, Regexp::IGNORECASE) if rx.is_a?(String)
         
     | 
| 
       124 
     | 
    
         
            -
             
     | 
| 
       125 
163 
     | 
    
         
             
                  string.gsub!(rx) do
         
     | 
| 
       126 
164 
     | 
    
         
             
                    m = Regexp.last_match
         
     | 
| 
       127 
165 
     | 
    
         
             
                    last = m.pre_match.last_color
         
     | 
| 
         @@ -135,14 +173,15 @@ class ::String 
     | 
|
| 
       135 
173 
     | 
    
         
             
              # @param max [Integer] Maximum allowed length of the string
         
     | 
| 
       136 
174 
     | 
    
         
             
              # @return [String] Truncated string with middle replaced if necessary
         
     | 
| 
       137 
175 
     | 
    
         
             
              def trunc_middle(max)
         
     | 
| 
       138 
     | 
    
         
            -
             
     | 
| 
       139 
     | 
    
         
            -
             
     | 
| 
      
 176 
     | 
    
         
            +
                return '' if nil?
         
     | 
| 
      
 177 
     | 
    
         
            +
             
     | 
| 
      
 178 
     | 
    
         
            +
                return self unless length > max
         
     | 
| 
       140 
179 
     | 
    
         | 
| 
       141 
     | 
    
         
            -
             
     | 
| 
       142 
     | 
    
         
            -
             
     | 
| 
       143 
     | 
    
         
            -
             
     | 
| 
       144 
     | 
    
         
            -
             
     | 
| 
       145 
     | 
    
         
            -
             
     | 
| 
      
 180 
     | 
    
         
            +
                half = (max / 2).floor - 3
         
     | 
| 
      
 181 
     | 
    
         
            +
                cs = chars
         
     | 
| 
      
 182 
     | 
    
         
            +
                pre = cs.slice(0, half)
         
     | 
| 
      
 183 
     | 
    
         
            +
                post = cs.reverse.slice(0, half).reverse
         
     | 
| 
      
 184 
     | 
    
         
            +
                "#{pre.join}[...]#{post.join}"
         
     | 
| 
       146 
185 
     | 
    
         
             
              end
         
     | 
| 
       147 
186 
     | 
    
         | 
| 
       148 
187 
     | 
    
         
             
              # Wrap the string to a given width, indenting each line and preserving tag formatting.
         
     | 
| 
         @@ -155,9 +194,9 @@ class ::String 
     | 
|
| 
       155 
194 
     | 
    
         
             
                output = []
         
     | 
| 
       156 
195 
     | 
    
         
             
                line = []
         
     | 
| 
       157 
196 
     | 
    
         
             
                length = 0
         
     | 
| 
       158 
     | 
    
         
            -
                gsub 
     | 
| 
      
 197 
     | 
    
         
            +
                text = gsub(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
         
     | 
| 
       159 
198 
     | 
    
         | 
| 
       160 
     | 
    
         
            -
                split 
     | 
| 
      
 199 
     | 
    
         
            +
                text.split.each do |word|
         
     | 
| 
       161 
200 
     | 
    
         
             
                  uncolored = NA::Color.uncolor(word)
         
     | 
| 
       162 
201 
     | 
    
         
             
                  if (length + uncolored.length + 1) <= width
         
     | 
| 
       163 
202 
     | 
    
         
             
                    line << word
         
     |