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/bin/commands/update.rb
    CHANGED
    
    | 
         @@ -9,6 +9,32 @@ class App 
     | 
|
| 
       9 
9 
     | 
    
         
             
              allow you to pick which file to act on.'
         
     | 
| 
       10 
10 
     | 
    
         
             
              arg_name 'ACTION'
         
     | 
| 
       11 
11 
     | 
    
         
             
              command %i[update] do |c|
         
     | 
| 
      
 12 
     | 
    
         
            +
                c.desc 'Run a plugin by name on selected actions'
         
     | 
| 
      
 13 
     | 
    
         
            +
                c.arg_name 'NAME'
         
     | 
| 
      
 14 
     | 
    
         
            +
                c.flag %i[plugin]
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                c.desc 'Plugin input format (json|yaml|csv|text)'
         
     | 
| 
      
 17 
     | 
    
         
            +
                c.arg_name 'TYPE'
         
     | 
| 
      
 18 
     | 
    
         
            +
                c.flag %i[input]
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                c.desc 'Plugin output format (json|yaml|csv|text)'
         
     | 
| 
      
 21 
     | 
    
         
            +
                c.arg_name 'TYPE'
         
     | 
| 
      
 22 
     | 
    
         
            +
                c.flag %i[output]
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                c.desc 'Divider string for text IO'
         
     | 
| 
      
 25 
     | 
    
         
            +
                c.arg_name 'STRING'
         
     | 
| 
      
 26 
     | 
    
         
            +
                c.flag %i[divider]
         
     | 
| 
      
 27 
     | 
    
         
            +
                c.desc 'Started time (natural language or ISO)'
         
     | 
| 
      
 28 
     | 
    
         
            +
                c.arg_name 'DATE'
         
     | 
| 
      
 29 
     | 
    
         
            +
                c.flag %i[started], type: :date_begin
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                c.desc 'End/Finished time (natural language or ISO)'
         
     | 
| 
      
 32 
     | 
    
         
            +
                c.arg_name 'DATE'
         
     | 
| 
      
 33 
     | 
    
         
            +
                c.flag %i[end finished], type: :date_end
         
     | 
| 
      
 34 
     | 
    
         
            +
             
     | 
| 
      
 35 
     | 
    
         
            +
                c.desc 'Duration (e.g. 45m, 2h, 1d2h30m, or minutes)'
         
     | 
| 
      
 36 
     | 
    
         
            +
                c.arg_name 'DURATION'
         
     | 
| 
      
 37 
     | 
    
         
            +
                c.flag %i[duration], type: :duration
         
     | 
| 
       12 
38 
     | 
    
         
             
                c.example 'na update --remove na "An existing task"',
         
     | 
| 
       13 
39 
     | 
    
         
             
                          desc: 'Find "An existing task" action and remove the @na tag from it'
         
     | 
| 
       14 
40 
     | 
    
         
             
                c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
         
     | 
| 
         @@ -299,7 +325,10 @@ class App 
     | 
|
| 
       299 
325 
     | 
    
         
             
                    options[:archive],
         
     | 
| 
       300 
326 
     | 
    
         
             
                    options[:restore],
         
     | 
| 
       301 
327 
     | 
    
         
             
                    options[:delete],
         
     | 
| 
       302 
     | 
    
         
            -
                    options[:edit]
         
     | 
| 
      
 328 
     | 
    
         
            +
                    options[:edit],
         
     | 
| 
      
 329 
     | 
    
         
            +
                    options[:started],
         
     | 
| 
      
 330 
     | 
    
         
            +
                    (options[:end] || options[:finished]),
         
     | 
| 
      
 331 
     | 
    
         
            +
                    options[:duration]
         
     | 
| 
       303 
332 
     | 
    
         
             
                  ].any?
         
     | 
| 
       304 
333 
     | 
    
         
             
                  unless actionable
         
     | 
| 
       305 
334 
     | 
    
         
             
                    # Interactive menu for actions
         
     | 
| 
         @@ -315,6 +344,17 @@ class App 
     | 
|
| 
       315 
344 
     | 
    
         
             
                      { key: :archive, label: 'Archive', param: nil },
         
     | 
| 
       316 
345 
     | 
    
         
             
                      { key: :note, label: 'Add Note', param: 'Note' }
         
     | 
| 
       317 
346 
     | 
    
         
             
                    ]
         
     | 
| 
      
 347 
     | 
    
         
            +
                    # Append available plugins
         
     | 
| 
      
 348 
     | 
    
         
            +
                    begin
         
     | 
| 
      
 349 
     | 
    
         
            +
                      NA::Plugins.ensure_plugins_home
         
     | 
| 
      
 350 
     | 
    
         
            +
                      NA::Plugins.list_plugins.each do |_key, path|
         
     | 
| 
      
 351 
     | 
    
         
            +
                        meta = NA::Plugins.parse_plugin_metadata(path)
         
     | 
| 
      
 352 
     | 
    
         
            +
                        disp = meta['name'] || File.basename(path, File.extname(path))
         
     | 
| 
      
 353 
     | 
    
         
            +
                        actions_menu << { key: :_plugin, label: "Plugin: #{disp}", param: nil, plugin_path: path }
         
     | 
