na 1.2.86 → 1.2.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
 - data/.cursor/commands/changelog.md +4 -0
 - data/.rubocop_todo.yml +30 -17
 - data/2025-10-29-one-more-na-update.md +142 -0
 - data/CHANGELOG.md +97 -1
 - data/Gemfile +8 -1
 - data/Gemfile.lock +40 -1
 - data/README.md +192 -2
 - data/Rakefile +78 -78
 - data/bin/commands/add.rb +31 -1
 - data/bin/commands/changes.rb +1 -0
 - data/bin/commands/complete.rb +11 -0
 - data/bin/commands/find.rb +71 -1
 - data/bin/commands/next.rb +100 -2
 - data/bin/commands/plugin.rb +75 -0
 - data/bin/commands/tagged.rb +153 -57
 - data/bin/commands/update.rb +90 -5
 - data/bin/na +7 -0
 - data/lib/na/action.rb +39 -3
 - data/lib/na/actions.rb +136 -6
 - data/lib/na/next_action.rb +180 -31
 - data/lib/na/plugins.rb +419 -0
 - data/lib/na/string.rb +15 -6
 - data/lib/na/theme.rb +1 -0
 - data/lib/na/types.rb +190 -0
 - data/lib/na/version.rb +1 -1
 - data/lib/na.rb +2 -0
 - data/na/Test.todo.markdown +32 -0
 - data/na/test.md +21 -0
 - data/na.gemspec +1 -0
 - data/plugins.md +38 -0
 - data/src/_README.md +153 -1
 - metadata +23 -1
 
    
        data/lib/na/next_action.rb
    CHANGED
    
    | 
         @@ -3,6 +3,98 @@ 
     | 
|
| 
       3 
3 
     | 
    
         
             
            # Next Action methods
         
     | 
| 
       4 
4 
     | 
    
         
             
            module NA
         
     | 
| 
       5 
5 
     | 
    
         
             
              class << self
         
     | 
| 
      
 6 
     | 
    
         
            +
                # Select actions across files using existing search pipeline
         
     | 
| 
      
 7 
     | 
    
         
            +
                # @return [Array<NA::Action>]
         
     | 
| 
      
 8 
     | 
    
         
            +
                def select_actions(file: nil, depth: 1, search: [], tagged: [], include_done: false)
         
     | 
| 
      
 9 
     | 
    
         
            +
                  files = if file
         
     | 
| 
      
 10 
     | 
    
         
            +
                            [file]
         
     | 
| 
      
 11 
     | 
    
         
            +
                          else
         
     | 
| 
      
 12 
     | 
    
         
            +
                            find_files(depth: depth)
         
     | 
| 
      
 13 
     | 
    
         
            +
                          end
         
     | 
| 
      
 14 
     | 
    
         
            +
                  out = []
         
     | 
| 
      
 15 
     | 
    
         
            +
                  files.each do |f|
         
     | 
| 
      
 16 
     | 
    
         
            +
                    _projects, actions = find_actions(f, search, tagged, done: include_done, all: true)
         
     | 
| 
      
 17 
     | 
    
         
            +
                    out.concat(actions) if actions
         
     | 
| 
      
 18 
     | 
    
         
            +
                  end
         
     | 
| 
      
 19 
     | 
    
         
            +
                  out
         
     | 
| 
      
 20 
     | 
    
         
            +
                end
         
     | 
| 
      
 21 
     | 
    
         
            +
             
     | 
| 
      
 22 
     | 
    
         
            +
                # Apply a plugin result hash back to the underlying file
         
     | 
| 
      
 23 
     | 
    
         
            +
                # - Move if parents changed (project path differs)
         
     | 
| 
      
 24 
     | 
    
         
            +
                # - Update text/note/tags
         
     | 
| 
      
 25 
     | 
    
         
            +
                def apply_plugin_result(io_hash)
         
     | 
| 
      
 26 
     | 
    
         
            +
                  file = io_hash['file_path']
         
     | 
| 
      
 27 
     | 
    
         
            +
                  line = io_hash['line'].to_i
         
     | 
| 
      
 28 
     | 
    
         
            +
                  parents = Array(io_hash['parents']).map(&:to_s)
         
     | 
| 
      
 29 
     | 
    
         
            +
                  text = io_hash['text'].to_s
         
     | 
| 
      
 30 
     | 
    
         
            +
                  note = io_hash['note'].to_s
         
     | 
| 
      
 31 
     | 
    
         
            +
                  tags = Array(io_hash['tags']).to_h { |t| [t['name'].to_s, t['value'].to_s] }
         
     | 
| 
      
 32 
     | 
    
         
            +
                  action_block = io_hash['action'] || { 'action' => 'UPDATE', 'arguments' => [] }
         
     | 
| 
      
 33 
     | 
    
         
            +
                  action_name = action_block['action'].to_s.upcase
         
     | 
| 
      
 34 
     | 
    
         
            +
                  action_args = Array(action_block['arguments'])
         
     | 
| 
      
 35 
     | 
    
         
            +
             
     | 
| 
      
 36 
     | 
    
         
            +
                  # Load current action
         
     | 
| 
      
 37 
     | 
    
         
            +
                  _projects, actions = find_actions(file, nil, nil, all: true, done: true, project: nil, search_note: true, target_line: line)
         
     | 
| 
      
 38 
     | 
    
         
            +
                  action = actions&.first
         
     | 
| 
      
 39 
     | 
    
         
            +
                  return unless action
         
     | 
| 
      
 40 
     | 
    
         
            +
             
     | 
| 
      
 41 
     | 
    
         
            +
                  # Determine new project path from parents array
         
     | 
| 
      
 42 
     | 
    
         
            +
                  new_project = ''
         
     | 
| 
      
 43 
     | 
    
         
            +
                  new_parent_chain = []
         
     | 
| 
      
 44 
     | 
    
         
            +
                  if parents.any?
         
     | 
| 
      
 45 
     | 
    
         
            +
                    new_project = parents.first.to_s
         
     | 
| 
      
 46 
     | 
    
         
            +
                    new_parent_chain = parents[1..] || []
         
     | 
| 
      
 47 
     | 
    
         
            +
                  end
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
                  case action_name
         
     | 
| 
      
 50 
     | 
    
         
            +
                  when 'DELETE'
         
     | 
| 
      
 51 
     | 
    
         
            +
                    update_action(file, { target_line: line }, delete: true, all: true)
         
     | 
| 
      
 52 
     | 
    
         
            +
                    return
         
     | 
| 
      
 53 
     | 
    
         
            +
                  when 'COMPLETE'
         
     | 
| 
      
 54 
     | 
    
         
            +
                    update_action(file, { target_line: line }, finish: true, all: true)
         
     | 
| 
      
 55 
     | 
    
         
            +
                    return
         
     | 
| 
      
 56 
     | 
    
         
            +
                  when 'RESTORE'
         
     | 
| 
      
 57 
     | 
    
         
            +
                    update_action(file, { target_line: line }, restore: true, all: true)
         
     | 
| 
      
 58 
     | 
    
         
            +
                    return
         
     | 
| 
      
 59 
     | 
    
         
            +
                  when 'ARCHIVE'
         
     | 
| 
      
 60 
     | 
    
         
            +
                    update_action(file, { target_line: line }, finish: true, move: 'Archive', all: true)
         
     | 
| 
      
 61 
     | 
    
         
            +
                    return
         
     | 
| 
      
 62 
     | 
    
         
            +
                  when 'ADD_TAG'
         
     | 
| 
      
 63 
     | 
    
         
            +
                    add_tags = action_args.map { |t| t.sub(/^@/, '') }
         
     | 
| 
      
 64 
     | 
    
         
            +
                    update_action(file, { target_line: line }, add: action, add_tag: add_tags, all: true)
         
     | 
| 
      
 65 
     | 
    
         
            +
                    return
         
     | 
| 
      
 66 
     | 
    
         
            +
                  when 'DELETE_TAG', 'REMOVE_TAG'
         
     | 
| 
      
 67 
     | 
    
         
            +
                    remove_tags = action_args.map { |t| t.sub(/^@/, '') }
         
     | 
| 
      
 68 
     | 
    
         
            +
                    update_action(file, { target_line: line }, add: action, remove_tag: remove_tags, all: true)
         
     | 
