na 1.2.80 → 1.2.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
 - data/.rubocop.yml +8 -2
 - data/.rubocop_todo.yml +33 -538
 - data/CHANGELOG.md +27 -0
 - data/Gemfile +2 -0
 - data/Gemfile.lock +27 -10
 - data/README.md +66 -21
 - data/Rakefile +6 -0
 - data/bin/commands/next.rb +4 -0
 - data/bin/commands/scan.rb +84 -0
 - data/bin/commands/update.rb +291 -14
 - data/bin/na +7 -7
 - data/lib/na/action.rb +103 -38
 - data/lib/na/actions.rb +79 -77
 - data/lib/na/array.rb +11 -7
 - data/lib/na/benchmark.rb +21 -9
 - data/lib/na/colors.rb +84 -86
 - data/lib/na/editor.rb +22 -22
 - data/lib/na/hash.rb +32 -9
 - data/lib/na/help_monkey_patch.rb +9 -1
 - data/lib/na/next_action.rb +347 -305
 - data/lib/na/pager.rb +38 -14
 - data/lib/na/project.rb +14 -1
 - data/lib/na/prompt.rb +25 -3
 - data/lib/na/string.rb +94 -133
 - data/lib/na/theme.rb +37 -31
 - data/lib/na/todo.rb +153 -132
 - data/lib/na/version.rb +3 -1
 - data/lib/na.rb +1 -0
 - data/na.gemspec +4 -2
 - data/scripts/generate-fish-completions.rb +18 -21
 - data/src/_README.md +17 -5
 - data/test_performance.rb +5 -5
 - metadata +53 -24
 
    
        data/lib/na/next_action.rb
    CHANGED
    
    | 
         @@ -5,27 +5,20 @@ module NA 
     | 
|
| 
       5 
5 
     | 
    
         
             
              class << self
         
     | 
| 
       6 
6 
     | 
    
         
             
                include NA::Editor
         
     | 
| 
       7 
7 
     | 
    
         | 
| 
       8 
     | 
    
         
            -
                attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file, 
     | 
| 
      
 8 
     | 
    
         
            +
                attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
         
     | 
| 
      
 9 
     | 
    
         
            +
                              :cwd_is, :cwd, :stdin, :show_cwd_indicator
         
     | 
| 
       9 
10 
     | 
    
         | 
| 
       10 
11 
     | 
    
         
             
                def theme
         
     | 
| 
       11 
12 
     | 
    
         
             
                  @theme ||= NA::Theme.load_theme
         
     | 
| 
       12 
13 
     | 
    
         
             
                end
         
     | 
| 
       13 
14 
     | 
    
         | 
| 
       14 
     | 
    
         
            -
                ##
         
     | 
| 
       15 
     | 
    
         
            -
                ## Output to STDERR
         
     | 
| 
       16 
     | 
    
         
            -
                ##
         
     | 
| 
       17 
     | 
    
         
            -
                ## @param      msg        [String] The message
         
     | 
| 
       18 
     | 
    
         
            -
                ## @param      exit_code  [Number] The exit code, no
         
     | 
| 
       19 
     | 
    
         
            -
                ##                        exit if false
         
     | 
| 
       20 
     | 
    
         
            -
                ## @param      debug      [Boolean] only display message if running :verbose
         
     | 
| 
       21 
     | 
    
         
            -
                ##
         
     | 
| 
       22 
15 
     | 
    
         
             
                def notify(msg, exit_code: false, debug: false)
         
     | 
| 
       23 
16 
     | 
    
         
             
                  return if debug && !NA.verbose
         
     | 
| 
       24 
17 
     | 
    
         | 
| 
       25 
18 
     | 
    
         
             
                  if debug
         
     | 
| 
       26 
     | 
    
         
            -
                     
     | 
| 
      
 19 
     | 
    
         
            +
                    warn NA::Color.template("{x}#{NA.theme[:debug]}#{msg}{x}")
         
     | 
| 
       27 
20 
     | 
    
         
             
                  else
         
     | 
| 
       28 
     | 
    
         
            -
                     
     | 
| 
      
 21 
     | 
    
         
            +
                    warn NA::Color.template("{x}#{msg}{x}")
         
     | 
| 
       29 
22 
     | 
    
         
             
                  end
         
     | 
| 
       30 
23 
     | 
    
         
             
                  Process.exit exit_code if exit_code
         
     | 
| 
       31 
24 
     | 
    
         
             
                end
         
     | 
| 
         @@ -38,16 +31,16 @@ module NA 
     | 
|
| 
       38 
31 
     | 
    
         
             
                  }
         
     | 
| 
       39 
32 
     | 
    
         
             
                end
         
     | 
| 
       40 
33 
     | 
    
         | 
| 
       41 
     | 
    
         
            -
                 
     | 
| 
       42 
     | 
    
         
            -
                 
     | 
| 
       43 
     | 
    
         
            -
                 
     | 
| 
       44 
     | 
    
         
            -
                 
     | 
| 
       45 
     | 
    
         
            -
                 
     | 
| 
       46 
     | 
    
         
            -
                 
     | 
| 
       47 
     | 
    
         
            -
                 
     | 
| 
       48 
     | 
    
         
            -
                 
     | 
| 
       49 
     | 
    
         
            -
                 
     | 
| 
       50 
     | 
    
         
            -
                 
     | 
| 
      
 34 
     | 
    
         
            +
                #
         
     | 
| 
      
 35 
     | 
    
         
            +
                # Display and read a Yes/No prompt
         
     | 
| 
      
 36 
     | 
    
         
            +
                #
         
     | 
| 
      
 37 
     | 
    
         
            +
                # @param      prompt   [String] The prompt string
         
     | 
| 
      
 38 
     | 
    
         
            +
                # @param      default  [Boolean] default value if
         
     | 
| 
      
 39 
     | 
    
         
            +
                #                      return is pressed or prompt is
         
     | 
| 
      
 40 
     | 
    
         
            +
                #                      skipped
         
     | 
| 
      
 41 
     | 
    
         
            +
                #
         
     | 
| 
      
 42 
     | 
    
         
            +
                # @return     [Boolean] result
         
     | 
| 
      
 43 
     | 
    
         
            +
                #
         
     | 
| 
       51 
44 
     | 
    
         
             
                def yn(prompt, default: true)
         
     | 
| 
       52 
45 
     | 
    
         
             
                  return default unless $stdout.isatty
         
     | 
| 
       53 
46 
     | 
    
         | 
| 
         @@ -63,14 +56,14 @@ module NA 
     | 
|
| 
       63 
56 
     | 
    
         
             
                  res.empty? ? default : res =~ /y/i
         
     | 
| 
       64 
57 
     | 
    
         
             
                end
         
     | 
| 
       65 
58 
     | 
    
         | 
| 
       66 
     | 
    
         
            -
                 
     | 
| 
       67 
     | 
    
         
            -
                 
     | 
| 
       68 
     | 
    
         
            -
                 
     | 
| 
       69 
     | 
    
         
            -
                 
     | 
| 
       70 
     | 
    
         
            -
                 
     | 
| 
       71 
     | 
    
         
            -
                 
     | 
| 
       72 
     | 
    
         
            -
                 
     | 
| 
       73 
     | 
    
         
            -
                 
     | 
| 
      
 59 
     | 
    
         
            +
                #
         
     | 
| 
      
 60 
     | 
    
         
            +
                # Helper function to colorize the Y/N prompt
         
     | 
| 
      
 61 
     | 
    
         
            +
                #
         
     | 
| 
      
 62 
     | 
    
         
            +
                # @param      choices  [Array] The choices with
         
     | 
| 
      
 63 
     | 
    
         
            +
                #                      default capitalized
         
     | 
| 
      
 64 
     | 
    
         
            +
                #
         
     | 
| 
      
 65 
     | 
    
         
            +
                # @return     [String] colorized string
         
     | 
| 
      
 66 
     | 
    
         
            +
                #
         
     | 
| 
       74 
67 
     | 
    
         
             
                def color_single_options(choices = %w[y n])
         
     | 
| 
       75 
68 
     | 
    
         
             
                  out = []
         
     | 
| 
       76 
69 
     | 
    
         
             
                  choices.each do |choice|
         
     | 
| 
         @@ -84,53 +77,55 @@ module NA 
     | 
|
| 
       84 
77 
     | 
    
         
             
                  NA::Color.template("{xg}[#{out.join('/')}{xg}]{x}")
         
     | 
| 
       85 
78 
     | 
    
         
             
                end
         
     | 
| 
       86 
79 
     | 
    
         | 
| 
       87 
     | 
    
         
            -
                 
     | 
| 
       88 
     | 
    
         
            -
                 
     | 
| 
       89 
     | 
    
         
            -
                 
     | 
| 
       90 
     | 
    
         
            -
                 
     | 
| 
       91 
     | 
    
         
            -
                 
     | 
| 
       92 
     | 
    
         
            -
                 
     | 
| 
      
 80 
     | 
    
         
            +
                #
         
     | 
| 
      
 81 
     | 
    
         
            +
                # Create a new todo file
         
     | 
| 
      
 82 
     | 
    
         
            +
                #
         
     | 
| 
      
 83 
     | 
    
         
            +
                # @param      target    [String] The target path
         
     | 
| 
      
 84 
     | 
    
         
            +
                # @param      basename  [String] The project base name
         
     | 
| 
      
 85 
     | 
    
         
            +
                #
         
     | 
| 
       93 
86 
     | 
    
         
             
                def create_todo(target, basename, template: nil)
         
     | 
| 
       94 
87 
     | 
    
         
             
                  File.open(target, 'w') do |f|
         
     | 
| 
       95 
     | 
    
         
            -
                    if template && File.exist?(template)
         
     | 
| 
       96 
     | 
    
         
            -
             
     | 
| 
       97 
     | 
    
         
            -
             
     | 
| 
       98 
     | 
    
         
            -
             
     | 
| 
       99 
     | 
    
         
            -
             
     | 
| 
       100 
     | 
    
         
            -
             
     | 
| 
       101 
     | 
    
         
            -
             
     | 
| 
       102 
     | 
    
         
            -
             
     | 
| 
       103 
     | 
    
         
            -
             
     | 
| 
       104 
     | 
    
         
            -
             
     | 
| 
       105 
     | 
    
         
            -
             
     | 
| 
       106 
     | 
    
         
            -
             
     | 
| 
       107 
     | 
    
         
            -
             
     | 
| 
       108 
     | 
    
         
            -
             
     | 
| 
       109 
     | 
    
         
            -
             
     | 
| 
       110 
     | 
    
         
            -
             
     | 
| 
       111 
     | 
    
         
            -
             
     | 
| 
      
 88 
     | 
    
         
            +
                    content = if template && File.exist?(template)
         
     | 
| 
      
 89 
     | 
    
         
            +
                                File.read(template)
         
     | 
| 
      
 90 
     | 
    
         
            +
                              else
         
     | 
| 
      
 91 
     | 
    
         
            +
                                <<~ENDCONTENT
         
     | 
| 
      
 92 
     | 
    
         
            +
                                  Inbox:
         
     | 
| 
      
 93 
     | 
    
         
            +
                                  #{basename}:
         
     | 
| 
      
 94 
     | 
    
         
            +
                                  \tFeature Requests:
         
     | 
| 
      
 95 
     | 
    
         
            +
                                  \tIdeas:
         
     | 
| 
      
 96 
     | 
    
         
            +
                                  \tBugs:
         
     | 
| 
      
 97 
     | 
    
         
            +
                                  Archive:
         
     | 
| 
      
 98 
     | 
    
         
            +
                                  Search Definitions:
         
     | 
| 
      
 99 
     | 
    
         
            +
                                  \tTop Priority @search(@priority = 5 and not @done)
         
     | 
| 
      
 100 
     | 
    
         
            +
                                  \tHigh Priority @search(@priority > 3 and not @done)
         
     | 
| 
      
 101 
     | 
    
         
            +
                                  \tMaybe @search(@maybe)
         
     | 