| 
      
 354 
     | 
    
         
            +
                      end
         
     | 
| 
      
 355 
     | 
    
         
            +
                    rescue StandardError
         
     | 
| 
      
 356 
     | 
    
         
            +
                      # ignore plugin discovery errors in menu
         
     | 
| 
      
 357 
     | 
    
         
            +
                    end
         
     | 
| 
       318 
358 
     | 
    
         
             
                    selector = nil
         
     | 
| 
       319 
359 
     | 
    
         
             
                    if TTY::Which.exist?('fzf')
         
     | 
| 
       320 
360 
     | 
    
         
             
                      selector = 'fzf --prompt="Select action> "'
         
     | 
| 
         @@ -361,6 +401,21 @@ class App 
     | 
|
| 
       361 
401 
     | 
    
         
             
                      options[:delete] = true
         
     | 
| 
       362 
402 
     | 
    
         
             
                    when :finish
         
     | 
| 
       363 
403 
     | 
    
         
             
                      options[:finish] = true
         
     | 
| 
      
 404 
     | 
    
         
            +
                      # Timed finish? Prompt user for optional start/date inputs
         
     | 
| 
      
 405 
     | 
    
         
            +
                      if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
         
     | 
| 
      
 406 
     | 
    
         
            +
                        # Ask for start date expression
         
     | 
| 
      
 407 
     | 
    
         
            +
                        start_expr = nil
         
     | 
| 
      
 408 
     | 
    
         
            +
                        if TTY::Which.exist?('gum')
         
     | 
| 
      
 409 
     | 
    
         
            +
                          gum = TTY::Which.which('gum')
         
     | 
| 
      
 410 
     | 
    
         
            +
                          prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
         
     | 
| 
      
 411 
     | 
    
         
            +
                          start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
         
     | 
| 
      
 412 
     | 
    
         
            +
                        else
         
     | 
| 
      
 413 
     | 
    
         
            +
                          print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
         
     | 
| 
      
 414 
     | 
    
         
            +
                          start_expr = (STDIN.gets || '').strip
         
     | 
| 
      
 415 
     | 
    
         
            +
                        end
         
     | 
| 
      
 416 
     | 
    
         
            +
                        start_time = NA::Types.parse_date_begin(start_expr)
         
     | 
| 
      
 417 
     | 
    
         
            +
                        options[:started] = start_time if start_time
         
     | 
| 
      
 418 
     | 
    
         
            +
                      end
         
     | 
| 
       364 
419 
     | 
    
         
             
                    when :edit
         
     | 
| 
       365 
420 
     | 
    
         
             
                      # Just set the flag - multi-action editor will handle it below
         
     | 
| 
       366 
421 
     | 
    
         
             
                      options[:edit] = true
         
     | 
| 
         @@ -412,6 +467,9 @@ class App 
     | 
|
| 
       412 
467 
     | 
    
         
             
                    when :note
         
     | 
| 
       413 
468 
     | 
    
         
             
                      options[:note] = true
         
     | 
| 
       414 
469 
     | 
    
         
             
                      note = [param_value]
         
     | 
| 
      
 470 
     | 
    
         
            +
                    when :_plugin
         
     | 
| 
      
 471 
     | 
    
         
            +
                      # Set plugin path directly
         
     | 
| 
      
 472 
     | 
    
         
            +
                      options[:plugin] = action_obj[:plugin_path]
         
     | 
| 
       415 
473 
     | 
    
         
             
                    end
         
     | 
| 
       416 
474 
     | 
    
         
             
                  end
         
     | 
| 
       417 
475 
     | 
    
         
             
                  did_direct_update = false
         
     | 
| 
         @@ -424,7 +482,31 @@ class App 
     | 
|
| 
       424 
482 
     | 
    
         
             
                    actions_by_file[file] << targets_for_selection[idx][:action]
         
     | 
| 
       425 
483 
     | 
    
         
             
                  end
         
     | 
| 
       426 
484 
     | 
    
         | 
| 
       427 
     | 
    
         
            -
                  #  
     | 
| 
      
 485 
     | 
    
         
            +
                  # If a plugin is specified, run it on all selected actions and apply results
         
     | 
| 
      
 486 
     | 
    
         
            +
                  if options[:plugin]
         
     | 
| 
      
 487 
     | 
    
         
            +
                    plugin_path = options[:plugin]
         
     | 
| 
      
 488 
     | 
    
         
            +
                    unless File.exist?(plugin_path)
         
     | 
| 
      
 489 
     | 
    
         
            +
                      # Resolve by name via registry
         
     | 
| 
      
 490 
     | 
    
         
            +
                      resolved = NA::Plugins.resolve_plugin(plugin_path)
         
     | 
| 
      
 491 
     | 
    
         
            +
                      plugin_path = resolved if resolved
         
     | 
| 
      
 492 
     | 
    
         
            +
                    end
         
     | 
| 
      
 493 
     | 
    
         
            +
                    meta = NA::Plugins.parse_plugin_metadata(plugin_path)
         
     | 
