na 1.1.26 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/Gemfile.lock +3 -1
- data/README.md +161 -23
- data/bin/na +364 -43
- data/lib/na/action.rb +11 -13
- data/lib/na/colors.rb +2 -1
- data/lib/na/next_action.rb +347 -49
- data/lib/na/project.rb +26 -0
- data/lib/na/string.rb +9 -5
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +1 -0
- data/na.gemspec +1 -0
- data/src/README.md +58 -4
- metadata +23 -2
    
        data/lib/na/next_action.rb
    CHANGED
    
    | @@ -89,6 +89,7 @@ module NA | |
| 89 89 | 
             
                    ENDCONTENT
         | 
| 90 90 | 
             
                    f.puts(content)
         | 
| 91 91 | 
             
                  end
         | 
| 92 | 
            +
                  save_working_dir(target)
         | 
| 92 93 | 
             
                  notify("{y}Created {bw}#{target}")
         | 
| 93 94 | 
             
                end
         | 
| 94 95 |  | 
| @@ -106,24 +107,28 @@ module NA | |
| 106 107 | 
             
                ##
         | 
| 107 108 | 
             
                ## Select from multiple files
         | 
| 108 109 | 
             
                ##
         | 
| 109 | 
            -
                ## @note | 
| 110 | 
            +
                ## @note       If `gum` or `fzf` are available, they'll
         | 
| 111 | 
            +
                ##             be used (in that order)
         | 
| 110 112 | 
             
                ##
         | 
| 111 | 
            -
                ## @param      files | 
| 113 | 
            +
                ## @param      files     [Array] The files
         | 
| 114 | 
            +
                ## @param      multiple  [Boolean] allow multiple selections
         | 
| 112 115 | 
             
                ##
         | 
| 113 | 
            -
                def select_file(files)
         | 
| 114 | 
            -
                  if TTY::Which.exist?(' | 
| 115 | 
            -
                     | 
| 116 | 
            -
                      '--cursor.foreground="151"',
         | 
| 117 | 
            -
                      '--item.foreground=""'
         | 
| 118 | 
            -
                    ]
         | 
| 119 | 
            -
                    `echo #{Shellwords.escape(files.join("\n"))}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`.strip
         | 
| 120 | 
            -
                  elsif TTY::Which.exist?('fzf')
         | 
| 121 | 
            -
                    res = choose_from(files, prompt: 'Use which file?')
         | 
| 116 | 
            +
                def select_file(files, multiple: false)
         | 
| 117 | 
            +
                  if TTY::Which.exist?('fzf')
         | 
| 118 | 
            +
                    res = choose_from(files, prompt: 'Use which file?', multiple: multiple)
         | 
| 122 119 | 
             
                    unless res
         | 
| 123 120 | 
             
                      notify('{r}No file selected, cancelled', exit_code: 1)
         | 
| 124 121 | 
             
                    end
         | 
| 125 122 |  | 
| 126 | 
            -
                    res.strip
         | 
| 123 | 
            +
                    multiple ? res.split("\n") : res.strip
         | 
| 124 | 
            +
                  elsif TTY::Which.exist?('gum')
         | 
| 125 | 
            +
                    args = [
         | 
| 126 | 
            +
                      '--cursor.foreground="151"',
         | 
| 127 | 
            +
                      '--item.foreground=""'
         | 
| 128 | 
            +
                    ]
         | 
| 129 | 
            +
                    args.push('--no-limit') if multiple
         | 
| 130 | 
            +
                    res = `echo #{Shellwords.escape(files.join("\n"))}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`
         | 
| 131 | 
            +
                    multiple ? res.split("\n") : res.strip
         | 
| 127 132 | 
             
                  else
         | 
| 128 133 | 
             
                    reader = TTY::Reader.new
         | 
| 129 134 | 
             
                    puts
         | 
| @@ -135,6 +140,195 @@ module NA | |
| 135 140 | 
             
                  end
         | 
| 136 141 | 
             
                end
         | 
| 137 142 |  | 
| 143 | 
            +
                def shift_index_after(projects, idx, length = 1)
         | 
| 144 | 
            +
                  projects.map do |proj|
         | 
| 145 | 
            +
                    proj.line = proj.line > idx ? proj.line - length : proj.line
         | 
| 146 | 
            +
                    proj
         | 
| 147 | 
            +
                  end
         | 
| 148 | 
            +
                end
         | 
| 149 | 
            +
             | 
| 150 | 
            +
                def find_projects(target)
         | 
| 151 | 
            +
                  _, _, projects = parse_actions(require_na: false, file_path: target)
         | 
| 152 | 
            +
                  projects
         | 
| 153 | 
            +
                end
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                def find_actions(target, search, tagged = nil, all: false)
         | 
| 156 | 
            +
                  _, actions, projects = parse_actions(search: search, require_na: false, file_path: target, tag: tagged)
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                  NA.notify('{r}No matching actions found', exit_code: 1) unless actions.count.positive?
         | 
| 159 | 
            +
                  return [projects, actions] if actions.count == 1 || all
         | 
| 160 | 
            +
             | 
| 161 | 
            +
                  options = actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
         | 
| 162 | 
            +
                  res = if TTY::Which.exist?('fzf')
         | 
| 163 | 
            +
                          choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
         | 
| 164 | 
            +
                        elsif TTY::Which.exist?('gum')
         | 
| 165 | 
            +
                          args = [
         | 
| 166 | 
            +
                            '--cursor.foreground="151"',
         | 
| 167 | 
            +
                            '--item.foreground=""',
         | 
| 168 | 
            +
                            '--no-limit'
         | 
| 169 | 
            +
                          ]
         | 
| 170 | 
            +
                          `echo #{Shellwords.escape(options.join("\n"))}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`.strip
         | 
