na 1.2.80 → 1.2.82
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/.rubocop.yml +8 -2
 - data/.rubocop_todo.yml +33 -538
 - data/CHANGELOG.md +27 -0
 - data/Gemfile +2 -0
 - data/Gemfile.lock +27 -10
 - data/README.md +66 -21
 - data/Rakefile +6 -0
 - data/bin/commands/next.rb +4 -0
 - data/bin/commands/scan.rb +84 -0
 - data/bin/commands/update.rb +291 -14
 - data/bin/na +7 -7
 - data/lib/na/action.rb +103 -38
 - data/lib/na/actions.rb +79 -77
 - data/lib/na/array.rb +11 -7
 - data/lib/na/benchmark.rb +21 -9
 - data/lib/na/colors.rb +84 -86
 - data/lib/na/editor.rb +22 -22
 - data/lib/na/hash.rb +32 -9
 - data/lib/na/help_monkey_patch.rb +9 -1
 - data/lib/na/next_action.rb +347 -305
 - data/lib/na/pager.rb +38 -14
 - data/lib/na/project.rb +14 -1
 - data/lib/na/prompt.rb +25 -3
 - data/lib/na/string.rb +94 -133
 - data/lib/na/theme.rb +37 -31
 - data/lib/na/todo.rb +153 -132
 - data/lib/na/version.rb +3 -1
 - data/lib/na.rb +1 -0
 - data/na.gemspec +4 -2
 - data/scripts/generate-fish-completions.rb +18 -21
 - data/src/_README.md +17 -5
 - data/test_performance.rb +5 -5
 - metadata +53 -24
 
    
        data/bin/commands/update.rb
    CHANGED
    
    | 
         @@ -104,6 +104,49 @@ class App 
     | 
|
| 
       104 
104 
     | 
    
         
             
                c.switch %i[x exact], negatable: false
         
     | 
| 
       105 
105 
     | 
    
         | 
| 
       106 
106 
     | 
    
         
             
                c.action do |global_options, options, args|
         
     | 
| 
      
 107 
     | 
    
         
            +
                  # Ensure all variables used in update loop are declared
         
     | 
| 
      
 108 
     | 
    
         
            +
                  target_proj = if options[:move]
         
     | 
| 
      
 109 
     | 
    
         
            +
                                  options[:move]
         
     | 
| 
      
 110 
     | 
    
         
            +
                                elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
         
     | 
| 
      
 111 
     | 
    
         
            +
                                  NA.cwd
         
     | 
| 
      
 112 
     | 
    
         
            +
                                end
         
     | 
| 
      
 113 
     | 
    
         
            +
             
     | 
| 
      
 114 
     | 
    
         
            +
                  priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
         
     | 
| 
      
 115 
     | 
    
         
            +
                  remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').respond_to?(:wildcard_to_rx) ? t.wildcard_to_rx : t } : []
         
     | 
| 
      
 116 
     | 
    
         
            +
                  remove_tags << 'done' if options[:restore]
         
     | 
| 
      
 117 
     | 
    
         
            +
             
     | 
| 
      
 118 
     | 
    
         
            +
                  stdin_note = NA.respond_to?(:stdin) && NA.stdin ? NA.stdin.split("\n") : []
         
     | 
| 
      
 119 
     | 
    
         
            +
                  line_note = if options[:note] && $stdin.isatty
         
     | 
| 
      
 120 
     | 
    
         
            +
                                puts stdin_note unless stdin_note.nil?
         
     | 
| 
      
 121 
     | 
    
         
            +
                                if TTY::Which.exist?('gum')
         
     | 
| 
      
 122 
     | 
    
         
            +
                                  args = ['--placeholder "Enter a note, CTRL-d to save"']
         
     | 
| 
      
 123 
     | 
    
         
            +
                                  args << '--char-limit 0'
         
     | 
| 
      
 124 
     | 
    
         
            +
                                  args << '--width $(tput cols)'
         
     | 
| 
      
 125 
     | 
    
         
            +
                                  gum = TTY::Which.which('gum')
         
     | 
| 
      
 126 
     | 
    
         
            +
                                  `#{gum} write #{args.join(' ')}`.strip.split("\n")
         
     | 
| 
      
 127 
     | 
    
         
            +
                                else
         
     | 
| 
      
 128 
     | 
    
         
            +
                                  NA.notify("#{NA.theme[:prompt]}Enter a note, {bw}CTRL-d#{NA.theme[:prompt]} to end editing:#{NA.theme[:action]}")
         
     | 
| 
      
 129 
     | 
    
         
            +
                                  reader.read_multiline
         
     | 
| 
      
 130 
     | 
    
         
            +
                                end
         
     | 
| 
      
 131 
     | 
    
         
            +
                              end
         
     | 
| 
      
 132 
     | 
    
         
            +
                  note = stdin_note.empty? ? [] : stdin_note
         
     | 
| 
      
 133 
     | 
    
         
            +
                  note.concat(line_note) unless line_note.nil? || line_note.empty?
         
     | 
| 
      
 134 
     | 
    
         
            +
             
     | 
| 
      
 135 
     | 
    
         
            +
                  append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
         
     | 
| 
      
 136 
     | 
    
         
            +
              add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').respond_to?(:wildcard_to_rx) ? t.wildcard_to_rx : t } : []
         
     | 
| 
      
 137 
     | 
    
         
            +
                  # Build tags array from options[:tagged]
         
     | 
| 
      
 138 
     | 
    
         
            +
                  all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
         
     | 
| 
      
 139 
     | 
    
         
            +
                  tags = []
         
     | 
| 
      
 140 
     | 
    
         
            +
                  options[:tagged].join(',').split(/ *, */).each do |arg|
         
     | 
| 
      
 141 
     | 
    
         
            +
                    m = arg.match(/^(?<req>[+!-])?(?<tag>[^ =<>$~\^]+?) *(?:(?<op>[=<>~]{1,2}|[*$\^]=) *(?<val>.*?))?$/)
         
     | 
| 
      
 142 
     | 
    
         
            +
                    tags.push({
         
     | 
| 
      
 143 
     | 
    
         
            +
                      tag: m['tag'].respond_to?(:wildcard_to_rx) ? m['tag'].wildcard_to_rx : m['tag'],
         
     | 
| 
      
 144 
     | 
    
         
            +
                      comp: m['op'],
         
     | 
| 
      
 145 
     | 
    
         
            +
                      value: m['val'],
         
     | 
| 
      
 146 
     | 
    
         
            +
                      required: all_req || (!m['req'].nil? && m['req'] == '+'),
         
     | 
| 
      
 147 
     | 
    
         
            +
                      negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
         
     | 
| 
      
 148 
     | 
    
         
            +
                    })
         
     | 
| 
      
 149 
     | 
    
         
            +
                  end
         
     | 
| 
       107 
150 
     | 
    
         
             
                  reader = TTY::Reader.new
         
     | 
| 
       108 
151 
     | 
    
         | 
| 
       109 
152 
     | 
    
         
             
                  args.concat(options[:search]) unless options[:search].nil?
         
     | 
| 
         @@ -121,13 +164,13 @@ class App 
     | 
|
| 
       121 
164 
     | 
    
         | 
| 
       122 
165 
     | 
    
         
             
                  options[:exact] = true unless options[:replace].nil?
         
     | 
| 
       123 
166 
     | 
    
         | 
| 
       124 
     | 
    
         
            -
                   
     | 
| 
       125 
     | 
    
         
            -
             
     | 
| 
       126 
     | 
    
         
            -
             
     | 
| 
       127 
     | 
    
         
            -
             
     | 
| 
       128 
     | 
    
         
            -
             
     | 
| 
       129 
     | 
    
         
            -
                   
     | 
| 
       130 
     | 
    
         
            -
             
     | 
| 
      
 167 
     | 
    
         
            +
                  if args.count.positive?
         
     | 
| 
      
 168 
     | 
    
         
            +
                    action = args.join(' ').strip
         
     | 
| 
      
 169 
     | 
    
         
            +
                  else
         
     | 
| 
      
 170 
     | 
    
         
            +
                    action = nil
         
     | 
| 
      
 171 
     | 
    
         
            +
                  end
         
     | 
| 
      
 172 
     | 
    
         
            +
                  tokens = nil
         
     | 