| 
      
 69 
     | 
    
         
            +
                    return
         
     | 
| 
      
 70 
     | 
    
         
            +
                  when 'MOVE'
         
     | 
| 
      
 71 
     | 
    
         
            +
                    move_to = action_args.first.to_s
         
     | 
| 
      
 72 
     | 
    
         
            +
                    update_action(file, { target_line: line }, add: action, move: move_to, all: true)
         
     | 
| 
      
 73 
     | 
    
         
            +
                    return
         
     | 
| 
      
 74 
     | 
    
         
            +
                  end
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
                  # Replace content on the existing action then write back in-place
         
     | 
| 
      
 77 
     | 
    
         
            +
                  original_line = action.file_line
         
     | 
| 
      
 78 
     | 
    
         
            +
                  action.action = text
         
     | 
| 
      
 79 
     | 
    
         
            +
                  action.note = note.to_s.split("\n")
         
     | 
| 
      
 80 
     | 
    
         
            +
                  action.action.gsub!(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
         
     | 
| 
      
 81 
     | 
    
         
            +
                  unless tags.empty?
         
     | 
| 
      
 82 
     | 
    
         
            +
                    tag_str = tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
         
     | 
| 
      
 83 
     | 
    
         
            +
                    action.action = action.action.strip + (tag_str.empty? ? "" : " #{tag_str}")
         
     | 
| 
      
 84 
     | 
    
         
            +
                  end
         
     | 
| 
      
 85 
     | 
    
         
            +
                  # Ensure we update this exact action in-place
         
     | 
| 
      
 86 
     | 
    
         
            +
                  update_action(file, { target_line: original_line }, add: action, all: true)
         
     | 
| 
      
 87 
     | 
    
         
            +
             
     | 
| 
      
 88 
     | 
    
         
            +
                  # If parents changed, set move target
         
     | 
| 
      
 89 
     | 
    
         
            +
                  move_to = nil
         
     | 
| 
      
 90 
     | 
    
         
            +
                  move_to = ([new_project] + new_parent_chain).join(':') if new_project.to_s.strip != action.project || new_parent_chain != action.parent
         
     | 
| 
      
 91 
     | 
    
         
            +
             
     | 
| 
      
 92 
     | 
    
         
            +
                  update_action(file, nil,
         
     | 
| 
      
 93 
     | 
    
         
            +
                                add: action,
         
     | 
| 
      
 94 
     | 
    
         
            +
                                project: action.project,
         
     | 
| 
      
 95 
     | 
    
         
            +
                                overwrite: true,
         
     | 
| 
      
 96 
     | 
    
         
            +
                                move: move_to)
         
     | 
| 
      
 97 
     | 
    
         
            +
                end
         
     | 
| 
       6 
98 
     | 
    
         
             
                include NA::Editor
         
     | 
| 
       7 
99 
     | 
    
         | 
| 
       8 
100 
     | 
    
         
             
                attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
         
     | 
| 
         @@ -327,7 +419,22 @@ module NA 
     | 
|
| 
       327 
419 
     | 
    
         
             
                                  move: nil,
         
     | 
| 
       328 
420 
     | 
    
         
             
                                  remove_tag: [],
         
     | 
| 
       329 
421 
     | 
    
         
             
                                  replace: nil,
         
     | 
| 
       330 
     | 
    
         
            -
                                  tagged: nil 
     | 
| 
      
 422 
     | 
    
         
            +
                                  tagged: nil,
         
     | 
| 
      
 423 
     | 
    
         
            +
                                  started_at: nil,
         
     | 
| 
      
 424 
     | 
    
         
            +
                                  done_at: nil,
         
     | 
| 
      
 425 
     | 
    
         
            +
                                  duration_seconds: nil)
         
     | 
| 
      
 426 
     | 
    
         
            +
                  # Coerce date/time inputs if passed as strings
         
     | 
| 
      
 427 
     | 
    
         
            +
                  begin
         
     | 
| 
      
 428 
     | 
    
         
            +
                    started_at = NA::Types.parse_date_begin(started_at) if started_at && !started_at.is_a?(Time)
         
     | 
| 
      
 429 
     | 
    
         
            +
                  rescue StandardError
         
     | 
| 
      
 430 
     | 
    
         
            +
                    # leave as-is
         
     | 
| 
      
 431 
     | 
    
         
            +
                  end
         
     | 
| 
      
 432 
     | 
    
         
            +
                  begin
         
     | 
| 
      
 433 
     | 
    
         
            +
                    done_at = NA::Types.parse_date_end(done_at) if done_at && !done_at.is_a?(Time)
         
     | 
| 
      
 434 
     | 
    
         
            +
                  rescue StandardError
         
     | 
| 
      
 435 
     | 
    
         
            +
                    # leave as-is
         
     | 
| 
      
 436 
     | 
    
         
            +
                  end
         
     | 
| 
      
 437 
     | 
    
         
            +
                  NA.notify("UPDATE parsed started_at=#{started_at.inspect} done_at=#{done_at.inspect} duration=#{duration_seconds.inspect}", debug: true)
         
     | 
| 
       331 
438 
     | 
    
         
             
                  # Expand target to absolute path to avoid path resolution issues
         
     | 
| 
       332 
439 
     | 
    
         
             
                  target = File.expand_path(target) unless Pathname.new(target).absolute?
         
     | 
| 
       333 
440 
     | 
    
         | 
| 
         @@ -359,12 +466,20 @@ module NA 
     | 
|
| 
       359 
466 
     | 
    
         
             
                    # So we don't need to handle it here - the action is already edited
         
     | 
| 
       360 
467 
     | 
    
         | 
| 
       361 
468 
     | 
    
         
             
                    add_tag ||= []
         
     | 
| 
       362 
     | 
    
         
            -
                     
     | 
| 
       363 
     | 
    
         
            -
             
     | 
| 
       364 
     | 
    
         
            -
             
     | 
| 
      
 469 
     | 
    
         
            +
                    NA.notify("PROCESS before add.process started_at=#{started_at.inspect} done_at=#{done_at.inspect}", debug: true)
         
     | 
| 
      
 470 
     | 
    
         
            +
                    add.process(priority: priority,
         
     | 
| 
      
 471 
     | 
    
         
            +
                                finish: finish,
         
     | 
| 
      
 472 
     | 
    
         
            +
                                add_tag: add_tag,
         
     | 
| 
      
 473 
     | 
    
         
            +
                                remove_tag: remove_tag,
         
     | 
| 
      
 474 
     | 
    
         
            +
                                started_at: started_at,
         
     | 
| 
      
 475 
     | 
    
         
            +
                                done_at: done_at,
         
     | 
| 
      
 476 
     | 
    
         
            +
                                duration_seconds: duration_seconds)
         
     | 
| 
      
 477 
     | 
    
         
            +
                    NA.notify("PROCESS after add.process action=\"#{add.action}\"", debug: true)
         
     | 
| 
      
 478 
     | 
    
         
            +
             
     | 
| 
      
 479 
     | 
    
         
            +
                    # Remove the original action and its notes if this is an existing action
         
     | 
| 
       365 
480 
     | 
    
         
             
                    action_line = add.file_line
         
     | 
| 
       366 
481 
     | 
    
         
             
                    note_lines = add.note.is_a?(Array) ? add.note.count : 0
         
     | 
| 
       367 
     | 
    
         
            -
                    contents.slice!(action_line, note_lines + 1)
         
     | 
| 
      
 482 
     | 
    
         
            +
                    contents.slice!(action_line, note_lines + 1) if action_line.is_a?(Integer)
         
     | 
| 
       368 
483 
     | 
    
         | 
| 
       369 
484 
     | 
    
         
             
                    # Prepare updated note
         
     | 
| 
       370 
485 
     | 
    
         
             
                    note = note.to_s.split("\n") unless note.is_a?(Array)
         
     | 
| 
         @@ -384,32 +499,52 @@ module NA 
     | 
|
| 
       384 
499 
     | 
    
         
             
                    # Format note for insertion
         
     | 
| 
       385 
500 
     | 
    
         
             
                    note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"
         
     | 
| 
       386 
501 
     | 
    
         | 
| 
       387 
     | 
    
         
            -
                    #  
     | 
| 
       388 
     | 
    
         
            -
                    if  
     | 
| 
       389 
     | 
    
         
            -
                       
     | 