| 171 | 
            +
                        else
         | 
| 172 | 
            +
                          reader = TTY::Reader.new
         | 
| 173 | 
            +
                          puts
         | 
| 174 | 
            +
                          options.each.with_index do |f, i|
         | 
| 175 | 
            +
                            puts NA::Color.template(format("{bw}%<idx> 2d{xw}) {y}%<action>s{x}\n", idx: i + 1, action: f))
         | 
| 176 | 
            +
                          end
         | 
| 177 | 
            +
                          result = reader.read_line(NA::Color.template('{bw}Use which file? {x}')).strip
         | 
| 178 | 
            +
                          if result && result.to_i.positive?
         | 
| 179 | 
            +
                            options[result.to_i - 1]
         | 
| 180 | 
            +
                          else
         | 
| 181 | 
            +
                            nil
         | 
| 182 | 
            +
                          end
         | 
| 183 | 
            +
                        end
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                  NA.notify('{r}Cancelled', exit_code: 1) unless res && res.length.positive?
         | 
| 186 | 
            +
             | 
| 187 | 
            +
                  selected = []
         | 
| 188 | 
            +
                  res.split(/\n/).each do |result|
         | 
| 189 | 
            +
                    idx = result.match(/^(\d+)(?= % )/)[1]
         | 
| 190 | 
            +
                    action = actions.select { |a| a.line == idx.to_i }.first
         | 
| 191 | 
            +
                    selected.push(action)
         | 
| 192 | 
            +
                  end
         | 
| 193 | 
            +
                  [projects, selected]
         | 
| 194 | 
            +
                end
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                def insert_project(target, project)
         | 
| 197 | 
            +
                  path = project.split(%r{[:/]})
         | 
| 198 | 
            +
                  _, _, projects = parse_actions(file_path: target)
         | 
| 199 | 
            +
                  built = []
         | 
| 200 | 
            +
                  last_match = nil
         | 
| 201 | 
            +
                  final_match = nil
         | 
| 202 | 
            +
                  new_path = []
         | 
| 203 | 
            +
                  matches = nil
         | 
| 204 | 
            +
                  path.each_with_index do |part, i|
         | 
| 205 | 
            +
                    built.push(part)
         | 
| 206 | 
            +
                    matches = projects.select { |proj| proj.project =~ /^#{built.join(':')}/i }
         | 
| 207 | 
            +
                    if matches.count.zero?
         | 
| 208 | 
            +
                      final_match = last_match
         | 
| 209 | 
            +
                      new_path = path.slice(i, path.count - i)
         | 
| 210 | 
            +
                      break
         | 
| 211 | 
            +
                    else
         | 
| 212 | 
            +
                      last_match = matches.last
         | 
| 213 | 
            +
                    end
         | 
| 214 | 
            +
                  end
         | 
| 215 | 
            +
             | 
| 216 | 
            +
                  content = target.read_file
         | 
| 217 | 
            +
                  if final_match.nil?
         | 
| 218 | 
            +
                    indent = 0
         | 
| 219 | 
            +
                    input = []
         | 
| 220 | 
            +
                    new_path.each do |part|
         | 
| 221 | 
            +
                      input.push("#{"\t" * indent}#{part.cap_first}:")
         | 
| 222 | 
            +
                      indent += 1
         | 
| 223 | 
            +
                    end
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                    if new_path.join('') =~ /Archive/i
         | 
| 226 | 
            +
                      content = "#{content.strip}\n#{input.join("\n")}"
         | 
| 227 | 
            +
                    else
         | 
| 228 | 
            +
                      content = "#{input.join("\n")}\n#{content}"
         | 
| 229 | 
            +
                    end
         | 
| 230 | 
            +
             | 
| 231 | 
            +
                    new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, input.count - 1)
         | 
| 232 | 
            +
                  else
         | 
| 233 | 
            +
                    line = final_match.line + 1
         | 
| 234 | 
            +
                    indent = final_match.indent + 1
         | 
| 235 | 
            +
                    input = []
         | 
| 236 | 
            +
                    new_path.each do |part|
         | 
| 237 | 
            +
                      input.push("#{"\t" * indent}#{part.cap_first}:")
         | 
| 238 | 
            +
                      indent += 1
         | 
| 239 | 
            +
                    end
         | 
| 240 | 
            +
                    content = content.split("\n").insert(line, input.join("\n")).join("\n")
         | 
| 241 | 
            +
                    new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1)
         | 
| 242 | 
            +
                  end
         | 
| 243 | 
            +
             | 
| 244 | 
            +
                  File.open(target, 'w') do |f|
         | 
| 245 | 
            +
                    f.puts content
         | 
| 246 | 
            +
                  end
         | 
| 247 | 
            +
             | 
| 248 | 
            +
                  new_project
         | 
| 249 | 
            +
                end
         | 
| 250 | 
            +
             | 
| 251 | 
            +
                def update_action(target,
         | 
| 252 | 
            +
                                  search,
         | 
| 253 | 
            +
                                  priority: 0,
         | 
| 254 | 
            +
                                  add_tag: [],
         | 
| 255 | 
            +
                                  remove_tag: [],
         | 
| 256 | 
            +
                                  finish: false,
         | 
| 257 | 
            +
                                  project: nil,
         | 
| 258 | 
            +
                                  delete: false,
         | 
| 259 | 
            +
                                  note: [],
         | 
| 260 | 
            +
                                  overwrite: false,
         | 
| 261 | 
            +
                                  tagged: nil,
         | 
| 262 | 
            +
                                  all: false)
         | 
| 263 | 
            +
             | 
| 264 | 
            +
                  projects = find_projects(target)
         | 
| 265 | 
            +
             | 
| 266 | 
            +
                  target_proj = nil
         | 