| 
      
 173 
     | 
    
         
            +
                  if action && !action.empty?
         
     | 
| 
       131 
174 
     | 
    
         
             
                    if options[:exact]
         
     | 
| 
       132 
175 
     | 
    
         
             
                      tokens = action
         
     | 
| 
       133 
176 
     | 
    
         
             
                    elsif options[:regex]
         
     | 
| 
         @@ -135,20 +178,254 @@ class App 
     | 
|
| 
       135 
178 
     | 
    
         
             
                    else
         
     | 
| 
       136 
179 
     | 
    
         
             
                      tokens = []
         
     | 
| 
       137 
180 
     | 
    
         
             
                      all_req = action !~ /[+!-]/ && !options[:or]
         
     | 
| 
       138 
     | 
    
         
            -
             
     | 
| 
       139 
181 
     | 
    
         
             
                      action.split(/ /).each do |arg|
         
     | 
| 
       140 
182 
     | 
    
         
             
                        m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
         
     | 
| 
       141 
183 
     | 
    
         
             
                        tokens.push({
         
     | 
| 
       142 
     | 
    
         
            -
             
     | 
| 
       143 
     | 
    
         
            -
             
     | 
| 
       144 
     | 
    
         
            -
             
     | 
| 
       145 
     | 
    
         
            -
             
     | 
| 
      
 184 
     | 
    
         
            +
                          token: m['tok'],
         
     | 
| 
      
 185 
     | 
    
         
            +
                          required: all_req || (!m['req'].nil? && m['req'] == '+'),
         
     | 
| 
      
 186 
     | 
    
         
            +
                          negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
         
     | 
| 
      
 187 
     | 
    
         
            +
                        })
         
     | 
| 
       146 
188 
     | 
    
         
             
                      end
         
     | 
| 
       147 
189 
     | 
    
         
             
                    end
         
     | 
| 
       148 
190 
     | 
    
         
             
                  end
         
     | 
| 
       149 
191 
     | 
    
         | 
| 
      
 192 
     | 
    
         
            +
                  # If no search query or tags, list all tasks for selection
         
     | 
| 
       150 
193 
     | 
    
         
             
                  if (action.nil? || action.empty?) && options[:tagged].empty?
         
     | 
| 
       151 
     | 
    
         
            -
                     
     | 
| 
      
 194 
     | 
    
         
            +
                    tokens = nil # No search, list all
         
     | 
| 
      
 195 
     | 
    
         
            +
                  end
         
     | 
| 
      
 196 
     | 
    
         
            +
             
     | 
| 
      
 197 
     | 
    
         
            +
                  # Gather all candidate actions for selection
         
     | 
| 
      
 198 
     | 
    
         
            +
                  candidate_actions = []
         
     | 
| 
      
 199 
     | 
    
         
            +
                  targets_for_selection = []
         
     | 
| 
      
 200 
     | 
    
         
            +
                  files = NA.find_files_matching({
         
     | 
| 
      
 201 
     | 
    
         
            +
                    depth: options[:depth],
         
     | 
| 
      
 202 
     | 
    
         
            +
                    done: options[:done],
         
     | 
| 
      
 203 
     | 
    
         
            +
                    project: options[:project],
         
     | 
| 
      
 204 
     | 
    
         
            +
                    regex: options[:regex],
         
     | 
| 
      
 205 
     | 
    
         
            +
                    require_na: false,
         
     | 
| 
      
 206 
     | 
    
         
            +
                    search: tokens,
         
     | 
| 
      
 207 
     | 
    
         
            +
                    tag: tags
         
     | 
| 
      
 208 
     | 
    
         
            +
                  })
         
     | 
| 
      
 209 
     | 
    
         
            +
                  files.each do |file|
         
     | 
| 
      
 210 
     | 
    
         
            +
                    safe_search = (tokens.is_a?(String) || tokens.is_a?(Array) || tokens.is_a?(Regexp)) ? tokens : nil
         
     | 
| 
      
 211 
     | 
    
         
            +
                    todo = NA::Todo.new({
         
     | 
| 
      
 212 
     | 
    
         
            +
                      search: safe_search,
         
     | 
| 
      
 213 
     | 
    
         
            +
                      search_note: options[:search_notes],
         
     | 
| 
      
 214 
     | 
    
         
            +
                      require_na: false,
         
     | 
| 
      
 215 
     | 
    
         
            +
                      file_path: file,
         
     | 
| 
      
 216 
     | 
    
         
            +
                      project: options[:project],
         
     | 
| 
      
 217 
     | 
    
         
            +
                      tag: tags,
         
     | 
| 
      
 218 
     | 
    
         
            +
                      done: options[:done]
         
     | 
| 
      
 219 
     | 
    
         
            +
                    })
         
     | 
| 
      
 220 
     | 
    
         
            +
                    todo.actions.each do |action_obj|
         
     | 
| 
      
 221 
     | 
    
         
            +
                      # Format: filename:project:parent > action
         
     | 
| 
      
 222 
     | 
    
         
            +
                      display = "#{File.basename(action_obj.file)}:#{action_obj.project}:#{action_obj.parent.join('>')} | #{action_obj.action}"
         
     | 
| 
      
 223 
     | 
    
         
            +
                      candidate_actions << display
         
     | 
| 
      
 224 
     | 
    
         
            +
                      targets_for_selection << { file: action_obj.file, line: action_obj.line, action: action_obj }
         
     | 
| 
      
 225 
     | 
    
         
            +
                    end
         
     | 
| 
      
 226 
     | 
    
         
            +
                  end
         
     | 
| 
      
 227 
     | 
    
         
            +
             
     | 
| 
      
 228 
     | 
    
         
            +
                  # Multi-select using fzf or gum if available
         
     | 
| 
      
 229 
     | 
    
         
            +
                  selected_indices = []
         
     | 
| 
      
 230 
     | 
    
         
            +
                  if candidate_actions.any?
         
     | 
| 
      
 231 
     | 
    
         
            +
                    selector = nil
         
     | 
| 
      
 232 
     | 
    
         
            +
                    if TTY::Which.exist?('fzf')
         
     | 
| 
      
 233 
     | 
    
         
            +
                      selector = 'fzf --multi --prompt="Select tasks> "'
         
     | 
| 
      
 234 
     | 
    
         
            +
                    elsif TTY::Which.exist?('gum')
         
     | 
| 
      
 235 
     | 
    
         
            +
                      selector = 'gum choose --no-limit'
         
     | 
| 
      
 236 
     | 
    
         
            +
                    end
         
     | 
| 
      
 237 
     | 
    
         
            +
                    if selector
         
     | 
| 
      
 238 
     | 
    
         
            +
                      require 'open3'
         
     | 
| 
      
 239 
     | 
    
         
            +
                      input = candidate_actions.join("\n")
         
     | 
| 
      
 240 
     | 
    
         
            +
                      output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
         
     | 
| 
      
 241 
     | 
    
         
            +
                      selected = output.split("\n").map(&:strip).reject(&:empty?)
         
     | 
| 
      
 242 
     | 
    
         
            +
                      selected_indices = candidate_actions.each_index.select { |i| selected.include?(candidate_actions[i]) }
         
     | 
| 
      
 243 
     | 
    
         
            +
                    else
         
     | 
| 
      
 244 
     | 
    
         
            +
                      # Fallback: select all or prompt for search string
         
     | 
| 
      
 245 
     | 
    
         
            +
                      selected_indices = (0...candidate_actions.size).to_a
         
     | 
| 
      
 246 
     | 
    
         
            +
                    end
         
     | 
| 
      
 247 
     | 
    
         
            +
                  end
         
     | 
| 
      
 248 
     | 
    
         
            +
             
     | 
| 
      
 249 
     | 
    
         
            +
                  # If no actions found, notify and exit
         
     | 
| 
      
 250 
     | 
    
         
            +
                  if selected_indices.empty?
         
     | 
| 
      
 251 
     | 
    
         
            +
                    NA.notify("#{NA.theme[:error]}No matching actions found for selection", exit_code: 1)
         
     | 
| 
      
 252 
     | 
    
         
            +
                  end
         
     | 
| 
      
 253 
     | 
    
         
            +
             
     | 
| 
      
 254 
     | 
    
         
            +
                  # Apply update to selected actions
         
     | 