| 
      
 102 
     | 
    
         
            +
                                  \tNext @search(@#{NA.na_tag} and not @done and not project = "Archive")
         
     | 
| 
      
 103 
     | 
    
         
            +
                                ENDCONTENT
         
     | 
| 
      
 104 
     | 
    
         
            +
                              end
         
     | 
| 
       112 
105 
     | 
    
         
             
                    f.puts(content)
         
     | 
| 
       113 
106 
     | 
    
         
             
                  end
         
     | 
| 
       114 
107 
     | 
    
         
             
                  save_working_dir(target)
         
     | 
| 
       115 
108 
     | 
    
         
             
                  notify("#{NA.theme[:warning]}Created #{NA.theme[:file]}#{target}")
         
     | 
| 
       116 
109 
     | 
    
         
             
                end
         
     | 
| 
       117 
110 
     | 
    
         | 
| 
       118 
     | 
    
         
            -
                 
     | 
| 
       119 
     | 
    
         
            -
                 
     | 
| 
       120 
     | 
    
         
            -
                 
     | 
| 
       121 
     | 
    
         
            -
                 
     | 
| 
       122 
     | 
    
         
            -
                 
     | 
| 
       123 
     | 
    
         
            -
                 
     | 
| 
       124 
     | 
    
         
            -
                 
     | 
| 
       125 
     | 
    
         
            -
                ## @param      multiple  [Boolean] allow multiple selections
         
     | 
| 
       126 
     | 
    
         
            -
                ##
         
     | 
| 
       127 
     | 
    
         
            -
                ## @return [String, Array] array if multiple
         
     | 
| 
      
 111 
     | 
    
         
            +
                # Select from multiple files
         
     | 
| 
      
 112 
     | 
    
         
            +
                #
         
     | 
| 
      
 113 
     | 
    
         
            +
                # If `gum` or `fzf` are available, they'll be used (in that order).
         
     | 
| 
      
 114 
     | 
    
         
            +
                #
         
     | 
| 
      
 115 
     | 
    
         
            +
                # @param files [Array<String>] The files to select from
         
     | 
| 
      
 116 
     | 
    
         
            +
                # @param multiple [Boolean] Allow multiple selections
         
     | 
| 
      
 117 
     | 
    
         
            +
                # @return [String, Array<String>] Selected file(s)
         
     | 
| 
       128 
118 
     | 
    
         
             
                def select_file(files, multiple: false)
         
     | 
| 
       129 
119 
     | 
    
         
             
                  res = choose_from(files, prompt: multiple ? 'Select files' : 'Select a file', multiple: multiple)
         
     | 
| 
       130 
     | 
    
         
            -
             
     | 
| 
       131 
     | 
    
         
            -
             
     | 
| 
       132 
     | 
    
         
            -
             
     | 
| 
       133 
     | 
    
         
            -
                   
     | 
| 
      
 120 
     | 
    
         
            +
                  if res.nil? || res == false || (res.respond_to?(:length) && res.empty?)
         
     | 
| 
      
 121 
     | 
    
         
            +
                    notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1)
         
     | 
| 
      
 122 
     | 
    
         
            +
                    return nil
         
     | 
| 
      
 123 
     | 
    
         
            +
                  end
         
     | 
| 
      
 124 
     | 
    
         
            +
                  if multiple
         
     | 
| 
      
 125 
     | 
    
         
            +
                    res
         
     | 
| 
      
 126 
     | 
    
         
            +
                  else
         
     | 
| 
      
 127 
     | 
    
         
            +
                    res.is_a?(Array) ? res.first : res
         
     | 
| 
      
 128 
     | 
    
         
            +
                  end
         
     | 
| 
       134 
129 
     | 
    
         
             
                end
         
     | 
| 
       135 
130 
     | 
    
         | 
| 
       136 
131 
     | 
    
         
             
                def shift_index_after(projects, idx, length = 1)
         
     | 
| 
         @@ -142,11 +137,25 @@ module NA 
     | 
|
| 
       142 
137 
     | 
    
         
             
                  end
         
     | 
| 
       143 
138 
     | 
    
         
             
                end
         
     | 
| 
       144 
139 
     | 
    
         | 
| 
      
 140 
     | 
    
         
            +
                # Find all projects in a todo file
         
     | 
| 
      
 141 
     | 
    
         
            +
                #
         
     | 
| 
      
 142 
     | 
    
         
            +
                # @param target [String] Path to the todo file
         
     | 
| 
      
 143 
     | 
    
         
            +
                # @return [Array<NA::Project>] List of projects
         
     | 
| 
       145 
144 
     | 
    
         
             
                def find_projects(target)
         
     | 
| 
       146 
145 
     | 
    
         
             
                  todo = NA::Todo.new(require_na: false, file_path: target)
         
     | 
| 
       147 
146 
     | 
    
         
             
                  todo.projects
         
     | 
| 
       148 
147 
     | 
    
         
             
                end
         
     | 
| 
       149 
148 
     | 
    
         | 
| 
      
 149 
     | 
    
         
            +
                # Find actions in a todo file matching criteria
         
     | 
| 
      
 150 
     | 
    
         
            +
                #
         
     | 
| 
      
 151 
     | 
    
         
            +
                # @param target [String] Path to the todo file
         
     | 
| 
      
 152 
     | 
    
         
            +
                # @param search [String, nil] Search string
         
     | 
| 
      
 153 
     | 
    
         
            +
                # @param tagged [String, nil] Tag to filter
         
     | 
| 
      
 154 
     | 
    
         
            +
                # @param all [Boolean] Return all actions
         
     | 
| 
      
 155 
     | 
    
         
            +
                # @param done [Boolean] Include done actions
         
     | 
| 
      
 156 
     | 
    
         
            +
                # @param project [String, nil] Project name
         
     | 
| 
      
 157 
     | 
    
         
            +
                # @param search_note [Boolean] Search notes
         
     | 
| 
      
 158 
     | 
    
         
            +
                # @return [Array] Projects and actions
         
     | 
| 
       150 
159 
     | 
    
         
             
                def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true)
         
     | 
| 
       151 
160 
     | 
    
         
             
                  todo = NA::Todo.new({ search: search,
         
     | 
| 
       152 
161 
     | 
    
         
             
                                        search_note: search_note,
         
     | 
| 
         @@ -157,8 +166,9 @@ module NA 
     | 
|
| 
       157 
166 
     | 
    
         
             
                                        done: done })
         
     | 
| 
       158 
167 
     | 
    
         | 
| 
       159 
168 
     | 
    
         
             
                  unless todo.actions.count.positive?
         
     | 
| 
       160 
     | 
    
         
            -
                    NA.notify("#{NA.theme[:error]}No matching actions found in #{File.basename(target, 
     | 
| 
       161 
     | 
    
         
            -
             
     | 
| 
      
 169 
     | 
    
         
            +
                    NA.notify("#{NA.theme[:error]}No matching actions found in #{File.basename(target,
         
     | 
| 
      
 170 
     | 
    
         
            +
                                                                                               ".#{NA.extension}").highlight_filename}")
         
     | 
| 
      
 171 
     | 
    
         
            +
                    return [todo.projects, NA::Actions.new]
         
     | 
| 
       162 
172 
     | 
    
         
             
                  end
         
     | 
| 
       163 
173 
     | 
    
         | 
| 
       164 
174 
     | 
    
         
             
                  return [todo.projects, todo.actions] if todo.actions.count == 1 || all
         
     | 
| 
         @@ -166,7 +176,10 @@ module NA 
     | 
|
| 
       166 
176 
     | 
    
         
             
                  options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
         
     | 
| 
       167 
177 
     | 
    
         
             
                  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
         
     | 
| 
       168 
178 
     | 
    
         | 
| 
       169 
     | 
    
         
            -
                   
     | 
| 
      
 179 
     | 
    
         
            +
                  unless res&.length&.positive?
         
     | 
| 
      
 180 
     | 
    
         
            +
                    NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
         
     | 
| 
      
 181 
     | 
    
         
            +
                    return [todo.projects, NA::Actions.new]
         
     | 
| 
      
 182 
     | 
    
         
            +
                  end
         
     | 
| 
       170 
183 
     | 
    
         | 
| 
       171 
184 
     | 
    
         
             
                  selected = NA::Actions.new
         
     | 
| 
       172 
185 
     | 
    
         
             
                  res.each do |result|
         
     | 
| 
         @@ -177,7 +190,13 @@ module NA 
     | 
|
| 
       177 
190 
     | 
    
         
             
                  [todo.projects, selected]
         
     | 
| 
       178 
191 
     | 
    
         
             
                end
         
     | 
| 
       179 
192 
     | 
    
         | 
| 
       180 
     | 
    
         
            -
                 
     | 
| 
      
 193 
     | 
    
         
            +
                # Insert a new project into a todo file
         
     | 
| 
      
 194 
     | 
    
         
            +
                #
         
     | 
| 
      
 195 
     | 
    
         
            +
                # @param target [String] Path to the todo file
         
     | 
| 
      
 196 
     | 
    
         
            +
                # @param project [String] Project name
         
     | 
| 
      
 197 
     | 
    
         
            +
                # @param projects [Array<NA::Project>] Existing projects
         
     | 
| 
      
 198 
     | 
    
         
            +
                # @return [NA::Project] The new project
         
     | 
| 
      
 199 
     | 
    
         
            +
                def insert_project(target, project, _projects)
         
     | 
| 
       181 
200 
     | 
    
         
             
                  path = project.split(%r{[:/]})
         
     | 
| 
       182 
201 
     | 
    
         
             
                  todo = NA::Todo.new(file_path: target)
         
     | 
| 
       183 
202 
     | 
    
         
             
                  built = []
         
     | 
| 
         @@ -207,11 +226,11 @@ module NA 
     | 
|
| 
       207 
226 
     | 
    
         
             
                      indent += 1
         
     | 
| 
       208 
227 
     | 
    
         
             
                    end
         
     | 
| 
       209 
228 
     | 
    
         | 
| 
       210 
     | 
    
         
            -
                    if new_path.join 
     | 
| 
      
 229 
     | 
    
         
            +
                    if new_path.join =~ /Archive/i
         
     | 
| 
       211 
230 
     | 
    
         
             
                      line = todo.projects.last&.last_line || 0
         
     | 