| 
      
 494 
     | 
    
         
            +
                    input_fmt = (options[:input] || meta['input'] || 'json').to_s
         
     | 
| 
      
 495 
     | 
    
         
            +
                    output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
         
     | 
| 
      
 496 
     | 
    
         
            +
                    divider = (options[:divider] || '||')
         
     | 
| 
      
 497 
     | 
    
         
            +
             
     | 
| 
      
 498 
     | 
    
         
            +
                    all_actions = []
         
     | 
| 
      
 499 
     | 
    
         
            +
                    actions_by_file.each_value { |list| all_actions.concat(list) }
         
     | 
| 
      
 500 
     | 
    
         
            +
                    io_actions = all_actions.map(&:to_plugin_io_hash)
         
     | 
| 
      
 501 
     | 
    
         
            +
                    stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
         
     | 
| 
      
 502 
     | 
    
         
            +
                    stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
         
     | 
| 
      
 503 
     | 
    
         
            +
                    returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
         
     | 
| 
      
 504 
     | 
    
         
            +
                    Array(returned).each { |h| NA.apply_plugin_result(h) }
         
     | 
| 
      
 505 
     | 
    
         
            +
                    did_direct_update = true
         
     | 
| 
      
 506 
     | 
    
         
            +
                    next
         
     | 
| 
      
 507 
     | 
    
         
            +
                  end
         
     | 
| 
      
 508 
     | 
    
         
            +
             
     | 
| 
      
 509 
     | 
    
         
            +
                  # Process each file's actions (non-plugin paths)
         
     | 
| 
       428 
510 
     | 
    
         
             
                  actions_by_file.each do |file, action_list|
         
     | 
| 
       429 
511 
     | 
    
         
             
                    # Rebuild all derived variables from options after menu-driven assignment
         
     | 
| 
       430 
512 
     | 
    
         
             
                    add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
         
     | 
| 
         @@ -477,8 +559,8 @@ class App 
     | 
|
| 
       477 
559 
     | 
    
         
             
                      end
         
     | 
| 
       478 
560 
     | 
    
         
             
                    end
         
     | 
| 
       479 
561 
     | 
    
         | 
| 
       480 
     | 
    
         
            -
                    # Update each action
         
     | 
| 
       481 
     | 
    
         
            -
                    action_list.each do |action_obj|
         
     | 
| 
      
 562 
     | 
    
         
            +
                    # Update each action (process from bottom to top to avoid line shifts)
         
     | 
| 
      
 563 
     | 
    
         
            +
                    action_list.sort_by(&:file_line).reverse.each do |action_obj|
         
     | 
| 
       482 
564 
     | 
    
         
             
                      NA.update_action(file, nil,
         
     | 
| 
       483 
565 
     | 
    
         
             
                        add: action_obj,
         
     | 
| 
       484 
566 
     | 
    
         
             
                        add_tag: add_tags,
         
     | 
| 
         @@ -639,7 +721,10 @@ class App 
     | 
|
| 
       639 
721 
     | 
    
         
             
                                     remove_tag: remove_tags,
         
     | 
| 
       640 
722 
     | 
    
         
             
                                     replace: options[:replace],
         
     | 
| 
       641 
723 
     | 
    
         
             
                                     search_note: options[:search_notes],
         
     | 
| 
       642 
     | 
    
         
            -
                                     tagged: tags 
     | 
| 
      
 724 
     | 
    
         
            +
                                     tagged: tags,
         
     | 
| 
      
 725 
     | 
    
         
            +
                                     started_at: options[:started],
         
     | 
| 
      
 726 
     | 
    
         
            +
                                     done_at: (options[:end] || options[:finished]),
         
     | 
| 
      
 727 
     | 
    
         
            +
                                     duration_seconds: options[:duration])
         
     | 
| 
       643 
728 
     | 
    
         
             
                  end
         
     | 
| 
       644 
729 
     | 
    
         
             
                end
         
     | 
| 
       645 
730 
     | 
    
         
             
              end
         
     | 
    
        data/bin/na
    CHANGED
    
    | 
         @@ -113,6 +113,7 @@ class App 
     | 
|
| 
       113 
113 
     | 
    
         | 
| 
       114 
114 
     | 
    
         
             
              pre do |global, _command, _options, _args|
         
     | 
| 
       115 
115 
     | 
    
         
             
                NA.move_deprecated_backups
         
     | 
| 
      
 116 
     | 
    
         
            +
                NA::Plugins.ensure_plugins_home
         
     | 
| 
       116 
117 
     | 
    
         
             
                NA.verbose = global[:debug]
         
     | 
| 
       117 
118 
     | 
    
         
             
                NA::Pager.paginate = global[:pager] && $stdout.isatty
         
     | 
| 
       118 
119 
     | 
    
         
             
                NA::Color.coloring = global[:color] && $stdout.isatty
         
     | 
| 
         @@ -195,6 +196,12 @@ class App 
     | 
|
| 
       195 
196 
     | 
    
         
             
                  true
         
     | 
| 
       196 
197 
     | 
    
         
             
                end
         
     | 
| 
       197 
198 
     | 
    
         
             
              end
         
     | 