| 
      
 255 
     | 
    
         
            +
                  actionable = [
         
     | 
| 
      
 256 
     | 
    
         
            +
                    options[:note],
         
     | 
| 
      
 257 
     | 
    
         
            +
                    (options[:priority].to_i if options[:priority]).to_i.positive?,
         
     | 
| 
      
 258 
     | 
    
         
            +
                    !options[:move].to_s.empty?,
         
     | 
| 
      
 259 
     | 
    
         
            +
                    !(options[:tag].nil? || options[:tag].empty?),
         
     | 
| 
      
 260 
     | 
    
         
            +
                    !(options[:remove].nil? || options[:remove].empty?),
         
     | 
| 
      
 261 
     | 
    
         
            +
                    !options[:replace].to_s.empty?,
         
     | 
| 
      
 262 
     | 
    
         
            +
                    options[:finish],
         
     | 
| 
      
 263 
     | 
    
         
            +
                    options[:archive],
         
     | 
| 
      
 264 
     | 
    
         
            +
                    options[:restore],
         
     | 
| 
      
 265 
     | 
    
         
            +
                    options[:delete],
         
     | 
| 
      
 266 
     | 
    
         
            +
                    options[:edit]
         
     | 
| 
      
 267 
     | 
    
         
            +
                  ].any?
         
     | 
| 
      
 268 
     | 
    
         
            +
                  unless actionable
         
     | 
| 
      
 269 
     | 
    
         
            +
                    # Interactive menu for actions
         
     | 
| 
      
 270 
     | 
    
         
            +
                    actions_menu = [
         
     | 
| 
      
 271 
     | 
    
         
            +
                      { key: :add_tag, label: 'Add Tag', param: 'Tag' },
         
     | 
| 
      
 272 
     | 
    
         
            +
                      { key: :remove_tag, label: 'Remove Tag', param: 'Tag' },
         
     | 
| 
      
 273 
     | 
    
         
            +
                      { key: :delete, label: 'Delete', param: nil },
         
     | 
| 
      
 274 
     | 
    
         
            +
                      { key: :finish, label: 'Finish (mark done)', param: nil },
         
     | 
| 
      
 275 
     | 
    
         
            +
                      { key: :edit, label: 'Edit', param: nil },
         
     | 
| 
      
 276 
     | 
    
         
            +
                      { key: :priority, label: 'Set Priority', param: 'Priority (1-5)' },
         
     | 
| 
      
 277 
     | 
    
         
            +
                      { key: :move, label: 'Move to Project', param: 'Project' },
         
     | 
| 
      
 278 
     | 
    
         
            +
                      { key: :restore, label: 'Restore', param: nil },
         
     | 
| 
      
 279 
     | 
    
         
            +
                      { key: :archive, label: 'Archive', param: nil },
         
     | 
| 
      
 280 
     | 
    
         
            +
                      { key: :note, label: 'Add Note', param: 'Note' }
         
     | 
| 
      
 281 
     | 
    
         
            +
                    ]
         
     | 
| 
      
 282 
     | 
    
         
            +
                    selector = nil
         
     | 
| 
      
 283 
     | 
    
         
            +
                    if TTY::Which.exist?('fzf')
         
     | 
| 
      
 284 
     | 
    
         
            +
                      selector = 'fzf --prompt="Select action> "'
         
     | 
| 
      
 285 
     | 
    
         
            +
                    elsif TTY::Which.exist?('gum')
         
     | 
| 
      
 286 
     | 
    
         
            +
                      selector = 'gum choose'
         
     | 
| 
      
 287 
     | 
    
         
            +
                    end
         
     | 
| 
      
 288 
     | 
    
         
            +
                    menu_labels = actions_menu.map { |a| a[:label] }
         
     | 
| 
      
 289 
     | 
    
         
            +
                    selected_action = nil
         
     | 
| 
      
 290 
     | 
    
         
            +
                    if selector
         
     | 
| 
      
 291 
     | 
    
         
            +
                      require 'open3'
         
     | 
| 
      
 292 
     | 
    
         
            +
                      input = menu_labels.join("\n")
         
     | 
| 
      
 293 
     | 
    
         
            +
                      output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
         
     | 
| 
      
 294 
     | 
    
         
            +
                      selected_action = output.strip
         
     | 
| 
      
 295 
     | 
    
         
            +
                    else
         
     | 
| 
      
 296 
     | 
    
         
            +
                      puts 'Select an action:'
         
     | 
| 
      
 297 
     | 
    
         
            +
                      menu_labels.each_with_index { |label, i| puts "#{i+1}. #{label}" }
         
     | 
| 
      
 298 
     | 
    
         
            +
                      idx = (STDIN.gets || '').strip.to_i - 1
         
     | 
| 
      
 299 
     | 
    
         
            +
                      selected_action = menu_labels[idx] if idx >= 0 && idx < menu_labels.size
         
     | 
| 
      
 300 
     | 
    
         
            +
                    end
         
     | 
| 
      
 301 
     | 
    
         
            +
                    action_obj = actions_menu.find { |a| a[:label] == selected_action }
         
     | 
| 
      
 302 
     | 
    
         
            +
                    if action_obj.nil?
         
     | 
| 
      
 303 
     | 
    
         
            +
                      NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1)
         
     | 
| 
      
 304 
     | 
    
         
            +
                    end
         
     | 
| 
      
 305 
     | 
    
         
            +
                    # Prompt for parameter if needed
         
     | 
| 
      
 306 
     | 
    
         
            +
                    param_value = nil
         
     | 
| 
      
 307 
     | 
    
         
            +
                    # Only prompt for param if not :move (which has custom menu logic)
         
     | 
| 
      
 308 
     | 
    
         
            +
                    if action_obj[:param] && action_obj[:key] != :move
         
     | 
| 
      
 309 
     | 
    
         
            +
                      if TTY::Which.exist?('gum')
         
     | 
| 
      
 310 
     | 
    
         
            +
                        gum = TTY::Which.which('gum')
         
     | 
| 
      
 311 
     | 
    
         
            +
                        prompt = "Enter #{action_obj[:param]}: "
         
     | 
| 
      
 312 
     | 
    
         
            +
                        param_value = `#{gum} input --placeholder "#{prompt}"`.strip
         
     | 
| 
      
 313 
     | 
    
         
            +
                      else
         
     | 
| 
      
 314 
     | 
    
         
            +
                        print "Enter #{action_obj[:param]}: "
         
     | 
| 
      
 315 
     | 
    
         
            +
                        param_value = (STDIN.gets || '').strip
         
     | 
| 
      
 316 
     | 
    
         
            +
                      end
         
     | 
| 
      
 317 
     | 
    
         
            +
                    end
         
     | 
| 
      
 318 
     | 
    
         
            +
                    # Set options for update
         
     | 
| 
      
 319 
     | 
    
         
            +
                    case action_obj[:key]
         
     | 
| 
      
 320 
     | 
    
         
            +
                    when :add_tag
         
     | 
| 
      
 321 
     | 
    
         
            +
                      options[:tag] = [param_value]
         
     | 
| 
      
 322 
     | 
    
         
            +
                    when :remove_tag
         
     | 
| 
      
 323 
     | 
    
         
            +
                      options[:remove] = [param_value]
         
     | 
| 
      
 324 
     | 
    
         
            +
                    when :delete
         
     | 
| 
      
 325 
     | 
    
         
            +
                      options[:delete] = true
         
     | 
| 
      
 326 
     | 
    
         
            +
                    when :finish
         
     | 
| 
      
 327 
     | 
    
         
            +
                      options[:finish] = true
         
     | 
| 
      
 328 
     | 
    
         
            +
                    when :edit
         
     | 
| 
      
 329 
     | 
    
         
            +
                      # Open editor for the selected action and update its content
         
     | 
| 
      
 330 
     | 
    
         
            +
                      edit_action = targets_for_selection[selected_indices.first][:action]
         
     | 
| 
      
 331 
     | 
    
         
            +
                      editor_content = "#{edit_action.action}\n#{edit_action.note.join("\n")}"
         
     | 
| 
      
 332 
     | 
    
         
            +
                      new_action, new_note = NA::Editor.format_input(NA::Editor.fork_editor(editor_content))
         
     | 
| 
      
 333 
     | 
    
         
            +
                      edit_action.action = new_action
         
     | 