| 
       390 
     | 
    
         
            -
                                      # End of project
         
     | 
| 
       391 
     | 
    
         
            -
                                      target_proj.last_line + 1
         
     | 
| 
       392 
     | 
    
         
            -
                                    else
         
     | 
| 
       393 
     | 
    
         
            -
                                      # Start of project (after project header)
         
     | 
| 
       394 
     | 
    
         
            -
                                      target_proj.line + 1
         
     | 
| 
       395 
     | 
    
         
            -
                                    end
         
     | 
| 
       396 
     | 
    
         
            -
                      contents.insert(insert_line, "#{indent}\t- #{add.action}#{note_str}")
         
     | 
| 
      
 502 
     | 
    
         
            +
                    # If delete was requested in this direct update path, do not re-insert
         
     | 
| 
      
 503 
     | 
    
         
            +
                    if delete
         
     | 
| 
      
 504 
     | 
    
         
            +
                      affected_actions << { action: add, desc: 'deleted' }
         
     | 
| 
       397 
505 
     | 
    
         
             
                    else
         
     | 
| 
       398 
     | 
    
         
            -
                      #  
     | 
| 
       399 
     | 
    
         
            -
                       
     | 
| 
       400 
     | 
    
         
            -
             
     | 
| 
      
 506 
     | 
    
         
            +
                      # Insert at correct location
         
     | 
| 
      
 507 
     | 
    
         
            +
                      if target_proj
         
     | 
| 
      
 508 
     | 
    
         
            +
                        insert_line = if append
         
     | 
| 
      
 509 
     | 
    
         
            +
                                        # End of project
         
     | 
| 
      
 510 
     | 
    
         
            +
                                        target_proj.last_line + 1
         
     | 
| 
      
 511 
     | 
    
         
            +
                                      else
         
     | 
| 
      
 512 
     | 
    
         
            +
                                        # Start of project (after project header)
         
     | 
| 
      
 513 
     | 
    
         
            +
                                        target_proj.line + 1
         
     | 
| 
      
 514 
     | 
    
         
            +
                                      end
         
     | 
| 
      
 515 
     | 
    
         
            +
                        # Ensure @started tag persists if provided
         
     | 
| 
      
 516 
     | 
    
         
            +
                        final_action = add.action.dup
         
     | 
| 
      
 517 
     | 
    
         
            +
                        if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
         
     | 
| 
      
 518 
     | 
    
         
            +
                          final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
         
     | 
| 
      
 519 
     | 
    
         
            +
                          final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
         
     | 
| 
      
 520 
     | 
    
         
            +
                        end
         
     | 
| 
      
 521 
     | 
    
         
            +
                        NA.notify("INSERT at #{insert_line} final_action=\"#{final_action}\"", debug: true)
         
     | 
| 
      
 522 
     | 
    
         
            +
                        contents.insert(insert_line, "#{indent}\t- #{final_action}#{note_str}")
         
     | 
| 
      
 523 
     | 
    
         
            +
                      else
         
     | 
| 
      
 524 
     | 
    
         
            +
                        # Fallback: append to end of file
         
     | 
| 
      
 525 
     | 
    
         
            +
                        final_action = add.action.dup
         
     | 
| 
      
 526 
     | 
    
         
            +
                        if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
         
     | 
| 
      
 527 
     | 
    
         
            +
                          final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
         
     | 
| 
      
 528 
     | 
    
         
            +
                          final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
         
     | 
| 
      
 529 
     | 
    
         
            +
                        end
         
     | 
| 
      
 530 
     | 
    
         
            +
                        NA.notify("APPEND final_action=\"#{final_action}\"", debug: true)
         
     | 
| 
      
 531 
     | 
    
         
            +
                        contents << "#{indent}\t- #{final_action}#{note_str}"
         
     | 
| 
      
 532 
     | 
    
         
            +
                      end
         
     | 
| 
       401 
533 
     | 
    
         | 
| 
       402 
     | 
    
         
            -
             
     | 
| 
      
 534 
     | 
    
         
            +
                      notify(add.pretty)
         
     | 
| 
      
 535 
     | 
    
         
            +
                    end
         
     | 
| 
       403 
536 
     | 
    
         | 
| 
       404 
537 
     | 
    
         
             
                    # Track affected action and description
         
     | 
| 
       405 
     | 
    
         
            -
                     
     | 
| 
       406 
     | 
    
         
            -
             
     | 
| 
       407 
     | 
    
         
            -
             
     | 
| 
       408 
     | 
    
         
            -
             
     | 
| 
       409 
     | 
    
         
            -
             
     | 
| 
       410 
     | 
    
         
            -
             
     | 
| 
       411 
     | 
    
         
            -
             
     | 
| 
       412 
     | 
    
         
            -
             
     | 
| 
      
 538 
     | 
    
         
            +
                    unless delete
         
     | 
| 
      
 539 
     | 
    
         
            +
                      changes = ['updated']
         
     | 
| 
      
 540 
     | 
    
         
            +
                      changes << 'finished' if finish
         
     | 
| 
      
 541 
     | 
    
         
            +
                      changes << "priority=#{priority}" if priority.to_i.positive?
         
     | 
| 
      
 542 
     | 
    
         
            +
                      changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
         
     | 
| 
      
 543 
     | 
    
         
            +
                      changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
         
     | 
| 
      
 544 
     | 
    
         
            +
                      changes << 'note updated' unless note.nil? || note.empty?
         
     | 
| 
      
 545 
     | 
    
         
            +
                      changes << "moved to #{target_proj.project}" if move && target_proj
         
     | 
| 
      
 546 
     | 
    
         
            +
                      affected_actions << { action: add, desc: changes.join(', ') }
         
     | 
| 
      
 547 
     | 
    
         
            +
                    end
         
     | 
| 
       413 
548 
     | 
    
         
             
                  else
         
     | 
| 
       414 
549 
     | 
    
         
             
                    # Check if search is actually target_line
         
     | 
| 
       415 
550 
     | 
    
         
             
                    target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
         
     | 
| 
         @@ -445,7 +580,13 @@ module NA 
     | 
|
| 
       445 
580 
     | 
    
         
             
                      # If replace is defined, use search to search and replace text in action
         
     | 
| 
       446 
581 
     | 
    
         
             
                      action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace
         
     | 
| 
       447 
582 
     | 
    
         | 