| 
      
 199 
     | 
    
         
            +
             
     | 
| 
      
 200 
     | 
    
         
            +
              # Register custom GLI types for natural language dates and durations
         
     | 
| 
      
 201 
     | 
    
         
            +
              # Return original string if parsing fails so commands can handle fallback parsing
         
     | 
| 
      
 202 
     | 
    
         
            +
              accept(:date_begin) { |v| NA::Types.parse_date_begin(v) || v }
         
     | 
| 
      
 203 
     | 
    
         
            +
              accept(:date_end) { |v| NA::Types.parse_date_end(v) || v }
         
     | 
| 
      
 204 
     | 
    
         
            +
              accept(:duration) { |v| NA::Types.parse_duration_seconds(v) }
         
     | 
| 
       198 
205 
     | 
    
         
             
            end
         
     | 
| 
       199 
206 
     | 
    
         | 
| 
       200 
207 
     | 
    
         
             
            NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
         
     | 
    
        data/lib/na/action.rb
    CHANGED
    
    | 
         @@ -28,6 +28,19 @@ module NA 
     | 
|
| 
       28 
28 
     | 
    
         
             
                  @note = note
         
     | 
| 
       29 
29 
     | 
    
         
             
                end
         
     | 
| 
       30 
30 
     | 
    
         | 
| 
      
 31 
     | 
    
         
            +
                # Convert action to plugin IO hash
         
     | 
| 
      
 32 
     | 
    
         
            +
                # @return [Hash]
         
     | 
| 
      
 33 
     | 
    
         
            +
                def to_plugin_io_hash
         
     | 
| 
      
 34 
     | 
    
         
            +
                  {
         
     | 
| 
      
 35 
     | 
    
         
            +
                    'file_path' => file_path,
         
     | 
| 
      
 36 
     | 
    
         
            +
                    'line' => file_line,
         
     | 
| 
      
 37 
     | 
    
         
            +
                    'parents' => [@project].concat(@parent),
         
     | 
| 
      
 38 
     | 
    
         
            +
                    'text' => @action.dup,
         
     | 
| 
      
 39 
     | 
    
         
            +
                    'note' => @note.join("\n"),
         
     | 
| 
      
 40 
     | 
    
         
            +
                    'tags' => @tags.map { |k, v| { 'name' => k, 'value' => (v || '').to_s } }
         
     | 
| 
      
 41 
     | 
    
         
            +
                  }
         
     | 
| 
      
 42 
     | 
    
         
            +
                end
         
     | 
| 
      
 43 
     | 
    
         
            +
             
     | 
| 
       31 
44 
     | 
    
         
             
                # Extract file path and line number from PATH:LINE format
         
     | 
| 
       32 
45 
     | 
    
         
             
                #
         
     | 
| 
       33 
46 
     | 
    
         
             
                # @return [Array] [file_path, line_number]
         
     | 
| 
         @@ -63,8 +76,8 @@ module NA 
     | 
|
| 
       63 
76 
     | 
    
         
             
                # @param note [Array<String>] Notes to set
         
     | 
| 
       64 
77 
     | 
    
         
             
                # @return [void]
         
     | 
| 
       65 
78 
     | 
    
         
             
                # @example
         
     | 
| 
       66 
     | 
    
         
            -
                #   action.process(priority: 5, finish: true, add_tag: [ 
     | 
| 
       67 
     | 
    
         
            -
                def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
         
     | 
| 
      
 79 
     | 
    
         
            +
                #   action.process(priority: 5, finish: true, add_tag: ["urgent"], remove_tag: ["waiting"], note: ["Call Bob"], started_at: Time.now, done_at: Time.now)
         
     | 
| 
      
 80 
     | 
    
         
            +
                def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [], started_at: nil, done_at: nil, duration_seconds: nil)
         
     | 
| 
       68 
81 
     | 
    
         
             
                  string = @action.dup
         
     | 
| 
       69 
82 
     | 
    
         | 
| 
       70 
83 
     | 
    
         
             
                  if priority&.positive?
         
     | 
| 
         @@ -84,9 +97,32 @@ module NA 
     | 
|
| 
       84 
97 
     | 
    
         
             
                    string += " @#{tag}"
         
     | 
| 
       85 
98 
     | 
    
         
             
                  end
         
     | 
| 
       86 
99 
     | 
    
         | 
| 
       87 
     | 
    
         
            -
                   
     | 
| 
      
 100 
     | 
    
         
            +
                  # Compute started/done from duration if provided
         
     | 
| 
      
 101 
     | 
    
         
            +
                  if duration_seconds && (done_at || finish)
         
     | 
| 
      
 102 
     | 
    
         
            +
                    done_time = done_at || Time.now
         
     | 
| 
      
 103 
     | 
    
         
            +
                    started_at ||= done_time - duration_seconds.to_i
         
     | 
| 
      
 104 
     | 
    
         
            +
                  elsif duration_seconds && started_at
         
     | 
| 
      
 105 
     | 
    
         
            +
                    done_at ||= started_at + duration_seconds.to_i
         
     | 
| 
      
 106 
     | 
    
         
            +
                  end
         
     | 