| 
      
 334 
     | 
    
         
            +
                      edit_action.note = new_note
         
     | 
| 
      
 335 
     | 
    
         
            +
                      options[:edit] = true
         
     | 
| 
      
 336 
     | 
    
         
            +
                    when :priority
         
     | 
| 
      
 337 
     | 
    
         
            +
                      options[:priority] = param_value
         
     | 
| 
      
 338 
     | 
    
         
            +
                    when :move
         
     | 
| 
      
 339 
     | 
    
         
            +
                      # Gather projects from the same file as the selected action
         
     | 
| 
      
 340 
     | 
    
         
            +
                      selected_file = targets_for_selection[selected_indices.first][:file]
         
     | 
| 
      
 341 
     | 
    
         
            +
                      todo = NA::Todo.new(file_path: selected_file)
         
     | 
| 
      
 342 
     | 
    
         
            +
                      project_names = todo.projects.map { |proj| proj.project }
         
     | 
| 
      
 343 
     | 
    
         
            +
                      project_menu = project_names + ['New project']
         
     | 
| 
      
 344 
     | 
    
         
            +
                      move_selector = nil
         
     | 
| 
      
 345 
     | 
    
         
            +
                      if TTY::Which.exist?('fzf')
         
     | 
| 
      
 346 
     | 
    
         
            +
                        move_selector = 'fzf --prompt="Select project> "'
         
     | 
| 
      
 347 
     | 
    
         
            +
                      elsif TTY::Which.exist?('gum')
         
     | 
| 
      
 348 
     | 
    
         
            +
                        move_selector = 'gum choose'
         
     | 
| 
      
 349 
     | 
    
         
            +
                      end
         
     | 
| 
      
 350 
     | 
    
         
            +
                      selected_project = nil
         
     | 
| 
      
 351 
     | 
    
         
            +
                      if move_selector
         
     | 
| 
      
 352 
     | 
    
         
            +
                        require 'open3'
         
     | 
| 
      
 353 
     | 
    
         
            +
                        input = project_menu.join("\n")
         
     | 
| 
      
 354 
     | 
    
         
            +
                        output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
         
     | 
| 
      
 355 
     | 
    
         
            +
                        selected_project = output.strip
         
     | 
| 
      
 356 
     | 
    
         
            +
                      else
         
     | 
| 
      
 357 
     | 
    
         
            +
                        puts 'Select a project:'
         
     | 
| 
      
 358 
     | 
    
         
            +
                        project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
         
     | 
| 
      
 359 
     | 
    
         
            +
                        idx = (STDIN.gets || '').strip.to_i - 1
         
     | 
| 
      
 360 
     | 
    
         
            +
                        selected_project = project_menu[idx] if idx >= 0 && idx < project_menu.size
         
     | 
| 
      
 361 
     | 
    
         
            +
                      end
         
     | 
| 
      
 362 
     | 
    
         
            +
                      if selected_project == 'New project'
         
     | 
| 
      
 363 
     | 
    
         
            +
                        if TTY::Which.exist?('gum')
         
     | 
| 
      
 364 
     | 
    
         
            +
                          gum = TTY::Which.which('gum')
         
     | 
| 
      
 365 
     | 
    
         
            +
                          prompt = 'Enter new project name: '
         
     | 
| 
      
 366 
     | 
    
         
            +
                          new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
         
     | 
| 
      
 367 
     | 
    
         
            +
                        else
         
     | 
| 
      
 368 
     | 
    
         
            +
                          print 'Enter new project name: '
         
     | 
| 
      
 369 
     | 
    
         
            +
                          new_proj_name = (STDIN.gets || '').strip
         
     | 
| 
      
 370 
     | 
    
         
            +
                        end
         
     | 
| 
      
 371 
     | 
    
         
            +
                        # Create the new project in the file
         
     | 
| 
      
 372 
     | 
    
         
            +
                        NA.insert_project(selected_file, new_proj_name, todo.projects)
         
     | 
| 
      
 373 
     | 
    
         
            +
                        options[:move] = new_proj_name
         
     | 
| 
      
 374 
     | 
    
         
            +
                      else
         
     | 
| 
      
 375 
     | 
    
         
            +
                        options[:move] = selected_project
         
     | 
| 
      
 376 
     | 
    
         
            +
                      end
         
     | 
| 
      
 377 
     | 
    
         
            +
                    when :restore
         
     | 
| 
      
 378 
     | 
    
         
            +
                      options[:restore] = true
         
     | 
| 
      
 379 
     | 
    
         
            +
                    when :archive
         
     | 
| 
      
 380 
     | 
    
         
            +
                      options[:archive] = true
         
     | 
| 
      
 381 
     | 
    
         
            +
                    when :note
         
     | 
| 
      
 382 
     | 
    
         
            +
                      options[:note] = true
         
     | 
| 
      
 383 
     | 
    
         
            +
                      note = [param_value]
         
     | 
| 
      
 384 
     | 
    
         
            +
                    end
         
     | 
| 
      
 385 
     | 
    
         
            +
                  end
         
     | 
| 
      
 386 
     | 
    
         
            +
                  did_direct_update = false
         
     | 
| 
      
 387 
     | 
    
         
            +
                  selected_indices.each do |idx|
         
     | 
| 
      
 388 
     | 
    
         
            +
                    # Rebuild all derived variables from options after menu-driven assignment
         
     | 
| 
      
 389 
     | 
    
         
            +
                    add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
         
     | 
| 
      
 390 
     | 
    
         
            +
                    remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
         
     | 
| 
      
 391 
     | 
    
         
            +
                    remove_tags << 'done' if options[:restore]
         
     | 
| 
      
 392 
     | 
    
         
            +
                    priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
         
     | 
| 
      
 393 
     | 
    
         
            +
                    target_proj = if options[:move]
         
     | 
| 
      
 394 
     | 
    
         
            +
                                    options[:move]
         
     | 
| 
      
 395 
     | 
    
         
            +
                                  elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
         
     | 
| 
      
 396 
     | 
    
         
            +
                                    NA.cwd
         
     | 
| 
      
 397 
     | 
    
         
            +
                                  end
         
     | 
| 
      
 398 
     | 
    
         
            +
                    note_val = note
         
     | 
| 
      
 399 
     | 
    
         
            +
                    if options[:note] && defined?(param_value) && param_value
         
     | 
| 
      
 400 
     | 
    
         
            +
                      note_val = [param_value]
         
     | 
| 
      
 401 
     | 
    
         
            +
                    end
         
     | 
| 
      
 402 
     | 
    
         
            +
                    # Pass the selected action object as 'add', set search to nil
         
     | 
| 
      
 403 
     | 
    
         
            +
                    # Pass the exact selected action object to update_action, bypassing all search/filter logic
         
     | 
| 
      
 404 
     | 
    
         
            +
                    target = targets_for_selection[idx][:file]
         
     | 
| 
      
 405 
     | 
    
         
            +
                    action_obj = targets_for_selection[idx][:action]
         
     | 
| 
      
 406 
     | 
    
         
            +
                    # Direct action mode: update only the selected action in the known file
         
     | 
| 
      
 407 
     | 
    
         
            +
                    NA.update_action(target, nil,
         
     | 
| 
      
 408 
     | 
    
         
            +
                      add: action_obj,
         
     | 
| 
      
 409 
     | 
    
         
            +
                      add_tag: add_tags,
         
     | 
| 
      
 410 
     | 
    
         
            +
                      all: true,
         
     | 
| 
      
 411 
     | 
    
         
            +
                      append: append,
         
     | 
| 
      
 412 
     | 
    
         
            +
                      delete: options[:delete],
         
     | 
| 
      
 413 
     | 
    
         
            +
                      done: options[:done],
         
     | 
| 
      
 414 
     | 
    
         
            +
                      edit: options[:edit],
         
     | 
| 
      
 415 
     | 
    
         
            +
                      finish: options[:finish],
         
     | 
| 
      
 416 
     | 
    
         
            +
                      move: target_proj,
         
     | 
| 
      
 417 
     | 
    
         
            +
                      note: note_val,
         
     | 
| 
      
 418 
     | 
    
         
            +
                      overwrite: options[:overwrite],
         
     | 
| 
      
 419 
     | 
    
         
            +
                      priority: priority,
         
     | 
| 
      
 420 
     | 
    
         
            +
                      project: options[:project],
         
     | 
| 
      
 421 
     | 
    
         
            +
                      remove_tag: remove_tags,
         
     | 
| 
      
 422 
     | 
    
         
            +
                      replace: options[:replace],
         
     | 
| 
      
 423 
     | 
    
         
            +
                      search_note: options[:search_notes],
         
     | 
| 
      
 424 
     | 
    
         
            +
                      tagged: nil)
         
     | 