| 
       212 
     | 
    
         
            -
                      content = content.split( 
     | 
| 
      
 231 
     | 
    
         
            +
                      content = content.split("\n").insert(line, input.join("\n")).join("\n")
         
     | 
| 
       213 
232 
     | 
    
         
             
                    else
         
     | 
| 
       214 
     | 
    
         
            -
                      split = content.split( 
     | 
| 
      
 233 
     | 
    
         
            +
                      split = content.split("\n")
         
     | 
| 
       215 
234 
     | 
    
         
             
                      line = todo.projects.first&.line || 0
         
     | 
| 
       216 
235 
     | 
    
         
             
                      before = split.slice(0, line).join("\n")
         
     | 
| 
       217 
236 
     | 
    
         
             
                      after = split.slice(line, split.count - 0).join("\n")
         
     | 
| 
         @@ -227,8 +246,9 @@ module NA 
     | 
|
| 
       227 
246 
     | 
    
         
             
                      input.push("#{"\t" * indent}#{part.cap_first}:")
         
     | 
| 
       228 
247 
     | 
    
         
             
                      indent += 1
         
     | 
| 
       229 
248 
     | 
    
         
             
                    end
         
     | 
| 
       230 
     | 
    
         
            -
                    content = content.split( 
     | 
| 
       231 
     | 
    
         
            -
                    new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1, 
     | 
| 
      
 249 
     | 
    
         
            +
                    content = content.split("\n").insert(line, input.join("\n")).join("\n")
         
     | 
| 
      
 250 
     | 
    
         
            +
                    new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1,
         
     | 
| 
      
 251 
     | 
    
         
            +
                                                  line + input.count - 1)
         
     | 
| 
       232 
252 
     | 
    
         
             
                  end
         
     | 
| 
       233 
253 
     | 
    
         | 
| 
       234 
254 
     | 
    
         
             
                  File.open(target, 'w') do |f|
         
     | 
| 
         @@ -238,6 +258,28 @@ module NA 
     | 
|
| 
       238 
258 
     | 
    
         
             
                  new_project
         
     | 
| 
       239 
259 
     | 
    
         
             
                end
         
     | 
| 
       240 
260 
     | 
    
         | 
| 
      
 261 
     | 
    
         
            +
                # Update actions in a todo file (add, edit, delete, move, etc.)
         
     | 
| 
      
 262 
     | 
    
         
            +
                #
         
     | 
| 
      
 263 
     | 
    
         
            +
                # @param target [String] Path to the todo file
         
     | 
| 
      
 264 
     | 
    
         
            +
                # @param search [String, nil] Search string
         
     | 
| 
      
 265 
     | 
    
         
            +
                # @param search_note [Boolean] Search notes
         
     | 
| 
      
 266 
     | 
    
         
            +
                # @param add [Action, nil] Action to add
         
     | 
| 
      
 267 
     | 
    
         
            +
                # @param add_tag [Array<String>] Tags to add
         
     | 
| 
      
 268 
     | 
    
         
            +
                # @param all [Boolean] Update all matching actions
         
     | 
| 
      
 269 
     | 
    
         
            +
                # @param append [Boolean] Append to project
         
     | 
| 
      
 270 
     | 
    
         
            +
                # @param delete [Boolean] Delete matching actions
         
     | 
| 
      
 271 
     | 
    
         
            +
                # @param done [Boolean] Mark as done
         
     | 
| 
      
 272 
     | 
    
         
            +
                # @param edit [Boolean] Edit matching actions
         
     | 
| 
      
 273 
     | 
    
         
            +
                # @param finish [Boolean] Mark as finished
         
     | 
| 
      
 274 
     | 
    
         
            +
                # @param note [Array<String>] Notes to add
         
     | 
| 
      
 275 
     | 
    
         
            +
                # @param overwrite [Boolean] Overwrite notes
         
     | 
| 
      
 276 
     | 
    
         
            +
                # @param priority [Integer] Priority value
         
     | 
| 
      
 277 
     | 
    
         
            +
                # @param project [String, nil] Project name
         
     | 
| 
      
 278 
     | 
    
         
            +
                # @param move [String, nil] Move to project
         
     | 
| 
      
 279 
     | 
    
         
            +
                # @param remove_tag [Array<String>] Tags to remove
         
     | 
| 
      
 280 
     | 
    
         
            +
                # @param replace [String, nil] Replacement text
         
     | 
| 
      
 281 
     | 
    
         
            +
                # @param tagged [String, nil] Tag to filter
         
     | 
| 
      
 282 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       241 
283 
     | 
    
         
             
                def update_action(target,
         
     | 
| 
       242 
284 
     | 
    
         
             
                                  search,
         
     | 
| 
       243 
285 
     | 
    
         
             
                                  search_note: true,
         
     | 
| 
         @@ -257,7 +299,6 @@ module NA 
     | 
|
| 
       257 
299 
     | 
    
         
             
                                  remove_tag: [],
         
     | 
| 
       258 
300 
     | 
    
         
             
                                  replace: nil,
         
     | 
| 
       259 
301 
     | 
    
         
             
                                  tagged: nil)
         
     | 
| 
       260 
     | 
    
         
            -
             
     | 
| 
       261 
302 
     | 
    
         
             
                  projects = find_projects(target)
         
     | 
| 
       262 
303 
     | 
    
         
             
                  affected_actions = []
         
     | 
| 
       263 
304 
     | 
    
         | 
| 
         @@ -265,9 +306,11 @@ module NA 
     | 
|
| 
       265 
306 
     | 
    
         | 
| 
       266 
307 
     | 
    
         
             
                  if move
         
     | 
| 
       267 
308 
     | 
    
         
             
                    move = move.sub(/:$/, '')
         
     | 
| 
       268 
     | 
    
         
            -
                    target_proj = projects.select { |pr| pr.project =~ /#{move.gsub( 
     | 
| 
      
 309 
     | 
    
         
            +
                    target_proj = projects.select { |pr| pr.project =~ /#{move.gsub(':', '.*?:.*?')}/i }.first
         
     | 
| 
       269 
310 
     | 
    
         
             
                    if target_proj.nil?
         
     | 
| 
       270 
     | 
    
         
            -
                      res = NA.yn( 
     | 
| 
      
 311 
     | 
    
         
            +
                      res = NA.yn(
         
     | 
| 
      
 312 
     | 
    
         
            +
                        NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{move}#{NA.theme[:warning]} doesn't exist, add it"), default: true
         
     | 
| 
      
 313 
     | 
    
         
            +
                      )
         
     | 
| 
       271 
314 
     | 
    
         
             
                      if res
         
     | 
| 
       272 
315 
     | 
    
         
             
                        target_proj = insert_project(target, move, projects)
         
     | 
| 
       273 
316 
     | 
    
         
             
                        projects << target_proj
         
     | 
| 
         @@ -277,90 +320,64 @@ module NA 
     | 
|
| 
       277 
320 
     | 
    
         
             
                    end
         
     | 
| 
       278 
321 
     | 
    
         
             
                  end
         
     | 
| 
       279 
322 
     | 
    
         | 
| 
       280 
     | 
    
         
            -
                  contents = target.read_file.split( 
     | 
| 
      
 323 
     | 
    
         
            +
                  contents = target.read_file.split("\n")
         
     | 
| 
       281 
324 
     | 
    
         | 
| 
       282 
325 
     | 
    
         
             
                  if add.is_a?(Action)
         
     | 
| 
       283 
326 
     | 
    
         
             
                    add_tag ||= []
         
     | 
| 
       284 
327 
     | 
    
         
             
                    add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
         
     | 
| 
       285 
328 
     | 
    
         | 
| 
       286 
     | 
    
         
            -
                     
     | 
| 
       287 
     | 
    
         
            -
             
     | 
| 
       288 
     | 
    
         
            -
                     
     | 
| 
       289 
     | 
    
         
            -
             
     | 
| 
       290 
     | 
    
         
            -
                                  else
         
     | 
| 
       291 
     | 
    
         
            -
                                    # First try exact full-path match
         
     | 
| 
       292 
     | 
    
         
            -
                                    projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/i }.first
         
     | 
| 
       293 
     | 
    
         
            -
                                  end
         
     | 
| 
       294 
     | 
    
         
            -
             
     | 
| 
       295 
     | 
    
         
            -
                    # If no exact match, try unique suffix match (e.g., :Ideas at end)
         
     | 
| 
       296 
     | 
    
         
            -
                    if target_proj.nil?
         
     | 
| 
       297 
     | 
    
         
            -
                      leaf = Regexp.escape(add.parent.join(':'))
         
     | 
| 
       298 
     | 
    
         
            -
                      suffix_matches = projects.select { |proj| proj.project =~ /(^|:)#{leaf}$/i }
         
     | 
| 
       299 
     | 
    
         
            -
                      if suffix_matches.count == 1
         
     | 
| 
       300 
     | 
    
         
            -
                        target_proj = suffix_matches.first
         
     | 
| 
       301 
     | 
    
         
            -
                      elsif suffix_matches.count > 1 && $stdout.isatty
         
     | 
| 
       302 
     | 
    
         
            -
                        choice = choose_from(suffix_matches.map(&:project), prompt: 'Select a target project: ', multiple: false)
         
     | 
| 
       303 
     | 
    
         
            -
                        target_proj = projects.select { |proj| proj.project == choice }.first if choice
         
     | 
| 
       304 
     | 
    
         
            -
                      end
         
     | 
| 
       305 
     | 
    
         
            -
                    end
         
     | 
| 
       306 
     | 
    
         
            -
             
     | 
| 
       307 
     | 
    
         
            -
                    if target_proj.nil?
         
     | 
| 
       308 
     | 
    
         
            -
                      res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{add.project}#{NA.theme[:warning]} doesn't exist, add it"), default: true)
         
     | 
| 
       309 
     | 
    
         
            -
             
     | 
| 
       310 
     | 
    
         
            -
                      if res
         
     | 
| 
       311 
     | 
    
         
            -
                        target_proj = insert_project(target, project, projects)
         
     | 
| 
       312 
     | 
    
         
            -
                        projects << target_proj
         
     | 
| 
       313 
     | 
    
         
            -
                      else
         
     | 
| 
       314 
     | 
    
         
            -
                        NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
         
     | 
| 
       315 
     | 
    
         
            -
                      end
         
     | 
| 
       316 
     | 
    
         
            -
             
     | 
| 
       317 
     | 
    
         
            -
                      NA.notify("#{NA.theme[:error]}Error parsing project #{NA.theme[:filename]}#{target}", exit_code: 1) if target_proj.nil?
         
     | 
| 
      
 329 
     | 
    
         
            +
                    # Remove the original action and its notes
         
     | 
| 
      
 330 
     | 
    
         
            +
                    action_line = add.line
         
     | 
| 
      
 331 
     | 
    
         
            +
                    note_lines = add.note.is_a?(Array) ? add.note.count : 0
         
     | 
| 
      
 332 
     | 
    
         
            +
                    contents.slice!(action_line, note_lines + 1)
         
     | 
| 
       318 
333 
     | 
    
         | 
| 
       319 
     | 
    
         
            -
             
     | 
| 
       320 
     | 
    
         
            -
             
     | 
| 
       321 
     | 
    
         
            -
                     
     | 
| 
      
 334 
     | 
    
         
            +
                    # Prepare updated note
         
     | 
| 
      
 335 
     | 
    
         
            +
                    note = note.to_s.split("\n") unless note.is_a?(Array)
         
     | 
| 
      
 336 
     | 
    
         
            +
                    updated_note = if note.empty?
         
     | 
| 
      
 337 
     | 
    
         
            +
                                     add.note
         
     | 
| 
      
 338 
     | 
    
         
            +
                                   else
         
     | 
| 
      
 339 
     | 
    
         
            +
                                     overwrite ? note : add.note.concat(note)
         
     | 
| 
      
 340 
     | 
    
         
            +
                                   end
         
     | 
| 
       322 
341 
     | 
    
         | 
| 
       323 
     | 
    
         
            -
                     
     | 
| 
       324 
     | 
    
         
            -
                     
     | 
| 
       325 
     | 
    
         
            -
                     
     | 
| 
       326 
     | 
    
         
            -
             
     | 
| 
       327 
     | 
    
         
            -
             
     | 
| 
       328 
     | 
    
         
            -
             
     | 
| 
       329 
     | 
    
         
            -
             
     | 
| 
       330 
     | 
    
         
            -
             
     | 
| 
       331 
     | 
    
         
            -
                     
     | 
| 
       332 
     | 
    
         
            -
             
     | 
| 
       333 
     | 
    
         
            -
                    if  
     | 
| 
       334 
     | 
    
         
            -
             
     | 
| 
       335 
     | 
    
         
            -
                       
     | 
| 
       336 
     | 
    
         
            -
             
     | 
| 
       337 
     | 
    
         
            -
             
     | 
| 
       338 
     | 
    
         
            -
                          break
         
     | 
| 
       339 
     | 
    
         
            -
                        end
         
     | 
| 
       340 
     | 
    
         
            -
                      end
         
     | 
| 
       341 
     | 
    
         
            -
                      target_line = if this_idx == projects.length - 1
         
     | 
| 
       342 
     | 
    
         
            -
                                      contents.count
         
     | 
| 
      
 342 
     | 
    
         
            +
                    # Prepare indentation
         
     | 
| 
      
 343 
     | 
    
         
            +
                    projects = find_projects(target)
         
     | 
| 
      
 344 
     | 
    
         
            +
                    # If move is set, update add.parent to the target project
         
     | 
| 
      
 345 
     | 
    
         
            +
                    add.parent = target_proj.project.split(':') if move && target_proj
         
     | 
| 
      
 346 
     | 
    
         
            +
                    target_proj = projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/i }.first
         
     | 
| 
      
 347 
     | 
    
         
            +
                    indent = target_proj ? ("\t" * target_proj.indent) : ''
         
     | 
| 
      
 348 
     | 
    
         
            +
             
     | 
| 
      
 349 
     | 
    
         
            +
                    # Format note for insertion
         
     | 
| 
      
 350 
     | 
    
         
            +
                    note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"
         
     | 
| 
      
 351 
     | 
    
         
            +
             
     | 
| 
      
 352 
     | 
    
         
            +
                    # Insert at correct location: if moving, insert at start/end of target project
         
     | 
| 
      
 353 
     | 
    
         
            +
                    if move && target_proj
         
     | 
| 
      
 354 
     | 
    
         
            +
                      insert_line = if append
         
     | 
| 
      
 355 
     | 
    
         
            +
                                      # End of project
         
     | 
| 
      
 356 
     | 
    
         
            +
                                      target_proj.last_line + 1
         
     | 
| 
       343 
357 
     | 
    
         
             
                                    else
         
     | 
| 
       344 
     | 
    
         
            -
                                       
     | 
| 
      
 358 
     | 
    
         
            +
                                      # Start of project (after project header)
         
     | 
| 
      
 359 
     | 
    
         
            +
                                      target_proj.line + 1
         
     | 
| 
       345 
360 
     | 
    
         
             
                                    end
         
     | 
| 
      
 361 
     | 
    
         
            +
                      contents.insert(insert_line, "#{indent}\t- #{add.action}#{note_str}")
         
     | 
| 
       346 
362 
     | 
    
         
             
                    else
         
     | 
| 
       347 
     | 
    
         
            -
                       
     | 
| 
      
 363 
     | 
    
         
            +
                      # Not moving, update in-place
         
     | 
| 
      
 364 
     | 
    
         
            +
                      contents.insert(action_line, "#{indent}\t- #{add.action}#{note_str}")
         
     | 
| 
       348 
365 
     | 
    
         
             
                    end
         
     | 
| 
       349 
366 
     | 
    
         | 
| 
       350 
     | 
    
         
            -
                    contents.insert(target_line, "#{indent}\t- #{add.action}#{note}")
         
     | 
| 
       351 
     | 
    
         
            -
             
     | 
| 
       352 
367 
     | 
    
         
             
                    notify(add.pretty)
         
     | 
| 
       353 
368 
     | 
    
         | 
| 
       354 
369 
     | 
    
         
             
                    # Track affected action and description
         
     | 
| 
       355 
     | 
    
         
            -
                    changes = [ 
     | 
| 
       356 
     | 
    
         
            -
                    changes <<  
     | 
| 
      
 370 
     | 
    
         
            +
                    changes = ['updated']
         
     | 
| 
      
 371 
     | 
    
         
            +
                    changes << 'finished' if finish
         
     | 
| 
       357 
372 
     | 
    
         
             
                    changes << "priority=#{priority}" if priority.to_i.positive?
         
     | 
| 
       358 
373 
     | 
    
         
             
                    changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
         
     | 
| 
       359 
374 
     | 
    
         
             
                    changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
         
     | 
| 
       360 
     | 
    
         
            -
                    changes <<  
     | 
| 
      
 375 
     | 
    
         
            +
                    changes << 'note updated' unless note.nil? || note.empty?
         
     | 
| 
      
 376 
     | 
    
         
            +
                    changes << "moved to #{target_proj.project}" if move && target_proj
         
     | 
| 
       361 
377 
     | 
    
         
             
                    affected_actions << { action: add, desc: changes.join(', ') }
         
     | 
| 
       362 
378 
     | 
    
         
             
                  else
         
     | 
| 
       363 
     | 
    
         
            -
                    _, actions = find_actions(target, search, tagged, done: done, all: all, project: project, 
     | 
| 
      
 379 
     | 
    
         
            +
                    _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
         
     | 
| 
      
 380 
     | 
    
         
            +
                                                                      search_note: search_note)
         
     | 
| 
       364 
381 
     | 
    
         | 
| 
       365 
382 
     | 
    
         
             
                    return if actions.nil?
         
     | 
| 
       366 
383 
     | 
    
         | 
| 
         @@ -425,15 +442,15 @@ module NA 
     | 
|
| 
       425 
442 
     | 
    
         | 
| 
       426 
443 
     | 
    
         
             
                      # Track affected action and description
         
     | 
| 
       427 
444 
     | 
    
         
             
                      changes = []
         
     | 
| 
       428 
     | 
    
         
            -
                      changes <<  
     | 
| 
       429 
     | 
    
         
            -
                      changes <<  
     | 
| 
      
 445 
     | 
    
         
            +
                      changes << 'finished' if finish
         
     | 
| 
      
 446 
     | 
    
         
            +
                      changes << 'edited' if edit
         
     | 
| 
       430 
447 
     | 
    
         
             
                      changes << "priority=#{priority}" if priority.to_i.positive?
         
     | 
| 
       431 
448 
     | 
    
         
             
                      changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
         
     | 
| 
       432 
449 
     | 
    
         
             
                      changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
         
     | 
| 
       433 
     | 
    
         
            -
                      changes <<  
     | 
| 
      
 450 
     | 
    
         
            +
                      changes << 'text replaced' if replace
         
     | 
| 
       434 
451 
     | 
    
         
             
                      changes << "moved to #{target_proj.project}" if target_proj
         
     | 
| 
       435 
     | 
    
         
            -
                      changes <<  
     | 
| 
       436 
     | 
    
         
            -
                      changes = [ 
     | 
| 
      
 452 
     | 
    
         
            +
                      changes << 'note updated' unless note.nil? || note.empty?
         
     | 
| 
      
 453 
     | 
    
         
            +
                      changes = ['updated'] if changes.empty?
         
     | 
| 
       437 
454 
     | 
    
         
             
                      affected_actions << { action: action, desc: changes.join(', ') }
         
     | 
| 
       438 
455 
     | 
    
         
             
                    end
         
     | 
| 
       439 
456 
     | 
    
         
             
                  end
         
     | 
| 
         @@ -454,23 +471,23 @@ module NA 
     | 
|
| 
       454 
471 
     | 
    
         
             
                      action_color = delete ? NA.theme[:error] : NA.theme[:success]
         
     | 
| 
       455 
472 
     | 
    
         
             
                      notify("  #{entry[:action].to_s_pretty} — #{action_color}#{entry[:desc]}")
         
     | 
| 
       456 
473 
     | 
    
         
             
                    end
         
     | 
| 
      
 474 
     | 
    
         
            +
                  elsif add
         
     | 
| 
      
 475 
     | 
    
         
            +
                    notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
         
     | 
| 
       457 
476 
     | 
    
         
             
                  else
         
     | 
| 
       458 
     | 
    
         
            -
                     
     | 
| 
       459 
     | 
    
         
            -
                      notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
         
     | 
| 
       460 
     | 
    
         
            -
                    else
         
     | 
| 
       461 
     | 
    
         
            -
                      notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
         
     | 
| 
       462 
     | 
    
         
            -
                    end
         
     | 
| 
      
 477 
     | 
    
         
            +
                    notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
         
     | 
| 
       463 
478 
     | 
    
         
             
                  end
         
     | 
| 
       464 
479 
     | 
    
         
             
                end
         
     | 
| 
       465 
480 
     | 
    
         | 
| 
       466 
     | 
    
         
            -
                 
     | 
| 
       467 
     | 
    
         
            -
                 
     | 
| 
       468 
     | 
    
         
            -
                 
     | 
| 
       469 
     | 
    
         
            -
                 
     | 
| 
       470 
     | 
    
         
            -
                 
     | 
| 
       471 
     | 
    
         
            -
                 
     | 
| 
       472 
     | 
    
         
            -
                 
     | 
| 
       473 
     | 
    
         
            -
                 
     | 
| 
      
 481 
     | 
    
         
            +
                # Add an action to a todo file
         
     | 
| 
      
 482 
     | 
    
         
            +
                #
         
     | 
| 
      
 483 
     | 
    
         
            +
                # @param file [String] Path to the todo file
         
     | 
| 
      
 484 
     | 
    
         
            +
                # @param project [String] Project name
         
     | 
| 
      
 485 
     | 
    
         
            +
                # @param action [String] Action text
         
     | 
| 
      
 486 
     | 
    
         
            +
                # @param note [Array<String>] Notes
         
     | 
| 
      
 487 
     | 
    
         
            +
                # @param priority [Integer] Priority value
         
     | 
| 
      
 488 
     | 
    
         
            +
                # @param finish [Boolean] Mark as finished
         
     | 
| 
      
 489 
     | 
    
         
            +
                # @param append [Boolean] Append to project
         
     | 
| 
      
 490 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       474 
491 
     | 
    
         
             
                def add_action(file, project, action, note = [], priority: 0, finish: false, append: false)
         
     | 
| 
       475 
492 
     | 
    
         
             
                  parent = project.split(%r{[:/]})
         
     | 
| 
       476 
493 
     | 
    
         | 
| 
         @@ -484,18 +501,21 @@ module NA 
     | 
|
| 
       484 
501 
     | 
    
         | 
| 
       485 
502 
     | 
    
         
             
                  action = Action.new(file, project, parent, action, nil, note)
         
     | 
| 
       486 
503 
     | 
    
         | 
| 
       487 
     | 
    
         
            -
                  update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish, 
     | 
| 
      
 504 
     | 
    
         
            +
                  update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish,
         
     | 
| 
      
 505 
     | 
    
         
            +
                                           append: append)
         
     | 
| 
       488 
506 
     | 
    
         
             
                end
         
     | 
| 
       489 
507 
     | 
    
         | 
| 
      
 508 
     | 
    
         
            +
                # Build a nested hash representing project hierarchy from actions
         
     | 
| 
      
 509 
     | 
    
         
            +
                #
         
     | 
| 
      
 510 
     | 
    
         
            +
                # @param actions [Array<Action>] List of actions
         
     | 
| 
      
 511 
     | 
    
         
            +
                # @return [Hash] Nested hierarchy
         
     | 
| 
       490 
512 
     | 
    
         
             
                def project_hierarchy(actions)
         
     | 
| 
       491 
513 
     | 
    
         
             
                  parents = { actions: [] }
         
     | 
| 
       492 
514 
     | 
    
         
             
                  actions.each do |a|
         
     | 
| 
       493 
515 
     | 
    
         
             
                    parent = a.parent
         
     | 
| 
       494 
516 
     | 
    
         
             
                    current_parent = parents
         
     | 
| 
       495 
517 
     | 
    
         
             
                    parent.each do |par|
         
     | 
| 
       496 
     | 
    
         
            -
                       
     | 
| 
       497 
     | 
    
         
            -
                        current_parent[par] = { actions: [] }
         
     | 
| 
       498 
     | 
    
         
            -
                      end
         
     | 
| 
      
 518 
     | 
    
         
            +
                      current_parent[par] = { actions: [] } unless current_parent.key?(par)
         
     | 
| 
       499 
519 
     | 
    
         
             
                      current_parent = current_parent[par]
         
     | 
| 
       500 
520 
     | 
    
         
             
                    end
         
     | 
| 
       501 
521 
     | 
    
         | 
| 
         @@ -512,14 +532,14 @@ module NA 
     | 
|
| 
       512 
532 
     | 
    
         
             
                def output_children(children, level = 1)
         
     | 
| 
       513 
533 
     | 
    
         
             
                  out = []
         
     | 
| 
       514 
534 
     | 
    
         
             
                  indent = "\t" * level
         
     | 
| 
      
 535 
     | 
    
         
            +
                  return out if children.nil? || children.empty?
         
     | 
| 
      
 536 
     | 
    
         
            +
             
     | 
| 
       515 
537 
     | 
    
         
             
                  children.each do |k, v|
         
     | 
| 
       516 
538 
     | 
    
         
             
                    if k.to_s =~ /actions/
         
     | 
| 
       517 
539 
     | 
    
         
             
                      indent += "\t"
         
     | 
| 
       518 
     | 
    
         
            -
             
     | 
| 
       519 
     | 
    
         
            -
                      v.each do |a|
         
     | 
| 
      
 540 
     | 
    
         
            +
                      v&.each do |a|
         
     | 
| 
       520 
541 
     | 
    
         
             
                        item = "#{indent}- #{a.action}"
         
     | 
| 
       521 
     | 
    
         
            -
             
     | 
| 
       522 
     | 
    
         
            -
                        unless a.tags.empty?
         
     | 
| 
      
 542 
     | 
    
         
            +
                        unless a.tags.nil? || a.tags.empty?
         
     | 
| 
       523 
543 
     | 
    
         
             
                          tags = []
         
     | 
| 
       524 
544 
     | 
    
         
             
                          a.tags.each do |key, val|
         
     | 
| 
       525 
545 
     | 
    
         
             
                            next if key =~ /^(due|flagged|done)$/
         
     | 
| 
         @@ -528,12 +548,9 @@ module NA 
     | 
|
| 
       528 
548 
     | 
    
         
             
                            tag += "-#{val}" unless val.nil? || val.empty?
         
     | 
| 
       529 
549 
     | 
    
         
             
                            tags.push(tag)
         
     | 
| 
       530 
550 
     | 
    
         
             
                          end
         
     | 
| 
       531 
     | 
    
         
            -
             
     | 
| 
       532 
551 
     | 
    
         
             
                          item += " @tags(#{tags.join(',')})" unless tags.empty?
         
     | 
| 
       533 
552 
     | 
    
         
             
                        end
         
     | 
| 
       534 
     | 
    
         
            -
             
     | 
| 
       535 
553 
     | 
    
         
             
                        item += "\n#{indent}\t#{a.note.join("\n#{indent}\t")}" unless a.note.empty?
         
     | 
| 
       536 
     | 
    
         
            -
             
     | 
| 
       537 
554 
     | 
    
         
             
                        out.push(item)
         
     | 
| 
       538 
555 
     | 
    
         
             
                      end
         
     | 
| 
       539 
556 
     | 
    
         
             
                    else
         
     | 
| 
         @@ -544,26 +561,42 @@ module NA 
     | 
|
| 
       544 
561 
     | 
    
         
             
                  out
         
     | 
| 
       545 
562 
     | 
    
         
             
                end
         
     | 
| 
       546 
563 
     | 
    
         | 
| 
      
 564 
     | 
    
         
            +
                # Open a file in the specified editor/application
         
     | 
| 
      
 565 
     | 
    
         
            +
                #
         
     | 
| 
      
 566 
     | 
    
         
            +
                # @param file [String, nil] Path to the file
         
     | 
| 
      
 567 
     | 
    
         
            +
                # @param app [String, nil] Application to use
         
     | 
| 
      
 568 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       547 
569 
     | 
    
         
             
                def edit_file(file: nil, app: nil)
         
     | 
| 
       548 
570 
     | 
    
         
             
                  os_open(file, app: app) if file && File.exist?(file)
         
     | 
| 
       549 
571 
     | 
    
         
             
                end
         
     | 
| 
       550 
572 
     | 
    
         | 
| 
       551 
     | 
    
         
            -
                 
     | 
| 
       552 
     | 
    
         
            -
                 
     | 
| 
       553 
     | 
    
         
            -
                 
     | 
| 
       554 
     | 
    
         
            -
                 
     | 
| 
       555 
     | 
    
         
            -
                 
     | 
| 
       556 
     | 
    
         
            -
                def find_files(depth: 1)
         
     | 
| 
      
 573 
     | 
    
         
            +
                # Locate files matching NA.extension up to a given depth
         
     | 
| 
      
 574 
     | 
    
         
            +
                #
         
     | 
| 
      
 575 
     | 
    
         
            +
                # @param depth [Integer] The depth at which to search
         
     | 
| 
      
 576 
     | 
    
         
            +
                # @param include_hidden [Boolean] Whether to include hidden directories/files
         
     | 
| 
      
 577 
     | 
    
         
            +
                # @return [Array<String>] List of matching file paths
         
     | 
| 
      
 578 
     | 
    
         
            +
                def find_files(depth: 1, include_hidden: false)
         
     | 
| 
       557 
579 
     | 
    
         
             
                  NA::Benchmark.measure("find_files (depth=#{depth})") do
         
     | 
| 
       558 
580 
     | 
    
         
             
                    return [NA.global_file] if NA.global_file
         
     | 
| 
       559 
581 
     | 
    
         | 
| 
       560 
     | 
    
         
            -
                    pattern  
     | 
| 
       561 
     | 
    
         
            -
             
     | 
| 
       562 
     | 
    
         
            -
             
     | 
| 
       563 
     | 
    
         
            -
             
     | 
| 
       564 
     | 
    
         
            -
             
     | 
| 
       565 
     | 
    
         
            -
             
     | 
| 
       566 
     | 
    
         
            -
             
     | 
| 
      
 582 
     | 
    
         
            +
                    # Build a brace-expanded pattern list covering 1..depth levels, e.g.:
         
     | 
| 
      
 583 
     | 
    
         
            +
                    # depth=1 -> "*.ext"
         
     | 
| 
      
 584 
     | 
    
         
            +
                    # depth=3 -> "{*.ext,*/*.ext,*/*/*.ext}"
         
     | 
| 
      
 585 
     | 
    
         
            +
                    ext = NA.extension
         
     | 
| 
      
 586 
     | 
    
         
            +
                    patterns = (1..[depth.to_i, 1].max).map do |d|
         
     | 
| 
      
 587 
     | 
    
         
            +
                      prefix = d > 1 ? ('*/' * (d - 1)) : ''
         
     | 
| 
      
 588 
     | 
    
         
            +
                      "#{prefix}*.#{ext}"
         
     | 
| 
      
 589 
     | 
    
         
            +
                    end
         
     | 
| 
      
 590 
     | 
    
         
            +
                    pattern = patterns.length == 1 ? patterns.first : "{#{patterns.join(',')}}"
         
     | 
| 
      
 591 
     | 
    
         
            +
             
     | 
| 
      
 592 
     | 
    
         
            +
                    files = Dir.glob(pattern, File::FNM_DOTMATCH)
         
     | 
| 
      
 593 
     | 
    
         
            +
                    # Exclude hidden directories/files unless explicitly requested
         
     | 
| 
      
 594 
     | 
    
         
            +
                    unless include_hidden
         
     | 
| 
      
 595 
     | 
    
         
            +
                      files.reject! do |f|
         
     | 
| 
      
 596 
     | 
    
         
            +
                        # reject any path segment beginning with '.' (excluding '.' and '..')
         
     | 
| 
      
 597 
     | 
    
         
            +
                        f.split('/').any? { |seg| seg.start_with?('.') && seg !~ /^\.\.?$/ }
         
     | 
| 
      
 598 
     | 
    
         
            +
                      end
         
     | 
| 
      
 599 
     | 
    
         
            +
                    end
         
     | 
| 
       567 
600 
     | 
    
         
             
                    files.each { |f| save_working_dir(File.expand_path(f)) }
         
     | 
| 
       568 
601 
     | 
    
         
             
                    files
         
     | 
| 
       569 
602 
     | 
    
         
             
                  end
         
     | 
| 
         @@ -575,15 +608,15 @@ module NA 
     | 
|
| 
       575 
608 
     | 
    
         
             
                    done: false,
         
     | 
| 
       576 
609 
     | 
    
         
             
                    file_path: nil,
         
     | 
| 
       577 
610 
     | 
    
         
             
                    negate: false,
         
     | 
| 
      
 611 
     | 
    
         
            +
                    hidden: false,
         
     | 
| 
       578 
612 
     | 
    
         
             
                    project: nil,
         
     | 
| 
       579 
613 
     | 
    
         
             
                    query: nil,
         
     | 
| 
       580 
614 
     | 
    
         
             
                    regex: false,
         
     | 
| 
       581 
     | 
    
         
            -
                    require_na: true,
         
     | 
| 
       582 
615 
     | 
    
         
             
                    search: nil,
         
     | 
| 
       583 
616 
     | 
    
         
             
                    tag: nil
         
     | 
| 
       584 
617 
     | 
    
         
             
                  }
         
     | 
| 
       585 
618 
     | 
    
         
             
                  options = defaults.merge(options)
         
     | 
| 
       586 
     | 
    
         
            -
                  files = find_files(depth: options[:depth])
         
     | 
| 
      
 619 
     | 
    
         
            +
                  files = find_files(depth: options[:depth], include_hidden: options[:hidden])
         
     | 
| 
       587 
620 
     | 
    
         | 
| 
       588 
621 
     | 
    
         
             
                  files.delete_if do |file|
         
     | 
| 
       589 
622 
     | 
    
         
             
                    cmd_options = {
         
     | 
| 
         @@ -605,20 +638,13 @@ module NA 
     | 
|
| 
       605 
638 
     | 
    
         
             
                  files
         
     | 
| 
       606 
639 
     | 
    
         
             
                end
         
     | 
| 
       607 
640 
     | 
    
         | 
| 
       608 
     | 
    
         
            -
                 
     | 
| 
       609 
     | 
    
         
            -
                 
     | 
| 
       610 
     | 
    
         
            -
                 
     | 
| 
       611 
     | 
    
         
            -
                 
     | 
| 
       612 
     | 
    
         
            -
                 
     | 
| 
       613 
     | 
    
         
            -
                 
     | 
| 
       614 
     | 
    
         
            -
                 
     | 
| 
       615 
     | 
    
         
            -
                ## @param      distance      [Integer] allowed distance
         
     | 
| 
       616 
     | 
    
         
            -
                ##                           between characters
         
     | 
| 
       617 
     | 
    
         
            -
                ## @param      require_last  [Boolean] require regex to
         
     | 
| 
       618 
     | 
    
         
            -
                ##                           match last element of path
         
     | 
| 
       619 
     | 
    
         
            -
                ##
         
     | 
| 
       620 
     | 
    
         
            -
                ## @return     [Array] array of matching directories/todo files
         
     | 
| 
       621 
     | 
    
         
            -
                ##
         
     | 
| 
      
 641 
     | 
    
         
            +
                # Find a matching path using semi-fuzzy matching.
         
     | 
| 
      
 642 
     | 
    
         
            +
                # Search tokens can include ! and + to negate or make required.
         
     | 
| 
      
 643 
     | 
    
         
            +
                #
         
     | 
| 
      
 644 
     | 
    
         
            +
                # @param search [Array<Hash>] Search tokens to match
         
     | 
| 
      
 645 
     | 
    
         
            +
                # @param distance [Integer] Allowed distance between characters
         
     | 
| 
      
 646 
     | 
    
         
            +
                # @param require_last [Boolean] Require regex to match last element of path
         
     | 
| 
      
 647 
     | 
    
         
            +
                # @return [Array<String>] Array of matching directories/todo files
         
     | 
| 
       622 
648 
     | 
    
         
             
                def match_working_dir(search, distance: 1, require_last: true)
         
     | 
| 
       623 
649 
     | 
    
         
             
                  file = database_path
         
     | 
| 
       624 
650 
     | 
    
         
             
                  NA.notify("#{NA.theme[:error]}No na database found", exit_code: 1) unless File.exist?(file)
         
     | 
| 
         @@ -637,7 +663,9 @@ module NA 
     | 
|
| 
       637 
663 
     | 
    
         | 
| 
       638 
664 
     | 
    
         
             
                  NA.notify("Optional directory regex: {x}#{optional.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
         
     | 
| 
       639 
665 
     | 
    
         
             
                  NA.notify("Required directory regex: {x}#{required.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
         
     | 
| 
       640 
     | 
    
         
            -
                  NA.notify("Negated directory regex: {x}#{negated.map  
     | 
| 
      
 666 
     | 
    
         
            +
                  NA.notify("Negated directory regex: {x}#{negated.map do |t|
         
     | 
| 
      
 667 
     | 
    
         
            +
                    t.dir_to_rx(distance: distance, require_last: false)
         
     | 
| 
      
 668 
     | 
    
         
            +
                  end}", debug: true)
         
     | 
| 
       641 
669 
     | 
    
         | 
| 
       642 
670 
     | 
    
         
             
                  if require_last
         
     | 
| 
       643 
671 
     | 
    
         
             
                    dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
         
     | 
| 
         @@ -673,32 +701,30 @@ module NA 
     | 
|
| 
       673 
701 
     | 
    
         
             
                  out
         
     | 
| 
       674 
702 
     | 
    
         
             
                end
         
     | 
| 
       675 
703 
     | 
    
         | 
| 
       676 
     | 
    
         
            -
                 
     | 
| 
       677 
     | 
    
         
            -
                 
     | 
| 
       678 
     | 
    
         
            -
                 
     | 
| 
       679 
     | 
    
         
            -
                 
     | 
| 
       680 
     | 
    
         
            -
                ##
         
     | 
| 
      
 704 
     | 
    
         
            +
                # Save a todo file path to the database
         
     | 
| 
      
 705 
     | 
    
         
            +
                #
         
     | 
| 
      
 706 
     | 
    
         
            +
                # @param todo_file [String] The todo file path
         
     | 
| 
      
 707 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       681 
708 
     | 
    
         
             
                def save_working_dir(todo_file)
         
     | 
| 
       682 
709 
     | 
    
         
             
                  NA::Benchmark.measure('save_working_dir') do
         
     | 
| 
       683 
710 
     | 
    
         
             
                    file = database_path
         
     | 
| 
       684 
711 
     | 
    
         
             
                    content = File.exist?(file) ? file.read_file : ''
         
     | 
| 
       685 
     | 
    
         
            -
                    dirs = content.split( 
     | 
| 
      
 712 
     | 
    
         
            +
                    dirs = content.split("\n")
         
     | 
| 
       686 
713 
     | 
    
         
             
                    dirs.push(File.expand_path(todo_file))
         
     | 
| 
       687 
714 
     | 
    
         
             
                    dirs.sort!.uniq!
         
     | 
| 
       688 
715 
     | 
    
         
             
                    File.open(file, 'w') { |f| f.puts dirs.join("\n") }
         
     | 
| 
       689 
716 
     | 
    
         
             
                  end
         
     | 
| 
       690 
717 
     | 
    
         
             
                end
         
     | 
| 
       691 
718 
     | 
    
         | 
| 
       692 
     | 
    
         
            -
                 
     | 
| 
       693 
     | 
    
         
            -
                 
     | 
| 
       694 
     | 
    
         
            -
                 
     | 
| 
       695 
     | 
    
         
            -
                 
     | 
| 
       696 
     | 
    
         
            -
                ##
         
     | 
| 
      
 719 
     | 
    
         
            +
                # Save a backed-up file to the database
         
     | 
| 
      
 720 
     | 
    
         
            +
                #
         
     | 
| 
      
 721 
     | 
    
         
            +
                # @param file [String] The file
         
     | 
| 
      
 722 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       697 
723 
     | 
    
         
             
                def save_modified_file(file)
         
     | 
| 
       698 
724 
     | 
    
         
             
                  db = database_path(file: 'last_modified.txt')
         
     | 
| 
       699 
725 
     | 
    
         
             
                  file = File.expand_path(file)
         
     | 
| 
       700 
726 
     | 
    
         
             
                  if File.exist? db
         
     | 
| 
       701 
     | 
    
         
            -
                    files =  
     | 
| 
      
 727 
     | 
    
         
            +
                    files = File.read(db).split("\n").map(&:strip)
         
     | 
| 
       702 
728 
     | 
    
         
             
                    files.delete(file)
         
     | 
| 
       703 
729 
     | 
    
         
             
                    files << file
         
     | 
| 
       704 
730 
     | 
    
         
             
                    File.open(db, 'w') { |f| f.puts(files.join("\n")) }
         
     | 
| 
         @@ -707,22 +733,20 @@ module NA 
     | 
|
| 
       707 
733 
     | 
    
         
             
                  end
         
     | 
| 
       708 
734 
     | 
    
         
             
                end
         
     | 
| 
       709 
735 
     | 
    
         | 
| 
       710 
     | 
    
         
            -
                 
     | 
| 
       711 
     | 
    
         
            -
                 
     | 
| 
       712 
     | 
    
         
            -
                 
     | 
| 
       713 
     | 
    
         
            -
                 
     | 
| 
       714 
     | 
    
         
            -
                ##
         
     | 
| 
      
 736 
     | 
    
         
            +
                # Get the last modified file from the database
         
     | 
| 
      
 737 
     | 
    
         
            +
                #
         
     | 
| 
      
 738 
     | 
    
         
            +
                # @param search [String, nil] Optional search string
         
     | 
| 
      
 739 
     | 
    
         
            +
                # @return [String, nil] Last modified file path
         
     | 
| 
       715 
740 
     | 
    
         
             
                def last_modified_file(search: nil)
         
     | 
| 
       716 
741 
     | 
    
         
             
                  files = backup_files
         
     | 
| 
       717 
742 
     | 
    
         
             
                  files.delete_if { |f| f !~ Regexp.new(search.dir_to_rx(require_last: true)) } if search
         
     | 
| 
       718 
743 
     | 
    
         
             
                  files.last
         
     | 
| 
       719 
744 
     | 
    
         
             
                end
         
     | 
| 
       720 
745 
     | 
    
         | 
| 
       721 
     | 
    
         
            -
                 
     | 
| 
       722 
     | 
    
         
            -
                 
     | 
| 
       723 
     | 
    
         
            -
                 
     | 
| 
       724 
     | 
    
         
            -
                 
     | 
| 
       725 
     | 
    
         
            -
                ##
         
     | 
| 
      
 746 
     | 
    
         
            +
                # Get last modified file and restore a backup
         
     | 
| 
      
 747 
     | 
    
         
            +
                #
         
     | 
| 
      
 748 
     | 
    
         
            +
                # @param search [String, nil] Optional search string
         
     | 
| 
      
 749 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       726 
750 
     | 
    
         
             
                def restore_last_modified_file(search: nil)
         
     | 
| 
       727 
751 
     | 
    
         
             
                  file = last_modified_file(search: search)
         
     | 
| 
       728 
752 
     | 
    
         
             
                  if file
         
     | 
| 
         @@ -732,22 +756,23 @@ module NA 
     | 
|
| 
       732 
756 
     | 
    
         
             
                  end
         
     | 
| 
       733 
757 
     | 
    
         
             
                end
         
     | 
| 
       734 
758 
     | 
    
         | 
| 
       735 
     | 
    
         
            -
                 
     | 
| 
       736 
     | 
    
         
            -
                 
     | 
| 
       737 
     | 
    
         
            -
                 
     | 
| 
       738 
     | 
    
         
            -
                ## @return     [Array] list of file paths
         
     | 
| 
       739 
     | 
    
         
            -
                ##
         
     | 
| 
      
 759 
     | 
    
         
            +
                # Get list of backed up files
         
     | 
| 
      
 760 
     | 
    
         
            +
                #
         
     | 
| 
      
 761 
     | 
    
         
            +
                # @return [Array<String>] List of file paths
         
     | 
| 
       740 
762 
     | 
    
         
             
                def backup_files
         
     | 
| 
       741 
763 
     | 
    
         
             
                  db = database_path(file: 'last_modified.txt')
         
     | 
| 
       742 
764 
     | 
    
         
             
                  if File.exist?(db)
         
     | 
| 
       743 
     | 
    
         
            -
                     
     | 
| 
      
 765 
     | 
    
         
            +
                    File.read(db).strip.split("\n").map(&:strip)
         
     | 
| 
       744 
766 
     | 
    
         
             
                  else
         
     | 
| 
       745 
767 
     | 
    
         
             
                    NA.notify("#{NA.theme[:error]}Backup database not found")
         
     | 
| 
       746 
     | 
    
         
            -
                    File.open(db, 'w' 
     | 
| 
      
 768 
     | 
    
         
            +
                    File.open(db, 'w', &:puts)
         
     | 
| 
       747 
769 
     | 
    
         
             
                    []
         
     | 
| 
       748 
770 
     | 
    
         
             
                  end
         
     | 
| 
       749 
771 
     | 
    
         
             
                end
         
     | 
| 
       750 
772 
     | 
    
         | 
| 
      
 773 
     | 
    
         
            +
                # Move deprecated backup files to new backup folder
         
     | 
| 
      
 774 
     | 
    
         
            +
                #
         
     | 
| 
      
 775 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       751 
776 
     | 
    
         
             
                def move_deprecated_backups
         
     | 
| 
       752 
777 
     | 
    
         
             
                  backup_files.each do |file|
         
     | 
| 
       753 
778 
     | 
    
         
             
                    if File.exist?(old_backup_path(file))
         
     | 
| 
         @@ -757,15 +782,18 @@ module NA 
     | 
|
| 
       757 
782 
     | 
    
         
             
                  end
         
     | 
| 
       758 
783 
     | 
    
         
             
                end
         
     | 
| 
       759 
784 
     | 
    
         | 
| 
      
 785 
     | 
    
         
            +
                # Get the old backup file path for a file
         
     | 
| 
      
 786 
     | 
    
         
            +
                #
         
     | 
| 
      
 787 
     | 
    
         
            +
                # @param file [String] The file
         
     | 
| 
      
 788 
     | 
    
         
            +
                # @return [String] Old backup file path
         
     | 
| 
       760 
789 
     | 
    
         
             
                def old_backup_path(file)
         
     | 
| 
       761 
790 
     | 
    
         
             
                  File.join(File.dirname(file), ".#{File.basename(file)}.bak")
         
     | 
| 
       762 
791 
     | 
    
         
             
                end
         
     | 
| 
       763 
792 
     | 
    
         | 
| 
       764 
     | 
    
         
            -
                 
     | 
| 
       765 
     | 
    
         
            -
                 
     | 
| 
       766 
     | 
    
         
            -
                 
     | 
| 
       767 
     | 
    
         
            -
                 
     | 
| 
       768 
     | 
    
         
            -
                ##
         
     | 
| 
      
 793 
     | 
    
         
            +
                # Get the backup file path for a file
         
     | 
| 
      
 794 
     | 
    
         
            +
                #
         
     | 
| 
      
 795 
     | 
    
         
            +
                # @param file [String] The file
         
     | 
| 
      
 796 
     | 
    
         
            +
                # @return [String] Backup file path
         
     | 
| 
       769 
797 
     | 
    
         
             
                def backup_path(file)
         
     | 
| 
       770 
798 
     | 
    
         
             
                  backup_home = File.expand_path('~/.local/share/na/backup')
         
     | 
| 
       771 
799 
     | 
    
         
             
                  backup = old_backup_path(file)
         
     | 
| 
         @@ -777,6 +805,10 @@ module NA 
     | 
|
| 
       777 
805 
     | 
    
         
             
                  backup_target
         
     | 
| 
       778 
806 
     | 
    
         
             
                end
         
     | 
| 
       779 
807 
     | 
    
         | 
| 
      
 808 
     | 
    
         
            +
                # Remove entries for missing backup files from the database
         
     | 
| 
      
 809 
     | 
    
         
            +
                #
         
     | 
| 
      
 810 
     | 
    
         
            +
                # @param file [String, nil] Optional file to filter
         
     | 
| 
      
 811 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       780 
812 
     | 
    
         
             
                def weed_modified_files(file = nil)
         
     | 
| 
       781 
813 
     | 
    
         
             
                  files = backup_files
         
     | 
| 
       782 
814 
     | 
    
         | 
| 
         @@ -787,11 +819,10 @@ module NA 
     | 
|
| 
       787 
819 
     | 
    
         
             
                  File.open(database_path(file: 'last_modified.txt'), 'w') { |f| f.puts files.join("\n") }
         
     | 
| 
       788 
820 
     | 
    
         
             
                end
         
     | 
| 
       789 
821 
     | 
    
         | 
| 
       790 
     | 
    
         
            -
                 
     | 
| 
       791 
     | 
    
         
            -
                 
     | 
| 
       792 
     | 
    
         
            -
                 
     | 
| 
       793 
     | 
    
         
            -
                 
     | 
| 
       794 
     | 
    
         
            -
                ##
         
     | 
| 
      
 822 
     | 
    
         
            +
                # Restore a file from backup
         
     | 
| 
      
 823 
     | 
    
         
            +
                #
         
     | 
| 
      
 824 
     | 
    
         
            +
                # @param file [String] The file
         
     | 
| 
      
 825 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       795 
826 
     | 
    
         
             
                def restore_modified_file(file)
         
     | 
| 
       796 
827 
     | 
    
         
             
                  bak_file = backup_path(file)
         
     | 
| 
       797 
828 
     | 
    
         
             
                  if File.exist?(bak_file)
         
     | 
| 
         @@ -804,11 +835,10 @@ module NA 
     | 
|
| 
       804 
835 
     | 
    
         
             
                  weed_modified_files(file)
         
     | 
| 
       805 
836 
     | 
    
         
             
                end
         
     | 
| 
       806 
837 
     | 
    
         | 
| 
       807 
     | 
    
         
            -
                 
     | 
| 
       808 
     | 
    
         
            -
                 
     | 
| 
       809 
     | 
    
         
            -
                 
     | 
| 
       810 
     | 
    
         
            -
                 
     | 
| 
       811 
     | 
    
         
            -
                ##
         
     | 
| 
      
 838 
     | 
    
         
            +
                # Get path to database of known todo files
         
     | 
| 
      
 839 
     | 
    
         
            +
                #
         
     | 
| 
      
 840 
     | 
    
         
            +
                # @param file [String] The database filename (default: 'tdlist.txt')
         
     | 
| 
      
 841 
     | 
    
         
            +
                # @return [String] File path
         
     | 
| 
       812 
842 
     | 
    
         
             
                def database_path(file: 'tdlist.txt')
         
     | 
| 
       813 
843 
     | 
    
         
             
                  db_dir = File.expand_path('~/.local/share/na')
         
     | 
| 
       814 
844 
     | 
    
         
             
                  # Create directory if needed
         
     | 
| 
         @@ -816,11 +846,11 @@ module NA 
     | 
|
| 
       816 
846 
     | 
    
         
             
                  File.join(db_dir, file)
         
     | 
| 
       817 
847 
     | 
    
         
             
                end
         
     | 
| 
       818 
848 
     | 
    
         | 
| 
       819 
     | 
    
         
            -
                 
     | 
| 
       820 
     | 
    
         
            -
                 
     | 
| 
       821 
     | 
    
         
            -
                 
     | 
| 
       822 
     | 
    
         
            -
                 
     | 
| 
       823 
     | 
    
         
            -
                 
     | 
| 
      
 849 
     | 
    
         
            +
                # Platform-agnostic open command
         
     | 
| 
      
 850 
     | 
    
         
            +
                #
         
     | 
| 
      
 851 
     | 
    
         
            +
                # @param file [String] The file to open
         
     | 
| 
      
 852 
     | 
    
         
            +
                # @param app [String, nil] Optional application to use
         
     | 
| 
      
 853 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       824 
854 
     | 
    
         
             
                def os_open(file, app: nil)
         
     | 
| 
       825 
855 
     | 
    
         
             
                  os = RbConfig::CONFIG['target_os']
         
     | 
| 
       826 
856 
     | 
    
         
             
                  case os
         
     | 
| 
         @@ -833,9 +863,9 @@ module NA 
     | 
|
| 
       833 
863 
     | 
    
         
             
                  end
         
     | 
| 
       834 
864 
     | 
    
         
             
                end
         
     | 
| 
       835 
865 
     | 
    
         | 
| 
       836 
     | 
    
         
            -
                 
     | 
| 
       837 
     | 
    
         
            -
                 
     | 
| 
       838 
     | 
    
         
            -
                 
     | 
| 
      
 866 
     | 
    
         
            +
                #
         
     | 
| 
      
 867 
     | 
    
         
            +
                # Remove entries from cache database that no longer exist
         
     | 
| 
      
 868 
     | 
    
         
            +
                #
         
     | 
| 
       839 
869 
     | 
    
         
             
                def weed_cache_file
         
     | 
| 
       840 
870 
     | 
    
         
             
                  db_dir = File.expand_path('~/.local/share/na')
         
     | 
| 
       841 
871 
     | 
    
         
             
                  db_file = 'tdlist.txt'
         
     | 
| 
         @@ -863,7 +893,7 @@ module NA 
     | 
|
| 
       863 
893 
     | 
    
         | 
| 
       864 
894 
     | 
    
         
             
                  projects = find_projects(target)
         
     | 
| 
       865 
895 
     | 
    
         
             
                  projects.each do |proj|
         
     | 
| 
       866 
     | 
    
         
            -
                    parts = proj.project.split( 
     | 
| 
      
 896 
     | 
    
         
            +
                    parts = proj.project.split(':')
         
     | 
| 
       867 
897 
     | 
    
         
             
                    output = if paths
         
     | 
| 
       868 
898 
     | 
    
         
             
                               "{bg}#{parts.join('{bw}/{bg}')}{x}"
         
     | 
| 
       869 
899 
     | 
    
         
             
                             else
         
     | 
| 
         @@ -883,12 +913,10 @@ module NA 
     | 
|
| 
       883 
913 
     | 
    
         
             
                           content = File.exist?(file) ? file.read_file.strip : ''
         
     | 
| 
       884 
914 
     | 
    
         
             
                           notify("#{NA.theme[:error]}Database empty", exit_code: 1) if content.empty?
         
     | 
| 
       885 
915 
     | 
    
         | 
| 
       886 
     | 
    
         
            -
                           content.split( 
     | 
| 
      
 916 
     | 
    
         
            +
                           content.split("\n")
         
     | 
| 
       887 
917 
     | 
    
         
             
                         end
         
     | 
| 
       888 
918 
     | 
    
         | 
| 
       889 
     | 
    
         
            -
                  dirs.map! 
     | 
| 
       890 
     | 
    
         
            -
                    dir.highlight_filename
         
     | 
| 
       891 
     | 
    
         
            -
                  end
         
     | 
| 
      
 919 
     | 
    
         
            +
                  dirs.map!(&:highlight_filename)
         
     | 
| 
       892 
920 
     | 
    
         | 
| 
       893 
921 
     | 
    
         
             
                  puts NA::Color.template(dirs.join("\n"))
         
     | 
| 
       894 
922 
     | 
    
         
             
                end
         
     | 
| 
         @@ -896,7 +924,7 @@ module NA 
     | 
|
| 
       896 
924 
     | 
    
         
             
                def save_search(title, search)
         
     | 
| 
       897 
925 
     | 
    
         
             
                  file = database_path(file: 'saved_searches.yml')
         
     | 
| 
       898 
926 
     | 
    
         
             
                  searches = load_searches
         
     | 
| 
       899 
     | 
    
         
            -
                  title = title.gsub(/[^a- 
     | 
| 
      
 927 
     | 
    
         
            +
                  title = title.gsub(/[^a-zA-Z0-9]/, '_').gsub(/_+/, '_').downcase
         
     | 
| 
       900 
928 
     | 
    
         | 
| 
       901 
929 
     | 
    
         
             
                  if searches.key?(title)
         
     | 
| 
       902 
930 
     | 
    
         
             
                    res = yn('Overwrite existing definition?', default: true)
         
     | 
| 
         @@ -909,10 +937,13 @@ module NA 
     | 
|
| 
       909 
937 
     | 
    
         
             
                  NA.notify("#{NA.theme[:success]}Search #{NA.theme[:filename]}#{title}#{NA.theme[:success]} saved", exit_code: 0)
         
     | 
| 
       910 
938 
     | 
    
         
             
                end
         
     | 
| 
       911 
939 
     | 
    
         | 
| 
      
 940 
     | 
    
         
            +
                # Load saved search definitions from YAML file
         
     | 
| 
      
 941 
     | 
    
         
            +
                #
         
     | 
| 
      
 942 
     | 
    
         
            +
                # @return [Hash] Hash of saved searches
         
     | 
| 
       912 
943 
     | 
    
         
             
                def load_searches
         
     | 
| 
       913 
944 
     | 
    
         
             
                  file = database_path(file: 'saved_searches.yml')
         
     | 
| 
       914 
945 
     | 
    
         
             
                  if File.exist?(file)
         
     | 
| 
       915 
     | 
    
         
            -
                    searches = YAML. 
     | 
| 
      
 946 
     | 
    
         
            +
                    searches = YAML.load(file.read_file)
         
     | 
| 
       916 
947 
     | 
    
         
             
                  else
         
     | 
| 
       917 
948 
     | 
    
         
             
                    searches = {
         
     | 
| 
       918 
949 
     | 
    
         
             
                      'soon' => 'tagged "due<in 2 days,due>yesterday"',
         
     | 
| 
         @@ -925,6 +956,10 @@ module NA 
     | 
|
| 
       925 
956 
     | 
    
         
             
                  searches
         
     | 
| 
       926 
957 
     | 
    
         
             
                end
         
     | 
| 
       927 
958 
     | 
    
         | 
| 
      
 959 
     | 
    
         
            +
                # Delete saved search definitions by name
         
     | 
| 
      
 960 
     | 
    
         
            +
                #
         
     | 
| 
      
 961 
     | 
    
         
            +
                # @param strings [Array<String>, String, nil] Names of searches to delete
         
     | 
| 
      
 962 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       928 
963 
     | 
    
         
             
                def delete_search(strings = nil)
         
     | 
| 
       929 
964 
     | 
    
         
             
                  NA.notify("#{NA.theme[:error]}Name of search required", exit_code: 1) if strings.nil? || strings.empty?
         
     | 
| 
       930 
965 
     | 
    
         | 
| 
         @@ -933,7 +968,7 @@ module NA 
     | 
|
| 
       933 
968 
     | 
    
         | 
| 
       934 
969 
     | 
    
         
             
                  strings = [strings] unless strings.is_a? Array
         
     | 
| 
       935 
970 
     | 
    
         | 
| 
       936 
     | 
    
         
            -
                  searches = YAML. 
     | 
| 
      
 971 
     | 
    
         
            +
                  searches = YAML.load(file.read_file)
         
     | 
| 
       937 
972 
     | 
    
         
             
                  keys = searches.keys.delete_if { |k| k !~ /(#{strings.map(&:wildcard_to_rx).join('|')})/ }
         
     | 
| 
       938 
973 
     | 
    
         | 
| 
       939 
974 
     | 
    
         
             
                  NA.notify("#{NA.theme[:error]}No search named #{strings.join(', ')} found", exit_code: 1) if keys.empty?
         
     | 
| 
         @@ -947,9 +982,14 @@ module NA 
     | 
|
| 
       947 
982 
     | 
    
         | 
| 
       948 
983 
     | 
    
         
             
                  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
         
     | 
| 
       949 
984 
     | 
    
         | 
| 
       950 
     | 
    
         
            -
                  NA.notify( 
     | 
| 
      
 985 
     | 
    
         
            +
                  NA.notify(
         
     | 
| 
      
 986 
     | 
    
         
            +
                    "#{NA.theme[:warning]}Deleted {bw}#{keys.count}{x}#{NA.theme[:warning]} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0
         
     | 
| 
      
 987 
     | 
    
         
            +
                  )
         
     | 
| 
       951 
988 
     | 
    
         
             
                end
         
     | 
| 
       952 
989 
     | 
    
         | 
| 
      
 990 
     | 
    
         
            +
                # Edit saved search definitions in the default editor
         
     | 
| 
      
 991 
     | 
    
         
            +
                #
         
     | 
| 
      
 992 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       953 
993 
     | 
    
         
             
                def edit_searches
         
     | 
| 
       954 
994 
     | 
    
         
             
                  file = database_path(file: 'saved_searches.yml')
         
     | 
| 
       955 
995 
     | 
    
         
             
                  searches = load_searches
         
     | 
| 
         @@ -963,23 +1003,22 @@ module NA 
     | 
|
| 
       963 
1003 
     | 
    
         
             
                  NA.notify("#{NA.theme[:success]}Opened #{file} in #{editor}", exit_code: 0)
         
     | 
| 
       964 
1004 
     | 
    
         
             
                end
         
     | 
| 
       965 
1005 
     | 
    
         | 
| 
       966 
     | 
    
         
            -
                 
     | 
| 
       967 
     | 
    
         
            -
                 
     | 
| 
       968 
     | 
    
         
            -
                 
     | 
| 
       969 
     | 
    
         
            -
                 
     | 
| 
       970 
     | 
    
         
            -
                ##
         
     | 
| 
      
 1006 
     | 
    
         
            +
                # Create a backup file
         
     | 
| 
      
 1007 
     | 
    
         
            +
                #
         
     | 
| 
      
 1008 
     | 
    
         
            +
                # @param target [String] The file to back up
         
     | 
| 
      
 1009 
     | 
    
         
            +
                # @return [void]
         
     | 
| 
       971 
1010 
     | 
    
         
             
                def backup_file(target)
         
     | 
| 
       972 
1011 
     | 
    
         
             
                  FileUtils.cp(target, backup_path(target))
         
     | 
| 
       973 
1012 
     | 
    
         
             
                  save_modified_file(target)
         
     | 
| 
       974 
1013 
     | 
    
         
             
                  NA.notify("#{NA.theme[:warning]}Backup file created for #{target.highlight_filename}", debug: true)
         
     | 
| 
       975 
1014 
     | 
    
         
             
                end
         
     | 
| 
       976 
1015 
     | 
    
         | 
| 
       977 
     | 
    
         
            -
                 
     | 
| 
       978 
     | 
    
         
            -
                 
     | 
| 
       979 
     | 
    
         
            -
                 
     | 
| 
       980 
     | 
    
         
            -
                 
     | 
| 
       981 
     | 
    
         
            -
                 
     | 
| 
       982 
     | 
    
         
            -
                 
     | 
| 
      
 1016 
     | 
    
         
            +
                #
         
     | 
| 
      
 1017 
     | 
    
         
            +
                # Request terminal input from user, readline style
         
     | 
| 
      
 1018 
     | 
    
         
            +
                #
         
     | 
| 
      
 1019 
     | 
    
         
            +
                # @param      options  [Hash] The options
         
     | 
| 
      
 1020 
     | 
    
         
            +
                # @param      prompt   [String] The prompt
         
     | 
| 
      
 1021 
     | 
    
         
            +
                #
         
     | 
| 
       983 
1022 
     | 
    
         
             
                def request_input(options, prompt: 'Enter text')
         
     | 
| 
       984 
1023 
     | 
    
         
             
                  if $stdin.isatty && TTY::Which.exist?('gum') && (options[:tagged].nil? || options[:tagged].empty?)
         
     | 
| 
       985 
1024 
     | 
    
         
             
                    opts = [%(--placeholder "#{prompt}"),
         
     | 
| 
         @@ -992,18 +1031,18 @@ module NA 
     | 
|
| 
       992 
1031 
     | 
    
         
             
                  end
         
     | 
| 
       993 
1032 
     | 
    
         
             
                end
         
     | 
| 
       994 
1033 
     | 
    
         | 
| 
       995 
     | 
    
         
            -
                 
     | 
| 
       996 
     | 
    
         
            -
                 
     | 
| 
       997 
     | 
    
         
            -
                 
     | 
| 
       998 
     | 
    
         
            -
                 
     | 
| 
       999 
     | 
    
         
            -
                 
     | 
| 
       1000 
     | 
    
         
            -
                 
     | 
| 
       1001 
     | 
    
         
            -
                 
     | 
| 
       1002 
     | 
    
         
            -
                 
     | 
| 
       1003 
     | 
    
         
            -
                 
     | 
| 
       1004 
     | 
    
         
            -
                 
     | 
| 
       1005 
     | 
    
         
            -
                 
     | 
| 
       1006 
     | 
    
         
            -
                 
     | 
| 
      
 1034 
     | 
    
         
            +
                #
         
     | 
| 
      
 1035 
     | 
    
         
            +
                # Generate a menu of options and allow user selection
         
     | 
| 
      
 1036 
     | 
    
         
            +
                #
         
     | 
| 
      
 1037 
     | 
    
         
            +
                # @return     [String] The selected option
         
     | 
| 
      
 1038 
     | 
    
         
            +
                #
         
     | 
| 
      
 1039 
     | 
    
         
            +
                # @param      options   [Array] The options from which to choose
         
     | 
| 
      
 1040 
     | 
    
         
            +
                # @param      prompt    [String] The prompt
         
     | 
| 
      
 1041 
     | 
    
         
            +
                # @param      multiple  [Boolean] If true, allow multiple selections
         
     | 
| 
      
 1042 
     | 
    
         
            +
                # @param      sorted    [Boolean] If true, sort selections alphanumerically
         
     | 
| 
      
 1043 
     | 
    
         
            +
                # @param      fzf_args  [Array] Additional fzf arguments
         
     | 
| 
      
 1044 
     | 
    
         
            +
                #
         
     | 
| 
      
 1045 
     | 
    
         
            +
                # @return [String, Array] array if multiple is true
         
     | 
| 
       1007 
1046 
     | 
    
         
             
                def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
         
     | 
| 
       1008 
1047 
     | 
    
         
             
                  return nil unless $stdout.isatty
         
     | 
| 
       1009 
1048 
     | 
    
         | 
| 
         @@ -1031,12 +1070,14 @@ module NA 
     | 
|
| 
       1031 
1070 
     | 
    
         
             
                          reader = TTY::Reader.new
         
     | 
| 
       1032 
1071 
     | 
    
         
             
                          puts
         
     | 
| 
       1033 
1072 
     | 
    
         
             
                          options.each.with_index do |f, i|
         
     | 
| 
       1034 
     | 
    
         
            -
                            puts NA::Color.template(format( 
     | 
| 
      
 1073 
     | 
    
         
            +
                            puts NA::Color.template(format(
         
     | 
| 
      
 1074 
     | 
    
         
            +
                                                      "#{NA.theme[:prompt]}%<idx> 2d{xw}) #{NA.theme[:filename]}%<action>s{x}\n", idx: i + 1, action: f
         
     | 
| 
      
 1075 
     | 
    
         
            +
                                                    ))
         
     | 
| 
       1035 
1076 
     | 
    
         
             
                          end
         
     | 
| 
       1036 
1077 
     | 
    
         
             
                          result = reader.read_line(NA::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")).strip
         
     | 
| 
       1037 
1078 
     | 
    
         
             
                          if multiple
         
     | 
| 
       1038 
1079 
     | 
    
         
             
                            mult_res = []
         
     | 
| 
       1039 
     | 
    
         
            -
                            result = result.gsub( 
     | 
| 
      
 1080 
     | 
    
         
            +
                            result = result.gsub(',', ' ').gsub(/ +/, ' ').split(/ /)
         
     | 
| 
       1040 
1081 
     | 
    
         
             
                            result.each do |r|
         
     | 
| 
       1041 
1082 
     | 
    
         
             
                              mult_res << options[r.to_i - 1] if r.to_i&.positive?
         
     | 
| 
       1042 
1083 
     | 
    
         
             
                            end
         
     | 
| 
         @@ -1046,19 +1087,20 @@ module NA 
     | 
|
| 
       1046 
1087 
     | 
    
         
             
                          end
         
     | 
| 
       1047 
1088 
     | 
    
         
             
                        end
         
     | 
| 
       1048 
1089 
     | 
    
         | 
| 
       1049 
     | 
    
         
            -
                  return false if res&.strip&. 
     | 
| 
      
 1090 
     | 
    
         
            +
                  return false if res&.strip&.empty?
         
     | 
| 
      
 1091 
     | 
    
         
            +
             
     | 
| 
       1050 
1092 
     | 
    
         
             
                  # pp NA::Color.uncolor(NA::Color.template(res))
         
     | 
| 
       1051 
     | 
    
         
            -
                  multiple ? NA::Color.uncolor(NA::Color.template(res)).split( 
     | 
| 
      
 1093 
     | 
    
         
            +
                  multiple ? NA::Color.uncolor(NA::Color.template(res)).split("\n") : NA::Color.uncolor(NA::Color.template(res))
         
     | 
| 
       1052 
1094 
     | 
    
         
             
                end
         
     | 
| 
       1053 
1095 
     | 
    
         | 
| 
       1054 
1096 
     | 
    
         
             
                private
         
     | 
| 
       1055 
1097 
     | 
    
         | 
| 
       1056 
     | 
    
         
            -
                 
     | 
| 
       1057 
     | 
    
         
            -
                 
     | 
| 
       1058 
     | 
    
         
            -
                 
     | 
| 
       1059 
     | 
    
         
            -
                 
     | 
| 
       1060 
     | 
    
         
            -
                 
     | 
| 
       1061 
     | 
    
         
            -
                 
     | 
| 
      
 1098 
     | 
    
         
            +
                #
         
     | 
| 
      
 1099 
     | 
    
         
            +
                # macOS open command
         
     | 
| 
      
 1100 
     | 
    
         
            +
                #
         
     | 
| 
      
 1101 
     | 
    
         
            +
                # @param      file  The file
         
     | 
| 
      
 1102 
     | 
    
         
            +
                # @param      app   The application
         
     | 
| 
      
 1103 
     | 
    
         
            +
                #
         
     | 
| 
       1062 
1104 
     | 
    
         
             
                def darwin_open(file, app: nil)
         
     | 
| 
       1063 
1105 
     | 
    
         
             
                  if app
         
     | 
| 
       1064 
1106 
     | 
    
         
             
                    `open -a "#{app}" #{Shellwords.escape(file)}`
         
     | 
| 
         @@ -1067,20 +1109,20 @@ module NA 
     | 
|
| 
       1067 
1109 
     | 
    
         
             
                  end
         
     | 
| 
       1068 
1110 
     | 
    
         
             
                end
         
     | 
| 
       1069 
1111 
     | 
    
         | 
| 
       1070 
     | 
    
         
            -
                 
     | 
| 
       1071 
     | 
    
         
            -
                 
     | 
| 
       1072 
     | 
    
         
            -
                 
     | 
| 
       1073 
     | 
    
         
            -
                 
     | 
| 
       1074 
     | 
    
         
            -
                 
     | 
| 
      
 1112 
     | 
    
         
            +
                #
         
     | 
| 
      
 1113 
     | 
    
         
            +
                # Windows open command
         
     | 
| 
      
 1114 
     | 
    
         
            +
                #
         
     | 
| 
      
 1115 
     | 
    
         
            +
                # @param      file  The file
         
     | 
| 
      
 1116 
     | 
    
         
            +
                #
         
     | 
| 
       1075 
1117 
     | 
    
         
             
                def win_open(file)
         
     | 
| 
       1076 
1118 
     | 
    
         
             
                  `start #{Shellwords.escape(file)}`
         
     | 
| 
       1077 
1119 
     | 
    
         
             
                end
         
     | 
| 
       1078 
1120 
     | 
    
         | 
| 
       1079 
     | 
    
         
            -
                 
     | 
| 
       1080 
     | 
    
         
            -
                 
     | 
| 
       1081 
     | 
    
         
            -
                 
     | 
| 
       1082 
     | 
    
         
            -
                 
     | 
| 
       1083 
     | 
    
         
            -
                 
     | 
| 
      
 1121 
     | 
    
         
            +
                #
         
     | 
| 
      
 1122 
     | 
    
         
            +
                # Linux open command
         
     | 
| 
      
 1123 
     | 
    
         
            +
                #
         
     | 
| 
      
 1124 
     | 
    
         
            +
                # @param      file  The file
         
     | 
| 
      
 1125 
     | 
    
         
            +
                #
         
     | 
| 
       1084 
1126 
     | 
    
         
             
                def linux_open(file)
         
     | 
| 
       1085 
1127 
     | 
    
         
             
                  if TTY::Which.exist?('xdg-open')
         
     | 
| 
       1086 
1128 
     | 
    
         
             
                    `xdg-open #{Shellwords.escape(file)}`
         
     |