| 
      
 107 
     | 
    
         
            +
             
     | 
| 
      
 108 
     | 
    
         
            +
                  # Insert @started if provided
         
     | 
| 
      
 109 
     | 
    
         
            +
                  if started_at
         
     | 
| 
      
 110 
     | 
    
         
            +
                    string.gsub!(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '')
         
     | 
| 
      
 111 
     | 
    
         
            +
                    string.strip!
         
     | 
| 
      
 112 
     | 
    
         
            +
                    string += " @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
         
     | 
| 
      
 113 
     | 
    
         
            +
                  end
         
     | 
| 
      
 114 
     | 
    
         
            +
             
     | 
| 
      
 115 
     | 
    
         
            +
                  # Insert @done if provided or finishing
         
     | 
| 
      
 116 
     | 
    
         
            +
                  if done_at
         
     | 
| 
      
 117 
     | 
    
         
            +
                    string.gsub!(/(?<=\A| )@done\(.*?\)/i, '')
         
     | 
| 
      
 118 
     | 
    
         
            +
                    string.strip!
         
     | 
| 
      
 119 
     | 
    
         
            +
                    string += " @done(#{done_at.strftime('%Y-%m-%d %H:%M')})"
         
     | 
| 
      
 120 
     | 
    
         
            +
                  elsif finish && string !~ /(?<=\A| )@done/
         
     | 
| 
      
 121 
     | 
    
         
            +
                    string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})"
         
     | 
| 
      
 122 
     | 
    
         
            +
                  end
         
     | 
| 
       88 
123 
     | 
    
         | 
| 
       89 
124 
     | 
    
         
             
                  @action = string.expand_date_tags
         
     | 
| 
      
 125 
     | 
    
         
            +
                  @tags = scan_tags
         
     | 
| 
       90 
126 
     | 
    
         
             
                  @note = note unless note.empty?
         
     | 
| 
       91 
127 
     | 
    
         
             
                end
         
     | 
| 
       92 
128 
     | 
    
         | 
    
        data/lib/na/actions.rb
    CHANGED
    
    | 
         @@ -26,12 +26,26 @@ module NA 
     | 
|
| 
       26 
26 
     | 
    
         
             
                      notes: false,
         
     | 
| 
       27 
27 
     | 
    
         
             
                      nest: false,
         
     | 
| 
       28 
28 
     | 
    
         
             
                      nest_projects: false,
         
     | 
| 
       29 
     | 
    
         
            -
                      no_files: false
         
     | 
| 
      
 29 
     | 
    
         
            +
                      no_files: false,
         
     | 
| 
      
 30 
     | 
    
         
            +
                      times: false,
         
     | 
| 
      
 31 
     | 
    
         
            +
                      human: false,
         
     | 
| 
      
 32 
     | 
    
         
            +
                      only_timed: false,
         
     | 
| 
      
 33 
     | 
    
         
            +
                      json_times: false
         
     | 
| 
       30 
34 
     | 
    
         
             
                    }
         
     | 
| 
       31 
35 
     | 
    
         
             
                    config = defaults.merge(config)
         
     | 
| 
       32 
36 
     | 
    
         | 
| 
       33 
37 
     | 
    
         
             
                    return if config[:files].nil?
         
     | 
| 
       34 
38 
     | 
    
         | 
| 
      
 39 
     | 
    
         
            +
                    # Optionally filter to only actions with a computable duration (@started and @done)
         
     | 
| 
      
 40 
     | 
    
         
            +
                    filtered_actions = if config[:only_timed]
         
     | 
| 
      
 41 
     | 
    
         
            +
                                         self.select do |a|
         
     | 
| 
      
 42 
     | 
    
         
            +
                                           t = a.tags
         
     | 
| 
      
 43 
     | 
    
         
            +
                                           (t['started'] || t['start']) && t['done']
         
     | 
| 
      
 44 
     | 
    
         
            +
                                         end
         
     | 
| 
      
 45 
     | 
    
         
            +
                                       else
         
     | 
| 
      
 46 
     | 
    
         
            +
                                         self
         
     | 
| 
      
 47 
     | 
    
         
            +
                                       end
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
       35 
49 
     | 
    
         
             
                    if config[:nest]
         
     | 
| 
       36 
50 
     | 
    
         
             
                      template = NA.theme[:templates][:default]
         
     | 
| 
       37 
51 
     | 
    
         
             
                      template = NA.theme[:templates][:no_file] if config[:no_files]
         
     | 
| 
         @@ -40,7 +54,7 @@ module NA 
     | 
|
| 
       40 
54 
     | 
    
         
             
                      out = []
         
     | 
| 
       41 
55 
     | 
    
         | 
| 
       42 
56 
     | 
    
         
             
                      if config[:nest_projects]
         
     | 
| 
       43 
     | 
    
         
            -
                        each do |action|
         
     | 
| 
      
 57 
     | 
    
         
            +
                        filtered_actions.each do |action|
         
     | 
| 
       44 
58 
     | 
    
         
             
                          parent_files[action.file] ||= []
         
     | 
| 
       45 
59 
     | 
    
         
             
                          parent_files[action.file].push(action)
         
     | 