| 
      
 425 
     | 
    
         
            +
                    did_direct_update = true
         
     | 
| 
      
 426 
     | 
    
         
            +
                  end
         
     | 
| 
      
 427 
     | 
    
         
            +
                  if did_direct_update
         
     | 
| 
      
 428 
     | 
    
         
            +
                    next
         
     | 
| 
       152 
429 
     | 
    
         
             
                  end
         
     | 
| 
       153 
430 
     | 
    
         | 
| 
       154 
431 
     | 
    
         
             
                  all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
         
     | 
| 
         @@ -192,7 +469,7 @@ class App 
     | 
|
| 
       192 
469 
     | 
    
         
             
                  # Require at least one actionable option to be provided
         
     | 
| 
       193 
470 
     | 
    
         
             
                  actionable = [
         
     | 
| 
       194 
471 
     | 
    
         
             
                    options[:note],
         
     | 
| 
       195 
     | 
    
         
            -
                    (options[:priority].to_i if options[:priority]).to_i 
     | 
| 
      
 472 
     | 
    
         
            +
                    (options[:priority].to_i if options[:priority]).to_i.positive?,
         
     | 
| 
       196 
473 
     | 
    
         
             
                    !options[:move].to_s.empty?,
         
     | 
| 
       197 
474 
     | 
    
         
             
                    !(options[:tag].nil? || options[:tag].empty?),
         
     | 
| 
       198 
475 
     | 
    
         
             
                    !(options[:remove].nil? || options[:remove].empty?),
         
     | 
    
        data/bin/na
    CHANGED
    
    | 
         @@ -10,11 +10,11 @@ require 'fcntl' 
     | 
|
| 
       10 
10 
     | 
    
         
             
            require 'tempfile'
         
     | 
| 
       11 
11 
     | 
    
         | 
| 
       12 
12 
     | 
    
         
             
            NA::Benchmark.init
         
     | 
| 
       13 
     | 
    
         
            -
            NA::Benchmark.measure('Gem loading') { nil } 
     | 
| 
      
 13 
     | 
    
         
            +
            NA::Benchmark.measure('Gem loading') { nil } # Measures time up to this point
         
     | 
| 
       14 
14 
     | 
    
         | 
| 
       15 
15 
     | 
    
         
             
            # Search for XDG compliant config first. Default to ~/.na.rc for compatibility
         
     | 
| 
       16 
16 
     | 
    
         
             
            def self.find_config_file
         
     | 
| 
       17 
     | 
    
         
            -
              home =  
     | 
| 
      
 17 
     | 
    
         
            +
              home = Dir.home
         
     | 
| 
       18 
18 
     | 
    
         
             
              xdg_config_home = ENV['XDG_CONFIG_HOME'] || File.join(home, '.config')
         
     | 
| 
       19 
19 
     | 
    
         | 
| 
       20 
20 
     | 
    
         
             
              rc_paths = [
         
     | 
| 
         @@ -30,7 +30,6 @@ def self.find_config_file 
     | 
|
| 
       30 
30 
     | 
    
         
             
              existing_path || File.join(xdg_config_home, 'na', 'na.rc')
         
     | 
| 
       31 
31 
     | 
    
         
             
            end
         
     | 
| 
       32 
32 
     | 
    
         | 
| 
       33 
     | 
    
         
            -
             
     | 
| 
       34 
33 
     | 
    
         
             
            # Main application
         
     | 
| 
       35 
34 
     | 
    
         
             
            class App
         
     | 
| 
       36 
35 
     | 
    
         
             
              extend GLI::App
         
     | 
| 
         @@ -84,7 +83,7 @@ class App 
     | 
|
| 
       84 
83 
     | 
    
         
             
              switch %i[repo-top], default_value: false
         
     | 
| 
       85 
84 
     | 
    
         | 
| 
       86 
85 
     | 
    
         
             
              desc 'Provide a template for new/blank todo files, use initconfig to make permanent'
         
     | 
| 
       87 
     | 
    
         
            -
              flag % 
     | 
| 
      
 86 
     | 
    
         
            +
              flag %(template)
         
     | 
| 
       88 
87 
     | 
    
         | 
| 
       89 
88 
     | 
    
         
             
              desc 'Use current working directory as [p]roject, [t]ag, or [n]one'
         
     | 
| 
       90 
89 
     | 
    
         
             
              arg_name 'TYPE'
         
     | 
| 
         @@ -121,7 +120,7 @@ class App 
     | 
|
| 
       121 
120 
     | 
    
         
             
                NA.include_ext = global[:include_ext]
         
     | 
| 
       122 
121 
     | 
    
         
             
                NA.na_tag = global[:na_tag]
         
     | 
| 
       123 
122 
     | 
    
         
             
                NA.global_file = global[:file]
         
     | 
| 
       124 
     | 
    
         
            -
                NA.cwd = File.basename(ENV 
     | 
| 
      
 123 
     | 
    
         
            +
                NA.cwd = File.basename(ENV.fetch('PWD', nil))
         
     | 
| 
       125 
124 
     | 
    
         
             
                NA.cwd_is = if global[:cwd_as] =~ /^n/
         
     | 
| 
       126 
125 
     | 
    
         
             
                              :none
         
     | 
| 
       127 
126 
     | 
    
         
             
                            else
         
     | 
| 
         @@ -146,7 +145,8 @@ class App 
     | 
|
| 
       146 
145 
     | 
    
         
             
                      NA.global_file = taskpaper_file
         
     | 
| 
       147 
146 
     | 
    
         
             
                      # Add this block to create the file if it doesn't exist
         
     | 
| 
       148 
147 
     | 
    
         
             
                      unless File.exist?(taskpaper_file)
         
     | 
| 
       149 
     | 
    
         
            -
                        res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Repository file not found, create #{taskpaper_file}"), 
     | 
| 
      
 148 
     | 
    
         
            +
                        res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Repository file not found, create #{taskpaper_file}"),
         
     | 
| 
      
 149 
     | 
    
         
            +
                                    default: true)
         
     | 
| 
       150 
150 
     | 
    
         
             
                        if res
         
     | 
| 
       151 
151 
     | 
    
         
             
                          NA.create_todo(taskpaper_file, repo_name, template: global[:template])
         
     | 
| 
       152 
152 
     | 
    
         
             
                        else
         
     | 
| 
         @@ -198,7 +198,7 @@ class App 
     | 
|
| 
       198 
198 
     | 
    
         
             
            end
         
     | 
| 
       199 
199 
     | 
    
         | 
| 
       200 
200 
     | 
    
         
             
            NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
         
     | 
| 
       201 
     | 
    
         
            -
            NA.stdin = nil unless NA.stdin 
     | 
| 
      
 201 
     | 
    
         
            +
            NA.stdin = nil unless NA.stdin&.length&.positive?
         
     | 
| 
       202 
202 
     | 
    
         | 
| 
       203 
203 
     | 
    
         
             
            NA.globals = []
         
     | 
| 
       204 
204 
     | 
    
         
             
            NA.command_line = []
         
     | 
    
        data/lib/na/action.rb
    CHANGED
    
    | 
         @@ -2,9 +2,8 @@ 
     | 
|
| 
       2 
2 
     | 
    
         | 
| 
       3 
3 
     | 
    
         
             
            module NA
         
     | 
| 
       4 
4 
     | 
    
         
             
              class Action < Hash
         
     | 
| 
       5 
     | 
    
         
            -
                attr_reader :file, :project, : 
     | 
| 
       6 
     | 
    
         
            -
             
     | 
| 
       7 
     | 
    
         
            -
                attr_accessor :action, :note
         
     | 
| 
      
 5 
     | 
    
         
            +
                attr_reader :file, :project, :tags, :line
         
     | 
| 
      
 6 
     | 
    
         
            +
                attr_accessor :parent, :action, :note
         
     | 
| 
       8 
7 
     | 
    
         | 
| 
       9 
8 
     | 
    
         
             
                def initialize(file, project, parent, action, idx, note = [])
         
     | 
| 
       10 
9 
     | 
    
         
             
                  super()
         
     | 
| 
         @@ -12,12 +11,20 @@ module NA 
     | 
|
| 
       12 
11 
     | 
    
         
             
                  @file = file
         
     | 
| 
       13 
12 
     | 
    
         
             
                  @project = project
         
     | 
| 
       14 
13 
     | 
    
         
             
                  @parent = parent
         
     | 
| 
       15 
     | 
    
         
            -
                  @action = action.gsub( 
     | 
| 
      
 14 
     | 
    
         
            +
                  @action = action.gsub('{', '\\{')
         
     | 
| 
       16 
15 
     | 
    
         
             
                  @tags = scan_tags
         
     | 
| 
       17 
16 
     | 
    
         
             
                  @line = idx
         
     | 
| 
       18 
17 
     | 
    
         
             
                  @note = note
         
     | 
| 
       19 
18 
     | 
    
         
             
                end
         
     | 
| 
       20 
19 
     | 
    
         | 
| 
      
 20 
     | 
    
         
            +
                # Update the action string and note with priority, tags, and completion status
         
     | 
| 
      
 21 
     | 
    
         
            +
                #
         
     | 
| 
      
 22 
     | 
    
         
            +
                # @param priority [Integer] Priority value to set
         
     | 
| 
      
 23 
     | 
    
         
            +
                # @param finish [Boolean] Mark as finished
         
     | 
| 
      
 24 
     | 
    
         
            +
                # @param add_tag [Array<String>] Tags to add
         
     | 
| 
      
 25 
     | 
    
         
            +
                # @param remove_tag [Array<String>] Tags to remove
         
     | 
| 
      
 26 
     | 
    
         
            +
                # @param note [Array<String>] Notes to set
         
     | 
| 
      
 27 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       21 
28 
     | 
    
         
             
                def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
         
     | 
| 
       22 
29 
     | 
    
         
             
                  string = @action.dup
         
     | 
| 
       23 
30 
     | 
    
         | 
| 
         @@ -44,6 +51,9 @@ module NA 
     | 
|
| 
       44 
51 
     | 
    
         
             
                  @note = note unless note.empty?
         
     | 
| 
       45 
52 
     | 
    
         
             
                end
         
     | 
| 
       46 
53 
     | 
    
         | 
| 
      
 54 
     | 
    
         
            +
                # String representation of the action
         
     | 
| 
      
 55 
     | 
    
         
            +
                #
         
     | 
| 
      
 56 
     | 
    
         
            +
                # @return [String]
         
     | 
| 
       47 
57 
     | 
    
         
             
                def to_s
         
     | 
| 
       48 
58 
     | 
    
         
             
                  note = if @note.count.positive?
         
     | 
| 
       49 
59 
     | 
    
         
             
                           "\n#{@note.join("\n")}"
         
     | 
| 
         @@ -53,36 +63,41 @@ module NA 
     | 
|
| 
       53 
63 
     | 
    
         
             
                  "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}#{note}"
         
     | 
| 
       54 
64 
     | 
    
         
             
                end
         
     | 
| 
       55 
65 
     | 
    
         | 
| 
      
 66 
     | 
    
         
            +
                # Pretty string representation of the action with color formatting
         
     | 
| 
      
 67 
     | 
    
         
            +
                #
         
     | 
| 
      
 68 
     | 
    
         
            +
                # @return [String]
         
     | 
| 
       56 
69 
     | 
    
         
             
                def to_s_pretty
         
     | 
| 
       57 
70 
     | 
    
         
             
                  note = if @note.count.positive?
         
     | 
| 
       58 
71 
     | 
    
         
             
                           "\n#{@note.join("\n")}"
         
     | 
| 
       59 
72 
     | 
    
         
             
                         else
         
     | 
| 
       60 
73 
     | 
    
         
             
                           ''
         
     | 
| 
       61 
74 
     | 
    
         
             
                         end
         
     | 
| 
       62 
     | 
    
         
            -
                  "#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}#{NA.theme[:bracket]}[#{NA.theme[:project]}#{@project}:#{@parent.join( 
     | 
| 
      
 75 
     | 
    
         
            +
                  "{x}#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}{x}#{NA.theme[:bracket]}[{x}#{NA.theme[:project]}#{@project}:#{@parent.join('>')}{x}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}{x}#{NA.theme[:note]}#{note}"
         
     | 
| 
       63 
76 
     | 
    
         
             
                end
         
     | 
| 
       64 
77 
     | 
    
         | 
| 
      
 78 
     | 
    
         
            +
                # Inspect the action object
         
     | 
| 
      
 79 
     | 
    
         
            +
                #
         
     | 
| 
      
 80 
     | 
    
         
            +
                # @return [String]
         
     | 
| 
       65 
81 
     | 
    
         
             
                def inspect
         
     | 
| 
       66 
82 
     | 
    
         
             
                  <<~EOINSPECT
         
     | 
| 
       67 
     | 
    
         
            -
             
     | 
| 
       68 
     | 
    
         
            -
             
     | 
| 
       69 
     | 
    
         
            -
             
     | 
| 
       70 
     | 
    
         
            -
             
     | 
| 
       71 
     | 
    
         
            -
             
     | 
| 
       72 
     | 
    
         
            -
             
     | 
| 
      
 83 
     | 
    
         
            +
                    @file: #{@file}
         
     | 
| 
      
 84 
     | 
    
         
            +
                    @project: #{@project}
         
     | 
| 
      
 85 
     | 
    
         
            +
                    @parent: #{@parent.join('>')}
         
     | 
| 
      
 86 
     | 
    
         
            +
                    @action: #{@action}
         
     | 
| 
      
 87 
     | 
    
         
            +
                    @tags: #{@tags}
         
     | 
| 
      
 88 
     | 
    
         
            +
                    @note: #{@note}
         
     | 
| 
       73 
89 
     | 
    
         
             
                  EOINSPECT
         
     | 
| 
       74 
90 
     | 
    
         
             
                end
         
     | 
| 
       75 
91 
     | 
    
         | 
| 
       76 
     | 
    
         
            -
                 
     | 
| 
       77 
     | 
    
         
            -
                 
     | 
| 
       78 
     | 
    
         
            -
                 
     | 
| 
       79 
     | 
    
         
            -
                 
     | 
| 
       80 
     | 
    
         
            -
                 
     | 
| 
       81 
     | 
    
         
            -
                 
     | 
| 
       82 
     | 
    
         
            -
                 
     | 
| 
       83 
     | 
    
         
            -
                 
     | 
| 
       84 
     | 
    
         
            -
                 
     | 
| 
       85 
     | 
    
         
            -
                ##
         
     | 
| 
      
 92 
     | 
    
         
            +
                #
         
     | 
| 
      
 93 
     | 
    
         
            +
                # Pretty print an action with color and template formatting
         
     | 
| 
      
 94 
     | 
    
         
            +
                #
         
     | 
| 
      
 95 
     | 
    
         
            +
                # @param extension [String] File extension
         
     | 
| 
      
 96 
     | 
    
         
            +
                # @param template [Hash] Color template
         
     | 
| 
      
 97 
     | 
    
         
            +
                # @param regexes [Array] Regexes to highlight
         
     | 
| 
      
 98 
     | 
    
         
            +
                # @param notes [Boolean] Include notes
         
     | 
| 
      
 99 
     | 
    
         
            +
                # @param detect_width [Boolean] Detect terminal width
         
     | 
| 
      
 100 
     | 
    
         
            +
                # @return [String]
         
     | 
| 
       86 
101 
     | 
    
         
             
                def pretty(extension: 'taskpaper', template: {}, regexes: [], notes: false, detect_width: true)
         
     | 
| 
       87 
102 
     | 
    
         
             
                  NA::Benchmark.measure('Action.pretty') do
         
     | 
| 
       88 
103 
     | 
    
         
             
                    # Use cached theme instead of loading every time
         
     | 
| 
         @@ -98,24 +113,29 @@ module NA 
     | 
|
| 
       98 
113 
     | 
    
         
             
                    # Create the hierarchical parent string (optimized)
         
     | 
| 
       99 
114 
     | 
    
         
             
                    parents = if needs_parents && @parent.any?
         
     | 
| 
       100 
115 
     | 
    
         
             
                                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} ")
         
     | 
| 
      
 116 
     | 
    
         
            +
                                NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}{x}#{template[:bracket]}]{x} ")
         
     | 
| 
       102 
117 
     | 
    
         
             
                              else
         
     | 
| 
       103 
118 
     | 
    
         
             
                                ''
         
     | 
| 
       104 
119 
     | 
    
         
             
                              end
         
     | 
| 
       105 
120 
     | 
    
         | 
| 
       106 
121 
     | 
    
         
             
                    # Create the project string (optimized)
         
     | 
| 
       107 
122 
     | 
    
         
             
                    project = if needs_project && !@project.empty?
         
     | 
| 
       108 
     | 
    
         
            -
                                NA::Color.template("#{template[:project]}#{@project}{x} ")
         
     | 
| 
      
 123 
     | 
    
         
            +
                                NA::Color.template("{x}#{template[:project]}#{@project}{x} ")
         
     | 
| 
       109 
124 
     | 
    
         
             
                              else
         
     | 
| 
       110 
125 
     | 
    
         
             
                                ''
         
     | 
| 
       111 
126 
     | 
    
         
             
                              end
         
     | 
| 
       112 
127 
     | 
    
         | 
| 
       113 
128 
     | 
    
         
             
                    # Create the source filename string (optimized)
         
     | 
| 
       114 
129 
     | 
    
         
             
                    filename = if needs_filename
         
     | 
| 
       115 
     | 
    
         
            -
                                  
     | 
| 
       116 
     | 
    
         
            -
                                  
     | 
| 
       117 
     | 
    
         
            -
             
     | 
| 
       118 
     | 
    
         
            -
             
     | 
| 
      
 130 
     | 
    
         
            +
                                 path = @file.sub(%r{^\./}, '').sub(/#{Dir.home}/, '~')
         
     | 
| 
      
 131 
     | 
    
         
            +
                                 if File.dirname(path) == '.'
         
     | 
| 
      
 132 
     | 
    
         
            +
                                   fname = NA.include_ext ? File.basename(path) : File.basename(path, ".#{extension}")
         
     | 
| 
      
 133 
     | 
    
         
            +
                                   fname = "./#{fname}" if NA.show_cwd_indicator
         
     | 
| 
      
 134 
     | 
    
         
            +
                                   NA::Color.template("#{template[:filename]}#{fname} {x}")
         
     | 
| 
      
 135 
     | 
    
         
            +
                                 else
         
     | 
| 
      
 136 
     | 
    
         
            +
                                   colored = (NA.include_ext ? path : path.sub(/\.#{extension}$/, '')).highlight_filename
         
     | 
| 
      
 137 
     | 
    
         
            +
                                   NA::Color.template("#{template[:filename]}#{colored} {x}")
         
     | 
| 
      
 138 
     | 
    
         
            +
                                 end
         
     | 
| 
       119 
139 
     | 
    
         
             
                               else
         
     | 
| 
       120 
140 
     | 
    
         
             
                                 ''
         
     | 
| 
       121 
141 
     | 
    
         
             
                               end
         
     | 
| 
         @@ -138,44 +158,58 @@ module NA 
     | 
|
| 
       138 
158 
     | 
    
         
             
                          # Cache width calculation
         
     | 
| 
       139 
159 
     | 
    
         
             
                          width = @cached_width ||= TTY::Screen.columns
         
     | 
| 
       140 
160 
     | 
    
         
             
                          # Calculate indent more efficiently - avoid repeated template processing
         
     | 
| 
       141 
     | 
    
         
            -
                          base_template = output_template.gsub( 
     | 
| 
       142 
     | 
    
         
            -
                          base_output = base_template.gsub( 
     | 
| 
      
 161 
     | 
    
         
            +
                          base_template = output_template.gsub('%action', '').gsub('%note', '')
         
     | 
| 
      
 162 
     | 
    
         
            +
                          base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/,
         
     | 
| 
      
 163 
     | 
    
         
            +
                                                                                                                 parents)
         
     | 
| 
       143 
164 
     | 
    
         
             
                          indent = NA::Color.uncolor(NA::Color.template(base_output)).length
         
     | 
| 
       144 
165 
     | 
    
         
             
                          note = NA::Color.template(@note.wrap(width, indent, template[:note]))
         
     | 
| 
       145 
166 
     | 
    
         
             
                        else
         
     | 
| 
       146 
     | 
    
         
            -
                          note = NA::Color.template("\n#{@note.map { |l| "  #{template[:note]}• #{l}{x}" }.join("\n")}")
         
     | 
| 
      
 167 
     | 
    
         
            +
                          note = NA::Color.template("\n#{@note.map { |l| "  {x}#{template[:note]}• #{l}{x}" }.join("\n")}")
         
     | 
| 
       147 
168 
     | 
    
         
             
                        end
         
     | 
| 
       148 
169 
     | 
    
         
             
                      else
         
     | 
| 
       149 
     | 
    
         
            -
                        action += "#{template[:note]}*"
         
     | 
| 
      
 170 
     | 
    
         
            +
                        action += "{x}#{template[:note]}*"
         
     | 
| 
       150 
171 
     | 
    
         
             
                      end
         
     | 
| 
       151 
172 
     | 
    
         
             
                    end
         
     | 
| 
       152 
173 
     | 
    
         | 
| 
       153 
174 
     | 
    
         
             
                    # Wrap action if needed (optimized)
         
     | 
| 
       154 
175 
     | 
    
         
             
                    if detect_width && !action.empty?
         
     | 
| 
       155 
176 
     | 
    
         
             
                      width = @cached_width ||= TTY::Screen.columns
         
     | 
| 
       156 
     | 
    
         
            -
                      base_template = output_template.gsub( 
     | 
| 
       157 
     | 
    
         
            -
                      base_output = base_template.gsub( 
     | 
| 
      
 177 
     | 
    
         
            +
                      base_template = output_template.gsub('%action', '').gsub('%note', '')
         
     | 
| 
      
 178 
     | 
    
         
            +
                      base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/, parents)
         
     | 
| 
       158 
179 
     | 
    
         
             
                      indent = NA::Color.uncolor(NA::Color.template(base_output)).length
         
     | 
| 
       159 
180 
     | 
    
         
             
                      action = action.wrap(width, indent)
         
     | 
| 
       160 
181 
     | 
    
         
             
                    end
         
     | 
| 
       161 
182 
     | 
    
         | 
| 
       162 
183 
     | 
    
         
             
                    # Replace variables in template string and output colorized (optimized)
         
     | 
| 
       163 
184 
     | 
    
         
             
                    final_output = output_template.dup
         
     | 
| 
       164 
     | 
    
         
            -
                    final_output.gsub!( 
     | 
| 
       165 
     | 
    
         
            -
                    final_output.gsub!( 
     | 
| 
      
 185 
     | 
    
         
            +
                    final_output.gsub!('%filename', filename)
         
     | 
| 
      
 186 
     | 
    
         
            +
                    final_output.gsub!('%project', project)
         
     | 
| 
       166 
187 
     | 
    
         
             
                    final_output.gsub!(/%parents?/, parents)
         
     | 
| 
       167 
     | 
    
         
            -
                    final_output.gsub!( 
     | 
| 
       168 
     | 
    
         
            -
                    final_output.gsub!( 
     | 
| 
       169 
     | 
    
         
            -
                    final_output.gsub!( 
     | 
| 
      
 188 
     | 
    
         
            +
                    final_output.gsub!('%action', action.highlight_search(regexes))
         
     | 
| 
      
 189 
     | 
    
         
            +
                    final_output.gsub!('%note', note)
         
     | 
| 
      
 190 
     | 
    
         
            +
                    final_output.gsub!('\\{', '{')
         
     | 
| 
       170 
191 
     | 
    
         | 
| 
       171 
192 
     | 
    
         
             
                    NA::Color.template(final_output)
         
     | 
| 
       172 
193 
     | 
    
         
             
                  end
         
     | 
| 
       173 
194 
     | 
    
         
             
                end
         
     | 
| 
       174 
195 
     | 
    
         | 
| 
      
 196 
     | 
    
         
            +
                # Check if action tags match any, all, and none criteria
         
     | 
| 
      
 197 
     | 
    
         
            +
                #
         
     | 
| 
      
 198 
     | 
    
         
            +
                # @param any [Array] Tags to match any
         
     | 
| 
      
 199 
     | 
    
         
            +
                # @param all [Array] Tags to match all
         
     | 
| 
      
 200 
     | 
    
         
            +
                # @param none [Array] Tags to match none
         
     | 
| 
      
 201 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       175 
202 
     | 
    
         
             
                def tags_match?(any: [], all: [], none: [])
         
     | 
| 
       176 
203 
     | 
    
         
             
                  tag_matches_any(any) && tag_matches_all(all) && tag_matches_none(none)
         
     | 
| 
       177 
204 
     | 
    
         
             
                end
         
     | 
| 
       178 
205 
     | 
    
         | 
| 
      
 206 
     | 
    
         
            +
                # Check if action or note matches any, all, and none search criteria
         
     | 
| 
      
 207 
     | 
    
         
            +
                #
         
     | 
| 
      
 208 
     | 
    
         
            +
                # @param any [Array] Regexes to match any
         
     | 
| 
      
 209 
     | 
    
         
            +
                # @param all [Array] Regexes to match all
         
     | 
| 
      
 210 
     | 
    
         
            +
                # @param none [Array] Regexes to match none
         
     | 
| 
      
 211 
     | 
    
         
            +
                # @param include_note [Boolean] Include note in search
         
     | 
| 
      
 212 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       179 
213 
     | 
    
         
             
                def search_match?(any: [], all: [], none: [], include_note: true)
         
     | 
| 
       180 
214 
     | 
    
         
             
                  search_matches_any(any, include_note: include_note) &&
         
     | 
| 
       181 
215 
     | 
    
         
             
                    search_matches_all(all, include_note: include_note) &&
         
     | 
| 
         @@ -184,6 +218,11 @@ module NA 
     | 
|
| 
       184 
218 
     | 
    
         | 
| 
       185 
219 
     | 
    
         
             
                private
         
     | 
| 
       186 
220 
     | 
    
         | 
| 
      
 221 
     | 
    
         
            +
                # Check if action and note do not match any regexes
         
     | 
| 
      
 222 
     | 
    
         
            +
                #
         
     | 
| 
      
 223 
     | 
    
         
            +
                # @param regexes [Array] Regexes to check
         
     | 
| 
      
 224 
     | 
    
         
            +
                # @param include_note [Boolean] Include note in search
         
     | 
| 
      
 225 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       187 
226 
     | 
    
         
             
                def search_matches_none(regexes, include_note: true)
         
     | 
| 
       188 
227 
     | 
    
         
             
                  regexes.each do |rx|
         
     | 
| 
       189 
228 
     | 
    
         
             
                    regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
         
     | 
| 
         @@ -193,6 +232,11 @@ module NA 
     | 
|
| 
       193 
232 
     | 
    
         
             
                  true
         
     | 
| 
       194 
233 
     | 
    
         
             
                end
         
     | 
| 
       195 
234 
     | 
    
         | 
| 
      
 235 
     | 
    
         
            +
                # Check if action or note matches any regexes
         
     | 
| 
      
 236 
     | 
    
         
            +
                #
         
     | 
| 
      
 237 
     | 
    
         
            +
                # @param regexes [Array] Regexes to check
         
     | 
| 
      
 238 
     | 
    
         
            +
                # @param include_note [Boolean] Include note in search
         
     | 
| 
      
 239 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       196 
240 
     | 
    
         
             
                def search_matches_any(regexes, include_note: true)
         
     | 
| 
       197 
241 
     | 
    
         
             
                  return true if regexes.empty?
         
     | 
| 
       198 
242 
     | 
    
         | 
| 
         @@ -204,6 +248,11 @@ module NA 
     | 
|
| 
       204 
248 
     | 
    
         
             
                  false
         
     | 
| 
       205 
249 
     | 
    
         
             
                end
         
     | 
| 
       206 
250 
     | 
    
         | 
| 
      
 251 
     | 
    
         
            +
                # Check if action or note matches all regexes
         
     | 
| 
      
 252 
     | 
    
         
            +
                #
         
     | 
| 
      
 253 
     | 
    
         
            +
                # @param regexes [Array] Regexes to check
         
     | 
| 
      
 254 
     | 
    
         
            +
                # @param include_note [Boolean] Include note in search
         
     | 
| 
      
 255 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       207 
256 
     | 
    
         
             
                def search_matches_all(regexes, include_note: true)
         
     | 
| 
       208 
257 
     | 
    
         
             
                  regexes.each do |rx|
         
     | 
| 
       209 
258 
     | 
    
         
             
                    regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
         
     | 
| 
         @@ -213,6 +262,10 @@ module NA 
     | 
|
| 
       213 
262 
     | 
    
         
             
                  true
         
     | 
| 
       214 
263 
     | 
    
         
             
                end
         
     | 
| 
       215 
264 
     | 
    
         | 
| 
      
 265 
     | 
    
         
            +
                # Check if none of the tags match
         
     | 
| 
      
 266 
     | 
    
         
            +
                #
         
     | 
| 
      
 267 
     | 
    
         
            +
                # @param tags [Array] Tags to check
         
     | 
| 
      
 268 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       216 
269 
     | 
    
         
             
                def tag_matches_none(tags)
         
     | 
| 
       217 
270 
     | 
    
         
             
                  tags.each do |tag|
         
     | 
| 
       218 
271 
     | 
    
         
             
                    return false if compare_tag(tag)
         
     | 
| 
         @@ -220,6 +273,10 @@ module NA 
     | 
|
| 
       220 
273 
     | 
    
         
             
                  true
         
     | 
| 
       221 
274 
     | 
    
         
             
                end
         
     | 
| 
       222 
275 
     | 
    
         | 
| 
      
 276 
     | 
    
         
            +
                # Check if any of the tags match
         
     | 
| 
      
 277 
     | 
    
         
            +
                #
         
     | 
| 
      
 278 
     | 
    
         
            +
                # @param tags [Array] Tags to check
         
     | 
| 
      
 279 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       223 
280 
     | 
    
         
             
                def tag_matches_any(tags)
         
     | 
| 
       224 
281 
     | 
    
         
             
                  return true if tags.empty?
         
     | 
| 
       225 
282 
     | 
    
         | 
| 
         @@ -229,6 +286,10 @@ module NA 
     | 
|
| 
       229 
286 
     | 
    
         
             
                  false
         
     | 
| 
       230 
287 
     | 
    
         
             
                end
         
     | 
| 
       231 
288 
     | 
    
         | 
| 
      
 289 
     | 
    
         
            +
                # Check if all of the tags match
         
     | 
| 
      
 290 
     | 
    
         
            +
                #
         
     | 
| 
      
 291 
     | 
    
         
            +
                # @param tags [Array] Tags to check
         
     | 
| 
      
 292 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       232 
293 
     | 
    
         
             
                def tag_matches_all(tags)
         
     | 
| 
       233 
294 
     | 
    
         
             
                  tags.each do |tag|
         
     | 
| 
       234 
295 
     | 
    
         
             
                    return false unless compare_tag(tag)
         
     | 
| 
         @@ -236,6 +297,10 @@ module NA 
     | 
|
| 
       236 
297 
     | 
    
         
             
                  true
         
     | 
| 
       237 
298 
     | 
    
         
             
                end
         
     | 
| 
       238 
299 
     | 
    
         | 
| 
      
 300 
     | 
    
         
            +
                # Compare a tag against the action's tags with optional value comparison
         
     | 
| 
      
 301 
     | 
    
         
            +
                #
         
     | 
| 
      
 302 
     | 
    
         
            +
                # @param tag [Hash] Tag criteria
         
     | 
| 
      
 303 
     | 
    
         
            +
                # @return [Boolean]
         
     | 
| 
       239 
304 
     | 
    
         
             
                def compare_tag(tag)
         
     | 
| 
       240 
305 
     | 
    
         
             
                  tag_regex = tag[:tag].is_a?(Regexp) ? tag[:tag] : Regexp.new(tag[:tag], Regexp::IGNORECASE)
         
     | 
| 
       241 
306 
     | 
    
         
             
                  keys = @tags.keys.delete_if { |k| k !~ tag_regex }
         
     |