na 1.1.26 → 1.2.1
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/CHANGELOG.md +54 -0
- data/Gemfile.lock +3 -1
- data/README.md +161 -23
- data/bin/na +364 -43
- data/lib/na/action.rb +11 -13
- data/lib/na/colors.rb +2 -1
- data/lib/na/next_action.rb +347 -49
- data/lib/na/project.rb +26 -0
- data/lib/na/string.rb +9 -5
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +1 -0
- data/na.gemspec +1 -0
- data/src/README.md +58 -4
- metadata +23 -2
    
        data/bin/na
    CHANGED
    
    | @@ -78,6 +78,9 @@ class App | |
| 78 78 | 
             
                c.arg_name 'PROJECT[/SUBPROJECT]'
         | 
| 79 79 | 
             
                c.flag %i[proj project]
         | 
| 80 80 |  | 
| 81 | 
            +
                c.desc 'Include @done actions'
         | 
| 82 | 
            +
                c.switch %i[done]
         | 
| 83 | 
            +
             | 
| 81 84 | 
             
                c.action do |global_options, options, args|
         | 
| 82 85 | 
             
                  if global_options[:add]
         | 
| 83 86 | 
             
                    cmd = ['add']
         | 
| @@ -95,13 +98,16 @@ class App | |
| 95 98 | 
             
                          end
         | 
| 96 99 |  | 
| 97 100 | 
             
                  if args.count.positive?
         | 
| 101 | 
            +
                    all_req = false
         | 
| 102 | 
            +
             | 
| 98 103 | 
             
                    tokens = []
         | 
| 99 104 | 
             
                    args.each do |arg|
         | 
| 100 105 | 
             
                      arg.split(/ *, */).each do |a|
         | 