| 
       46 
60 
     | 
    
         
             
                        end
         
     | 
| 
         @@ -54,7 +68,7 @@ module NA 
     | 
|
| 
       54 
68 
     | 
    
         
             
                        template = NA.theme[:templates][:default]
         
     | 
| 
       55 
69 
     | 
    
         
             
                        template = NA.theme[:templates][:no_file] if config[:no_files]
         
     | 
| 
       56 
70 
     | 
    
         | 
| 
       57 
     | 
    
         
            -
                        each do |action|
         
     | 
| 
      
 71 
     | 
    
         
            +
                        filtered_actions.each do |action|
         
     | 
| 
       58 
72 
     | 
    
         
             
                          parent_files[action.file] ||= []
         
     | 
| 
       59 
73 
     | 
    
         
             
                          parent_files[action.file].push(action)
         
     | 
| 
       60 
74 
     | 
    
         
             
                        end
         
     | 
| 
         @@ -94,14 +108,71 @@ module NA 
     | 
|
| 
       94 
108 
     | 
    
         | 
| 
       95 
109 
     | 
    
         
             
                      # Optimize output generation - compile all output first, then apply regexes
         
     | 
| 
       96 
110 
     | 
    
         
             
                      output = String.new
         
     | 
| 
      
 111 
     | 
    
         
            +
                      total_seconds = 0
         
     | 
| 
      
 112 
     | 
    
         
            +
                      totals_by_tag = Hash.new(0)
         
     | 
| 
      
 113 
     | 
    
         
            +
                      timed_items = []
         
     | 
| 
       97 
114 
     | 
    
         
             
                      NA::Benchmark.measure('Generate action strings') do
         
     | 
| 
       98 
     | 
    
         
            -
                        each_with_index do |action, idx|
         
     | 
| 
      
 115 
     | 
    
         
            +
                        filtered_actions.each_with_index do |action, idx|
         
     | 
| 
       99 
116 
     | 
    
         
             
                          # Generate raw output without regex processing
         
     | 
| 
       100 
     | 
    
         
            -
                           
     | 
| 
       101 
     | 
    
         
            -
             
     | 
| 
      
 117 
     | 
    
         
            +
                          line = action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
         
     | 
| 
      
 118 
     | 
    
         
            +
             
     | 
| 
      
 119 
     | 
    
         
            +
                          if config[:times]
         
     | 
| 
      
 120 
     | 
    
         
            +
                            # compute duration from @started/@done
         
     | 
| 
      
 121 
     | 
    
         
            +
                            tags = action.tags
         
     | 
| 
      
 122 
     | 
    
         
            +
                            begun = tags['started'] || tags['start']
         
     | 
| 
      
 123 
     | 
    
         
            +
                            finished = tags['done']
         
     | 
| 
      
 124 
     | 
    
         
            +
                            if begun && finished
         
     | 
| 
      
 125 
     | 
    
         
            +
                              begin
         
     | 
| 
      
 126 
     | 
    
         
            +
                                start_t = Time.parse(begun)
         
     | 
| 
      
 127 
     | 
    
         
            +
                                end_t = Time.parse(finished)
         
     | 
| 
      
 128 
     | 
    
         
            +
                                secs = [end_t - start_t, 0].max.to_i
         
     | 
| 
      
 129 
     | 
    
         
            +
                                total_seconds += secs
         
     | 
| 
      
 130 
     | 
    
         
            +
                                dur_color = NA.theme[:duration] || '{y}'
         
     | 
| 
      
 131 
     | 
    
         
            +
                                line << NA::Color.template(" #{dur_color}[#{format_duration(secs, human: config[:human])}]{x}")
         
     | 
| 
      
 132 
     | 
    
         
            +
             
     | 
| 
      
 133 
     | 
    
         
            +
                                # collect for JSON output
         
     | 
| 
      
 134 
     | 
    
         
            +
                                timed_items << {
         
     | 
| 
      
 135 
     | 
    
         
            +
                                  action: NA::Color.uncolor(action.action),
         
     | 
| 
      
 136 
     | 
    
         
            +
                                  started: start_t.iso8601,
         
     | 
| 
      
 137 
     | 
    
         
            +
                                  ended: end_t.iso8601,
         
     | 
| 
      
 138 
     | 
    
         
            +
                                  duration: secs
         
     | 
| 
      
 139 
     | 
    
         
            +
                                }
         
     | 
| 
      
 140 
     | 
    
         
            +
             
     | 
| 
      
 141 
     | 
    
         
            +
                                # accumulate per-tag durations (exclude time-control tags)
         
     | 
| 
      
 142 
     | 
    
         
            +
                                tags.each_key do |k|
         
     | 
| 
      
 143 
     | 
    
         
            +
                                  next if k =~ /^(start|started|done)$/i
         
     | 
| 
      
 144 
     | 
    
         
            +
             
     | 
| 
      
 145 
     | 
    
         
            +
                                  totals_by_tag[k.sub(/^@/, '')] += secs
         
     | 
| 
      
 146 
     | 
    
         
            +
                                end
         
     | 