| 267 | 
            +
             | 
| 268 | 
            +
                  if project
         | 
| 269 | 
            +
                    target_proj = projects.select { |pr| pr.project =~ /#{project.gsub(/:/, '.*?:.*?')}/i }.first
         | 
| 270 | 
            +
                    if target_proj.nil?
         | 
| 271 | 
            +
                      res = NA.yn(NA::Color.template("{y}Project {bw}#{project}{xy} doesn't exist, add it"), default: true)
         | 
| 272 | 
            +
                      if res
         | 
| 273 | 
            +
                        target_proj = insert_project(target, project)
         | 
| 274 | 
            +
                      else
         | 
| 275 | 
            +
                        NA.notify('{x}Cancelled', exit_code: 1)
         | 
| 276 | 
            +
                      end
         | 
| 277 | 
            +
                    end
         | 
| 278 | 
            +
                  end
         | 
| 279 | 
            +
             | 
| 280 | 
            +
                  projects, actions = find_actions(target, search, tagged, all: all)
         | 
| 281 | 
            +
             | 
| 282 | 
            +
                  contents = target.read_file.split(/\n/)
         | 
| 283 | 
            +
             | 
| 284 | 
            +
                  actions.sort_by(&:line).reverse.each do |action|
         | 
| 285 | 
            +
                    string = action.action
         | 
| 286 | 
            +
             | 
| 287 | 
            +
                    if priority&.positive?
         | 
| 288 | 
            +
                      string.gsub!(/@priority\(\d+\)/, '').strip!
         | 
| 289 | 
            +
                      string += " @priority(#{priority})"
         | 
| 290 | 
            +
                    end
         | 
| 291 | 
            +
             | 
| 292 | 
            +
                    add_tag.each do |tag|
         | 
| 293 | 
            +
                      string.gsub!(/@#{tag.gsub(/([()*?])/, '\\\\1')}(\(.*?\))?/, '')
         | 
| 294 | 
            +
                      string.strip!
         | 
| 295 | 
            +
                      string += " @#{tag}"
         | 
| 296 | 
            +
                    end
         | 
| 297 | 
            +
             | 
| 298 | 
            +
                    remove_tag.each do |tag|
         | 
| 299 | 
            +
                      string.gsub!(/@#{tag}(\(.*?\))?/, '')
         | 
| 300 | 
            +
                      string.strip!
         | 
| 301 | 
            +
                    end
         | 
| 302 | 
            +
             | 
| 303 | 
            +
                    string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /@done/
         | 
| 304 | 
            +
             | 
| 305 | 
            +
                    contents.slice!(action.line, action.note.count + 1)
         | 
| 306 | 
            +
                    next if delete
         | 
| 307 | 
            +
             | 
| 308 | 
            +
                    projects = shift_index_after(projects, action.line, action.note.count + 1)
         | 
| 309 | 
            +
             | 
| 310 | 
            +
                    target_proj = if target_proj
         | 
| 311 | 
            +
                                    projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
         | 
| 312 | 
            +
                                  else
         | 
| 313 | 
            +
                                    projects.select { |proj| proj.project =~ /^#{action.parent.join(':')}$/ }.first
         | 
| 314 | 
            +
                                  end
         | 
| 315 | 
            +
             | 
| 316 | 
            +
                    indent = "\t" * target_proj.indent
         | 
| 317 | 
            +
                    note = note.split("\n") unless note.is_a?(Array)
         | 
| 318 | 
            +
                    note = if note.empty?
         | 
| 319 | 
            +
                             action.note
         | 
| 320 | 
            +
                           else
         | 
| 321 | 
            +
                             overwrite ? note : action.note.concat(note)
         | 
| 322 | 
            +
                           end
         | 
| 323 | 
            +
                    note = note.empty? ? '' : "\n#{indent}\t\t#{note.join("\n#{indent}\t\t").strip}"
         | 
| 324 | 
            +
                    contents.insert(target_proj.line, "#{indent}\t- #{string}#{note}")
         | 
| 325 | 
            +
                  end
         | 
| 326 | 
            +
                  backup_file(target)
         | 
| 327 | 
            +
                  File.open(target, 'w') { |f| f.puts contents.join("\n") }
         | 
| 328 | 
            +
             | 
| 329 | 
            +
                  notify("{by}Task updated in {bw}#{target}")
         | 
| 330 | 
            +
                end
         | 
| 331 | 
            +
             | 
| 138 332 | 
             
                ##
         | 
| 139 333 | 
             
                ## Add an action to a todo file
         | 
| 140 334 | 
             
                ##
         | 
| @@ -198,16 +392,19 @@ module NA | |
| 198 392 | 
             
                ##
         | 
| 199 393 | 
             
                ## Read a todo file and create a list of actions
         | 
| 200 394 | 
             
                ##
         | 
| 201 | 
            -
                ## @param      depth       [Number] The directory depth | 
| 202 | 
            -
                ##  | 
| 203 | 
            -
                ## @param       | 
| 395 | 
            +
                ## @param      depth       [Number] The directory depth
         | 
| 396 | 
            +
                ##                         to search for files
         | 
| 397 | 
            +
                ## @param      query       [Hash] The todo file query
         | 
| 398 | 
            +
                ## @param      tag         [Array] Tags to search for
         | 
| 204 399 | 
             
                ## @param      search      [String] A search string
         | 
| 205 400 | 
             
                ## @param      negate      [Boolean] Invert results
         | 
| 206 | 
            -
                ## @param      regex       [Boolean] Interpret as | 
| 401 | 
            +
                ## @param      regex       [Boolean] Interpret as
         | 
| 402 | 
            +
                ##                         regular expression
         | 
| 207 403 | 
             
                ## @param      project     [String] The project
         | 
| 208 404 | 
             
                ## @param      require_na  [Boolean] Require @na tag
         | 
| 405 | 
            +
                ## @param      file        [String] file path to parse
         | 
| 209 406 | 
             
                ##
         | 
| 210 | 
            -
                def parse_actions(depth: 1, query: nil, tag: nil, search: nil, negate: false, regex: false, project: nil, require_na: true)
         | 
| 407 | 
            +
                def parse_actions(depth: 1, done: false, query: nil, tag: nil, search: nil, negate: false, regex: false, project: nil, require_na: true, file_path: nil)
         | 
| 211 408 | 
             
                  actions = []
         | 
| 212 409 | 
             
                  required = []
         | 
| 213 410 | 
             
                  optional = []
         | 
| @@ -215,6 +412,7 @@ module NA | |
| 215 412 | 
             
                  required_tag = []
         | 
| 216 413 | 
             
                  optional_tag = []
         | 
| 217 414 | 
             
                  negated_tag = []
         | 
| 415 | 
            +
                  projects = []
         | 
| 218 416 |  | 
| 219 417 | 
             
                  tag&.each do |t|
         | 
| 220 418 | 
             
                    unless t[:tag].nil?
         | 
| @@ -248,7 +446,9 @@ module NA | |
| 248 446 | 
             
                    end
         | 
| 249 447 | 
             
                  end
         | 
| 250 448 |  | 
| 251 | 
            -
                  files = if  | 
| 449 | 
            +
                  files = if !file_path.nil?
         | 
| 450 | 
            +
                            [file_path]
         | 
| 451 | 
            +
                          elsif query.nil?
         | 
| 252 452 | 
             
                            find_files(depth: depth)
         | 
| 253 453 | 
             
                          else
         | 
| 254 454 | 
             
                            match_working_dir(query)
         | 
| @@ -259,8 +459,10 @@ module NA | |
| 259 459 | 
             
                    content = file.read_file
         | 
| 260 460 | 
             
                    indent_level = 0
         | 
| 261 461 | 
             
                    parent = []
         | 
| 262 | 
            -
                     | 
| 263 | 
            -
             | 
| 462 | 
            +
                    in_action = false
         | 
| 463 | 
            +
                    content.split("\n").each.with_index do |line, idx|
         | 
| 464 | 
            +
                      if line =~ /^([ \t]*)([^\-@()]+?): *(@\S+ *)*$/
         | 
| 465 | 
            +
                        in_action = false
         | 
| 264 466 | 
             
                        proj = Regexp.last_match(2)
         | 
| 265 467 | 
             
                        indent = line.indent_level
         | 
| 266 468 |  | 
| @@ -273,14 +475,22 @@ module NA | |
| 273 475 | 
             
                          parent.push(proj)
         | 
| 274 476 | 
             
                        end
         | 
| 275 477 |  | 
| 478 | 
            +
                        projects.push(NA::Project.new(parent.join(':'), indent, idx + 1))
         | 
| 479 | 
            +
             | 
| 276 480 | 
             
                        indent_level = indent
         | 
| 277 | 
            -
                      elsif line =~ /^[ \t]*- / | 
| 481 | 
            +
                      elsif line =~ /^[ \t]*- /
         | 
| 482 | 
            +
                        in_action = false
         | 
| 483 | 
            +
                        # search_for_done = false
         | 
| 484 | 
            +
                        # optional_tag.each { |t| search_for_done = true if t[:tag] =~ /done/ }
         | 
| 485 | 
            +
                        next if line =~ /@done/ && !done
         | 
| 486 | 
            +
             | 
| 278 487 | 
             
                        next if require_na && line !~ /@#{NA.na_tag}\b/
         | 
| 279 488 |  | 
| 280 489 | 
             
                        action = line.sub(/^[ \t]*- /, '')
         | 
| 281 | 
            -
                        new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action)
         | 
| 490 | 
            +
                        new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
         | 
| 282 491 |  | 
| 283 492 | 
             
                        has_search = !optional.empty? || !required.empty? || !negated.empty?
         | 
| 493 | 
            +
             | 
| 284 494 | 
             
                        next if has_search && !new_action.search_match?(any: optional,
         | 
| 285 495 | 
             
                                                                        all: required,
         | 
| 286 496 | 
             
                                                                        none: negated)
         | 
| @@ -296,10 +506,14 @@ module NA | |
| 296 506 | 
             
                                                                   none: negated_tag)
         | 
| 297 507 |  | 
| 298 508 | 
             
                        actions.push(new_action)
         | 
| 509 | 
            +
                        in_action = true
         | 
| 510 | 
            +
                      else
         | 
| 511 | 
            +
                        actions[-1].note.push(line.strip) if actions.count.positive? && in_action
         | 
| 299 512 | 
             
                      end
         | 
| 300 513 | 
             
                    end
         | 
| 301 514 | 
             
                  end
         | 
| 302 | 
            -
             | 
| 515 | 
            +
             | 
| 516 | 
            +
                  [files, actions, projects]
         | 
| 303 517 | 
             
                end
         | 
| 304 518 |  | 
| 305 519 | 
             
                def edit_file(file: nil, app: nil)
         | 
| @@ -337,9 +551,32 @@ module NA | |
| 337 551 | 
             
                  end
         | 
| 338 552 | 
             
                end
         | 
| 339 553 |  | 
| 554 | 
            +
                def list_projects(query: [], file_path: nil, depth: 1, paths: true)
         | 
| 555 | 
            +
                  files = if !file_path.nil?
         | 
| 556 | 
            +
                            [file_path]
         | 
| 557 | 
            +
                          elsif query.nil?
         | 
| 558 | 
            +
                            find_files(depth: depth)
         | 
| 559 | 
            +
                          else
         | 
| 560 | 
            +
                            match_working_dir(query)
         | 
| 561 | 
            +
                          end
         | 
| 562 | 
            +
                  target = files.count > 1 ? NA.select_file(files) : files[0]
         | 
| 563 | 
            +
                  projects = find_projects(target)
         | 
| 564 | 
            +
                  projects.each do |proj|
         | 
| 565 | 
            +
                    parts = proj.project.split(/:/)
         | 
| 566 | 
            +
                    output = if paths
         | 
| 567 | 
            +
                               "{bg}#{parts.join('{bw}/{bg}')}{x}"
         | 
| 568 | 
            +
                             else
         | 
| 569 | 
            +
                               parts.fill("{bw}—{bg}", 0..-2)
         | 
| 570 | 
            +
                               "{bg}#{parts.join(' ')}{x}"
         | 
| 571 | 
            +
                             end
         | 
| 572 | 
            +
             | 
| 573 | 
            +
                    puts NA::Color.template(output)
         | 
| 574 | 
            +
                  end
         | 
| 575 | 
            +
                end
         | 
| 576 | 
            +
             | 
| 340 577 | 
             
                def list_todos(query: [])
         | 
| 341 578 | 
             
                  if query
         | 
| 342 | 
            -
                    dirs = match_working_dir(query)
         | 
| 579 | 
            +
                    dirs = match_working_dir(query, distance: 2, require_last: false)
         | 
| 343 580 | 
             
                  else
         | 
| 344 581 | 
             
                    file = database_path
         | 
| 345 582 | 
             
                    content = File.exist?(file) ? file.read_file.strip : ''
         | 
| @@ -387,6 +624,40 @@ module NA | |
| 387 624 | 
             
                  searches
         | 
| 388 625 | 
             
                end
         | 
| 389 626 |  | 
| 627 | 
            +
                def delete_search(strings = nil)
         | 
| 628 | 
            +
                  NA.notify('{r}Name search required', exit_code: 1) if strings.nil? || strings.empty?
         | 
| 629 | 
            +
             | 
| 630 | 
            +
                  file = database_path(file: 'saved_searches.yml')
         | 
| 631 | 
            +
                  NA.notify('{r}No search definitions file found', exit_code: 1) unless File.exist?(file)
         | 
| 632 | 
            +
             | 
| 633 | 
            +
                  searches = YAML.safe_load(file.read_file)
         | 
| 634 | 
            +
                  keys = searches.keys.delete_if { |k| k !~ /(#{strings.join('|')})/ }
         | 
| 635 | 
            +
             | 
| 636 | 
            +
                  res = yn(NA::Color.template(%({y}Remove #{keys.count > 1 ? 'searches' : 'search'} {bw}"#{keys.join(', ')}"{x})),
         | 
| 637 | 
            +
                           default: false)
         | 
| 638 | 
            +
             | 
| 639 | 
            +
                  NA.notify('{r}Cancelled', exit_code: 1) unless res
         | 
| 640 | 
            +
             | 
| 641 | 
            +
                  searches.delete_if { |k| keys.include?(k) }
         | 
| 642 | 
            +
             | 
| 643 | 
            +
                  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
         | 
| 644 | 
            +
             | 
| 645 | 
            +
                  NA.notify("{y}Deleted {bw}#{keys.count}{xy} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0)
         | 
| 646 | 
            +
                end
         | 
| 647 | 
            +
             | 
| 648 | 
            +
                def edit_searches
         | 
| 649 | 
            +
                  file = database_path(file: 'saved_searches.yml')
         | 
| 650 | 
            +
                  searches = load_searches
         | 
| 651 | 
            +
             | 
| 652 | 
            +
                  NA.notify('{r}No search definitions found', exit_code: 1) unless searches.count.positive?
         | 
| 653 | 
            +
             | 
| 654 | 
            +
                  editor = ENV['EDITOR']
         | 
| 655 | 
            +
                  NA.notify('{r}No $EDITOR defined', exit_code: 1) unless editor && TTY::Which.exist?(editor)
         | 
| 656 | 
            +
             | 
| 657 | 
            +
                  system %(#{editor} "#{file}")
         | 
| 658 | 
            +
                  NA.notify("Opened #{file} in #{editor}", exit_code: 0)
         | 
| 659 | 
            +
                end
         | 
| 660 | 
            +
             | 
| 390 661 | 
             
                ##
         | 
| 391 662 | 
             
                ## Get path to database of known todo files
         | 
| 392 663 | 
             
                ##
         | 
| @@ -399,6 +670,58 @@ module NA | |
| 399 670 | 
             
                  File.join(db_dir, file)
         | 
| 400 671 | 
             
                end
         | 
| 401 672 |  | 
| 673 | 
            +
                ##
         | 
| 674 | 
            +
                ## Create a backup file
         | 
| 675 | 
            +
                ##
         | 
| 676 | 
            +
                ## @param      target [String] The file to back up
         | 
| 677 | 
            +
                ##
         | 
| 678 | 
            +
                def backup_file(target)
         | 
| 679 | 
            +
                  FileUtils.cp(target, "#{target}~")
         | 
| 680 | 
            +
                end
         | 
| 681 | 
            +
             | 
| 682 | 
            +
                ##
         | 
| 683 | 
            +
                ## Find a matching path using semi-fuzzy matching.
         | 
| 684 | 
            +
                ## Search tokens can include ! and + to negate or make
         | 
| 685 | 
            +
                ## required.
         | 
| 686 | 
            +
                ##
         | 
| 687 | 
            +
                ## @param      search        [Array] search tokens to
         | 
| 688 | 
            +
                ##                           match
         | 
| 689 | 
            +
                ## @param      distance      [Integer] allowed distance
         | 
| 690 | 
            +
                ##                           between characters
         | 
| 691 | 
            +
                ## @param      require_last  [Boolean] require regex to
         | 
| 692 | 
            +
                ##                           match last element of path
         | 
| 693 | 
            +
                ##
         | 
| 694 | 
            +
                ## @return     [Array] array of matching directories/todo files
         | 
| 695 | 
            +
                ##
         | 
| 696 | 
            +
                def match_working_dir(search, distance: 1, require_last: true)
         | 
| 697 | 
            +
                  file = database_path
         | 
| 698 | 
            +
                  notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
         | 
| 699 | 
            +
             | 
| 700 | 
            +
                  dirs = file.read_file.split("\n")
         | 
| 701 | 
            +
             | 
| 702 | 
            +
                  optional = search.map { |t| t[:token] }
         | 
| 703 | 
            +
                  required = search.filter { |s| s[:required] }.map { |t| t[:token] }
         | 
| 704 | 
            +
                  negated = search.filter { |s| s[:negate] }.map { |t| t[:token] }
         | 
| 705 | 
            +
             | 
| 706 | 
            +
                  NA.notify("{dw}Optional directory regex: {x}#{optional.map(&:dir_to_rx)}", debug: true)
         | 
| 707 | 
            +
                  NA.notify("{dw}Required directory regex: {x}#{required.map(&:dir_to_rx)}", debug: true)
         | 
| 708 | 
            +
                  NA.notify("{dw}Negated directory regex: {x}#{negated.map { |t| t.dir_to_rx(distance: 1, require_last: false) }}", debug: true)
         | 
| 709 | 
            +
             | 
| 710 | 
            +
                  if require_last
         | 
| 711 | 
            +
                    dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
         | 
| 712 | 
            +
                  else
         | 
| 713 | 
            +
                    dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated, distance: 2, require_last: false) }
         | 
| 714 | 
            +
                  end
         | 
| 715 | 
            +
             | 
| 716 | 
            +
                  dirs = dirs.sort.uniq
         | 
| 717 | 
            +
                  if dirs.empty? && require_last
         | 
| 718 | 
            +
                    NA.notify("{y}No matches, loosening search", debug: true)
         | 
| 719 | 
            +
                    match_working_dir(search, distance: 2, require_last: false)
         | 
| 720 | 
            +
                  else
         | 
| 721 | 
            +
                    dirs
         | 
| 722 | 
            +
                  end
         | 
| 723 | 
            +
                end
         | 
| 724 | 
            +
             | 
| 402 725 | 
             
                private
         | 
| 403 726 |  | 
| 404 727 | 
             
                ##
         | 
| @@ -447,31 +770,6 @@ module NA | |
| 447 770 | 
             
                  [optional, required, negated]
         | 
| 448 771 | 
             
                end
         | 
| 449 772 |  | 
| 450 | 
            -
                ##
         | 
| 451 | 
            -
                ## Find a matching path using semi-fuzzy matching.
         | 
| 452 | 
            -
                ## Search tokens can include ! and + to negate or make
         | 
| 453 | 
            -
                ## required.
         | 
| 454 | 
            -
                ##
         | 
| 455 | 
            -
                ## @param      search    [Array] search tokens to match
         | 
| 456 | 
            -
                ## @param      distance  [Integer] allowed distance
         | 
| 457 | 
            -
                ##                       between characters
         | 
| 458 | 
            -
                ##
         | 
| 459 | 
            -
                def match_working_dir(search, distance: 1)
         | 
| 460 | 
            -
                  file = database_path
         | 
| 461 | 
            -
                  notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
         | 
| 462 | 
            -
             | 
| 463 | 
            -
                  dirs = file.read_file.split("\n")
         | 
| 464 | 
            -
             | 
| 465 | 
            -
                  optional = search.map { |t| t[:token] }
         | 
| 466 | 
            -
                  required = search.filter { |s| s[:required] }.map { |t| t[:token] }
         | 
| 467 | 
            -
             | 
| 468 | 
            -
                  NA.notify("{bw}Optional directory regex: {x}#{optional.map(&:dir_to_rx)}", debug: true)
         | 
| 469 | 
            -
                  NA.notify("{bw}Required directory regex: {x}#{required.map(&:dir_to_rx)}", debug: true)
         | 
| 470 | 
            -
             | 
| 471 | 
            -
                  dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required) }
         | 
| 472 | 
            -
                  dirs.sort.uniq
         | 
| 473 | 
            -
                end
         | 
| 474 | 
            -
             | 
| 475 773 | 
             
                ##
         | 
| 476 774 | 
             
                ## Save a todo file path to the database
         | 
| 477 775 | 
             
                ##
         | 
    
        data/lib/na/project.rb
    ADDED
    
    | @@ -0,0 +1,26 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module NA
         | 
| 4 | 
            +
              class Project < Hash
         | 
| 5 | 
            +
                attr_accessor :project, :indent, :line
         | 
| 6 | 
            +
             | 
| 7 | 
            +
                def initialize(project, indent = 0, line = 0)
         | 
| 8 | 
            +
                  super()
         | 
| 9 | 
            +
                  @project = project
         | 
| 10 | 
            +
                  @indent = indent
         | 
| 11 | 
            +
                  @line = line
         | 
| 12 | 
            +
                end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                def to_s
         | 
| 15 | 
            +
                  { project: @project, indent: @indent, line: @line }.to_s
         | 
| 16 | 
            +
                end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                def inspect
         | 
| 19 | 
            +
                  [
         | 
| 20 | 
            +
                    "@project: #{@project}",
         | 
| 21 | 
            +
                    "@indent: #{@indent}",
         | 
| 22 | 
            +
                    "@line: #{@line}"
         | 
| 23 | 
            +
                  ].join("\n")
         | 
| 24 | 
            +
                end
         | 
| 25 | 
            +
              end
         | 
| 26 | 
            +
            end
         | 
    
        data/lib/na/string.rb
    CHANGED
    
    | @@ -94,14 +94,18 @@ class ::String | |
| 94 94 | 
             
              ##             slashes and requires that last segment
         | 
| 95 95 | 
             
              ##             match last segment of target path
         | 
| 96 96 | 
             
              ##
         | 
| 97 | 
            -
              ## @param      distance | 
| 97 | 
            +
              ## @param      distance      The distance allowed between characters
         | 
| 98 | 
            +
              ## @param      require_last  Require match to be last element in path
         | 
| 98 99 | 
             
              ##
         | 
| 99 | 
            -
              def dir_to_rx(distance:  | 
| 100 | 
            -
                "#{split(%r{[/:]}).map { |comp| comp.split('').join(".{0,#{distance}}").gsub(/\*/, '[^ ]*?') }.join('.*?/.*?')}[^/]*?$"
         | 
| 100 | 
            +
              def dir_to_rx(distance: 1, require_last: true)
         | 
| 101 | 
            +
                "#{split(%r{[/:]}).map { |comp| comp.split('').join(".{0,#{distance}}").gsub(/\*/, '[^ ]*?') }.join('.*?/.*?')}#{require_last ? '[^/]*?$' : ''}"
         | 
| 101 102 | 
             
              end
         | 
| 102 103 |  | 
| 103 | 
            -
              def dir_matches(any: [], all: [])
         | 
| 104 | 
            -
                 | 
| 104 | 
            +
              def dir_matches(any: [], all: [], none: [], require_last: true, distance: 1)
         | 
| 105 | 
            +
                any_rx = any.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
         | 
| 106 | 
            +
                all_rx = all.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
         | 
| 107 | 
            +
                none_rx = none.map { |q| q.dir_to_rx(distance: distance, require_last: false) }
         | 
| 108 | 
            +
                matches_any(any_rx) && matches_all(all_rx) && matches_none(none_rx)
         | 
| 105 109 | 
             
              end
         | 
| 106 110 |  | 
| 107 111 | 
             
              def matches(any: [], all: [], none: [])
         | 
    
        data/lib/na/version.rb
    CHANGED
    
    
    
        data/lib/na.rb
    CHANGED
    
    
    
        data/na.gemspec
    CHANGED
    
    | @@ -31,4 +31,5 @@ spec = Gem::Specification.new do |s| | |
| 31 31 | 
             
              s.add_runtime_dependency('tty-screen', '~> 0.8', '>= 0.8.1')
         | 
| 32 32 | 
             
              s.add_runtime_dependency('tty-which', '~> 0.5', '>= 0.5.0')
         | 
| 33 33 | 
             
              s.add_runtime_dependency('chronic', '~> 0.10', '>= 0.10.2')
         | 
| 34 | 
            +
              s.add_runtime_dependency('mdless', '~> 1.0', '>= 1.0.32')
         | 
| 34 35 | 
             
            end
         | 
    
        data/src/README.md
    CHANGED
    
    | @@ -9,7 +9,7 @@ | |
| 9 9 | 
             
            _If you're one of the rare people like me who find this useful, feel free to
         | 
| 10 10 | 
             
            [buy me some coffee][donate]._
         | 
| 11 11 |  | 
| 12 | 
            -
            The current version of `na` is <!--VER-->1. | 
| 12 | 
            +
            The current version of `na` is <!--VER-->1.2.0<!--END VER-->.
         | 
| 13 13 |  | 
| 14 14 | 
             
            `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder. 
         | 
| 15 15 |  | 
| @@ -100,16 +100,70 @@ Examples: | |
| 100 100 | 
             
            @cli(bundle exec bin/na help next)
         | 
| 101 101 | 
             
            ```
         | 
| 102 102 |  | 
| 103 | 
            +
            ##### projects
         | 
| 104 | 
            +
             | 
| 105 | 
            +
            List all projects in a file. If arguments are provided, they're used to match a todo file from history, otherwise the todo file(s) in the current directory will be used.
         | 
| 106 | 
            +
             | 
| 107 | 
            +
            ```
         | 
| 108 | 
            +
            @cli(bundle exec bin/na help projects)
         | 
| 109 | 
            +
            ```
         | 
| 110 | 
            +
             | 
| 111 | 
            +
            ##### saved
         | 
| 112 | 
            +
             | 
| 113 | 
            +
            The saved command runs saved searches. To save a search, add `--save SEARCH_NAME` to a `find` or `tagged` command. The arguments provided on the command line will be saved to a search file (`/.local/share/na/saved_searches.yml`), with the search named with the SEARCH_NAME parameter. You can then run the search again with `na saved SEARCH_NAME`. Repeating the SEARCH_NAME with a new `find/tagged` command will overwrite the previous definition.
         | 
| 114 | 
            +
             | 
| 115 | 
            +
            Search names can be partially matched when calling them, so if you have a search named "overdue," you can match it with `na saved over` (shortest match will be used).
         | 
| 116 | 
            +
             | 
| 117 | 
            +
            Run `na saved` without an argument to list your saved searches.
         | 
| 118 | 
            +
             | 
| 119 | 
            +
            ```
         | 
| 120 | 
            +
            @cli(bundle exec bin/na help saved)
         | 
| 121 | 
            +
            ```
         | 
| 122 | 
            +
             | 
| 103 123 | 
             
            ##### tagged
         | 
| 104 124 |  | 
| 105 125 | 
             
            Example: `na tagged feature +maybe`.
         | 
| 106 126 |  | 
| 107 | 
            -
            Separate multiple tags with  | 
| 127 | 
            +
            Separate multiple tags/value comparisons with commas. By default tags are combined with AND, so actions matching all of the tags listed will be displayed. Use `+` to make a tag required and `!` to negate a tag (only display if the action does _not_ contain the tag). When `+` and/or `!` are used, undecorated tokens become optional matches. Use `-v` to invert the search and display all actions that _don't_ match.
         | 
| 128 | 
            +
             | 
| 129 | 
            +
            You can also perform value comparisons on tags. A value in a TaskPaper tag is added by including it in parenthesis after the tag, e.g. `@due(2022-10-10 05:00)`. You can perform numeric comparisons with `<`, `>`, `<=`, `>=`, `==`, and `!=`. If comparing to a date, you can use natural language, e.g. `na tagged "due<today"`.
         | 
| 130 | 
            +
             | 
| 131 | 
            +
            To perform a string comparison, you can use `*=` (contains), `^=` (starts with), `$=` (ends with), or `=` (matches). E.g. `na tagged "note*=video"`.
         | 
| 108 132 |  | 
| 109 133 | 
             
            ```
         | 
| 110 134 | 
             
            @cli(bundle exec bin/na help show)
         | 
| 111 135 | 
             
            ```
         | 
| 112 136 |  | 
| 137 | 
            +
            ##### todos
         | 
| 138 | 
            +
             | 
| 139 | 
            +
            List all known todo files from history.
         | 
| 140 | 
            +
             | 
| 141 | 
            +
            ```
         | 
| 142 | 
            +
            @cli(bundle exec bin/na help todos)
         | 
| 143 | 
            +
            ```
         | 
| 144 | 
            +
             | 
| 145 | 
            +
            ##### update
         | 
| 146 | 
            +
             | 
| 147 | 
            +
            Example: `na update --in na --archive my cool action`
         | 
| 148 | 
            +
             | 
| 149 | 
            +
            The above will locate a todo file matching "na" in todo history, find any action matching "my cool action", add a dated @done tag and move it to the Archive project, creating it if needed. If multiple actions are matched, a menu is presented (multi-select if fzf is available).
         | 
| 150 | 
            +
             | 
| 151 | 
            +
            This command will perform actions (tag, untag, complete, archive, add note, etc.) on existing actions by matching your search text. Arguments will be interpreted as search tokens similar to `na find`. You can use `--exact` and `--regex`, as well as wildcards in the search string. You can also use `--tagged TAG_QUERY` in addition to or instead of a search query.
         | 
| 152 | 
            +
             | 
| 153 | 
            +
            You can specify a particular todo file using `--file PATH` or any todo from history using `--in QUERY`.
         | 
| 154 | 
            +
             | 
| 155 | 
            +
            If more than one file is matched, a menu will be presented, multiple selections allowed. If multiple actions match the search within the selected file(s), a menu will be presented. If you have fzf installed, you can select one action to update with return, or use tab to mark multiple tasks to which the action will be applied. With gum you can use j, k, and x to mark multiple actions. Use the `--all` switch to force operation on all matched tasks, skipping the menu.
         | 
| 156 | 
            +
             | 
| 157 | 
            +
            Any time an update action is carried out, a backup of the file before modification will be made in the same directory with a `~` appended to the file extension (e.g. "marked.taskpaper" is backed up to "marked.taskpaper~"). Only one undo step is available, but if something goes wrong (and this feature is still experimental, so be wary), you can just copy the "~" file back to the original.
         | 
| 158 | 
            +
             | 
| 159 | 
            +
            You can specify a new project for an action (moving it) with `--proj PROJECT_PATH`. A project path is hierarchical, with each level separated by a colon or slash. If the project path provided roughly matches an existing project, e.g. "mark:bug" would match "Marked:Bugs", then that project will be used. If no match is found, na will offer to generate a new project/hierarchy for the path provided. Strings will be exact but the first letter will be uppercased.
         | 
| 160 | 
            +
             | 
| 161 | 
            +
            See the help output for a list of available actions.
         | 
| 162 | 
            +
             | 
| 163 | 
            +
            ```
         | 
| 164 | 
            +
            @cli(bundle exec bin/na help update)
         | 
| 165 | 
            +
            ```
         | 
| 166 | 
            +
             | 
| 113 167 | 
             
            ### Configuration
         | 
| 114 168 |  | 
| 115 169 | 
             
            Global options such as todo extension and default next action tag can be stored permanently by using the `na initconfig` command. Run na with the global options you'd like to set, and add `initconfig` at the end of the command. A file will be written to `~/.na.rc`. You can edit this manually, or just update it using the `initconfig --force` command to overwrite it with new settings.
         | 
| @@ -148,11 +202,11 @@ You can add a prompt command to your shell to have na automatically list your ne | |
| 148 202 |  | 
| 149 203 | 
             
            After installing a hook, you'll need to close your terminal and start a new session to initialize the new commands.
         | 
| 150 204 |  | 
| 151 | 
            -
             | 
| 152 205 | 
             
            ### Misc
         | 
| 153 206 |  | 
| 154 | 
            -
            If you have [gum][] installed, na will use it for command line input when adding tasks and notes.
         | 
| 207 | 
            +
            If you have [gum][] installed, na will use it for command line input when adding tasks and notes. If you have [fzf][] installed, it will be used for menus, falling back to gum if available.
         | 
| 155 208 |  | 
| 209 | 
            +
            [fzf]: https://github.com/junegunn/fzf
         | 
| 156 210 | 
             
            [gum]: https://github.com/charmbracelet/gum
         | 
| 157 211 | 
             
            [donate]: http://brettterpstra.com/donate/
         | 
| 158 212 | 
             
            [github]: https://github.com/ttscoff/na_gem/
         |