| 
       448 
     | 
    
         
            -
                      action.process(priority: priority, 
     | 
| 
      
 583 
     | 
    
         
            +
                      action.process(priority: priority,
         
     | 
| 
      
 584 
     | 
    
         
            +
                                     finish: finish,
         
     | 
| 
      
 585 
     | 
    
         
            +
                                     add_tag: add_tag,
         
     | 
| 
      
 586 
     | 
    
         
            +
                                     remove_tag: remove_tag,
         
     | 
| 
      
 587 
     | 
    
         
            +
                                     started_at: started_at,
         
     | 
| 
      
 588 
     | 
    
         
            +
                                     done_at: done_at,
         
     | 
| 
      
 589 
     | 
    
         
            +
                                     duration_seconds: duration_seconds)
         
     | 
| 
       449 
590 
     | 
    
         | 
| 
       450 
591 
     | 
    
         
             
                      target_proj = if target_proj
         
     | 
| 
       451 
592 
     | 
    
         
             
                                      projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
         
     | 
| 
         @@ -532,7 +673,7 @@ module NA 
     | 
|
| 
       532 
673 
     | 
    
         
             
                # @param finish [Boolean] Mark as finished
         
     | 
| 
       533 
674 
     | 
    
         
             
                # @param append [Boolean] Append to project
         
     | 
| 
       534 
675 
     | 
    
         
             
                # @return [void]
         
     | 
| 
       535 
     | 
    
         
            -
                def add_action(file, project, action, note = [], priority: 0, finish: false, append: false)
         
     | 
| 
      
 676 
     | 
    
         
            +
                def add_action(file, project, action, note = [], priority: 0, finish: false, append: false, started_at: nil, done_at: nil, duration_seconds: nil)
         
     | 
| 
       536 
677 
     | 
    
         
             
                  parent = project.split(%r{[:/]})
         
     | 
| 
       537 
678 
     | 
    
         | 
| 
       538 
679 
     | 
    
         
             
                  if NA.global_file
         
     | 
| 
         @@ -545,8 +686,16 @@ module NA 
     | 
|
| 
       545 
686 
     | 
    
         | 
| 
       546 
687 
     | 
    
         
             
                  action = Action.new(file, project, parent, action, nil, note)
         
     | 
| 
       547 
688 
     | 
    
         | 
| 
       548 
     | 
    
         
            -
                  update_action(file, nil, 
     | 
| 
       549 
     | 
    
         
            -
             
     | 
| 
      
 689 
     | 
    
         
            +
                  update_action(file, nil,
         
     | 
| 
      
 690 
     | 
    
         
            +
                                add: action,
         
     | 
| 
      
 691 
     | 
    
         
            +
                                project: project,
         
     | 
| 
      
 692 
     | 
    
         
            +
                                add_tag: add_tag,
         
     | 
| 
      
 693 
     | 
    
         
            +
                                priority: priority,
         
     | 
| 
      
 694 
     | 
    
         
            +
                                finish: finish,
         
     | 
| 
      
 695 
     | 
    
         
            +
                                append: append,
         
     | 
| 
      
 696 
     | 
    
         
            +
                                started_at: started_at,
         
     | 
| 
      
 697 
     | 
    
         
            +
                                done_at: done_at,
         
     | 
| 
      
 698 
     | 
    
         
            +
                                duration_seconds: duration_seconds)
         
     | 
| 
       550 
699 
     | 
    
         
             
                end
         
     | 
| 
       551 
700 
     | 
    
         | 
| 
       552 
701 
     | 
    
         
             
                # Build a nested hash representing project hierarchy from actions
         
     | 
    
        data/lib/na/plugins.rb
    ADDED
    
    | 
         @@ -0,0 +1,419 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require 'json'
         
     | 
| 
      
 4 
     | 
    
         
            +
            require 'yaml'
         
     | 
| 
      
 5 
     | 
    
         
            +
            require 'csv'
         
     | 
| 
      
 6 
     | 
    
         
            +
             
     | 
| 
      
 7 
     | 
    
         
            +
            module NA
         
     | 
| 
      
 8 
     | 
    
         
            +
              # Plugins module for NA
         
     | 
| 
      
 9 
     | 
    
         
            +
              module Plugins
         
     | 
| 
      
 10 
     | 
    
         
            +
                module_function
         
     | 
| 
      
 11 
     | 
    
         
            +
             
     | 
| 
      
 12 
     | 
    
         
            +
                def plugins_home
         
     | 
| 
      
 13 
     | 
    
         
            +
                  File.expand_path('~/.local/share/na/plugins')
         
     | 
| 
      
 14 
     | 
    
         
            +
                end
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                def ensure_plugins_home
         
     | 
| 
      
 17 
     | 
    
         
            +
                  dir = plugins_home
         
     | 
| 
      
 18 
     | 
    
         
            +
                  return if File.directory?(dir)
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                  FileUtils.mkdir_p(dir)
         
     | 
| 
      
 21 
     | 
    
         
            +
                  readme = File.join(dir, 'README.md')
         
     | 
| 
      
 22 
     | 
    
         
            +
                  File.write(readme, default_readme_contents) unless File.exist?(readme)
         
     | 
| 
      
 23 
     | 
    
         
            +
                  create_sample_plugins(dir)
         
     | 
| 
      
 24 
     | 
    
         
            +
                end
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                def list_plugins
         
     | 
| 
      
 27 
     | 
    
         
            +
                  dir = plugins_home
         
     | 
| 
      
 28 
     | 
    
         
            +
                  return {} unless File.directory?(dir)
         
     | 
| 
      
 29 
     | 
    
         
            +
             
     | 
| 
      
 30 
     | 
    
         
            +
                  Dir.children(dir).each_with_object({}) do |entry, acc|
         
     | 
| 
      
 31 
     | 
    
         
            +
                    path = File.join(dir, entry)
         
     | 
| 
      
 32 
     | 
    
         
            +
                    next unless File.file?(path)
         
     | 
| 
      
 33 
     | 
    
         
            +
                    next if entry =~ /\.(md|bak)$/i
         
     | 
| 
      
 34 
     | 
    
         
            +
                    next unless shebang?(path)
         
     | 
| 
      
 35 
     | 
    
         
            +
             
     | 
| 
      
 36 
     | 
    
         
            +
                    base = File.basename(entry, File.extname(entry))
         
     | 
| 
      
 37 
     | 
    
         
            +
                    key = base.gsub(/[\s_]/, '')
         
     | 
| 
      
 38 
     | 
    
         
            +
                    acc[key.downcase] = path
         
     | 
| 
      
 39 
     | 
    
         
            +
                  end
         
     | 
| 
      
 40 
     | 
    
         
            +
                end
         
     | 
| 
      
 41 
     | 
    
         
            +
             
     | 
| 
      
 42 
     | 
    
         
            +
                def resolve_plugin(name)
         
     | 
| 
      
 43 
     | 
    
         
            +
                  return nil unless name && !name.to_s.strip.empty?
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                  normalized = name.to_s.strip.gsub(/[\s_]/, '').downcase
         
     | 
| 
      
 46 
     | 
    
         
            +
                  candidates = list_plugins
         
     | 
| 
      
 47 
     | 
    
         
            +
                  return candidates[normalized] if candidates.key?(normalized)
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
                  # Fallback: try exact filename match in dir
         
     | 
| 
      
 50 
     | 
    
         
            +
                  path = File.join(plugins_home, name)
         
     | 
| 
      
 51 
     | 
    
         
            +
                  File.file?(path) ? path : nil
         
     | 
| 
      
 52 
     | 
    
         
            +
                end
         
     | 
| 
      
 53 
     | 
    
         
            +
             
     | 
| 
      
 54 
     | 
    
         
            +
                def shebang_for(file)
         
     | 
| 
      
 55 
     | 
    
         
            +
                  first = begin
         
     | 
| 
      
 56 
     | 
    
         
            +
                    File.open(file, 'r', &:readline)
         
     | 
| 
      
 57 
     | 
    
         
            +
                  rescue StandardError
         
     | 
| 
      
 58 
     | 
    
         
            +
                    ''
         
     | 
| 
      
 59 
     | 
    
         
            +
                  end
         
     | 
| 
      
 60 
     | 
    
         
            +
                  first.start_with?('#!') ? first.sub('#!', '').strip : nil
         
     | 
| 
      
 61 
     | 
    
         
            +
                end
         
     | 
| 
      
 62 
     | 
    
         
            +
             
     | 
| 
      
 63 
     | 
    
         
            +
                def parse_plugin_metadata(file)
         
     | 
| 
      
 64 
     | 
    
         
            +
                  meta = { 'input' => nil, 'output' => nil, 'name' => nil }
         
     | 
| 
      
 65 
     | 
    
         
            +
                  lines = File.readlines(file, chomp: true)
         
     | 
| 
      
 66 
     | 
    
         
            +
                  return meta if lines.empty?
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
                  # skip shebang
         
     | 
| 
      
 69 
     | 
    
         
            +
                  i = 0
         
     | 
| 
      
 70 
     | 
    
         
            +
                  i += 1 if lines[0].to_s.start_with?('#!')
         
     | 
| 
      
 71 
     | 
    
         
            +
                  # skip leading blanks
         
     | 
| 
      
 72 
     | 
    
         
            +
                  i += 1 while i < lines.length && lines[i].strip.empty?
         
     | 
| 
      
 73 
     | 
    
         
            +
                  while i < lines.length
         
     | 
| 
      
 74 
     | 
    
         
            +
                    line = lines[i]
         
     | 
| 
      
 75 
     | 
    
         
            +
                    break if line.strip.empty?
         
     | 
| 
      
 76 
     | 
    
         
            +
             
     | 
| 
      
 77 
     | 
    
         
            +
                    # strip common comment leaders
         
     | 
| 
      
 78 
     | 
    
         
            +
                    stripped = line.sub(%r{^\s*(#|//)}, '').strip
         
     | 
| 
      
 79 
     | 
    
         
            +
                    if (m = stripped.match(/^([A-Za-z]+)\s*:\s*(.+)$/))
         
     | 
| 
      
 80 
     | 
    
         
            +
                      key = m[1].downcase
         
     | 
| 
      
 81 
     | 
    
         
            +
                      val = m[2].strip
         
     | 
| 
      
 82 
     | 
    
         
            +
                      case key
         
     | 
| 
      
 83 
     | 
    
         
            +
                      when 'input', 'output'
         
     | 
| 
      
 84 
     | 
    
         
            +
                        meta[key] = val.downcase
         
     | 
| 
      
 85 
     | 
    
         
            +
                      when 'name', 'title'
         
     | 
| 
      
 86 
     | 
    
         
            +
                        meta['name'] = val
         
     | 
| 
      
 87 
     | 
    
         
            +
                      end
         
     | 
| 
      
 88 
     | 
    
         
            +
                    end
         
     | 
| 
      
 89 
     | 
    
         
            +
                    break if meta.values_at('input', 'output', 'name').compact.size == 3
         
     | 
| 
      
 90 
     | 
    
         
            +
             
     | 
| 
      
 91 
     | 
    
         
            +
                    i += 1
         
     | 
| 
      
 92 
     | 
    
         
            +
                  end
         
     | 
| 
      
 93 
     | 
    
         
            +
                  meta
         
     | 
| 
      
 94 
     | 
    
         
            +
                end
         
     | 
| 
      
 95 
     | 
    
         
            +
             
     | 
| 
      
 96 
     | 
    
         
            +
                def run_plugin(file, stdin_str)
         
     | 
| 
      
 97 
     | 
    
         
            +
                  interp = shebang_for(file)
         
     | 
| 
      
 98 
     | 
    
         
            +
                  cmd = interp ? %(#{interp} #{Shellwords.escape(file)}) : %(sh #{Shellwords.escape(file)})
         
     | 
| 
      
 99 
     | 
    
         
            +
                  IO.popen(cmd, 'r+', err: %i[child out]) do |io|
         
     | 
| 
      
 100 
     | 
    
         
            +
                    io.write(stdin_str.to_s)
         
     | 
| 
      
 101 
     | 
    
         
            +
                    io.close_write
         
     | 
| 
      
 102 
     | 
    
         
            +
                    io.read
         
     | 
| 
      
 103 
     | 
    
         
            +
                  end
         
     | 
| 
      
 104 
     | 
    
         
            +
                end
         
     | 
| 
      
 105 
     | 
    
         
            +
             
     | 
| 
      
 106 
     | 
    
         
            +
                def serialize_actions(actions, format: 'json', divider: '||')
         
     | 
| 
      
 107 
     | 
    
         
            +
                  case format.to_s.downcase
         
     | 
| 
      
 108 
     | 
    
         
            +
                  when 'json'
         
     | 
| 
      
 109 
     | 
    
         
            +
                    JSON.pretty_generate(actions)
         
     | 
| 
      
 110 
     | 
    
         
            +
                  when 'yaml', 'yml'
         
     | 
| 
      
 111 
     | 
    
         
            +
                    YAML.dump(actions)
         
     | 
| 
      
 112 
     | 
    
         
            +
                  when 'csv'
         
     | 
| 
      
 113 
     | 
    
         
            +
                    CSV.generate(force_quotes: true) do |csv|
         
     | 
| 
      
 114 
     | 
    
         
            +
                      csv << %w[action arguments file_path line parents text note tags]
         
     | 
| 
      
 115 
     | 
    
         
            +
                      actions.each do |a|
         
     | 
| 
      
 116 
     | 
    
         
            +
                        csv << [
         
     | 
| 
      
 117 
     | 
    
         
            +
                          (a['action'] && a['action']['action']) || 'UPDATE',
         
     | 
| 
      
 118 
     | 
    
         
            +
                          Array(a['action'] && a['action']['arguments']).join(','),
         
     | 
| 
      
 119 
     | 
    
         
            +
                          a['file_path'],
         
     | 
| 
      
 120 
     | 
    
         
            +
                          a['line'],
         
     | 
| 
      
 121 
     | 
    
         
            +
                          Array(a['parents']).join('>'),
         
     | 
| 
      
 122 
     | 
    
         
            +
                          a['text'] || '',
         
     | 
| 
      
 123 
     | 
    
         
            +
                          a['note'] || '',
         
     | 
| 
      
 124 
     | 
    
         
            +
                          serialize_tags(a['tags'])
         
     | 
| 
      
 125 
     | 
    
         
            +
                        ]
         
     | 
| 
      
 126 
     | 
    
         
            +
                      end
         
     | 
| 
      
 127 
     | 
    
         
            +
                    end
         
     | 
| 
      
 128 
     | 
    
         
            +
                  when 'text', 'txt'
         
     | 
| 
      
 129 
     | 
    
         
            +
                    actions.map { |a| serialize_text(a, divider: divider) }.join("\n")
         
     | 
| 
      
 130 
     | 
    
         
            +
                  else
         
     | 
| 
      
 131 
     | 
    
         
            +
                    JSON.generate(actions)
         
     | 
| 
      
 132 
     | 
    
         
            +
                  end
         
     | 
| 
      
 133 
     | 
    
         
            +
                end
         
     | 
| 
      
 134 
     | 
    
         
            +
             
     | 
| 
      
 135 
     | 
    
         
            +
                def parse_actions(str, format: 'json', divider: '||')
         
     | 
| 
      
 136 
     | 
    
         
            +
                  case format.to_s.downcase
         
     | 
| 
      
 137 
     | 
    
         
            +
                  when 'json'
         
     | 
| 
      
 138 
     | 
    
         
            +
                    JSON.parse(str)
         
     | 
| 
      
 139 
     | 
    
         
            +
                  when 'yaml', 'yml'
         
     | 
| 
      
 140 
     | 
    
         
            +
                    YAML.safe_load(str, permitted_classes: [Time], aliases: true)
         
     | 
| 
      
 141 
     | 
    
         
            +
                  when 'csv'
         
     | 
| 
      
 142 
     | 
    
         
            +
                    rows = CSV.parse(str.to_s, headers: true)
         
     | 
| 
      
 143 
     | 
    
         
            +
                    rows = CSV.parse(str.to_s) if rows.nil? || rows.empty?
         
     | 
| 
      
 144 
     | 
    
         
            +
                    rows.map do |row|
         
     | 
| 
      
 145 
     | 
    
         
            +
                      r = if row.is_a?(CSV::Row)
         
     | 
| 
      
 146 
     | 
    
         
            +
                            row.to_h
         
     | 
| 
      
 147 
     | 
    
         
            +
                          else
         
     | 
| 
      
 148 
     | 
    
         
            +
                            {
         
     | 
| 
      
 149 
     | 
    
         
            +
                              'action' => row[0], 'arguments' => row[1], 'file_path' => row[2], 'line' => row[3],
         
     | 
| 
      
 150 
     | 
    
         
            +
                              'parents' => row[4], 'text' => row[5], 'note' => row[6], 'tags' => row[7]
         
     | 
| 
      
 151 
     | 
    
         
            +
                            }
         
     | 
| 
      
 152 
     | 
    
         
            +
                          end
         
     | 
| 
      
 153 
     | 
    
         
            +
                      {
         
     | 
| 
      
 154 
     | 
    
         
            +
                        'file_path' => r['file_path'].to_s,
         
     | 
| 
      
 155 
     | 
    
         
            +
                        'line' => r['line'].to_i,
         
     | 
| 
      
 156 
     | 
    
         
            +
                        'parents' => (r['parents'].to_s.empty? ? [] : r['parents'].split('>').map(&:strip)),
         
     | 
| 
      
 157 
     | 
    
         
            +
                        'text' => r['text'].to_s,
         
     | 
| 
      
 158 
     | 
    
         
            +
                        'note' => r['note'].to_s,
         
     | 
| 
      
 159 
     | 
    
         
            +
                        'tags' => parse_tags(r['tags']),
         
     | 
| 
      
 160 
     | 
    
         
            +
                        'action' => normalize_action_block(r['action'], r['arguments'])
         
     | 
| 
      
 161 
     | 
    
         
            +
                      }
         
     | 
| 
      
 162 
     | 
    
         
            +
                    end
         
     | 
| 
      
 163 
     | 
    
         
            +
                  when 'text', 'txt'
         
     | 
| 
      
 164 
     | 
    
         
            +
                    str.to_s.split(/\r?\n/).reject(&:empty?).map { |line| parse_text(line, divider: divider) }
         
     | 
| 
      
 165 
     | 
    
         
            +
                  end
         
     | 
| 
      
 166 
     | 
    
         
            +
                end
         
     | 
| 
      
 167 
     | 
    
         
            +
             
     | 
| 
      
 168 
     | 
    
         
            +
                def serialize_text(action, divider: '||')
         
     | 
| 
      
 169 
     | 
    
         
            +
                  parts = []
         
     | 
| 
      
 170 
     | 
    
         
            +
                  act = action['action'] && action['action']['action']
         
     | 
| 
      
 171 
     | 
    
         
            +
                  args = Array(action['action'] && action['action']['arguments']).join(',')
         
     | 
| 
      
 172 
     | 
    
         
            +
                  parts << (act || 'UPDATE')
         
     | 
| 
      
 173 
     | 
    
         
            +
                  parts << args
         
     | 
| 
      
 174 
     | 
    
         
            +
                  parts << "#{action['file_path']}:#{action['line']}"
         
     | 
| 
      
 175 
     | 
    
         
            +
                  parts << Array(action['parents']).join('>')
         
     | 
| 
      
 176 
     | 
    
         
            +
                  parts << (action['text'] || '')
         
     | 
| 
      
 177 
     | 
    
         
            +
                  parts << (action['note'] || '').gsub("\n", '\\n')
         
     | 
| 
      
 178 
     | 
    
         
            +
                  parts << serialize_tags(action['tags'])
         
     | 
| 
      
 179 
     | 
    
         
            +
                  parts.join(divider)
         
     | 
| 
      
 180 
     | 
    
         
            +
                end
         
     | 
| 
      
 181 
     | 
    
         
            +
             
     | 
| 
      
 182 
     | 
    
         
            +
                def parse_text(line, divider: '||')
         
     | 
| 
      
 183 
     | 
    
         
            +
                  tokens = line.split(divider, 7)
         
     | 
| 
      
 184 
     | 
    
         
            +
                  action_token = tokens[0].to_s.strip
         
     | 
| 
      
 185 
     | 
    
         
            +
                  if action_name?(action_token)
         
     | 
| 
      
 186 
     | 
    
         
            +
                    act = action_token
         
     | 
| 
      
 187 
     | 
    
         
            +
                    args = tokens[1]
         
     | 
| 
      
 188 
     | 
    
         
            +
                    fileline = tokens[2]
         
     | 
| 
      
 189 
     | 
    
         
            +
                    parents = tokens[3]
         
     | 
| 
      
 190 
     | 
    
         
            +
                    text = tokens[4]
         
     | 
| 
      
 191 
     | 
    
         
            +
                    note = tokens[5]
         
     | 
| 
      
 192 
     | 
    
         
            +
                    tags = tokens[6]
         
     | 
| 
      
 193 
     | 
    
         
            +
                  else
         
     | 
| 
      
 194 
     | 
    
         
            +
                    act = 'UPDATE'
         
     | 
| 
      
 195 
     | 
    
         
            +
                    args = ''
         
     | 
| 
      
 196 
     | 
    
         
            +
                    fileline = tokens[0]
         
     | 
| 
      
 197 
     | 
    
         
            +
                    parents = tokens[1]
         
     | 
| 
      
 198 
     | 
    
         
            +
                    text = tokens[2]
         
     | 
| 
      
 199 
     | 
    
         
            +
                    note = tokens[3]
         
     | 
| 
      
 200 
     | 
    
         
            +
                    tags = tokens[4]
         
     | 
| 
      
 201 
     | 
    
         
            +
                  end
         
     | 
| 
      
 202 
     | 
    
         
            +
                  fp, ln = (fileline || '').split(':', 2)
         
     | 
| 
      
 203 
     | 
    
         
            +
                  {
         
     | 
| 
      
 204 
     | 
    
         
            +
                    'file_path' => fp.to_s,
         
     | 
| 
      
 205 
     | 
    
         
            +
                    'line' => ln.to_i,
         
     | 
| 
      
 206 
     | 
    
         
            +
                    'parents' => (parents.to_s.empty? ? [] : parents.split('>').map(&:strip)),
         
     | 
| 
      
 207 
     | 
    
         
            +
                    'text' => text.to_s,
         
     | 
| 
      
 208 
     | 
    
         
            +
                    'note' => note.to_s.gsub('\\n', "\n"),
         
     | 
| 
      
 209 
     | 
    
         
            +
                    'tags' => parse_tags(tags),
         
     | 
| 
      
 210 
     | 
    
         
            +
                    'action' => normalize_action_block(act, args)
         
     | 
| 
      
 211 
     | 
    
         
            +
                  }
         
     | 
| 
      
 212 
     | 
    
         
            +
                end
         
     | 
| 
      
 213 
     | 
    
         
            +
             
     | 
| 
      
 214 
     | 
    
         
            +
                def serialize_tags(tags)
         
     | 
| 
      
 215 
     | 
    
         
            +
                  Array(tags).map { |t| t['value'].to_s.empty? ? t['name'].to_s : %(#{t['name']}(#{t['value']})) }.join(';')
         
     | 
| 
      
 216 
     | 
    
         
            +
                end
         
     | 
| 
      
 217 
     | 
    
         
            +
             
     | 
| 
      
 218 
     | 
    
         
            +
                def parse_tags(str)
         
     | 
| 
      
 219 
     | 
    
         
            +
                  return [] if str.to_s.strip.empty?
         
     | 
| 
      
 220 
     | 
    
         
            +
             
     | 
| 
      
 221 
     | 
    
         
            +
                  str.split(';').map do |part|
         
     | 
| 
      
 222 
     | 
    
         
            +
                    if (m = part.match(/^([^()]+)\((.*)\)$/))
         
     | 
| 
      
 223 
     | 
    
         
            +
                      { 'name' => m[1].strip, 'value' => m[2].to_s }
         
     | 
| 
      
 224 
     | 
    
         
            +
                    else
         
     | 
| 
      
 225 
     | 
    
         
            +
                      { 'name' => part.strip, 'value' => '' }
         
     | 
| 
      
 226 
     | 
    
         
            +
                    end
         
     | 
| 
      
 227 
     | 
    
         
            +
                  end
         
     | 
| 
      
 228 
     | 
    
         
            +
                end
         
     | 
| 
      
 229 
     | 
    
         
            +
             
     | 
| 
      
 230 
     | 
    
         
            +
                def shebang?(file)
         
     | 
| 
      
 231 
     | 
    
         
            +
                  first = begin
         
     | 
| 
      
 232 
     | 
    
         
            +
                    File.open(file, 'r', &:readline)
         
     | 
| 
      
 233 
     | 
    
         
            +
                  rescue StandardError
         
     | 
| 
      
 234 
     | 
    
         
            +
                    ''
         
     | 
| 
      
 235 
     | 
    
         
            +
                  end
         
     | 
| 
      
 236 
     | 
    
         
            +
                  first.start_with?('#!')
         
     | 
| 
      
 237 
     | 
    
         
            +
                end
         
     | 
| 
      
 238 
     | 
    
         
            +
             
     | 
| 
      
 239 
     | 
    
         
            +
                def action_name?(name)
         
     | 
| 
      
 240 
     | 
    
         
            +
                  return false if name.to_s.strip.empty?
         
     | 
| 
      
 241 
     | 
    
         
            +
             
     | 
| 
      
 242 
     | 
    
         
            +
                  %w[update delete complete finish restore unfinish archive add_tag delete_tag remove_tag move].include?(name.to_s.downcase)
         
     | 
| 
      
 243 
     | 
    
         
            +
                end
         
     | 
| 
      
 244 
     | 
    
         
            +
             
     | 
| 
      
 245 
     | 
    
         
            +
                def normalize_action_block(action_name, args)
         
     | 
| 
      
 246 
     | 
    
         
            +
                  name = (action_name || 'UPDATE').to_s.upcase
         
     | 
| 
      
 247 
     | 
    
         
            +
                  name = 'DELETE_TAG' if name == 'REMOVE_TAG'
         
     | 
| 
      
 248 
     | 
    
         
            +
                  name = 'COMPLETE' if name == 'FINISH'
         
     | 
| 
      
 249 
     | 
    
         
            +
                  name = 'RESTORE' if name == 'UNFINISH'
         
     | 
| 
      
 250 
     | 
    
         
            +
                  {
         
     | 
| 
      
 251 
     | 
    
         
            +
                    'action' => name,
         
     | 
| 
      
 252 
     | 
    
         
            +
                    'arguments' => args.is_a?(Array) ? args : args.to_s.split(/[,;]/).map(&:strip).reject(&:empty?)
         
     | 
| 
      
 253 
     | 
    
         
            +
                  }
         
     | 
| 
      
 254 
     | 
    
         
            +
                end
         
     | 
| 
      
 255 
     | 
    
         
            +
             
     | 
| 
      
 256 
     | 
    
         
            +
                def default_readme_contents
         
     | 
| 
      
 257 
     | 
    
         
            +
                  <<~MD
         
     | 
| 
      
 258 
     | 
    
         
            +
                    # NA Plugins
         
     | 
| 
      
 259 
     | 
    
         
            +
             
     | 
| 
      
 260 
     | 
    
         
            +
                    Put your scripts in this folder. Each plugin must start with a shebang (#!) so NA knows how to execute it.
         
     | 
| 
      
 261 
     | 
    
         
            +
             
     | 
| 
      
 262 
     | 
    
         
            +
                    - Plugins receive input on STDIN and must write output to STDOUT
         
     | 
| 
      
 263 
     | 
    
         
            +
                    - Do not modify the original files; NA applies changes based on your output
         
     | 
| 
      
 264 
     | 
    
         
            +
                    - Do not change `file_path` or `line` in your output
         
     | 
| 
      
 265 
     | 
    
         
            +
                    - You may change `parents` (to move), `text`, `note`, and `tags`
         
     | 
| 
      
 266 
     | 
    
         
            +
             
     | 
| 
      
 267 
     | 
    
         
            +
                    ## Metadata (optional)
         
     | 
| 
      
 268 
     | 
    
         
            +
                    Add a comment block (after the shebang) with key: value pairs to declare defaults. Keys are case-insensitive.
         
     | 
| 
      
 269 
     | 
    
         
            +
             
     | 
| 
      
 270 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 271 
     | 
    
         
            +
                    # input: json
         
     | 
| 
      
 272 
     | 
    
         
            +
                    # output: json
         
     | 
| 
      
 273 
     | 
    
         
            +
                    # name: My Fancy Plugin
         
     | 
| 
      
 274 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 275 
     | 
    
         
            +
             
     | 
| 
      
 276 
     | 
    
         
            +
                    CLI flags `--input/--output/--divider` override metadata when provided.
         
     | 
| 
      
 277 
     | 
    
         
            +
             
     | 
| 
      
 278 
     | 
    
         
            +
                    ## Formats
         
     | 
| 
      
 279 
     | 
    
         
            +
                    Valid input/output formats: `json`, `yaml`, `csv`, `text`.
         
     | 
| 
      
 280 
     | 
    
         
            +
             
     | 
| 
      
 281 
     | 
    
         
            +
                    Text format line:
         
     | 
| 
      
 282 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 283 
     | 
    
         
            +
                    ACTION||ARGS||file_path:line||parents||text||note||tags
         
     | 
| 
      
 284 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 285 
     | 
    
         
            +
                    - If the first token isn’t a known ACTION, it’s treated as `file_path:line` and ACTION defaults to `UPDATE`.
         
     | 
| 
      
 286 
     | 
    
         
            +
                    - `parents`: `Parent>Child>Leaf`
         
     | 
| 
      
 287 
     | 
    
         
            +
                    - `tags`: `name(value);name;other(value)`
         
     | 
| 
      
 288 
     | 
    
         
            +
             
     | 
| 
      
 289 
     | 
    
         
            +
                    JSON/YAML object schema per action:
         
     | 
| 
      
 290 
     | 
    
         
            +
                    ```json
         
     | 
| 
      
 291 
     | 
    
         
            +
                    {
         
     | 
| 
      
 292 
     | 
    
         
            +
                      "action": { "action": "UPDATE", "arguments": ["arg1"] },
         
     | 
| 
      
 293 
     | 
    
         
            +
                      "file_path": "/path/to/todo.taskpaper",
         
     | 
| 
      
 294 
     | 
    
         
            +
                      "line": 15,
         
     | 
| 
      
 295 
     | 
    
         
            +
                      "parents": ["Project", "Subproject"],
         
     | 
| 
      
 296 
     | 
    
         
            +
                      "text": "- Do something @tag(value)",
         
     | 
| 
      
 297 
     | 
    
         
            +
                      "note": "Notes can\nspan lines",
         
     | 
| 
      
 298 
     | 
    
         
            +
                      "tags": [ { "name": "tag", "value": "value" } ]
         
     | 
| 
      
 299 
     | 
    
         
            +
                    }
         
     | 
| 
      
 300 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 301 
     | 
    
         
            +
             
     | 
| 
      
 302 
     | 
    
         
            +
                    ACTION values (case-insensitive): `UPDATE` (default), `DELETE`, `COMPLETE`/`FINISH`, `RESTORE`/`UNFINISH`, `ARCHIVE`, `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, `MOVE`.
         
     | 
| 
      
 303 
     | 
    
         
            +
                    - For `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, and `MOVE`, provide arguments (e.g., tags or target project).
         
     | 
| 
      
 304 
     | 
    
         
            +
             
     | 
| 
      
 305 
     | 
    
         
            +
                    ## Examples
         
     | 
| 
      
 306 
     | 
    
         
            +
             
     | 
| 
      
 307 
     | 
    
         
            +
                    JSON input example (2 actions):
         
     | 
| 
      
 308 
     | 
    
         
            +
                    ```json
         
     | 
| 
      
 309 
     | 
    
         
            +
                    [
         
     | 
| 
      
 310 
     | 
    
         
            +
                      {
         
     | 
| 
      
 311 
     | 
    
         
            +
                        "file_path": "/projects/todo.taskpaper",
         
     | 
| 
      
 312 
     | 
    
         
            +
                        "line": 21,
         
     | 
| 
      
 313 
     | 
    
         
            +
                        "parents": ["Inbox"],
         
     | 
| 
      
 314 
     | 
    
         
            +
                        "text": "- Example action",
         
     | 
| 
      
 315 
     | 
    
         
            +
                        "note": "",
         
     | 
| 
      
 316 
     | 
    
         
            +
                        "tags": []
         
     | 
| 
      
 317 
     | 
    
         
            +
                      },
         
     | 
| 
      
 318 
     | 
    
         
            +
                      {
         
     | 
| 
      
 319 
     | 
    
         
            +
                        "file_path": "/projects/todo.taskpaper",
         
     | 
| 
      
 320 
     | 
    
         
            +
                        "line": 42,
         
     | 
| 
      
 321 
     | 
    
         
            +
                        "parents": ["Work", "Feature"],
         
     | 
| 
      
 322 
     | 
    
         
            +
                        "text": "- Add feature @na",
         
     | 
| 
      
 323 
     | 
    
         
            +
                        "note": "Spec TKT-123",
         
     | 
| 
      
 324 
     | 
    
         
            +
                        "tags": [{"name":"na","value":""}]
         
     | 
| 
      
 325 
     | 
    
         
            +
                      }
         
     | 
| 
      
 326 
     | 
    
         
            +
                    ]
         
     | 
| 
      
 327 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 328 
     | 
    
         
            +
             
     | 
| 
      
 329 
     | 
    
         
            +
                    Text input example (2 actions):
         
     | 
| 
      
 330 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 331 
     | 
    
         
            +
                    UPDATE||||/projects/todo.taskpaper:21||Inbox||- Example action||||
         
     | 
| 
      
 332 
     | 
    
         
            +
                    MOVE||Work:NewFeature||/projects/todo.taskpaper:42||Work>Feature||- Add feature @na||Spec TKT-123||na
         
     | 
| 
      
 333 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 334 
     | 
    
         
            +
             
     | 
| 
      
 335 
     | 
    
         
            +
                    A plugin would read from STDIN, transform, and write the same shape to STDOUT. For example, a shell plugin that adds `@bar`:
         
     | 
| 
      
 336 
     | 
    
         
            +
                    ```bash
         
     | 
| 
      
 337 
     | 
    
         
            +
                    #!/usr/bin/env bash
         
     | 
| 
      
 338 
     | 
    
         
            +
                    # input: text
         
     | 
| 
      
 339 
     | 
    
         
            +
                    # output: text
         
     | 
| 
      
 340 
     | 
    
         
            +
                    while IFS= read -r line; do
         
     | 
| 
      
 341 
     | 
    
         
            +
                      [[ -z "$line" ]] && continue
         
     | 
| 
      
 342 
     | 
    
         
            +
                      IFS='||' read -r a1 a2 a3 a4 a5 a6 a7 <<<"$line"
         
     | 
| 
      
 343 
     | 
    
         
            +
                      # If first token is not an action, treat it as file:line
         
     | 
| 
      
 344 
     | 
    
         
            +
                      case "${a1^^}" in
         
     | 
| 
      
 345 
     | 
    
         
            +
                        UPDATE|DELETE|COMPLETE|FINISH|RESTORE|UNFINISH|ARCHIVE|ADD_TAG|DELETE_TAG|REMOVE_TAG|MOVE) : ;;
         
     | 
| 
      
 346 
     | 
    
         
            +
                        *) a7="$a6"; a6="$a5"; a5="$a4"; a4="$a3"; a3="$a2"; a2=""; a1="UPDATE";;
         
     | 
| 
      
 347 
     | 
    
         
            +
                      esac
         
     | 
| 
      
 348 
     | 
    
         
            +
                      tags="$a7"; tags=${tags:+"$tags;bar"}; tags=${tags:-bar}
         
     | 
| 
      
 349 
     | 
    
         
            +
                      echo "$a1||$a2||$a3||$a4||$a5||$a6||$tags"
         
     | 
| 
      
 350 
     | 
    
         
            +
                    done
         
     | 
| 
      
 351 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 352 
     | 
    
         
            +
             
     | 
| 
      
 353 
     | 
    
         
            +
                    Python example (JSON):
         
     | 
| 
      
 354 
     | 
    
         
            +
                    ```python
         
     | 
| 
      
 355 
     | 
    
         
            +
                    #!/usr/bin/env python3
         
     | 
| 
      
 356 
     | 
    
         
            +
                    # input: json
         
     | 
| 
      
 357 
     | 
    
         
            +
                    # output: json
         
     | 
| 
      
 358 
     | 
    
         
            +
                    import sys, json, time
         
     | 
| 
      
 359 
     | 
    
         
            +
                    data = json.load(sys.stdin)
         
     | 
| 
      
 360 
     | 
    
         
            +
                    for a in data:
         
     | 
| 
      
 361 
     | 
    
         
            +
                        act = a.get('action') or {'action':'UPDATE','arguments':[]}
         
     | 
| 
      
 362 
     | 
    
         
            +
                        a['action'] = act
         
     | 
| 
      
 363 
     | 
    
         
            +
                        tags = a.get('tags', [])
         
     | 
| 
      
 364 
     | 
    
         
            +
                        tags.append({'name':'foo','value':time.strftime('%Y-%m-%d %H:%M:%S')})
         
     | 
| 
      
 365 
     | 
    
         
            +
                        a['tags'] = tags
         
     | 
| 
      
 366 
     | 
    
         
            +
                    json.dump(data, sys.stdout)
         
     | 
| 
      
 367 
     | 
    
         
            +
                    ```
         
     | 
| 
      
 368 
     | 
    
         
            +
             
     | 
| 
      
 369 
     | 
    
         
            +
                    Tips:
         
     | 
| 
      
 370 
     | 
    
         
            +
                    - Always preserve `file_path` and `line`
         
     | 
| 
      
 371 
     | 
    
         
            +
                    - Return only actions you want changed; others can be omitted
         
     | 
| 
      
 372 
     | 
    
         
            +
                    - For text IO, the field divider defaults to `||` and can be overridden with `--divider`
         
     | 
| 
      
 373 
     | 
    
         
            +
                  MD
         
     | 
| 
      
 374 
     | 
    
         
            +
                end
         
     | 
| 
      
 375 
     | 
    
         
            +
             
     | 
| 
      
 376 
     | 
    
         
            +
                def create_sample_plugins(dir)
         
     | 
| 
      
 377 
     | 
    
         
            +
                  py = File.join(dir, 'Add Foo.py')
         
     | 
| 
      
 378 
     | 
    
         
            +
                  sh = File.join(dir, 'Add Bar.sh')
         
     | 
| 
      
 379 
     | 
    
         
            +
                  unless File.exist?(py)
         
     | 
| 
      
 380 
     | 
    
         
            +
                    File.write(py, <<~PY)
         
     | 
| 
      
 381 
     | 
    
         
            +
                      #!/usr/bin/env python3
         
     | 
| 
      
 382 
     | 
    
         
            +
                      # name: Add Foo
         
     | 
| 
      
 383 
     | 
    
         
            +
                      # input: json
         
     | 
| 
      
 384 
     | 
    
         
            +
                      # output: json
         
     | 
| 
      
 385 
     | 
    
         
            +
                      import sys, json, time
         
     | 
| 
      
 386 
     | 
    
         
            +
                      data = json.load(sys.stdin)
         
     | 
| 
      
 387 
     | 
    
         
            +
                      now = time.strftime('%Y-%m-%d %H:%M:%S')
         
     | 
| 
      
 388 
     | 
    
         
            +
                      for a in data:
         
     | 
| 
      
 389 
     | 
    
         
            +
                          tags = a.get('tags', [])
         
     | 
| 
      
 390 
     | 
    
         
            +
                          tags.append({'name':'foo','value':now})
         
     | 
| 
      
 391 
     | 
    
         
            +
                          a['tags'] = tags
         
     | 
| 
      
 392 
     | 
    
         
            +
                      json.dump(data, sys.stdout)
         
     | 
| 
      
 393 
     | 
    
         
            +
                    PY
         
     | 
| 
      
 394 
     | 
    
         
            +
                  end
         
     | 
| 
      
 395 
     | 
    
         
            +
                  return if File.exist?(sh)
         
     | 
| 
      
 396 
     | 
    
         
            +
             
     | 
| 
      
 397 
     | 
    
         
            +
                  File.write(sh, <<~SH)
         
     | 
| 
      
 398 
     | 
    
         
            +
                    #!/usr/bin/env bash
         
     | 
| 
      
 399 
     | 
    
         
            +
                    # name: Add Bar
         
     | 
| 
      
 400 
     | 
    
         
            +
                    # input: text
         
     | 
| 
      
 401 
     | 
    
         
            +
                    # output: text
         
     | 
| 
      
 402 
     | 
    
         
            +
                    while IFS= read -r line; do
         
     | 
| 
      
 403 
     | 
    
         
            +
                      if [[ -z "$line" ]]; then continue; fi
         
     | 
| 
      
 404 
     | 
    
         
            +
                      if [[ "$line" == *"||"* ]]; then
         
     | 
| 
      
 405 
     | 
    
         
            +
                        fileline=${line%%||*}
         
     | 
| 
      
 406 
     | 
    
         
            +
                        rest=${line#*||}
         
     | 
| 
      
 407 
     | 
    
         
            +
                        parents=${rest%%||*}; rest=${rest#*||}
         
     | 
| 
      
 408 
     | 
    
         
            +
                        text=${rest%%||*}; rest=${rest#*||}
         
     | 
| 
      
 409 
     | 
    
         
            +
                        note=${rest%%||*}; tags=${rest#*||}
         
     | 
| 
      
 410 
     | 
    
         
            +
                        if [[ -z "$tags" ]]; then tags="bar"; else tags="$tags;bar"; fi
         
     | 
| 
      
 411 
     | 
    
         
            +
                        echo "$fileline||$parents||$text||$note||$tags"
         
     | 
| 
      
 412 
     | 
    
         
            +
                      else
         
     | 
| 
      
 413 
     | 
    
         
            +
                        echo "$line"
         
     | 
| 
      
 414 
     | 
    
         
            +
                      fi
         
     | 
| 
      
 415 
     | 
    
         
            +
                    done
         
     | 
| 
      
 416 
     | 
    
         
            +
                  SH
         
     | 
| 
      
 417 
     | 
    
         
            +
                end
         
     | 
| 
      
 418 
     | 
    
         
            +
              end
         
     | 
| 
      
 419 
     | 
    
         
            +
            end
         
     |