| 101 | 
            -
                        m = a.match(/^(?<req | 
| 106 | 
            +
                        m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
         | 
| 102 107 | 
             
                        tokens.push({
         | 
| 103 108 | 
             
                                      token: m['tok'],
         | 
| 104 | 
            -
                                      required: !m['req'].nil?
         | 
| 109 | 
            +
                                      required: all_req || (!m['req'].nil? && m['req'] == '+'),
         | 
| 110 | 
            +
                                      negate: !m['req'].nil? && m['req'] =~ /[!\-]/
         | 
| 105 111 | 
             
                                    })
         | 
| 106 112 | 
             
                      end
         | 
| 107 113 | 
             
                    end
         | 
| @@ -110,12 +116,13 @@ class App | |
| 110 116 | 
             
                  NA.na_tag = options[:tag] unless options[:tag].nil?
         | 
| 111 117 | 
             
                  require_na = true
         | 
| 112 118 |  | 
| 113 | 
            -
                  tag = [{ tag: tag, value: nil }]
         | 
| 114 | 
            -
                  files, actions = NA.parse_actions(depth: depth,
         | 
| 115 | 
            -
             | 
| 116 | 
            -
             | 
| 117 | 
            -
             | 
| 118 | 
            -
             | 
| 119 | 
            +
                  tag = [{ tag: tag, value: nil }, { tag: 'done', value: nil, negate: true}]
         | 
| 120 | 
            +
                  files, actions, = NA.parse_actions(depth: depth,
         | 
| 121 | 
            +
                                                     done: options[:done],
         | 
| 122 | 
            +
                                                     query: tokens,
         | 
| 123 | 
            +
                                                     tag: tag,
         | 
| 124 | 
            +
                                                     project: options[:project],
         | 
| 125 | 
            +
                                                     require_na: require_na)
         | 
| 119 126 |  | 
| 120 127 | 
             
                  NA.output_actions(actions, depth, files: files)
         | 
| 121 128 | 
             
                end
         | 
| @@ -146,7 +153,11 @@ class App | |
| 146 153 | 
             
                c.desc 'Add action to specific project'
         | 
| 147 154 | 
             
                c.arg_name 'PROJECT'
         | 
| 148 155 | 
             
                c.default_value 'Inbox'
         | 
| 149 | 
            -
                c.flag %i[to]
         | 
| 156 | 
            +
                c.flag %i[to project proj]
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                c.desc 'Add to a known todo file, partial matches allowed'
         | 
| 159 | 
            +
                c.arg_name 'TODO_FILE'
         | 
| 160 | 
            +
                c.flag %i[in todo]
         | 
| 150 161 |  | 
| 151 162 | 
             
                c.desc 'Use a tag other than the default next action tag'
         | 
| 152 163 | 
             
                c.arg_name 'TAG'
         | 
| @@ -183,7 +194,7 @@ class App | |
| 183 194 | 
             
                    action = "#{action.gsub(/@priority\(\d+\)/, '')} @priority(#{options[:priority]})"
         | 
| 184 195 | 
             
                  end
         | 
| 185 196 |  | 
| 186 | 
            -
                  note_rx = /^(.+)\((.*?)\)$/
         | 
| 197 | 
            +
                  note_rx = /^(.+) \((.*?)\)$/
         | 
| 187 198 | 
             
                  split_note = if action =~ note_rx
         | 
| 188 199 | 
             
                                 n = Regexp.last_match(2)
         | 
| 189 200 | 
             
                                 action.sub!(note_rx, '\1').strip!
         | 
| @@ -229,6 +240,34 @@ class App | |
| 229 240 | 
             
                        Process.exit 1
         | 
| 230 241 | 
             
                      end
         | 
| 231 242 | 
             
                    end
         | 
| 243 | 
            +
                  elsif options[:todo]
         | 
| 244 | 
            +
                    todo = []
         | 
| 245 | 
            +
                    options[:todo].split(/ *, */).each do |a|
         | 
| 246 | 
            +
                      m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
         | 
| 247 | 
            +
                      todo.push({
         | 
| 248 | 
            +
                                  token: m['tok'],
         | 
| 249 | 
            +
                                  required: !m['req'].nil?
         | 
| 250 | 
            +
                                })
         | 
| 251 | 
            +
                    end
         | 
| 252 | 
            +
                    dirs = NA.match_working_dir(todo)
         | 
| 253 | 
            +
                    if dirs.count.positive?
         | 
| 254 | 
            +
                      target = dirs[0]
         | 
| 255 | 
            +
                    else
         | 
| 256 | 
            +
                      todo = "#{options[:todo].sub(/#{NA.extension}$/, '')}.#{NA.extension}"
         | 
| 257 | 
            +
                      target = File.expand_path(todo)
         | 
| 258 | 
            +
                      unless File.exist?(target)
         | 
| 259 | 
            +
             | 
| 260 | 
            +
                        res = NA.yn(NA::Color.template("{by}Specified file not found, create #{todo}"), default: true)
         | 
| 261 | 
            +
                        if res
         | 
| 262 | 
            +
                          basename = File.basename(target, ".#{NA.extension}")
         | 
| 263 | 
            +
                          NA.create_todo(target, basename)
         | 
| 264 | 
            +
                        else
         | 
| 265 | 
            +
                          NA.notify('{r}Cancelled{x}', exit_code: 1)
         | 
| 266 | 
            +
             | 
| 267 | 
            +
                        end
         | 
| 268 | 
            +
                      end
         | 
| 269 | 
            +
             | 
| 270 | 
            +
                    end
         | 
| 232 271 | 
             
                  else
         | 
| 233 272 | 
             
                    files = NA.find_files(depth: options[:depth])
         | 
| 234 273 | 
             
                    if files.count.zero?
         | 
| @@ -243,12 +282,209 @@ class App | |
| 243 282 | 
             
                    end
         | 
| 244 283 | 
             
                    target = files.count > 1 ? NA.select_file(files) : files[0]
         | 
| 245 284 | 
             
                    unless files.count.positive? && File.exist?(target)
         | 
| 246 | 
            -
                       | 
| 247 | 
            -
             | 
| 285 | 
            +
                      NA.notify('{r}Cancelled{x}', exit_code: 1)
         | 
| 286 | 
            +
             | 
| 287 | 
            +
                    end
         | 
| 288 | 
            +
                  end
         | 
| 289 | 
            +
             | 
| 290 | 
            +
                  NA.add_action(target, options[:project], action, note)
         | 
| 291 | 
            +
                end
         | 
| 292 | 
            +
              end
         | 
| 293 | 
            +
             | 
| 294 | 
            +
              desc 'Update an existing action'
         | 
| 295 | 
            +
              long_desc 'Provides an easy way to complete, prioritize, and tag existing actions.
         | 
| 296 | 
            +
             | 
| 297 | 
            +
              If multiple todo files are found in the current directory, a menu will
         | 
| 298 | 
            +
              allow you to pick which file to act on.'
         | 
| 299 | 
            +
              arg_name 'ACTION'
         | 
| 300 | 
            +
              command %i[update] do |c|
         | 
| 301 | 
            +
                c.example 'na update --remove na "An existing task"', desc: 'Find "An existing task" action and remove the @na tag from it'
         | 
| 302 | 
            +
                c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
         | 
| 303 | 
            +
                          desc: 'Find "A bug..." action, add @waiting, add/update @priority(4), and prompt for an additional note'
         | 
| 304 | 
            +
                c.example 'na update --archive My cool action', desc: 'Add @done to "My cool action" and immediately move to Archive'
         | 
| 305 | 
            +
             | 
| 306 | 
            +
                c.desc 'Prompt for additional notes. Input will be appended to any existing note.'
         | 
| 307 | 
            +
                c.switch %i[n note], negatable: false
         | 
| 308 | 
            +
             | 
| 309 | 
            +
                c.desc 'Overwrite note instead of appending'
         | 
| 310 | 
            +
                c.switch %i[o overwrite], negatable: false
         | 
| 311 | 
            +
             | 
| 312 | 
            +
                c.desc 'Add/change a priority level 1-5'
         | 
| 313 | 
            +
                c.arg_name 'PRIO'
         | 
| 314 | 
            +
                c.flag %i[p priority], must_match: /[1-5]/, type: :integer, default_value: 0
         | 
| 315 | 
            +
             | 
| 316 | 
            +
                c.desc 'Move action to specific project'
         | 
| 317 | 
            +
                c.arg_name 'PROJECT'
         | 
| 318 | 
            +
                c.flag %i[to project proj]
         | 
| 319 | 
            +
             | 
| 320 | 
            +
                c.desc 'Use a known todo file, partial matches allowed'
         | 
| 321 | 
            +
                c.arg_name 'TODO_FILE'
         | 
| 322 | 
            +
                c.flag %i[in todo]
         | 
| 323 | 
            +
             | 
| 324 | 
            +
                c.desc 'Add a tag to the action, @tag(values) allowed'
         | 
| 325 | 
            +
                c.arg_name 'TAG'
         | 
| 326 | 
            +
                c.flag %i[t tag], multiple: true
         | 
| 327 | 
            +
             | 
| 328 | 
            +
                c.desc 'Remove a tag to the action'
         | 
| 329 | 
            +
                c.arg_name 'TAG'
         | 
| 330 | 
            +
                c.flag %i[r remove], multiple: true
         | 
| 331 | 
            +
             | 
| 332 | 
            +
                c.desc 'Add a @done tag to action'
         | 
| 333 | 
            +
                c.switch %i[f finish done], negatable: false
         | 
| 334 | 
            +
             | 
| 335 | 
            +
                c.desc 'Add a @done tag to action and move to Archive'
         | 
| 336 | 
            +
                c.switch %i[a archive], negatable: false
         | 
| 337 | 
            +
             | 
| 338 | 
            +
                c.desc 'Delete an action'
         | 
| 339 | 
            +
                c.switch %i[delete], negatable: false
         | 
| 340 | 
            +
             | 
| 341 | 
            +
                c.desc 'Specify the file to search for the task'
         | 
| 342 | 
            +
                c.arg_name 'PATH'
         | 
| 343 | 
            +
                c.flag %i[file]
         | 
| 344 | 
            +
             | 
| 345 | 
            +
                c.desc 'Search for files X directories deep'
         | 
| 346 | 
            +
                c.arg_name 'DEPTH'
         | 
| 347 | 
            +
                c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
         | 
| 348 | 
            +
             | 
| 349 | 
            +
                c.desc 'Match actions containing tag. Allows value comparisons'
         | 
| 350 | 
            +
                c.arg_name 'TAG'
         | 
| 351 | 
            +
                c.flag %i[tagged], multiple: true
         | 
| 352 | 
            +
             | 
| 353 | 
            +
                c.desc 'Act on all matches immediately (no menu)'
         | 
| 354 | 
            +
                c.switch %i[all], negatable: false
         | 
| 355 | 
            +
             | 
| 356 | 
            +
                c.desc 'Interpret search pattern as regular expression'
         | 
| 357 | 
            +
                c.switch %i[e regex], negatable: false
         | 
| 358 | 
            +
             | 
| 359 | 
            +
                c.desc 'Match pattern exactly'
         | 
| 360 | 
            +
                c.switch %i[x exact], negatable: false
         | 
| 361 | 
            +
             | 
| 362 | 
            +
                c.action do |_global_options, options, args|
         | 
| 363 | 
            +
                  reader = TTY::Reader.new
         | 
| 364 | 
            +
                  action = if args.count.positive?
         | 
| 365 | 
            +
                             args.join(' ').strip
         | 
| 366 | 
            +
                           elsif TTY::Which.exist?('gum') && options[:tagged].empty?
         | 
| 367 | 
            +
                             options = [
         | 
| 368 | 
            +
                               %(--placeholder "Enter a task to search for"),
         | 
| 369 | 
            +
                               '--char-limit=500',
         | 
| 370 | 
            +
                               "--width=#{TTY::Screen.columns}"
         | 
| 371 | 
            +
                             ]
         | 
| 372 | 
            +
                             `gum input #{options.join(' ')}`.strip
         | 
| 373 | 
            +
                           elsif options[:tagged].empty?
         | 
| 374 | 
            +
                             puts NA::Color.template('{bm}Enter search string:{x}')
         | 
| 375 | 
            +
                             reader.read_line(NA::Color.template('{by}> {bw}')).strip
         | 
| 376 | 
            +
                           end
         | 
| 377 | 
            +
             | 
| 378 | 
            +
                  if action
         | 
| 379 | 
            +
                    tokens = nil
         | 
| 380 | 
            +
                    if options[:exact]
         | 
| 381 | 
            +
                      tokens = action
         | 
| 382 | 
            +
                    elsif options[:regex]
         | 
| 383 | 
            +
                      tokens = Regexp.new(action, Regexp::IGNORECASE)
         | 
| 384 | 
            +
                    else
         | 
| 385 | 
            +
                      tokens = []
         | 
| 386 | 
            +
                      all_req = action !~ /[+!\-]/ && !options[:or]
         | 
| 387 | 
            +
             | 
| 388 | 
            +
                      action.split(/ /).each do |arg|
         | 
| 389 | 
            +
                        m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
         | 
| 390 | 
            +
                        tokens.push({
         | 
| 391 | 
            +
                                      token: m['tok'],
         | 
| 392 | 
            +
                                      required: all_req || (!m['req'].nil? && m['req'] == '+'),
         | 
| 393 | 
            +
                                      negate: !m['req'].nil? && m['req'] =~ /[!\-]/
         | 
| 394 | 
            +
                                    })
         | 
| 395 | 
            +
                      end
         | 
| 396 | 
            +
                    end
         | 
| 397 | 
            +
                  end
         | 
| 398 | 
            +
             | 
| 399 | 
            +
                  if (action.nil? || action.empty?) && options[:tagged].empty?
         | 
| 400 | 
            +
                    puts 'Empty input, cancelled'
         | 
| 401 | 
            +
                    Process.exit 1
         | 
| 402 | 
            +
                  end
         | 
| 403 | 
            +
             | 
| 404 | 
            +
                  all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
         | 
| 405 | 
            +
                  tags = []
         | 
| 406 | 
            +
                  options[:tagged].join(',').split(/ *, */).each do |arg|
         | 
| 407 | 
            +
                    m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
         | 
| 408 | 
            +
             | 
| 409 | 
            +
                    tags.push({
         | 
| 410 | 
            +
                                tag: m['tag'].wildcard_to_rx,
         | 
| 411 | 
            +
                                comp: m['op'],
         | 
| 412 | 
            +
                                value: m['val'],
         | 
| 413 | 
            +
                                required: all_req || (!m['req'].nil? && m['req'] == '+'),
         | 
| 414 | 
            +
                                negate: !m['req'].nil? && m['req'] =~ /[!\-]/
         | 
| 415 | 
            +
                              })
         | 
| 416 | 
            +
                  end
         | 
| 417 | 
            +
             | 
| 418 | 
            +
                  priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
         | 
| 419 | 
            +
                  add_tags = options[:tag].map { |t| t.sub(/^@/, '').wildcard_to_rx }
         | 
| 420 | 
            +
                  remove_tags = options[:remove].map { |t| t.sub(/^@/, '').wildcard_to_rx }
         | 
| 421 | 
            +
             | 
| 422 | 
            +
                  line_note = if options[:note]
         | 
| 423 | 
            +
                                if TTY::Which.exist?('gum')
         | 
| 424 | 
            +
                                  args = ['--placeholder "Enter a note, CTRL-d to save"']
         | 
| 425 | 
            +
                                  args << '--char-limit 0'
         | 
| 426 | 
            +
                                  args << '--width $(tput cols)'
         | 
| 427 | 
            +
                                  `gum write #{args.join(' ')}`.strip.split("\n")
         | 
| 428 | 
            +
                                else
         | 
| 429 | 
            +
                                  puts NA::Color.template('{bm}Enter a note, {bw}CTRL-d{bm} to end editing{bw}')
         | 
| 430 | 
            +
                                  reader.read_multiline
         | 
| 431 | 
            +
                                end
         | 
| 432 | 
            +
                              end
         | 
| 433 | 
            +
             | 
| 434 | 
            +
                  note = line_note.nil? || line_note.empty? ? [] : line_note
         | 
| 435 | 
            +
             | 
| 436 | 
            +
                  if options[:file]
         | 
| 437 | 
            +
                    file = File.expand_path(options[:file])
         | 
| 438 | 
            +
                    NA.notify('{r}File not found', exit_code: 1) unless File.exist?(file)
         | 
| 439 | 
            +
             | 
| 440 | 
            +
                    targets = [file]
         | 
| 441 | 
            +
                  elsif options[:todo]
         | 
| 442 | 
            +
                    todo = []
         | 
| 443 | 
            +
                    options[:todo].split(/ *, */).each do |a|
         | 
| 444 | 
            +
                      m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
         | 
| 445 | 
            +
                      todo.push({
         | 
| 446 | 
            +
                                  token: m['tok'],
         | 
| 447 | 
            +
                                  required: !m['req'].nil?
         | 
| 448 | 
            +
                                })
         | 
| 449 | 
            +
                    end
         | 
| 450 | 
            +
                    dirs = NA.match_working_dir(todo)
         | 
| 451 | 
            +
             | 
| 452 | 
            +
                    if dirs.count == 1
         | 
| 453 | 
            +
                      targets = [dirs[0]]
         | 
| 454 | 
            +
                    elsif dirs.count.positive?
         | 
| 455 | 
            +
                      targets = NA.select_file(dirs, multiple: true)
         | 
| 456 | 
            +
                      NA.notify('{r}Cancelled', exit_code: 1) unless targets && targets.count.positive?
         | 
| 457 | 
            +
                    else
         | 
| 458 | 
            +
                      NA.notify('{r}Todo not found', exit_code: 1) unless targets && targets.count.positive?
         | 
| 459 | 
            +
             | 
| 248 460 | 
             
                    end
         | 
| 461 | 
            +
                  else
         | 
| 462 | 
            +
                    files = NA.find_files(depth: options[:depth])
         | 
| 463 | 
            +
                    NA.notify('{r}No todo file found', exit_code: 1) if files.count.zero?
         | 
| 464 | 
            +
             | 
| 465 | 
            +
                    targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
         | 
| 466 | 
            +
                    NA.notify('{r}Cancelled{x}', exit_code: 1) unless files.count.positive?
         | 
| 467 | 
            +
             | 
| 249 468 | 
             
                  end
         | 
| 250 469 |  | 
| 251 | 
            -
                   | 
| 470 | 
            +
                  options[:finish] = true if options[:archive]
         | 
| 471 | 
            +
                  options[:project] = 'Archive' if options[:archive]
         | 
| 472 | 
            +
             | 
| 473 | 
            +
                  NA.notify('{r}No search terms provided', exit_code: 1) if tokens.nil? && options[:tagged].empty?
         | 
| 474 | 
            +
             | 
| 475 | 
            +
                  targets.each do |target|
         | 
| 476 | 
            +
                    NA.update_action(target, tokens,
         | 
| 477 | 
            +
                                     priority: priority,
         | 
| 478 | 
            +
                                     add_tag: add_tags,
         | 
| 479 | 
            +
                                     remove_tag: remove_tags,
         | 
| 480 | 
            +
                                     finish: options[:finish],
         | 
| 481 | 
            +
                                     project: options[:project],
         | 
| 482 | 
            +
                                     delete: options[:delete],
         | 
| 483 | 
            +
                                     note: note,
         | 
| 484 | 
            +
                                     overwrite: options[:overwrite],
         | 
| 485 | 
            +
                                     tagged: tags,
         | 
| 486 | 
            +
                                     all: options[:all])
         | 
| 487 | 
            +
                  end
         | 
| 252 488 | 
             
                end
         | 
| 253 489 | 
             
              end
         | 
| 254 490 |  | 
| @@ -281,6 +517,9 @@ class App | |
| 281 517 | 
             
                c.arg_name 'PROJECT[/SUBPROJECT]'
         | 
| 282 518 | 
             
                c.flag %i[proj project]
         | 
| 283 519 |  | 
| 520 | 
            +
                c.desc 'Include @done actions'
         | 
| 521 | 
            +
                c.switch %i[done]
         | 
| 522 | 
            +
             | 
| 284 523 | 
             
                c.desc 'Show actions not matching search pattern'
         | 
| 285 524 | 
             
                c.switch %i[v invert], negatable: false
         | 
| 286 525 |  | 
| @@ -291,7 +530,7 @@ class App | |
| 291 530 | 
             
                c.action do |global_options, options, args|
         | 
| 292 531 | 
             
                  if options[:save]
         | 
| 293 532 | 
             
                    title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
         | 
| 294 | 
            -
                    NA.save_search(title, "find #{NA.command_line.map { | | 
| 533 | 
            +
                    NA.save_search(title, "find #{NA.command_line.map { |cmd| "\"#{cmd}\"" }.join(' ')}")
         | 
| 295 534 | 
             
                  end
         | 
| 296 535 |  | 
| 297 536 | 
             
                  depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
         | 
| @@ -324,24 +563,25 @@ class App | |
| 324 563 | 
             
                    options[:in].split(/ *, */).each do |a|
         | 
| 325 564 | 
             
                      m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
         | 
| 326 565 | 
             
                      todo.push({
         | 
| 327 | 
            -
             | 
| 328 | 
            -
             | 
| 329 | 
            -
             | 
| 566 | 
            +
                                  token: m['tok'],
         | 
| 567 | 
            +
                                  required: !m['req'].nil?
         | 
| 568 | 
            +
                                })
         | 
| 330 569 | 
             
                    end
         | 
| 331 570 | 
             
                  end
         | 
| 332 571 |  | 
| 333 | 
            -
                  files, actions = NA.parse_actions(depth: depth,
         | 
| 334 | 
            -
             | 
| 335 | 
            -
             | 
| 336 | 
            -
             | 
| 337 | 
            -
             | 
| 338 | 
            -
             | 
| 339 | 
            -
             | 
| 340 | 
            -
             | 
| 341 | 
            -
             | 
| 342 | 
            -
             | 
| 343 | 
            -
             | 
| 344 | 
            -
             | 
| 572 | 
            +
                  files, actions, = NA.parse_actions(depth: depth,
         | 
| 573 | 
            +
                                                     done: options[:done],
         | 
| 574 | 
            +
                                                     query: todo,
         | 
| 575 | 
            +
                                                     search: tokens,
         | 
| 576 | 
            +
                                                     negate: options[:invert],
         | 
| 577 | 
            +
                                                     regex: options[:regex],
         | 
| 578 | 
            +
                                                     project: options[:project],
         | 
| 579 | 
            +
                                                     require_na: false)
         | 
| 580 | 
            +
                  regexes = if tokens.is_a?(Array)
         | 
| 581 | 
            +
                              tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
         | 
| 582 | 
            +
                            else
         | 
| 583 | 
            +
                              [tokens]
         | 
| 584 | 
            +
                            end
         | 
| 345 585 |  | 
| 346 586 | 
             
                  NA.output_actions(actions, depth, files: files, regexes: regexes)
         | 
| 347 587 | 
             
                end
         | 
| @@ -378,6 +618,9 @@ class App | |
| 378 618 | 
             
                c.arg_name 'PROJECT[/SUBPROJECT]'
         | 
| 379 619 | 
             
                c.flag %i[proj project]
         | 
| 380 620 |  | 
| 621 | 
            +
                c.desc 'Include @done actions'
         | 
| 622 | 
            +
                c.switch %i[done]
         | 
| 623 | 
            +
             | 
| 381 624 | 
             
                c.desc 'Show actions not matching tags'
         | 
| 382 625 | 
             
                c.switch %i[v invert], negatable: false
         | 
| 383 626 |  | 
| @@ -401,7 +644,6 @@ class App | |
| 401 644 |  | 
| 402 645 | 
             
                  all_req = args.join(' ') !~ /[+!\-]/ && !options[:or]
         | 
| 403 646 | 
             
                  args.join(',').split(/ *, */).each do |arg|
         | 
| 404 | 
            -
                    # TODO: <> comparisons do nothing right now
         | 
| 405 647 | 
             
                    m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
         | 
| 406 648 |  | 
| 407 649 | 
             
                    tags.push({
         | 
| @@ -413,6 +655,10 @@ class App | |
| 413 655 | 
             
                              })
         | 
| 414 656 | 
             
                  end
         | 
| 415 657 |  | 
| 658 | 
            +
                  search_for_done = false
         | 
| 659 | 
            +
                  tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
         | 
| 660 | 
            +
                  tags.push({ tag: 'done', value: nil, negate: true}) unless search_for_done
         | 
| 661 | 
            +
             | 
| 416 662 | 
             
                  todo = nil
         | 
| 417 663 | 
             
                  if options[:in]
         | 
| 418 664 | 
             
                    todo = []
         | 
| @@ -425,12 +671,13 @@ class App | |
| 425 671 | 
             
                    end
         | 
| 426 672 | 
             
                  end
         | 
| 427 673 |  | 
| 428 | 
            -
                  files, actions = NA.parse_actions(depth: depth,
         | 
| 429 | 
            -
             | 
| 430 | 
            -
             | 
| 431 | 
            -
             | 
| 432 | 
            -
             | 
| 433 | 
            -
             | 
| 674 | 
            +
                  files, actions, = NA.parse_actions(depth: depth,
         | 
| 675 | 
            +
                                                     done: options[:done],
         | 
| 676 | 
            +
                                                     query: todo,
         | 
| 677 | 
            +
                                                     tag: tags,
         | 
| 678 | 
            +
                                                     negate: options[:invert],
         | 
| 679 | 
            +
                                                     project: options[:project],
         | 
| 680 | 
            +
                                                     require_na: false)
         | 
| 434 681 | 
             
                  regexes = tags.delete_if { |token| token[:negate] }.map { |token| token[:token] }
         | 
| 435 682 | 
             
                  NA.output_actions(actions, depth, files: files, regexes: regexes)
         | 
| 436 683 | 
             
                end
         | 
| @@ -484,13 +731,15 @@ class App | |
| 484 731 | 
             
                c.arg_name 'EDITOR'
         | 
| 485 732 | 
             
                c.flag %i[a app]
         | 
| 486 733 |  | 
| 487 | 
            -
                c.action do |global_options, options,  | 
| 734 | 
            +
                c.action do |global_options, options, args|
         | 
| 488 735 | 
             
                  depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
         | 
| 489 736 | 
             
                            3
         | 
| 490 737 | 
             
                          else
         | 
| 491 738 | 
             
                            options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
         | 
| 492 739 | 
             
                          end
         | 
| 493 740 | 
             
                  files = NA.find_files(depth: depth)
         | 
| 741 | 
            +
                  files.delete_if { |f| f !~ /.*?(#{args.join('|')}).*?.#{NA.extension}/ } if args.count.positive?
         | 
| 742 | 
            +
             | 
| 494 743 | 
             
                  file = if files.count > 1
         | 
| 495 744 | 
             
                           NA.select_file(files)
         | 
| 496 745 | 
             
                         else
         | 
| @@ -513,13 +762,16 @@ class App | |
| 513 762 | 
             
              command %i[todos] do |c|
         | 
| 514 763 | 
             
                c.action do |_global_options, _options, args|
         | 
| 515 764 | 
             
                  if args.count.positive?
         | 
| 516 | 
            -
                     | 
| 765 | 
            +
                    all_req = args.join(' ') !~ /[+!\-]/
         | 
| 766 | 
            +
             | 
| 767 | 
            +
                    tokens = [{ token: '*', required: all_req, negate: false }]
         | 
| 517 768 | 
             
                    args.each do |arg|
         | 
| 518 769 | 
             
                      arg.split(/ *, */).each do |a|
         | 
| 519 | 
            -
                        m = a.match(/^(?<req | 
| 770 | 
            +
                        m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
         | 
| 520 771 | 
             
                        tokens.push({
         | 
| 521 772 | 
             
                                      token: m['tok'],
         | 
| 522 | 
            -
                                      required: !m['req'].nil?
         | 
| 773 | 
            +
                                      required: all_req || (!m['req'].nil? && m['req'] == '+'),
         | 
| 774 | 
            +
                                      negate: !m['req'].nil? && m['req'] =~ /[!\-]/
         | 
| 523 775 | 
             
                                    })
         | 
| 524 776 | 
             
                      end
         | 
| 525 777 | 
             
                    end
         | 
| @@ -529,6 +781,39 @@ class App | |
| 529 781 | 
             
                end
         | 
| 530 782 | 
             
              end
         | 
| 531 783 |  | 
| 784 | 
            +
              desc 'Show list of projects for a file'
         | 
| 785 | 
            +
              long_desc 'Arguments will be interpreted as a query for a known todo file,
         | 
| 786 | 
            +
              fuzzy matched. Separate directories with /, :, or a space, e.g. `na projects code/marked`'
         | 
| 787 | 
            +
              arg_name 'QUERY', optional: true
         | 
| 788 | 
            +
              command %i[projects] do |c|
         | 
| 789 | 
            +
                c.desc 'Search for files X directories deep'
         | 
| 790 | 
            +
                c.arg_name 'DEPTH'
         | 
| 791 | 
            +
                c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
         | 
| 792 | 
            +
             | 
| 793 | 
            +
                c.desc 'Output projects as paths instead of hierarchy'
         | 
| 794 | 
            +
                c.switch %i[p paths], negatable: false
         | 
| 795 | 
            +
             | 
| 796 | 
            +
                c.action do |_global_options, options, args|
         | 
| 797 | 
            +
                  if args.count.positive?
         | 
| 798 | 
            +
                    all_req = args.join(' ') !~ /[+!\-]/
         | 
| 799 | 
            +
             | 
| 800 | 
            +
                    tokens = [{ token: '*', required: all_req, negate: false }]
         | 
| 801 | 
            +
                    args.each do |arg|
         | 
| 802 | 
            +
                      arg.split(/ *, */).each do |a|
         | 
| 803 | 
            +
                        m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
         | 
| 804 | 
            +
                        tokens.push({
         | 
| 805 | 
            +
                                      token: m['tok'],
         | 
| 806 | 
            +
                                      required: all_req || (!m['req'].nil? && m['req'] == '+'),
         | 
| 807 | 
            +
                                      negate: !m['req'].nil? && m['req'] =~ /[!\-]/
         | 
| 808 | 
            +
                                    })
         | 
| 809 | 
            +
                      end
         | 
| 810 | 
            +
                    end
         | 
| 811 | 
            +
                  end
         | 
| 812 | 
            +
             | 
| 813 | 
            +
                  NA.list_projects(query: tokens, depth: options[:depth], paths: options[:paths])
         | 
| 814 | 
            +
                end
         | 
| 815 | 
            +
              end
         | 
| 816 | 
            +
             | 
| 532 817 | 
             
              desc 'Show or install prompt hooks for the current shell'
         | 
| 533 818 | 
             
              long_desc 'Installing the prompt hook allows you to automatically
         | 
| 534 819 | 
             
              list next actions when you cd into a directory'
         | 
| @@ -577,16 +862,52 @@ class App | |
| 577 862 | 
             
                end
         | 
| 578 863 | 
             
              end
         | 
| 579 864 |  | 
| 865 | 
            +
              desc 'Display the changelog'
         | 
| 866 | 
            +
              command %i[changes changelog] do |c|
         | 
| 867 | 
            +
                c.action do |_, _, _|
         | 
| 868 | 
            +
                  changelog = File.expand_path(File.join(File.dirname(__FILE__), '..', 'CHANGELOG.md'))
         | 
| 869 | 
            +
                  pagers = [
         | 
| 870 | 
            +
                    'mdless',
         | 
| 871 | 
            +
                    'mdcat',
         | 
| 872 | 
            +
                    'bat',
         | 
| 873 | 
            +
                    ENV['PAGER'],
         | 
| 874 | 
            +
                    'less -FXr',
         | 
| 875 | 
            +
                    ENV['GIT_PAGER'],
         | 
| 876 | 
            +
                    'more -r'
         | 
| 877 | 
            +
                  ]
         | 
| 878 | 
            +
                  pager = pagers.find { |cmd| TTY::Which.exist?(cmd.split.first) }
         | 
| 879 | 
            +
                  system %(#{pager} "#{changelog}")
         | 
| 880 | 
            +
                end
         | 
| 881 | 
            +
              end
         | 
| 882 | 
            +
             | 
| 580 883 | 
             
              desc 'Execute a saved search'
         | 
| 581 884 | 
             
              long_desc 'Run without argument to list saved searches'
         | 
| 582 885 | 
             
              arg_name 'SEARCH_TITLE', optional: true
         | 
| 583 886 | 
             
              command %i[saved] do |c|
         | 
| 584 | 
            -
                c. | 
| 887 | 
            +
                c.example 'na saved overdue', description: 'perform the search named "overdue"'
         | 
| 888 | 
            +
                c.example 'na saved over', description: 'perform the search named "overdue", assuming no other searches match "over"'
         | 
| 889 | 
            +
                c.example 'na saved', description: 'list available searches'
         | 
| 890 | 
            +
             | 
| 891 | 
            +
                c.desc 'Open the saved search file in $EDITOR'
         | 
| 892 | 
            +
                c.switch %i[e edit]
         | 
| 893 | 
            +
             | 
| 894 | 
            +
                c.desc 'Delete the specified search definition'
         | 
| 895 | 
            +
                c.switch %i[d delete]
         | 
| 896 | 
            +
             | 
| 897 | 
            +
                c.action do |_global_options, options, args|
         | 
| 898 | 
            +
                  if options[:edit]
         | 
| 899 | 
            +
                    NA.edit_searches
         | 
| 900 | 
            +
                  end
         | 
| 901 | 
            +
             | 
| 585 902 | 
             
                  searches = NA.load_searches
         | 
| 586 903 | 
             
                  if args.empty?
         | 
| 587 904 | 
             
                    NA.notify("{bg}Saved searches stored in {bw}#{NA.database_path(file: 'saved_searches.yml')}")
         | 
| 588 905 | 
             
                    NA.notify(searches.map { |k, v| "{y}#{k}: {w}#{v}" }.join("\n"), exit_code: 0)
         | 
| 589 906 | 
             
                  else
         | 
| 907 | 
            +
                    if options[:delete]
         | 
| 908 | 
            +
                      NA.delete_search(args)
         | 
| 909 | 
            +
                    end
         | 
| 910 | 
            +
             | 
| 590 911 | 
             
                    keys = searches.keys.delete_if { |k| k !~ /#{args[0]}/ }
         | 
| 591 912 | 
             
                    NA.notify("{r}Search #{args[0]} not found", exit_code: 1) if keys.empty?
         | 
| 592 913 |  | 
| @@ -614,8 +935,8 @@ class App | |
| 614 935 | 
             
              on_error do |exception|
         | 
| 615 936 | 
             
                case exception
         | 
| 616 937 | 
             
                when GLI::UnknownCommand
         | 
| 617 | 
            -
                  cmd = [' | 
| 618 | 
            -
                  cmd.concat(ARGV.unshift($first_arg)) | 
| 938 | 
            +
                  cmd = ['saved']
         | 
| 939 | 
            +
                  cmd.concat(ARGV.unshift($first_arg))
         | 
| 619 940 |  | 
| 620 941 | 
             
                  exit run(cmd)
         | 
| 621 942 | 
             
                when SystemExit
         | 
    
        data/lib/na/action.rb
    CHANGED
    
    | @@ -2,20 +2,22 @@ | |
| 2 2 |  | 
| 3 3 | 
             
            module NA
         | 
| 4 4 | 
             
              class Action < Hash
         | 
| 5 | 
            -
                attr_reader :file, :project, :parent, :action, :tags
         | 
| 5 | 
            +
                attr_reader :file, :project, :parent, :action, :tags, :line, :note
         | 
| 6 6 |  | 
| 7 | 
            -
                def initialize(file, project, parent, action)
         | 
| 7 | 
            +
                def initialize(file, project, parent, action, idx, note = [])
         | 
| 8 8 | 
             
                  super()
         | 
| 9 9 |  | 
| 10 10 | 
             
                  @file = file
         | 
| 11 11 | 
             
                  @project = project
         | 
| 12 12 | 
             
                  @parent = parent
         | 
| 13 | 
            -
                  @action = action
         | 
| 13 | 
            +
                  @action = action.gsub(/\{/, '\\{')
         | 
| 14 14 | 
             
                  @tags = scan_tags
         | 
| 15 | 
            +
                  @line = idx
         | 
| 16 | 
            +
                  @note = note
         | 
| 15 17 | 
             
                end
         | 
| 16 18 |  | 
| 17 19 | 
             
                def to_s
         | 
| 18 | 
            -
                  "(#{@file}) #{@project}:#{@parent.join('>')} | #{@action}"
         | 
| 20 | 
            +
                  "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}"
         | 
| 19 21 | 
             
                end
         | 
| 20 22 |  | 
| 21 23 | 
             
                def inspect
         | 
| @@ -41,14 +43,10 @@ module NA | |
| 41 43 | 
             
                  }
         | 
| 42 44 | 
             
                  template = default_template.merge(template)
         | 
| 43 45 |  | 
| 44 | 
            -
                   | 
| 45 | 
            -
                     | 
| 46 | 
            -
             | 
| 47 | 
            -
             | 
| 48 | 
            -
                    parents = "{dc}[{x}#{parents}{dc}]{x} "
         | 
| 49 | 
            -
                  else
         | 
| 50 | 
            -
                    parents = ''
         | 
| 51 | 
            -
                  end
         | 
| 46 | 
            +
                  parents = @parent.map do |par|
         | 
| 47 | 
            +
                    NA::Color.template("#{template[:parent]}#{par}")
         | 
| 48 | 
            +
                  end.join(NA::Color.template(template[:parent_divider]))
         | 
| 49 | 
            +
                  parents = "{dc}[{x}#{parents}{dc}]{x} "
         | 
| 52 50 |  | 
| 53 51 | 
             
                  project = NA::Color.template("#{template[:project]}#{@project}{x} ")
         | 
| 54 52 |  | 
| @@ -67,7 +65,7 @@ module NA | |
| 67 65 | 
             
                  NA::Color.template(template[:output].gsub(/%filename/, filename)
         | 
| 68 66 | 
             
                                      .gsub(/%project/, project)
         | 
| 69 67 | 
             
                                      .gsub(/%parents?/, parents)
         | 
| 70 | 
            -
                                      .gsub(/%action/, action.highlight_search(regexes)))
         | 
| 68 | 
            +
                                      .gsub(/%action/, action.highlight_search(regexes))).gsub(/\\\{/, '{')
         | 
| 71 69 | 
             
                end
         | 
| 72 70 |  | 
| 73 71 | 
             
                def tags_match?(any: [], all: [], none: [])
         | 
    
        data/lib/na/colors.rb
    CHANGED
    
    | @@ -226,8 +226,9 @@ module NA | |
| 226 226 | 
             
                  ##
         | 
| 227 227 | 
             
                  def template(input)
         | 
| 228 228 | 
             
                    input = input.join(' ') if input.is_a? Array
         | 
| 229 | 
            +
             | 
| 229 230 | 
             
                    fmt = input.gsub(/%/, '%%')
         | 
| 230 | 
            -
                    fmt = fmt.gsub(/(?<!\\ | 
| 231 | 
            +
                    fmt = fmt.gsub(/(?<!\\)\{(\w+)\}/i) do
         | 
| 231 232 | 
             
                      Regexp.last_match(1).split('').map { |c| "%<#{c}>s" }.join('')
         | 
| 232 233 | 
             
                    end
         | 
| 233 234 |  |