| 
      
 147 
     | 
    
         
            +
                              rescue StandardError
         
     | 
| 
      
 148 
     | 
    
         
            +
                                # ignore parse errors
         
     | 
| 
      
 149 
     | 
    
         
            +
                              end
         
     | 
| 
      
 150 
     | 
    
         
            +
                            end
         
     | 
| 
      
 151 
     | 
    
         
            +
                          end
         
     | 
| 
      
 152 
     | 
    
         
            +
             
     | 
| 
      
 153 
     | 
    
         
            +
                          unless config[:only_times]
         
     | 
| 
      
 154 
     | 
    
         
            +
                            output << line
         
     | 
| 
      
 155 
     | 
    
         
            +
                            output << "\n" unless idx == filtered_actions.size - 1
         
     | 
| 
      
 156 
     | 
    
         
            +
                          end
         
     | 
| 
       102 
157 
     | 
    
         
             
                        end
         
     | 
| 
       103 
158 
     | 
    
         
             
                      end
         
     | 
| 
       104 
159 
     | 
    
         | 
| 
      
 160 
     | 
    
         
            +
                      # If JSON output requested, emit JSON and return immediately
         
     | 
| 
      
 161 
     | 
    
         
            +
                      if config[:json_times]
         
     | 
| 
      
 162 
     | 
    
         
            +
                        require 'json'
         
     | 
| 
      
 163 
     | 
    
         
            +
                        json = {
         
     | 
| 
      
 164 
     | 
    
         
            +
                          timed: timed_items,
         
     | 
| 
      
 165 
     | 
    
         
            +
                          tags: totals_by_tag.map { |k, v| { tag: k, duration: v } }.sort_by { |h| -h[:duration] },
         
     | 
| 
      
 166 
     | 
    
         
            +
                          total: {
         
     | 
| 
      
 167 
     | 
    
         
            +
                            seconds: total_seconds,
         
     | 
| 
      
 168 
     | 
    
         
            +
                            timestamp: format_duration(total_seconds, human: false),
         
     | 
| 
      
 169 
     | 
    
         
            +
                            human: format_duration(total_seconds, human: true)
         
     | 
| 
      
 170 
     | 
    
         
            +
                          }
         
     | 
| 
      
 171 
     | 
    
         
            +
                        }
         
     | 
| 
      
 172 
     | 
    
         
            +
                        puts JSON.pretty_generate(json)
         
     | 
| 
      
 173 
     | 
    
         
            +
                        return
         
     | 
| 
      
 174 
     | 
    
         
            +
                      end
         
     | 
| 
      
 175 
     | 
    
         
            +
             
     | 
| 
       105 
176 
     | 
    
         
             
                      # Apply regex highlighting to the entire output at once
         
     | 
| 
       106 
177 
     | 
    
         
             
                      if config[:regexes].any?
         
     | 
| 
       107 
178 
     | 
    
         
             
                        NA::Benchmark.measure('Apply regex highlighting') do
         
     | 
| 
         @@ -109,11 +180,70 @@ module NA 
     | 
|
| 
       109 
180 
     | 
    
         
             
                        end
         
     | 
| 
       110 
181 
     | 
    
         
             
                      end
         
     | 
| 
       111 
182 
     | 
    
         | 
| 
      
 183 
     | 
    
         
            +
                      if config[:times] && total_seconds.positive?
         
     | 
| 
      
 184 
     | 
    
         
            +
                        # Build Markdown table of per-tag totals
         
     | 
| 
      
 185 
     | 
    
         
            +
                        if totals_by_tag.empty?
         
     | 
| 
      
 186 
     | 
    
         
            +
                          # No tag totals, just show total line
         
     | 
| 
      
 187 
     | 
    
         
            +
                          dur_color = NA.theme[:duration] || '{y}'
         
     | 
| 
      
 188 
     | 
    
         
            +
                          output << "\n"
         
     | 
| 
      
 189 
     | 
    
         
            +
                          output << NA::Color.template("{x}#{dur_color}Total time: [#{format_duration(total_seconds, human: config[:human])}]{x}")
         
     | 
| 
      
 190 
     | 
    
         
            +
                        else
         
     | 
| 
      
 191 
     | 
    
         
            +
                          rows = totals_by_tag.sort_by { |_, v| -v }.map do |tag, secs|
         
     | 
| 
      
 192 
     | 
    
         
            +
                            disp = format_duration(secs, human: config[:human])
         
     | 
| 
      
 193 
     | 
    
         
            +
                            ["@#{tag}", disp]
         
     | 
| 
      
 194 
     | 
    
         
            +
                          end
         
     | 
| 
      
 195 
     | 
    
         
            +
                          # Pre-compute total display for width calculation
         
     | 
| 
      
 196 
     | 
    
         
            +
                          total_disp = format_duration(total_seconds, human: config[:human])
         
     | 
| 
      
 197 
     | 
    
         
            +
                          # Determine column widths, including footer labels/values
         
     | 
| 
      
 198 
     | 
    
         
            +
                          tag_header = 'Tag'
         
     | 
| 
      
 199 
     | 
    
         
            +
                          dur_header = config[:human] ? 'Duration (human)' : 'Duration'
         
     | 
| 
      
 200 
     | 
    
         
            +
                          tag_width = ([tag_header.length, 'Total'.length] + rows.map { |r| r[0].length }).max
         
     | 
| 
      
 201 
     | 
    
         
            +
                          dur_width = ([dur_header.length, total_disp.length] + rows.map { |r| r[1].length }).max
         
     | 
| 
      
 202 
     | 
    
         
            +
             
     | 
| 
      
 203 
     | 
    
         
            +
                          # Header
         
     | 
| 
      
 204 
     | 
    
         
            +
                          output << "\n"
         
     | 
| 
      
 205 
     | 
    
         
            +
                          output << "| #{tag_header.ljust(tag_width)} | #{dur_header.ljust(dur_width)} |\n"
         
     | 
| 
      
 206 
     | 
    
         
            +
                          # Separator for header
         
     | 
| 
      
 207 
     | 
    
         
            +
                          output << "| #{'-' * tag_width} | #{'-' * dur_width} |\n"
         
     | 
| 
      
 208 
     | 
    
         
            +
                          # Body rows
         
     | 
| 
      
 209 
     | 
    
         
            +
                          rows.each do |tag, disp|
         
     | 
| 
      
 210 
     | 
    
         
            +
                            output << "| #{tag.ljust(tag_width)} | #{disp.ljust(dur_width)} |\n"
         
     | 
| 
      
 211 
     | 
    
         
            +
                          end
         
     | 
| 
      
 212 
     | 
    
         
            +
                          # Footer separator (kramdown footer separator with '=') and footer row
         
     | 
| 
      
 213 
     | 
    
         
            +
                          output << "| #{'=' * tag_width} | #{'=' * dur_width} |\n"
         
     | 
| 
      
 214 
     | 
    
         
            +
                          output << "| #{'Total'.ljust(tag_width)} | #{total_disp.ljust(dur_width)} |\n"
         
     | 
| 
      
 215 
     | 
    
         
            +
                        end
         
     | 
| 
      
 216 
     | 
    
         
            +
                      end
         
     | 
| 
      
 217 
     | 
    
         
            +
             
     | 
| 
       112 
218 
     | 
    
         
             
                      NA::Benchmark.measure('Pager.page call') do
         
     | 
| 
       113 
219 
     | 
    
         
             
                        NA::Pager.page(output)
         
     | 
| 
       114 
220 
     | 
    
         
             
                      end
         
     | 
| 
       115 
221 
     | 
    
         
             
                    end
         
     | 
| 
       116 
222 
     | 
    
         
             
                  end
         
     | 
| 
       117 
223 
     | 
    
         
             
                end
         
     | 
| 
      
 224 
     | 
    
         
            +
             
     | 
| 
      
 225 
     | 
    
         
            +
                private
         
     | 
| 
      
 226 
     | 
    
         
            +
             
     | 
| 
      
 227 
     | 
    
         
            +
                def format_duration(secs, human: false)
         
     | 
| 
      
 228 
     | 
    
         
            +
                  return '' if secs.nil?
         
     | 
| 
      
 229 
     | 
    
         
            +
             
     | 
| 
      
 230 
     | 
    
         
            +
                  secs = secs.to_i
         
     | 
| 
      
 231 
     | 
    
         
            +
                  days = secs / 86_400
         
     | 
| 
      
 232 
     | 
    
         
            +
                  rem = secs % 86_400
         
     | 
| 
      
 233 
     | 
    
         
            +
                  hours = rem / 3600
         
     | 
| 
      
 234 
     | 
    
         
            +
                  rem %= 3600
         
     | 
| 
      
 235 
     | 
    
         
            +
                  minutes = rem / 60
         
     | 
| 
      
 236 
     | 
    
         
            +
                  seconds = rem % 60
         
     | 
| 
      
 237 
     | 
    
         
            +
                  if human
         
     | 
| 
      
 238 
     | 
    
         
            +
                    parts = []
         
     | 
| 
      
 239 
     | 
    
         
            +
                    parts << "#{days} days" if days.positive?
         
     | 
| 
      
 240 
     | 
    
         
            +
                    parts << "#{hours} hours" if hours.positive?
         
     | 
| 
      
 241 
     | 
    
         
            +
                    parts << "#{minutes} minutes" if minutes.positive?
         
     | 
| 
      
 242 
     | 
    
         
            +
                    parts << "#{seconds} seconds" if seconds.positive? || parts.empty?
         
     | 
| 
      
 243 
     | 
    
         
            +
                    parts.join(', ')
         
     | 
| 
      
 244 
     | 
    
         
            +
                  else
         
     | 
| 
      
 245 
     | 
    
         
            +
                    format('%<d>02d:%<h>02d:%<m>02d:%<s>02d', d: days, h: hours, m: minutes, s: seconds)
         
     | 
| 
      
 246 
     | 
    
         
            +
                  end
         
     | 
| 
      
 247 
     | 
    
         
            +
                end
         
     | 
| 
       118 
248 
     | 
    
         
             
              end
         
     | 
| 
       119 
249 
     | 
    
         
             
            end
         
     |