├── .github └── FUNDING.yml ├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── README.rdoc ├── Rakefile ├── Test.todo.markdown ├── bin ├── commands │ ├── add.rb │ ├── archive.rb │ ├── changes.rb │ ├── complete.rb │ ├── completed.rb │ ├── edit.rb │ ├── find.rb │ ├── init.rb │ ├── move.rb │ ├── next.rb │ ├── open.rb │ ├── projects.rb │ ├── prompt.rb │ ├── restore.rb │ ├── saved.rb │ ├── tag.rb │ ├── tagged.rb │ ├── todos.rb │ ├── undo.rb │ └── update.rb └── na ├── docker ├── Dockerfile ├── Dockerfile-2.6 ├── Dockerfile-2.7 ├── Dockerfile-3.0 ├── Dockerfile-3.3 ├── bash_profile ├── inputrc └── sources.list ├── lib ├── na.rb └── na │ ├── action.rb │ ├── actions.rb │ ├── array.rb │ ├── colors.rb │ ├── editor.rb │ ├── hash.rb │ ├── help_monkey_patch.rb │ ├── next_action.rb │ ├── pager.rb │ ├── project.rb │ ├── prompt.rb │ ├── string.rb │ ├── theme.rb │ ├── todo.rb │ └── version.rb ├── na.gemspec ├── na.rdoc ├── na.taskpaper ├── na_gem.taskpaper ├── scripts ├── fixreadme.rb ├── generate-fish-completions.rb └── runtests.sh ├── src └── _README.md ├── test.md ├── test ├── default_test.rb └── test_helper.rb └── test2.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ttscoff] 2 | custom: ['https://brettterpstra.com/support/', 'https://brettterpstra.com/donate/'] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | html/ 2 | *.bak 3 | na*gem 4 | *~ 5 | .*~ 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | inherit_from: .rubocop_todo.yml 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | sudo: required 4 | dist: trusty 5 | cache: bundler 6 | rvm: 7 | - ruby-3.1.0 8 | install: 9 | - gem install bundler --version '2.6.6' 10 | - bundle install 11 | script: "bundle exec rake test" 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | gem 'rake' 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | na (1.2.77) 5 | chronic (~> 0.10, >= 0.10.2) 6 | git (~> 3.0.0) 7 | gli (~> 2.21.0) 8 | mdless (~> 1.0, >= 1.0.32) 9 | tty-reader (~> 0.9, >= 0.9.0) 10 | tty-screen (~> 0.8, >= 0.8.1) 11 | tty-which (~> 0.5, >= 0.5.0) 12 | 13 | GEM 14 | remote: https://rubygems.org/ 15 | specs: 16 | activesupport (8.0.2) 17 | base64 18 | benchmark (>= 0.3) 19 | bigdecimal 20 | concurrent-ruby (~> 1.0, >= 1.3.1) 21 | connection_pool (>= 2.2.5) 22 | drb 23 | i18n (>= 1.6, < 2) 24 | logger (>= 1.4.2) 25 | minitest (>= 5.1) 26 | securerandom (>= 0.3) 27 | tzinfo (~> 2.0, >= 2.0.5) 28 | uri (>= 0.13.1) 29 | addressable (2.8.7) 30 | public_suffix (>= 2.0.2, < 7.0) 31 | base64 (0.2.0) 32 | benchmark (0.4.0) 33 | bigdecimal (3.1.9) 34 | chronic (0.10.2) 35 | concurrent-ruby (1.3.5) 36 | connection_pool (2.5.1) 37 | drb (2.2.1) 38 | git (3.0.0) 39 | activesupport (>= 5.0) 40 | addressable (~> 2.8) 41 | process_executer (~> 1.3) 42 | rchardet (~> 1.9) 43 | gli (2.21.5) 44 | i18n (1.14.7) 45 | concurrent-ruby (~> 1.0) 46 | logger (1.6.6) 47 | mdless (1.0.37) 48 | minitest (5.25.5) 49 | process_executer (1.3.0) 50 | public_suffix (6.0.1) 51 | rake (13.2.1) 52 | rchardet (1.9.0) 53 | rdoc (4.3.0) 54 | securerandom (0.4.1) 55 | tty-cursor (0.7.1) 56 | tty-reader (0.9.0) 57 | tty-cursor (~> 0.7) 58 | tty-screen (~> 0.8) 59 | wisper (~> 2.0) 60 | tty-screen (0.8.2) 61 | tty-spinner (0.9.3) 62 | tty-cursor (~> 0.7) 63 | tty-which (0.5.0) 64 | tzinfo (2.0.6) 65 | concurrent-ruby (~> 1.0) 66 | uri (1.0.3) 67 | wisper (2.0.1) 68 | 69 | PLATFORMS 70 | aarch64-linux-gnu 71 | aarch64-linux-musl 72 | arm-linux-gnu 73 | arm-linux-musl 74 | arm64-darwin 75 | ruby 76 | x86-linux-gnu 77 | x86-linux-musl 78 | x86_64-darwin 79 | x86_64-linux-gnu 80 | x86_64-linux-musl 81 | 82 | DEPENDENCIES 83 | minitest (~> 5.14) 84 | na! 85 | rake 86 | rdoc (~> 4.3) 87 | tty-spinner (~> 0.9, >= 0.9.0) 88 | 89 | BUNDLED WITH 90 | 2.6.6 91 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2022 Brett Terpstra 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 19 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = na 2 | 3 | A command line tool for adding and listing project todos in TaskPaper format. 4 | 5 | :include:na.rdoc 6 | 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/clean" 2 | require "rubygems" 3 | require "rubygems/package_task" 4 | require "rdoc/task" 5 | require "bump/tasks" 6 | require "bundler/gem_tasks" 7 | require "rspec/core/rake_task" 8 | require "rubocop/rake_task" 9 | require "yard" 10 | require "tty-spinner" 11 | require "English" 12 | 13 | YARD::Rake::YardocTask.new do |t| 14 | t.files = ["lib/na/*.rb"] 15 | t.options = ["--markup-provider=redcarpet", "--markup=markdown", "--no-private", "-p", "yard_templates"] 16 | t.stats_options = ["--list-undoc"] # Uncommented this line for stats options 17 | end 18 | 19 | ## Docker error class 20 | class DockerError < StandardError 21 | def initialize(msg = nil) 22 | msg = msg ? "Docker error: #{msg}" : "Docker error" 23 | super(msg) 24 | end 25 | end 26 | 27 | task default: %i[test yard] 28 | 29 | desc "Run test suite" 30 | task test: %i[rubocop spec] 31 | 32 | RSpec::Core::RakeTask.new do |t| 33 | t.rspec_opts = "--format documentation" 34 | end 35 | 36 | RuboCop::RakeTask.new do |t| 37 | t.formatters = ["progress"] 38 | end 39 | 40 | task :doc, [*Rake.application[:yard].arg_names] => [:yard] 41 | 42 | Rake::RDocTask.new do |rd| 43 | rd.main = "README.rdoc" 44 | rd.rdoc_files.include("README.rdoc", "lib/**/*.rb", "bin/**/*") 45 | rd.title = "na" 46 | end 47 | 48 | spec = eval(File.read("na.gemspec")) 49 | 50 | Gem::PackageTask.new(spec) do |pkg| 51 | end 52 | require "rake/testtask" 53 | Rake::TestTask.new do |t| 54 | t.libs << "test" 55 | t.test_files = FileList["test/*_test.rb"] 56 | end 57 | 58 | desc "Install current gem in all versions of asdf-controlled ruby" 59 | task :install do 60 | Rake::Task["clobber"].invoke 61 | Rake::Task["package"].invoke 62 | Dir.chdir "pkg" 63 | file = Dir.glob("*.gem").last 64 | 65 | current_ruby = `asdf current ruby`.match(/(\d.\d+.\d+)/)[1] 66 | 67 | `asdf list ruby`.split.map { |ruby| ruby.strip.sub(/^*/, "") }.each do |ruby| 68 | `asdf shell ruby #{ruby}` 69 | puts `gem install #{file}` 70 | end 71 | 72 | `asdf shell ruby #{current_ruby}` 73 | end 74 | 75 | desc "Development version check" 76 | task :ver do 77 | gver = `git ver` 78 | cver = IO.read(File.join(File.dirname(__FILE__), "CHANGELOG.md")).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 79 | res = `grep VERSION lib/na/version.rb` 80 | version = res.match(/VERSION *= *['"](\d+\.\d+\.\d+(\w+)?)/)[1] 81 | puts "git tag: #{gver}" 82 | puts "version.rb: #{version}" 83 | puts "changelog: #{cver}" 84 | end 85 | 86 | desc "Changelog version check" 87 | task :cver do 88 | puts IO.read(File.join(File.dirname(__FILE__), "CHANGELOG.md")).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 89 | end 90 | 91 | desc "Bump incremental version number" 92 | task :bump, :type do |_, args| 93 | args.with_defaults(type: "inc") 94 | version_file = "lib/na/version.rb" 95 | content = IO.read(version_file) 96 | content.sub!(/VERSION = '(?\d+)\.(?\d+)\.(?\d+)(?
\S+)?'/) do
 97 |     m = Regexp.last_match
 98 |     major = m["major"].to_i
 99 |     minor = m["minor"].to_i
100 |     inc = m["inc"].to_i
101 |     pre = m["pre"]
102 | 
103 |     case args[:type]
104 |     when /^maj/
105 |       major += 1
106 |       minor = 0
107 |       inc = 0
108 |     when /^min/
109 |       minor += 1
110 |       inc = 0
111 |     else
112 |       inc += 1
113 |     end
114 | 
115 |     $stdout.puts "At version #{major}.#{minor}.#{inc}#{pre}"
116 |     "VERSION = '#{major}.#{minor}.#{inc}#{pre}'"
117 |   end
118 |   File.open(version_file, "w+") { |f| f.puts content }
119 | end
120 | 
121 | # task default: %i[test clobber package]
122 | 
123 | desc "Remove packages"
124 | task :clobber_packages do
125 |   FileUtils.rm_f "pkg/*"
126 | end
127 | # Make a prerequisite of the preexisting clobber task
128 | desc "Clobber files"
129 | task clobber: :clobber_packages
130 | 
131 | desc "Get Script Version"
132 | task :sver do
133 |   res = `grep VERSION lib/na/version.rb`
134 |   version = res.match(/VERSION *= *['"](\d+\.\d+\.\d+(\w+)?)/)[1]
135 |   print version
136 | end
137 | 
138 | desc "Run tests in Docker"
139 | task :dockertest, :version, :login, :attempt do |_, args|
140 |   args.with_defaults(version: "all", login: false, attempt: 1)
141 |   `open -a Docker`
142 | 
143 |   Rake::Task["clobber"].reenable
144 |   Rake::Task["clobber"].invoke
145 |   Rake::Task["build"].reenable
146 |   Rake::Task["build"].invoke
147 | 
148 |   case args[:version]
149 |   when /^a/
150 |     %w[6 7 3].each do |v|
151 |       Rake::Task["dockertest"].reenable
152 |       Rake::Task["dockertest"].invoke(v, false)
153 |     end
154 |     Process.exit 0
155 |   when /^3\.?3/
156 |     img = "natest33"
157 |     file = "docker/Dockerfile-3.3"
158 |   when /^3/
159 |     version = "3.0"
160 |     img = "natest3"
161 |     file = "docker/Dockerfile-3.0"
162 |   when /6$/
163 |     version = "2.6"
164 |     img = "natest26"
165 |     file = "docker/Dockerfile-2.6"
166 |   when /(^2|7$)/
167 |     version = "2.7"
168 |     img = "natest27"
169 |     file = "docker/Dockerfile-2.7"
170 |   else
171 |     version = "3.0.1"
172 |     img = "natest"
173 |     file = "docker/Dockerfile"
174 |   end
175 | 
176 |   puts `docker build . --file #{file} -t #{img}`
177 | 
178 |   raise DockerError, "Error building docker image" unless $CHILD_STATUS.success?
179 | 
180 |   dirs = {
181 |     File.dirname(__FILE__) => "/na",
182 |     File.expand_path("~/.config") => "/root/.config"
183 |   }
184 |   dir_args = dirs.map { |s, d| " -v '#{s}:#{d}'" }.join(" ")
185 |   exec "docker run #{dir_args} -it #{img} /bin/bash -l" if args[:login]
186 | 
187 |   spinner = TTY::Spinner.new("[:spinner] Running tests (#{version})...", hide_cursor: true)
188 | 
189 |   spinner.auto_spin
190 |   `docker run --rm #{dir_args} -it #{img}`
191 |   # raise DockerError.new("Error running docker image") unless $CHILD_STATUS.success?
192 | 
193 |   # commit = puts `bash -c "docker commit $(docker ps -a|grep #{img}|awk '{print $1}'|head -n 1) #{img}"`.strip
194 |   $CHILD_STATUS.success? ? spinner.success : spinner.error
195 |   spinner.stop
196 | 
197 |   # puts res
198 |   # puts commit&.empty? ? "Error commiting Docker tag #{img}" : "Committed Docker tag #{img}"
199 | rescue DockerError
200 |   raise StandardError.new("Docker not responding") if args[:attempt] > 3
201 | 
202 |   `open -a Docker`
203 |   sleep 3
204 |   Rake::Task["dockertest"].reenable
205 |   Rake::Task["dockertest"].invoke(args[:version], args[:login], args[:attempt] + 1)
206 | end
207 | 
208 | desc "alias for build"
209 | task package: :build
210 | 


--------------------------------------------------------------------------------
/Test.todo.markdown:
--------------------------------------------------------------------------------
 1 | ---
 2 | comment: 2023-09-03
 3 | keywords: 
 4 | ---
 5 | 
 6 | Project3:
 7 | Project0:
 8 | 	- This is another task @na
 9 | 	- How about this one? @na
10 | 	- what happens now? @na
11 | 	Subproject:
12 | 		- Bollocks @na
13 | 		Subsub:
14 | 			- Hey, I think it's all working @na
15 | 			- Is this at the end? @na
16 | 	- This better work @na
17 | 2023-09-08:
18 | 	Project2:
19 | 		- new_task @na
20 | 		- new_task @na
21 | 		- test task @na
22 | 	Project0:
23 | 		- other task @na
24 | 		- other task @na
25 | 		- There, that's better @na
26 | 		Subproject:
27 | 			- new_task 2 @na
28 | 			- new_task @na
29 | 			- new_task 2 @na
30 | 	Project1:
31 | 		- Test4
32 | 		- Test5
33 | 		- Test6
34 | 


--------------------------------------------------------------------------------
/bin/commands/add.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc "Add a new next action"
  6 |   long_desc 'Provides an easy way to store todos while you work. Add quick
  7 |   reminders and (if you set up Prompt Hooks) they\'ll automatically display
  8 |   next time you enter the directory.
  9 | 
 10 |   If multiple todo files are found in the current directory, a menu will
 11 |   allow you to pick to which file the action gets added.'
 12 |   arg_name "ACTION"
 13 |   command :add do |c|
 14 |     c.example 'na add "A cool feature I thought of @idea"', desc: "Add a new action to the Inbox, including a tag"
 15 |     c.example 'na add "A bug I need to fix" -p 4 -n',
 16 |               desc: "Add a new action to the Inbox, set its @priority to 4, and prompt for an additional note."
 17 |     c.example 'na add "An action item (with a note)"',
 18 |               desc: "A parenthetical at the end of an action is interpreted as a note"
 19 | 
 20 |     c.desc "Prompt for additional notes. STDIN input (piped) will be treated as a note if present."
 21 |     c.switch %i[n note], negatable: false
 22 | 
 23 |     c.desc "Add a priority level 1-5 or h, m, l"
 24 |     c.arg_name "PRIO"
 25 |     c.flag %i[p priority], must_match: /[1-5hml]/, default_value: 0
 26 | 
 27 |     c.desc "Add action to specific project"
 28 |     c.arg_name "PROJECT"
 29 |     c.default_value "Inbox"
 30 |     c.flag %i[to project proj]
 31 | 
 32 |     c.desc "Add task at [s]tart or [e]nd of target project"
 33 |     c.arg_name "POSITION"
 34 |     c.flag %i[at], must_match: /^[sbea].*?$/i
 35 | 
 36 |     c.desc "Add to a known todo file, partial matches allowed"
 37 |     c.arg_name "TODO_FILE"
 38 |     c.flag %i[in todo]
 39 | 
 40 |     c.desc "Use a tag other than the default next action tag"
 41 |     c.arg_name "TAG"
 42 |     c.flag %i[t tag]
 43 | 
 44 |     c.desc 'Don\'t add next action tag to new entry'
 45 |     c.switch %i[x], negatable: false
 46 | 
 47 |     c.desc "Specify the file to which the task should be added"
 48 |     c.arg_name "PATH"
 49 |     c.flag %i[f file]
 50 | 
 51 |     c.desc "Mark task as @done with date"
 52 |     c.switch %i[finish done], negatable: false
 53 | 
 54 |     c.desc "Search for files X directories deep"
 55 |     c.arg_name "DEPTH"
 56 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
 57 | 
 58 |     c.action do |global_options, options, args|
 59 |       reader = TTY::Reader.new
 60 |       append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/
 61 | 
 62 |       priority = options[:priority].to_s || "0"
 63 |       if priority =~ /^[1-5]$/
 64 |         priority = priority.to_i
 65 |       elsif priority =~ /^[hml]$/
 66 |         priority = NA.priority_map[priority]
 67 |       else
 68 |         priority = 0
 69 |       end
 70 | 
 71 |       if NA.global_file
 72 |         target = File.expand_path(NA.global_file)
 73 |         unless File.exist?(target)
 74 |           res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Specified file not found, create it"), default: true)
 75 |           if res
 76 |             basename = File.basename(target, ".#{NA.extension}")
 77 |             NA.create_todo(target, basename, template: global_options[:template])
 78 |           else
 79 |             NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
 80 |           end
 81 |         end
 82 |       elsif options[:file]
 83 |         target = File.expand_path(options[:file])
 84 |         unless File.exist?(target)
 85 |           res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Specified file not found, create it"), default: true)
 86 |           if res
 87 |             basename = File.basename(target, ".#{NA.extension}")
 88 |             NA.create_todo(target, basename, template: global_options[:template])
 89 |           else
 90 |             NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
 91 |           end
 92 |         end
 93 |       elsif options[:todo]
 94 |         todo = []
 95 |         all_req = options[:todo] !~ /[+!\-]/
 96 |         options[:todo].split(/ *, */).each do |a|
 97 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
 98 |           todo.push({
 99 |                       token: m["tok"],
100 |                       required: all_req || (!m["req"].nil? && m["req"] == "+"),
101 |                       negate: !m["req"].nil? && m["req"] =~ /[!\-]/,
102 |                     })
103 |         end
104 |         dirs = NA.match_working_dir(todo)
105 |         if dirs.count.positive?
106 |           target = dirs[0]
107 |         else
108 |           todo = "#{options[:todo].sub(/#{NA.extension}$/, "")}.#{NA.extension}"
109 |           target = File.expand_path(todo)
110 |           unless File.exist?(target)
111 |             res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Specified file not found, create #{todo}"), default: true)
112 |             NA.notify("#{NA.theme[:error]}Cancelled{x}", exit_code: 1) unless res
113 | 
114 |             basename = File.basename(target, ".#{NA.extension}")
115 |             NA.create_todo(target, basename, template: global_options[:template])
116 |           end
117 |         end
118 |       else
119 |         files = NA.find_files(depth: options[:depth])
120 |         if files.count.zero?
121 |           res = NA.yn(NA::Color.template("#{NA.theme[:warning]}No todo file found, create one"), default: true)
122 |           if res
123 |             basename = File.expand_path(".").split("/").last
124 |             target = "#{basename}.#{NA.extension}"
125 |             NA.create_todo(target, basename, template: global_options[:template])
126 |             files = NA.find_files(depth: 1)
127 |           end
128 |         end
129 |         target = files.count > 1 ? NA.select_file(files) : files[0]
130 |         NA.notify("#{NA.theme[:error]}Cancelled{x}", exit_code: 1) unless files.count.positive? && File.exist?(target)
131 |       end
132 | 
133 |       action = if args.count.positive?
134 |           args.join(" ").strip
135 |         else
136 |           NA.request_input(options, prompt: "Enter a task")
137 |         end
138 | 
139 |       if action.nil? || action.empty?
140 |         puts "Empty input, cancelled"
141 |         Process.exit 1
142 |       end
143 | 
144 |       note_rx = /^(.+) \(([^)]+)\)$/
145 |       split_note = if action =~ note_rx
146 |           n = Regexp.last_match(2)
147 |           action.sub!(note_rx, '\1').strip!
148 |           n
149 |         end
150 | 
151 |       if priority&.to_i&.positive?
152 |         action = "#{action.gsub(/@priority\(\d+\)/, "")} @priority(#{priority})"
153 |       end
154 | 
155 |       na_tag = NA.na_tag
156 |       if options[:x]
157 |         na_tag = ""
158 |       else
159 |         na_tag = options[:tag] unless options[:tag].nil?
160 |         na_tag = " @#{na_tag}"
161 |       end
162 | 
163 |       action = "#{action.gsub(/#{na_tag}\b/, "")}#{na_tag}"
164 | 
165 |       stdin_note = NA.stdin ? NA.stdin.split("\n") : []
166 | 
167 |       line_note = if options[:note] && $stdin.isatty
168 |           puts stdin_note unless stdin_note.nil?
169 |           if TTY::Which.exist?("gum")
170 |             args = ['--placeholder "Enter additional note, CTRL-d to save"']
171 |             args << "--char-limit 0"
172 |             args << "--width $(tput cols)"
173 |             `gum write #{args.join(" ")}`.strip.split("\n")
174 |           else
175 |             NA.notify("#{NA.theme[:prompt]}Enter a note, {bw}CTRL-d#{NA.theme[:prompt]} to end editing#{NA.theme[:action]}")
176 |             reader.read_multiline
177 |           end
178 |         end
179 | 
180 |       note = stdin_note.empty? ? [] : stdin_note
181 |       note.<< split_note unless split_note.nil?
182 |       note.concat(line_note) unless line_note.nil?
183 | 
184 |       NA.add_action(target, options[:project], action, note, finish: options[:finish], append: append)
185 |     end
186 |   end
187 | end
188 | 


--------------------------------------------------------------------------------
/bin/commands/archive.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Mark an action as @done and archive'
 6 |   arg_name 'ACTION'
 7 |   command %i[archive] do |c|
 8 |     c.example 'na archive "An existing task"',
 9 |               desc: 'Find "An existing task", mark @done if needed, and move to archive'
10 | 
11 |     c.desc 'Prompt for additional notes. Input will be appended to any existing note.
12 |     If STDIN input (piped) is detected, it will be used as a note.'
13 |     c.switch %i[n note], negatable: false
14 | 
15 |     c.desc 'Overwrite note instead of appending'
16 |     c.switch %i[o overwrite], negatable: false
17 | 
18 |     c.desc 'Archive all done tasks'
19 |     c.switch %i[done], negatable: false
20 | 
21 |     c.desc 'Specify the file to search for the task'
22 |     c.arg_name 'PATH'
23 |     c.flag %i[file]
24 | 
25 |     c.desc 'Search for files X directories deep'
26 |     c.arg_name 'DEPTH'
27 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
28 | 
29 |     c.desc 'Match actions containing tag. Allows value comparisons'
30 |     c.arg_name 'TAG'
31 |     c.flag %i[tagged], multiple: true
32 | 
33 |     c.desc 'Affect actions from a specific project'
34 |     c.arg_name 'PROJECT[/SUBPROJECT]'
35 |     c.flag %i[proj project]
36 | 
37 |     c.desc 'Act on all matches immediately (no menu)'
38 |     c.switch %i[all], negatable: false
39 | 
40 |     c.desc 'Filter results using search terms'
41 |     c.arg_name 'QUERY'
42 |     c.flag %i[search find grep], multiple: true
43 | 
44 |     c.desc 'Interpret search pattern as regular expression'
45 |     c.switch %i[e regex], negatable: false
46 | 
47 |     c.desc 'Match pattern exactly'
48 |     c.switch %i[x exact], negatable: false
49 | 
50 |     c.desc 'Use a known todo file, partial matches allowed'
51 |     c.arg_name 'TODO_FILE'
52 |     c.flag %i[in todo]
53 | 
54 |     c.action do |global, options, args|
55 |       args.concat(options[:search])
56 | 
57 |       if options[:done]
58 |         options[:tagged] << 'done'
59 |         options[:all] = true
60 |       else
61 |         options[:tagged] << '-done'
62 |       end
63 | 
64 |       options[:done] = true
65 |       options['done'] = true
66 |       options[:finish] = true
67 |       options[:move] = 'Archive'
68 |       options[:archive] = true
69 |       options[:a] = true
70 | 
71 |       cmd = commands[:update]
72 |       action = cmd.send(:get_action, nil)
73 |       action.call(global, options, args)
74 |     end
75 |   end
76 | end
77 | 


--------------------------------------------------------------------------------
/bin/commands/changes.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Display the changelog'
 6 |   command %i[changes changelog] do |c|
 7 |     c.action do |_, _, _|
 8 |       changelog = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'CHANGELOG.md'))
 9 |       pagers = [
10 |         'mdless',
11 |         'mdcat',
12 |         'bat',
13 |         ENV['PAGER'],
14 |         'less -FXr',
15 |         ENV['GIT_PAGER'],
16 |         'more -r'
17 |       ]
18 |       pager = pagers.find { |cmd| TTY::Which.exist?(cmd.split.first) }
19 |       system %(#{pager} "#{changelog}")
20 |     end
21 |   end
22 | end
23 | 


--------------------------------------------------------------------------------
/bin/commands/complete.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Find and mark an action as @done'
 6 |   arg_name 'ACTION'
 7 |   command %i[complete finish] do |c|
 8 |     c.example 'na complete "An existing task"',
 9 |               desc: 'Find "An existing task" and mark @done'
10 |     c.example 'na finish "An existing task"',
11 |               desc: 'Alias for complete'
12 | 
13 |     c.desc 'Prompt for additional notes. Input will be appended to any existing note.
14 |     If STDIN input (piped) is detected, it will be used as a note.'
15 |     c.switch %i[n note], negatable: false
16 | 
17 |     c.desc 'Overwrite note instead of appending'
18 |     c.switch %i[o overwrite], negatable: false
19 | 
20 |     c.desc 'Add a @done tag to action and move to Archive'
21 |     c.switch %i[a archive], negatable: false
22 | 
23 |     c.desc 'Add a @done tag and move action to specific project'
24 |     c.arg_name 'PROJECT'
25 |     c.flag %i[to move]
26 | 
27 |     c.desc 'Affect actions from a specific project'
28 |     c.arg_name 'PROJECT[/SUBPROJECT]'
29 |     c.flag %i[proj project]
30 | 
31 |     c.desc 'Specify the file to search for the task'
32 |     c.arg_name 'PATH'
33 |     c.flag %i[file]
34 | 
35 |     c.desc 'Use a known todo file, partial matches allowed'
36 |     c.arg_name 'TODO_FILE'
37 |     c.flag %i[in todo]
38 | 
39 |     c.desc 'Search for files X directories deep'
40 |     c.arg_name 'DEPTH'
41 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
42 | 
43 |     c.desc 'Filter results using search terms'
44 |     c.arg_name 'QUERY'
45 |     c.flag %i[search find grep], multiple: true
46 | 
47 |     c.desc 'Include notes in search'
48 |     c.switch %i[search_notes], negatable: true, default_value: true
49 | 
50 |     c.desc 'Match actions containing tag. Allows value comparisons'
51 |     c.arg_name 'TAG'
52 |     c.flag %i[tagged], multiple: true
53 | 
54 |     c.desc 'Act on all matches immediately (no menu)'
55 |     c.switch %i[all], negatable: false
56 | 
57 |     c.desc 'Interpret search pattern as regular expression'
58 |     c.switch %i[e regex], negatable: false
59 | 
60 |     c.desc 'Match pattern exactly'
61 |     c.switch %i[x exact], negatable: false
62 | 
63 |     c.action do |global, options, args|
64 |       args.concat(options[:search])
65 | 
66 |       options[:finish] = true
67 |       options[:f] = true
68 |       options[:to] = 'Archive' if options[:archive] && !options[:to]
69 |       options[:move] = 'Archive' if options[:archive] && !options[:move]
70 | 
71 |       cmd = commands[:update]
72 |       action = cmd.send(:get_action, nil)
73 |       action.call(global, options, args)
74 |     end
75 |   end
76 | end
77 | 


--------------------------------------------------------------------------------
/bin/commands/completed.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc 'Display completed actions'
  6 |   long_desc 'Search tokens are separated by spaces. Actions matching all tokens in the pattern will be shown
  7 |     (partial matches allowed). Add a + before a token to make it required, e.g. `na completed +feature +maybe`,
  8 |     add a - or ! to ignore matches containing that token.'
  9 |   arg_name 'PATTERN', optional: true, multiple: true
 10 |   command %i[completed finished] do |c|
 11 |     c.example 'na completed', desc: 'display completed actions'
 12 |     c.example 'na completed --before "2 days ago"',
 13 |               desc: 'display actions completed more than two days ago'
 14 |     c.example 'na completed --on yesterday',
 15 |               desc: 'display actions completed yesterday'
 16 |     c.example 'na completed --after "1 week ago"',
 17 |               desc: 'display actions completed in the last week'
 18 |     c.example 'na completed feature',
 19 |               desc: 'display completed actions matcning "feature"'
 20 | 
 21 |     c.desc 'Display actions completed before (natural language) date string'
 22 |     c.arg_name 'DATE_STRING'
 23 |     c.flag %i[b before]
 24 | 
 25 |     c.desc 'Display actions completed on (natural language) date string'
 26 |     c.arg_name 'DATE_STRING'
 27 |     c.flag %i[on]
 28 | 
 29 |     c.desc 'Display actions completed after (natural language) date string'
 30 |     c.arg_name 'DATE_STRING'
 31 |     c.flag %i[a after]
 32 | 
 33 |     c.desc 'Combine before, on, and/or after with OR, displaying actions matching ANY of the ranges'
 34 |     c.switch %i[o or], negatable: false
 35 | 
 36 |     c.desc 'Recurse to depth'
 37 |     c.arg_name 'DEPTH'
 38 |     c.flag %i[d depth], type: :integer, must_match: /^\d+$/
 39 | 
 40 |     c.desc 'Show actions from a specific todo file in history. May use wildcards (* and ?)'
 41 |     c.arg_name 'TODO_PATH'
 42 |     c.flag %i[in]
 43 | 
 44 |     c.desc 'Include notes in output'
 45 |     c.switch %i[notes], negatable: true, default_value: false
 46 | 
 47 |     c.desc 'Include notes in search'
 48 |     c.switch %i[search_notes], negatable: true, default_value: true
 49 | 
 50 |     c.desc 'Show actions from a specific project'
 51 |     c.arg_name 'PROJECT[/SUBPROJECT]'
 52 |     c.flag %i[proj project]
 53 | 
 54 |     c.desc 'Match actions containing tag. Allows value comparisons'
 55 |     c.arg_name 'TAG'
 56 |     c.flag %i[tagged], multiple: true
 57 | 
 58 |     c.desc 'Output actions nested by file'
 59 |     c.switch %[nest], negatable: false
 60 | 
 61 |     c.desc 'Output actions nested by file and project'
 62 |     c.switch %[omnifocus], negatable: false
 63 | 
 64 |     c.desc 'Save this search for future use'
 65 |     c.arg_name 'TITLE'
 66 |     c.flag %i[save]
 67 | 
 68 |     c.action do |_global_options, options, args|
 69 |       tag_string = []
 70 |       if options[:before] || options[:on] || options[:after]
 71 |         tag_string << "done<#{options[:before]}" if options[:before]
 72 |         tag_string << "done=#{options[:on]}" if options[:on]
 73 |         tag_string << "done>#{options[:after]}" if options[:after]
 74 |       else
 75 |         tag_string << 'done'
 76 |       end
 77 | 
 78 |       tag_string.concat(options[:tagged]) if options[:tagged]
 79 | 
 80 |       if args.empty?
 81 |         cmd_string = %(tagged --done)
 82 |       else
 83 |         cmd_string = %(find --tagged "#{tag_string.join(',')}" --done)
 84 |       end
 85 | 
 86 |       cmd_string += ' --or' if options[:or]
 87 |       cmd_string += %( --in "#{options[:in]}") if options[:in]
 88 |       cmd_string += %( --project "#{options[:project]}") if options[:project]
 89 |       cmd_string += %( --depth #{options[:depth]}) if options[:depth]
 90 |       cmd_string += ' --nest' if options[:nest]
 91 |       cmd_string += ' --omnifocus' if options[:omnifocus]
 92 |       cmd_string += " --#{options[:search_notes] ? 'search_notes' : 'no-search_notes'}"
 93 |       cmd_string += " --#{options[:notes] ? 'notes' : 'no-notes' }"
 94 | 
 95 |       if args.empty?
 96 |         cmd_string += " #{tag_string.join(',')}"
 97 |       else
 98 |         cmd_string += " #{args.join(' ')}"
 99 |       end
100 | 
101 |       if options[:save]
102 |         title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
103 |         NA.save_search(title, cmd_string)
104 |       end
105 | 
106 |       exit run(Shellwords.shellsplit(cmd_string))
107 |     end
108 |   end
109 | end
110 | 


--------------------------------------------------------------------------------
/bin/commands/edit.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc 'Edit an existing action'
  6 |   long_desc 'Open a matching action in your default $EDITOR.
  7 | 
  8 |   If multiple todo files are found in the current directory, a menu will
  9 |   allow you to pick which file to act on.
 10 | 
 11 |   Natural language dates are expanded in known date-based tags.'
 12 |   arg_name 'ACTION'
 13 |   command %i[edit] do |c|
 14 |     c.example 'na edit "An existing task"',
 15 |               desc: 'Find "An existing task" action and open it for editing'
 16 | 
 17 |     c.desc 'Use a known todo file, partial matches allowed'
 18 |     c.arg_name 'TODO_FILE'
 19 |     c.flag %i[in todo]
 20 | 
 21 |     c.desc 'Include @done actions'
 22 |     c.switch %i[done]
 23 | 
 24 |     c.desc 'Specify the file to search for the task'
 25 |     c.arg_name 'PATH'
 26 |     c.flag %i[file]
 27 | 
 28 |     c.desc 'Search for files X directories deep'
 29 |     c.arg_name 'DEPTH'
 30 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
 31 | 
 32 |     c.desc 'Match actions containing tag. Allows value comparisons'
 33 |     c.arg_name 'TAG'
 34 |     c.flag %i[tagged], multiple: true
 35 | 
 36 |     c.desc 'Interpret search pattern as regular expression'
 37 |     c.switch %i[e regex], negatable: false
 38 | 
 39 |     c.desc 'Match pattern exactly'
 40 |     c.switch %i[x exact], negatable: false
 41 | 
 42 |     c.desc 'Include notes in search'
 43 |     c.switch %i[search_notes], negatable: true, default_value: true
 44 | 
 45 |     c.action do |global_options, options, args|
 46 |       options[:edit] = true
 47 |       action = if args.count.positive?
 48 |                  args.join(' ').strip
 49 |                else
 50 |                  NA.request_input(options, prompt: 'Enter a task to search for')
 51 |                end
 52 | 
 53 |       NA.notify("#{NA.theme[:error]}Empty input", exit_code: 1) if (action.nil? || action.empty?) && options[:tagged].empty?
 54 | 
 55 |       if action
 56 |         tokens = nil
 57 |         if options[:exact]
 58 |           tokens = action
 59 |         elsif options[:regex]
 60 |           tokens = Regexp.new(action, Regexp::IGNORECASE)
 61 |         else
 62 |           tokens = []
 63 |           all_req = action !~ /[+!\-]/ && !options[:or]
 64 | 
 65 |           action.split(/ /).each do |arg|
 66 |             m = arg.match(/^(?[+\-!])?(?.*?)$/)
 67 |             tokens.push({
 68 |                           token: m['tok'],
 69 |                           required: all_req || (!m['req'].nil? && m['req'] == '+'),
 70 |                           negate: !m['req'].nil? && m['req'] =~ /[!\-]/
 71 |                         })
 72 |           end
 73 |         end
 74 |       end
 75 | 
 76 |       if (action.nil? || action.empty?) && options[:tagged].empty?
 77 |         NA.notify("#{NA.theme[:error]}Empty input, cancelled", exit_code: 1)
 78 |       end
 79 | 
 80 |       all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
 81 |       tags = []
 82 |       options[:tagged].join(',').split(/ *, */).each do |arg|
 83 |         m = arg.match(/^(?[+\-!])?(?[^ =<>$\^~]+?)(?:(?[=<>~]{1,2}|[*$\^]=)(?.*?))?$/)
 84 | 
 85 |         tags.push({
 86 |                     tag: m['tag'].wildcard_to_rx,
 87 |                     comp: m['op'],
 88 |                     value: m['val'],
 89 |                     required: all_req || (!m['req'].nil? && m['req'] == '+'),
 90 |                     negate: !m['req'].nil? && m['req'] =~ /[!-]/
 91 |                   })
 92 |       end
 93 | 
 94 |       target_proj = NA.cwd_is == :project ? NA.cwd : nil
 95 | 
 96 |       if options[:file]
 97 |         file = File.expand_path(options[:file])
 98 |         NA.notify("#{NA.theme[:error]}File not found", exit_code: 1) unless File.exist?(file)
 99 | 
100 |         targets = [file]
101 |       elsif options[:todo]
102 |         todo = []
103 |         options[:todo].split(/ *, */).each do |a|
104 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
105 |           todo.push({
106 |                       token: m['tok'],
107 |                       required: all_req || (!m['req'].nil? && m['req'] == '+'),
108 |                       negate: !m['req'].nil? && m['req'] =~ /[!-]/
109 |                     })
110 |         end
111 |         dirs = NA.match_working_dir(todo)
112 | 
113 |         if dirs.count == 1
114 |           targets = [dirs[0]]
115 |         elsif dirs.count.positive?
116 |           targets = NA.select_file(dirs, multiple: true)
117 |           NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless targets && targets.count.positive?
118 |         else
119 |           NA.notify("#{NA.theme[:error]}Todo not found", exit_code: 1) unless targets && targets.count.positive?
120 | 
121 |         end
122 |       else
123 |         files = NA.find_files(depth: options[:depth])
124 |         NA.notify("#{NA.theme[:error]}No todo file found", exit_code: 1) if files.count.zero?
125 | 
126 |         targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
127 |         NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless files.count.positive?
128 | 
129 |       end
130 | 
131 |       NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
132 | 
133 |       targets.each do |target|
134 |         NA.update_action(target,
135 |                          tokens,
136 |                          search_note: options[:search_notes],
137 |                          done: options[:done],
138 |                          edit: options[:edit],
139 |                          project: target_proj,
140 |                          tagged: tags)
141 |       end
142 |     end
143 |   end
144 | end
145 | 


--------------------------------------------------------------------------------
/bin/commands/find.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc "Find actions matching a search pattern"
  6 |   long_desc "Search tokens are separated by spaces. Actions matching all tokens in the pattern will be shown
  7 |   (partial matches allowed). Add a + before a token to make it required, e.g. `na find +feature +maybe`,
  8 |   add a - or ! to ignore matches containing that token."
  9 |   arg_name "PATTERN"
 10 |   command %i[find grep search] do |c|
 11 |     c.example "na find feature idea swift", desc: "Find all actions containing feature, idea, and swift"
 12 |     c.example "na find feature idea -swift", desc: "Find all actions containing feature and idea but NOT swift"
 13 |     c.example "na find -x feature idea", desc: 'Find all actions containing the exact text "feature idea"'
 14 | 
 15 |     c.desc "Interpret search pattern as regular expression"
 16 |     c.switch %i[e regex], negatable: false
 17 | 
 18 |     c.desc "Match pattern exactly"
 19 |     c.switch %i[x exact], negatable: false
 20 | 
 21 |     c.desc "Recurse to depth"
 22 |     c.arg_name "DEPTH"
 23 |     c.flag %i[d depth], type: :integer, must_match: /^\d+$/
 24 | 
 25 |     c.desc "Show actions from a specific todo file in history. May use wildcards (* and ?)"
 26 |     c.arg_name "TODO_PATH"
 27 |     c.flag %i[in]
 28 | 
 29 |     c.desc "Include notes in output"
 30 |     c.switch %i[notes], negatable: true, default_value: false
 31 | 
 32 |     c.desc "Include notes in search"
 33 |     c.switch %i[search_notes], negatable: true, default_value: true
 34 | 
 35 |     c.desc "Combine search tokens with OR, displaying actions matching ANY of the terms"
 36 |     c.switch %i[o or], negatable: false
 37 | 
 38 |     c.desc "Show actions from a specific project"
 39 |     c.arg_name "PROJECT[/SUBPROJECT]"
 40 |     c.flag %i[proj project]
 41 | 
 42 |     c.desc "Match actions containing tag. Allows value comparisons"
 43 |     c.arg_name "TAG"
 44 |     c.flag %i[tagged], multiple: true
 45 | 
 46 |     c.desc "Include @done actions"
 47 |     c.switch %i[done]
 48 | 
 49 |     c.desc "Show actions not matching search pattern"
 50 |     c.switch %i[v invert], negatable: false
 51 | 
 52 |     c.desc "Save this search for future use"
 53 |     c.arg_name "TITLE"
 54 |     c.flag %i[save]
 55 | 
 56 |     c.desc "Output actions nested by file"
 57 |     c.switch %[nest], negatable: false
 58 | 
 59 |     c.desc "No filename in output"
 60 |     c.switch %i[no_file], negatable: false
 61 | 
 62 |     c.desc "Output actions nested by file and project"
 63 |     c.switch %[omnifocus], negatable: false
 64 | 
 65 |     c.action do |global_options, options, args|
 66 |       options[:nest] = true if options[:omnifocus]
 67 | 
 68 |       if options[:save]
 69 |         title = options[:save].gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_")
 70 |         cmd = NA.command_line.join(" ").sub(/ --save[= ]*\S+/, "").split(" ").map { |t| %("#{t}") }.join(" ")
 71 |         NA.save_search(title, cmd)
 72 |       end
 73 | 
 74 |       depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
 75 |           3
 76 |         else
 77 |           options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
 78 |         end
 79 | 
 80 |       if options[:exact] || options[:regex]
 81 |         search = args.join(" ")
 82 |       else
 83 |         rx = [
 84 |           '(?<=\A|[ ,])(?[+!-])?@(?[^ *=<>$*\^,@(]+)',
 85 |           '(?:\((?.*?)\)| *(?[=<>~]{1,2}|[*$\^]=) *',
 86 |           '(?.*?(?=\Z|[,@])))?',
 87 |         ].join("")
 88 |         search = args.join(" ").gsub(Regexp.new(rx)) do
 89 |           m = Regexp.last_match
 90 |           string = if m["value"]
 91 |               "#{m["req"]}#{m["tag"]}=#{m["value"]}"
 92 |             else
 93 |               m[0]
 94 |             end
 95 |           options[:tagged] << string.sub(/@/, "")
 96 |           ""
 97 |         end
 98 |       end
 99 | 
100 |       search = search.gsub(/ +/, " ").strip
101 | 
102 |       all_req = options[:tagged].join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
103 |       tags = []
104 |       options[:tagged].join(",").split(/ *, */).each do |arg|
105 |         m = arg.match(/^(?[+!-])?(?[^ =<>$~\^]+?) *(?:(?[=<>~]{1,2}|[*$\^]=) *(?.*?))?$/)
106 | 
107 |         tags.push({
108 |                     tag: m["tag"].wildcard_to_rx,
109 |                     comp: m["op"],
110 |                     value: m["val"],
111 |                     required: all_req || (!m["req"].nil? && m["req"] == "+"),
112 |                     negate: !m["req"].nil? && m["req"] =~ /[!-]/ ? true : false,
113 |                   })
114 |       end
115 | 
116 |       search_for_done = false
117 |       tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
118 |       options[:done] = true if search_for_done
119 | 
120 |       tokens = nil
121 |       if options[:exact]
122 |         tokens = search
123 |       elsif options[:regex]
124 |         tokens = Regexp.new(search, Regexp::IGNORECASE)
125 |       else
126 |         tokens = []
127 |         all_req = search !~ /(?<=[, ])[+!-]/ && !options[:or]
128 | 
129 |         search.split(/ /).each do |arg|
130 |           m = arg.match(/^(?[+\-!])?(?.*?)$/)
131 | 
132 |           tokens.push({
133 |                         token: m["tok"],
134 |                         required: all_req || (!m["req"].nil? && m["req"] == "+"),
135 |                         negate: !m["req"].nil? && m["req"] =~ /[!-]/ ? true : false,
136 |                       })
137 |         end
138 |       end
139 | 
140 |       todos = nil
141 |       if options[:in]
142 |         todos = []
143 |         options[:in].split(/ *, */).each do |a|
144 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
145 |           todos.push({
146 |                        token: m["tok"],
147 |                        required: all_req || (!m["req"].nil? && m["req"] == "+"),
148 |                        negate: !m["req"].nil? && m["req"] =~ /[!-]/,
149 |                      })
150 |         end
151 |       end
152 | 
153 |       todo = NA::Todo.new({
154 |                             depth: depth,
155 |                             done: options[:done],
156 |                             query: todos,
157 |                             search: tokens,
158 |                             search_note: options[:search_notes],
159 |                             tag: tags,
160 |                             negate: options[:invert],
161 |                             regex: options[:regex],
162 |                             project: options[:project],
163 |                             require_na: false,
164 |                           })
165 | 
166 |       regexes = if tokens.is_a?(Array)
167 |           tokens.delete_if { |token| token[:negate] }.map { |token| token[:token].wildcard_to_rx }
168 |         else
169 |           [tokens]
170 |         end
171 | 
172 |       todo.actions.output(depth,
173 |                           { files: todo.files,
174 |                             regexes: regexes,
175 |                             notes: options[:notes],
176 |                             nest: options[:nest],
177 |                             nest_projects: options[:omnifocus],
178 |                             no_files: options[:no_file] })
179 |     end
180 |   end
181 | end
182 | 


--------------------------------------------------------------------------------
/bin/commands/init.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Create a new todo file in the current directory'
 6 |   arg_name 'PROJECT', optional: true
 7 |   command %i[init create] do |c|
 8 |     c.example 'na init', desc: 'Generate a new todo file, prompting for project name'
 9 |     c.example 'na init warpspeed', desc: 'Generate a new todo for a project called warpspeed'
10 | 
11 |     c.action do |global_options, _options, args|
12 |       reader = TTY::Reader.new
13 |       if args.count.positive?
14 |         project = args.join(' ')
15 |       elsif
16 |         project = File.expand_path('.').split('/').last
17 |         project = reader.read_line(NA::Color.template("#{NA.theme[:prompt]}Project name #{NA.theme[:filename]}> "), value: project).strip if $stdin.isatty
18 |       end
19 | 
20 |       target = "#{project}.#{NA.extension}"
21 | 
22 |       if File.exist?(target)
23 |         res = NA.yn(NA::Color.template("{r}File {bw}#{target}{r} already exists, overwrite it"), default: false)
24 |         Process.exit 1 unless res
25 | 
26 |       end
27 | 
28 |       NA.create_todo(target, project, template: global_options[:template])
29 |     end
30 |   end
31 | end
32 | 


--------------------------------------------------------------------------------
/bin/commands/move.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc 'Move an existing action to a different section'
  6 |   long_desc 'Provides an easy way to move an action.
  7 | 
  8 |   If multiple todo files are found in the current directory, a menu will
  9 |   allow you to pick which file to act on.'
 10 |   arg_name 'ACTION'
 11 |   command %i[move] do |c|
 12 |     c.example 'na move "A bug in inbox" --to Bugs',
 13 |               desc: 'Find "A bug in inbox" action and move it to section Bugs'
 14 | 
 15 |     c.desc 'Prompt for additional notes. Input will be appended to any existing note.
 16 |     If STDIN input (piped) is detected, it will be used as a note.'
 17 |     c.switch %i[n note], negatable: false
 18 | 
 19 |     c.desc 'Overwrite note instead of appending'
 20 |     c.switch %i[o overwrite], negatable: false
 21 | 
 22 |     c.desc 'Move action to specific project. If not provided, a menu will be shown'
 23 |     c.arg_name 'PROJECT'
 24 |     c.flag %i[to]
 25 | 
 26 |     c.desc 'When moving task, add at [s]tart or [e]nd of target project'
 27 |     c.arg_name 'POSITION'
 28 |     c.flag %i[at], must_match: /^[sbea].*?$/i
 29 | 
 30 |     c.desc 'Search for actions in a specific project'
 31 |     c.arg_name 'PROJECT[/SUBPROJECT]'
 32 |     c.flag %i[from]
 33 | 
 34 |     c.desc 'Use a known todo file, partial matches allowed'
 35 |     c.arg_name 'TODO_FILE'
 36 |     c.flag %i[in todo]
 37 | 
 38 |     c.desc 'Specify the file to search for the task'
 39 |     c.arg_name 'PATH'
 40 |     c.flag %i[file]
 41 | 
 42 |     c.desc 'Search for files X directories deep'
 43 |     c.arg_name 'DEPTH'
 44 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
 45 | 
 46 |     c.desc 'Include notes in search'
 47 |     c.switch %i[search_notes], negatable: true, default_value: true
 48 | 
 49 |     c.desc 'Match actions containing tag. Allows value comparisons'
 50 |     c.arg_name 'TAG'
 51 |     c.flag %i[tagged], multiple: true
 52 | 
 53 |     c.desc 'Act on all matches immediately (no menu)'
 54 |     c.switch %i[all], negatable: false
 55 | 
 56 |     c.desc 'Interpret search pattern as regular expression'
 57 |     c.switch %i[e regex], negatable: false
 58 | 
 59 |     c.desc 'Match pattern exactly'
 60 |     c.switch %i[x exact], negatable: false
 61 | 
 62 |     c.action do |global_options, options, args|
 63 |       reader = TTY::Reader.new
 64 | 
 65 |       args.concat(options[:search]) unless options[:search].nil?
 66 | 
 67 |       append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
 68 | 
 69 |       options[:done] = true
 70 | 
 71 |       action = if args.count.positive?
 72 |                  args.join(' ').strip
 73 |                else
 74 |                  NA.request_input(options, prompt: 'Enter a task to search for')
 75 |                end
 76 |       if action
 77 |         tokens = nil
 78 |         if options[:exact]
 79 |           tokens = action
 80 |         elsif options[:regex]
 81 |           tokens = Regexp.new(action, Regexp::IGNORECASE)
 82 |         else
 83 |           tokens = []
 84 |           all_req = action !~ /[+!-]/ && !options[:or]
 85 | 
 86 |           action.split(/ /).each do |arg|
 87 |             m = arg.match(/^(?[+\-!])?(?.*?)$/)
 88 |             tokens.push({
 89 |                           token: m['tok'],
 90 |                           required: all_req || (!m['req'].nil? && m['req'] == '+'),
 91 |                           negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
 92 |                         })
 93 |           end
 94 |         end
 95 |       end
 96 | 
 97 |       if (action.nil? || action.empty?) && options[:tagged].empty?
 98 |         NA.notify("#{NA.theme[:error]}Empty input, cancelled", exit_code: 1)
 99 |       end
100 | 
101 |       all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
102 |       tags = []
103 |       options[:tagged].join(',').split(/ *, */).each do |arg|
104 |         m = arg.match(/^(?[+!-])?(?[^ =<>$~\^]+?) *(?:(?[=<>~]{1,2}|[*$\^]=) *(?.*?))?$/)
105 | 
106 |         tags.push({
107 |                     tag: m['tag'].wildcard_to_rx,
108 |                     comp: m['op'],
109 |                     value: m['val'],
110 |                     required: all_req || (!m['req'].nil? && m['req'] == '+'),
111 |                     negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
112 |                   })
113 |       end
114 | 
115 |       stdin_note = NA.stdin ? NA.stdin.split("\n") : []
116 | 
117 |       line_note = if options[:note] && $stdin.isatty
118 |                     puts stdin_note unless stdin_note.nil?
119 |                     if TTY::Which.exist?('gum')
120 |                       args = ['--placeholder "Enter a note, CTRL-d to save"']
121 |                       args << '--char-limit 0'
122 |                       args << '--width $(tput cols)'
123 |                       gum = TTY::Which.which('gum')
124 |                       `#{gum} write #{args.join(' ')}`.strip.split("\n")
125 |                     else
126 |                       NA.notify("#{NA.theme[:prompt]}Enter a note, {bw}CTRL-d#{NA.theme[:prompt]} to end editing:#{NA.theme[:action]}")
127 |                       reader.read_multiline
128 |                     end
129 |                   end
130 | 
131 |       note = stdin_note.empty? ? [] : stdin_note
132 |       note.concat(line_note) unless line_note.nil? || line_note.empty?
133 | 
134 |       if options[:file]
135 |         file = File.expand_path(options[:file])
136 |         NA.notify("#{NA.theme[:error]}File not found", exit_code: 1) unless File.exist?(file)
137 | 
138 |         targets = [file]
139 |       elsif options[:todo]
140 |         todo = []
141 |         options[:todo].split(/ *, */).each do |a|
142 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
143 |           todo.push({
144 |                       token: m['tok'],
145 |                       required: all_req || (!m['req'].nil? && m['req'] == '+'),
146 |                       negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
147 |                     })
148 |         end
149 |         dirs = NA.match_working_dir(todo)
150 | 
151 |         if dirs.count == 1
152 |           targets = [dirs[0]]
153 |         elsif dirs.count.positive?
154 |           targets = NA.select_file(dirs, multiple: true)
155 |           NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless targets && targets.count.positive?
156 |         else
157 |           NA.notify("#{NA.theme[:error]}Todo not found", exit_code: 1) unless targets && targets.count.positive?
158 | 
159 |         end
160 |       else
161 |         files = NA.find_files_matching({
162 |                                          depth: options[:depth],
163 |                                          done: options[:done],
164 |                                          project: options[:from],
165 |                                          regex: options[:regex],
166 |                                          require_na: false,
167 |                                          search: tokens,
168 |                                          tag: tags
169 |                                        })
170 |         NA.notify("#{NA.theme[:error]}No todo file found", exit_code: 1) if files.count.zero?
171 | 
172 |         targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
173 |         NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless files.count.positive?
174 |       end
175 | 
176 |       target_proj = if options[:to]
177 |                       options[:to]
178 |                     else
179 |                       todo = NA::Todo.new(require_na: false, file_path: targets[0])
180 |                       projects = todo.projects
181 |                       menu = projects.each_with_object([]) { |proj, arr| arr << proj.project }
182 | 
183 |                       NA.choose_from(menu, prompt: 'Move to: ', multiple: false, sorted: false)
184 |                     end
185 | 
186 |       NA.notify("#{NA.theme[:error]}No target selected", exit_code: 1) unless target_proj
187 | 
188 |       NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
189 | 
190 |       targets.each do |target|
191 |         NA.update_action(target, tokens,
192 |                          all: options[:all],
193 |                          append: append,
194 |                          move: target_proj,
195 |                          note: note,
196 |                          overwrite: options[:overwrite],
197 |                          project: options[:from],
198 |                          search_note: options[:search_notes],
199 |                          tagged: tags)
200 |       end
201 |     end
202 |   end
203 | end
204 | 


--------------------------------------------------------------------------------
/bin/commands/next.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc "Show next actions"
  6 |   long_desc 'Next actions are actions which contain the next action tag (default @na),
  7 |   do not contain @done, and are not in the Archive project.
  8 | 
  9 |   Arguments will target a todo file from history, whether it\'s in the current
 10 |   directory or not. Todo file queries can include path components separated by /
 11 |   or :, and may use wildcards (`*` to match any text, `?` to match a single character). Multiple queries allowed (separate arguments or separated by comma).'
 12 |   arg_name "QUERY", optional: true
 13 |   command %i[next show] do |c|
 14 |     c.example "na next", desc: "display the next actions from any todo files in the current directory"
 15 |     c.example "na next -d 3", desc: "display the next actions from the current directory, traversing 3 levels deep"
 16 |     c.example "na next marked", desc: "display next actions for a project you visited in the past"
 17 | 
 18 |     c.desc "Recurse to depth"
 19 |     c.arg_name "DEPTH"
 20 |     c.flag %i[d depth], type: :integer, must_match: /^[1-9]$/
 21 | 
 22 |     c.desc "Show next actions from all known todo files (in any directory)"
 23 |     c.switch %i[all], negatable: false, default_value: false
 24 | 
 25 |     c.desc "Display matches from a known todo file anywhere in history (short name)"
 26 |     c.arg_name "TODO"
 27 |     c.flag %i[in todo], multiple: true
 28 | 
 29 |     c.desc "Display matches from specific todo file ([relative] path)"
 30 |     c.arg_name "TODO_FILE"
 31 |     c.flag %i[file]
 32 | 
 33 |     c.desc "Alternate tag to search for"
 34 |     c.arg_name "TAG"
 35 |     c.flag %i[t tag]
 36 | 
 37 |     c.desc "Show actions from a specific project"
 38 |     c.arg_name "PROJECT[/SUBPROJECT]"
 39 |     c.flag %i[proj project]
 40 | 
 41 |     c.desc "Match actions containing tag. Allows value comparisons"
 42 |     c.arg_name "TAG"
 43 |     c.flag %i[tagged], multiple: true
 44 | 
 45 |     c.desc "Match actions with priority, allows <>= comparison"
 46 |     c.arg_name "PRIORITY"
 47 |     c.flag %i[p prio priority], multiple: true
 48 | 
 49 |     c.desc "Filter results using search terms"
 50 |     c.arg_name "QUERY"
 51 |     c.flag %i[search find grep], multiple: true
 52 | 
 53 |     c.desc "Include notes in search"
 54 |     c.switch %i[search_notes], negatable: true, default_value: true
 55 | 
 56 |     c.desc "Search query is regular expression"
 57 |     c.switch %i[regex], negatable: false
 58 | 
 59 |     c.desc "Search query is exact text match (not tokens)"
 60 |     c.switch %i[exact], negatable: false
 61 | 
 62 |     c.desc "Include notes in output"
 63 |     c.switch %i[notes], negatable: true, default_value: false
 64 | 
 65 |     c.desc "Include @done actions"
 66 |     c.switch %i[done]
 67 | 
 68 |     c.desc "Output actions nested by file"
 69 |     c.switch %i[nest], negatable: false
 70 | 
 71 |     c.desc "No filename in output"
 72 |     c.switch %i[no_file], negatable: false
 73 | 
 74 |     c.desc "Output actions nested by file and project"
 75 |     c.switch %i[omnifocus], negatable: false
 76 | 
 77 |     c.desc "Save this search for future use"
 78 |     c.arg_name "TITLE"
 79 |     c.flag %i[save]
 80 | 
 81 |     c.action do |global_options, options, args|
 82 |       # For backward compatibility with na -a
 83 |       if global_options[:add]
 84 |         cmd = ["add"]
 85 |         cmd.push("--note") if global_options[:note]
 86 |         cmd.concat(["--priority", global_options[:priority]]) if global_options[:priority]
 87 |         cmd.push(NA.command_line) if NA.command_line.count > 1
 88 |         cmd.unshift(*NA.globals)
 89 |         exit run(cmd)
 90 |       end
 91 | 
 92 |       if options[:save]
 93 |         title = options[:save].gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_")
 94 |         NA.save_search(title, "#{NA.command_line.join(" ").sub(/ --save[= ]*\S+/, "").split(" ").map { |t| %("#{t}") }.join(" ")}")
 95 |       end
 96 | 
 97 |       options[:nest] = true if options[:omnifocus]
 98 | 
 99 |       depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
100 |           3
101 |         else
102 |           options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
103 |         end
104 | 
105 |       if options[:exact] || options[:regex]
106 |         search = options[:search].join(" ")
107 |       else
108 |         #  This regex matches the following:
109 |         #  @tag(value)
110 |         #  @tag=value (or > < >= <=)
111 |         #  @tag=~value
112 |         #  @tag ^= value (or *=, $=)
113 |         rx = [
114 |           '(?<=\A|[ ,])(?[+!-])?@(?[^ *=<>$~\^,@(]+)',
115 |           '(?:\((?.*?)\)| *(?=~|[=<>~]{1,2}|[*$\^]=) *',
116 |           '(?.*?(?=\Z|[,@])))?',
117 |         ].join("")
118 |         # convert tag(value) to tag=value
119 |         search = options[:search].join(" ").gsub(Regexp.new(rx)) do
120 |           m = Regexp.last_match
121 |           string = if m["value"]
122 |               "#{m["req"]}#{m["tag"]}=#{m["value"]}"
123 |             else
124 |               m[0]
125 |             end
126 |           options[:tagged] << string.sub(/@/, "")
127 |           ""
128 |         end
129 |       end
130 | 
131 |       search = search.gsub(/,/, "").gsub(/ +/, " ") unless search.nil?
132 | 
133 |       if options[:priority].count.positive?
134 |         prios = options[:priority].join(",").split(/,/)
135 |         options[:or] = true if prios.count > 1
136 |         prios.map! do |p|
137 |           p.sub(/([hml])$/) do
138 |             case Regexp.last_match[1]
139 |             when "h"
140 |               NA.priority_map["h"]
141 |             when "m"
142 |               NA.priority_map["m"]
143 |             when "l"
144 |               NA.priority_map["l"]
145 |             end
146 |           end
147 |         end
148 |         prios.each do |p|
149 |           options[:tagged] << if p =~ /^[<>=]{1,2}/
150 |             "priority#{p}"
151 |           else
152 |             "priority=#{p}"
153 |           end
154 |         end
155 |       end
156 | 
157 |       all_req = options[:tagged].join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
158 |       tags = []
159 |       options[:tagged].join(",").split(/ *, */).each do |arg|
160 |         m = arg.match(/^(?[+!-])?(?[^ =<>$~\^]+?) *(?:(?[=<>~]{1,2}|[*$\^]=) *(?.*?))?$/)
161 | 
162 |         tags.push({
163 |                     tag: m["tag"].wildcard_to_rx,
164 |                     comp: m["op"],
165 |                     value: m["val"],
166 |                     required: all_req || (!m["req"].nil? && m["req"] == "+"),
167 |                     negate: !m["req"].nil? && m["req"] =~ /[!-]/ ? true : false,
168 |                   })
169 |       end
170 | 
171 |       args.concat(options[:in])
172 |       args << "*" if options[:all]
173 |       if args.count.positive?
174 |         all_req = args.join(" ") !~ /(?<=[, ])[+!-]/
175 | 
176 |         tokens = []
177 |         args.each do |arg|
178 |           arg.split(/ *, */).each do |a|
179 |             m = a.match(/^(?[+!-])?(?.*?)$/)
180 |             tokens.push({
181 |                           token: m["tok"],
182 |                           required: !m["req"].nil? && m["req"] == "+",
183 |                           negate: !m["req"].nil? && m["req"] =~ /[!-]/ ? true : false,
184 |                         })
185 |           end
186 |         end
187 |       end
188 | 
189 |       options[:done] = true if tags.any? { |tag| tag[:tag] =~ /done/ }
190 | 
191 |       search_tokens = nil
192 |       if options[:exact]
193 |         search_tokens = search
194 |       elsif options[:regex]
195 |         search_tokens = Regexp.new(search, Regexp::IGNORECASE)
196 |       else
197 |         search_tokens = []
198 |         all_req = search !~ /(?<=[, ])[+!-]/ && !options[:or]
199 | 
200 |         search.split(/ /).each do |arg|
201 |           m = arg.match(/^(?[+\-!])?(?.*?)$/)
202 |           search_tokens.push({
203 |                                token: m["tok"],
204 |                                required: all_req || (!m["req"].nil? && m["req"] == "+"),
205 |                                negate: !m["req"].nil? && m["req"] =~ /[!-]/ ? true : false,
206 |                              })
207 |         end
208 |       end
209 | 
210 |       NA.na_tag = options[:tag] unless options[:tag].nil?
211 |       require_na = true
212 | 
213 |       tag = [{ tag: NA.na_tag, value: nil, required: true, negate: false }]
214 |       tag << { tag: "done", value: nil, negate: true } unless options[:done]
215 |       tag.concat(tags)
216 | 
217 |       file_path = options[:file] ? File.expand_path(options[:file]) : nil
218 | 
219 |       todo = NA::Todo.new({ depth: depth,
220 |                             done: options[:done],
221 |                             file_path: file_path,
222 |                             project: options[:project],
223 |                             query: tokens,
224 |                             require_na: require_na,
225 |                             search: search_tokens,
226 |                             search_note: options[:search_notes],
227 |                             tag: tag })
228 |       if todo.files.empty? && tokens
229 |         NA.notify("#{NA.theme[:error]}No matches found for #{tokens[0][:token]}.
230 |                   Run `na todos` to see available todo files.")
231 |       end
232 |       NA::Pager.paginate = false if options[:omnifocus]
233 |       todo.actions.output(depth,
234 |                           { files: todo.files,
235 |                             nest: options[:nest],
236 |                             nest_projects: options[:omnifocus],
237 |                             notes: options[:notes],
238 |                             no_files: options[:no_file] })
239 |     end
240 |   end
241 | end
242 | 


--------------------------------------------------------------------------------
/bin/commands/open.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Open a todo file in the default editor'
 6 |   long_desc 'Let the system choose the defualt, (e.g. TaskPaper), or specify a command line utility (e.g. vim).
 7 |              If more than one todo file is found, a menu is displayed.'
 8 |   command %i[open] do |c|
 9 |     c.example 'na open', desc: 'Open the main todo file in the default editor'
10 |     c.example 'na open -d 3 -a vim', desc: 'Display a menu of all todo files three levels deep from the
11 |                current directory, open selection in vim.'
12 | 
13 |     c.desc 'Recurse to depth'
14 |     c.arg_name 'DEPTH'
15 |     c.default_value 1
16 |     c.flag %i[d depth], type: :integer, must_match: /^\d+$/
17 | 
18 |     c.desc 'Specify an editor CLI'
19 |     c.arg_name 'EDITOR'
20 |     c.flag %i[e editor]
21 | 
22 |     c.desc 'Specify a Mac app'
23 |     c.arg_name 'EDITOR'
24 |     c.flag %i[a app]
25 | 
26 |     c.action do |global_options, options, args|
27 |       depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
28 |                 3
29 |               else
30 |                 options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
31 |               end
32 |       files = NA.find_files(depth: depth)
33 |       files.delete_if { |f| f !~ /.*?(#{args.join('|')}).*?.#{NA.extension}/ } if args.count.positive?
34 | 
35 |       file = if files.count > 1
36 |                NA.select_file(files)
37 |              else
38 |                files[0]
39 |              end
40 | 
41 |       if options[:editor]
42 |         system options[:editor], file
43 |       else
44 |         NA.edit_file(file: file, app: options[:app])
45 |       end
46 |     end
47 |   end
48 | end
49 | 


--------------------------------------------------------------------------------
/bin/commands/projects.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Show list of projects for a file'
 6 |   long_desc 'Arguments will be interpreted as a query for a known todo file,
 7 |   fuzzy matched. Separate directories with /, :, or a space, e.g. `na projects code/marked`'
 8 |   arg_name 'QUERY', optional: true
 9 |   command %i[projects] do |c|
10 |     c.desc 'Search for files X directories deep'
11 |     c.arg_name 'DEPTH'
12 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
13 | 
14 |     c.desc 'Output projects as paths instead of hierarchy'
15 |     c.switch %i[p paths], negatable: false
16 | 
17 |     c.action do |_global_options, options, args|
18 |       if args.count.positive?
19 |         all_req = args.join(' ') !~ /(?<=[, ])[+!-]/
20 | 
21 |         tokens = [{ token: '*', required: all_req, negate: false }]
22 |         args.each do |arg|
23 |           arg.split(/ *, */).each do |a|
24 |             m = a.match(/^(?[+\-!])?(?.*?)$/)
25 |             tokens.push({
26 |                           token: m['tok'],
27 |                           required: all_req || (!m['req'].nil? && m['req'] == '+'),
28 |                           negate: !m['req'].nil? && m['req'] =~ /[!-]/
29 |                         })
30 |           end
31 |         end
32 |       end
33 | 
34 |       NA.list_projects(query: tokens, depth: options[:depth], paths: options[:paths])
35 |     end
36 |   end
37 | end
38 | 


--------------------------------------------------------------------------------
/bin/commands/prompt.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Show or install prompt hooks for the current shell'
 6 |   long_desc 'Installing the prompt hook allows you to automatically
 7 |   list next actions when you cd into a directory'
 8 |   command %i[prompt] do |c|
 9 |     c.desc 'Output the prompt hook for the current shell to STDOUT. Pass an argument to
10 |             specify a shell (zsh, bash, fish)'
11 |     c.arg_name 'SHELL', optional: true
12 |     c.default_command :show
13 | 
14 |     c.command %i[show] do |s|
15 |       s.action do |_global_options, _options, args|
16 |         shell = if args.count.positive?
17 |                   args[0]
18 |                 else
19 |                   File.basename(ENV['SHELL'])
20 |                 end
21 | 
22 |         case shell
23 |         when /^f/i
24 |           NA::Prompt.show_prompt_hook(:fish)
25 |         when /^z/i
26 |           NA::Prompt.show_prompt_hook(:zsh)
27 |         when /^b/i
28 |           NA::Prompt.show_prompt_hook(:bash)
29 |         end
30 |       end
31 |     end
32 | 
33 |     c.desc 'Install the hook for the current shell to the appropriate startup file.'
34 |     c.arg_name 'SHELL', optional: true
35 |     c.command %i[install] do |s|
36 |       s.action do |_global_options, _options, args|
37 |         shell = if args.count.positive?
38 |                   args[0]
39 |                 else
40 |                   File.basename(ENV['SHELL'])
41 |                 end
42 | 
43 |         case shell
44 |         when /^f/i
45 |           NA::Prompt.install_prompt_hook(:fish)
46 |         when /^z/i
47 |           NA::Prompt.install_prompt_hook(:zsh)
48 |         when /^b/i
49 |           NA::Prompt.install_prompt_hook(:bash)
50 |         end
51 |       end
52 |     end
53 |   end
54 | end
55 | 


--------------------------------------------------------------------------------
/bin/commands/restore.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Find and remove @done tag from an action'
 6 |   arg_name 'PATTERN'
 7 |   command %i[restore unfinish] do |c|
 8 |     c.example 'na restore "An existing task"',
 9 |               desc: 'Find "An existing task" and remove @done'
10 |     c.example 'na unfinish "An existing task"',
11 |               desc: 'Alias for restore'
12 | 
13 |     c.desc 'Prompt for additional notes. Input will be appended to any existing note.
14 |     If STDIN input (piped) is detected, it will be used as a note.'
15 |     c.switch %i[n note], negatable: false
16 | 
17 |     c.desc 'Overwrite note instead of appending'
18 |     c.switch %i[o overwrite], negatable: false
19 | 
20 |     c.desc 'Move action to specific project'
21 |     c.arg_name 'PROJECT'
22 |     c.flag %i[to project proj]
23 | 
24 |     c.desc 'Specify the file to search for the task'
25 |     c.arg_name 'PATH'
26 |     c.flag %i[file in]
27 | 
28 |     c.desc 'Search for files X directories deep'
29 |     c.arg_name 'DEPTH'
30 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
31 | 
32 |     c.desc 'Match actions containing tag. Allows value comparisons'
33 |     c.arg_name 'TAG'
34 |     c.flag %i[tagged], multiple: true
35 | 
36 |     c.desc 'Act on all matches immediately (no menu)'
37 |     c.switch %i[all], negatable: false
38 | 
39 |     c.desc 'Interpret search pattern as regular expression'
40 |     c.switch %i[e regex], negatable: false
41 | 
42 |     c.desc 'Match pattern exactly'
43 |     c.switch %i[x exact], negatable: false
44 | 
45 |     c.desc 'Include notes in search'
46 |     c.switch %i[search_notes], negatable: true, default_value: true
47 | 
48 |     c.action do |global, options, args|
49 |       options[:remove] = ['done']
50 |       options[:done] = true
51 |       options[:finish] = false
52 |       options[:f] = false
53 | 
54 |       cmd = commands[:update]
55 |       action = cmd.send(:get_action, nil)
56 |       action.call(global, options, args)
57 |     end
58 |   end
59 | end
60 | 


--------------------------------------------------------------------------------
/bin/commands/saved.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Execute a saved search'
 6 |   long_desc 'Run without argument to list saved searches'
 7 |   arg_name 'SEARCH_TITLE', optional: true, multiple: true
 8 |   command %i[saved] do |c|
 9 |     c.example 'na tagged "+maybe,+priority<=3" --save maybelater', desc: 'save a search called "maybelater"'
10 |     c.example 'na saved maybelater', desc: 'perform the search named "maybelater"'
11 |     c.example 'na saved maybe',
12 |               desc: 'perform the search named "maybelater", assuming no other searches match "maybe"'
13 |     c.example 'na maybe',
14 |               desc: 'na run with no command and a single argument automatically performs a matching saved search'
15 |     c.example 'na saved', desc: 'list available searches'
16 | 
17 |     c.desc 'Open the saved search file in $EDITOR'
18 |     c.switch %i[e edit], negatable: false
19 | 
20 |     c.desc 'Delete the specified search definition'
21 |     c.switch %i[d delete], negatable: false
22 | 
23 |     c.desc 'Interactively select a saved search to run'
24 |     c.switch %i[s select], negatable: false
25 | 
26 |     c.action do |_global_options, options, args|
27 |       NA.edit_searches if options[:edit]
28 | 
29 |       if args.empty? && !options[:select]
30 |         searches = NA.load_searches
31 |         NA.notify("#{NA.theme[:success]}Saved searches stored in #{NA.database_path(file: 'saved_searches.yml').highlight_filename}")
32 |         NA.notify(searches.map { |k, v| "#{NA.theme[:filename]}#{k}: #{NA.theme[:values]}#{v}" }.join("\n"))
33 |       else
34 |         NA.delete_search(args.join(',').split(/[ ,]/)) if options[:delete]
35 | 
36 |         if options[:select]
37 |           searches = NA.load_searches
38 |           res = NA.choose_from(searches.map { |k, v| "#{NA.theme[:filename]}#{k} #{NA.theme[:value]}(#{v})" }, multiple: true)
39 |           NA.notify("#{NA.theme[:error]}Nothing selected", exit_code: 0) if res&.empty?
40 |           args = res.map { |r| r.match(/(\S+)(?= \()/)[1] }
41 |         end
42 | 
43 |         args.each do |arg|
44 |           searches = NA.load_searches
45 | 
46 |           keys = searches.keys.delete_if { |k| k !~ /#{arg.wildcard_to_rx}/ }
47 |           NA.notify("#{NA.theme[:error]}Search #{arg} not found", exit_code: 1) if keys.empty?
48 | 
49 |           keys.each do |key|
50 |             NA.notify("#{NA.theme[:prompt]}Saved search #{NA.theme[:filename]}#{key}#{NA.theme[:warning]}:")
51 |             cmd = Shellwords.shellsplit(searches[key])
52 |             run(cmd)
53 |           end
54 |         end
55 |       end
56 |     end
57 |   end
58 | end
59 | 


--------------------------------------------------------------------------------
/bin/commands/tag.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc 'Add tags to matching action(s)'
  6 |   long_desc 'Provides an easy way to tag existing actions.
  7 | 
  8 |   Use !tag to remove a tag, use ~tag(new value) to change a tag or add a value.
  9 | 
 10 |   If multiple todo files are found in the current directory, a menu will
 11 |   allow you to pick which file to act on, or use --all to apply to all matches.'
 12 |   arg_name 'TAG', mutliple: true
 13 |   command %i[tag] do |c|
 14 |     c.example 'na tag "project(warpspeed)" --search "An existing task"',
 15 |               desc: 'Find "An existing task" action and add @project(warpspeed) to it'
 16 |     c.example 'na tag "!project1" --tagged project2 --all',
 17 |               desc: 'Find all actions tagged @project2 and remove @project1 from them'
 18 |     c.example 'na tag "!project2" --all',
 19 |               desc: 'Remove @project2 from all actions'
 20 |     c.example 'na tag "~project(dirt nap)" --search "An existing task"',
 21 |               desc: 'Find "An existing task" and change (or add) its @project tag value to "dirt nap"'
 22 | 
 23 |     c.desc 'Use a known todo file, partial matches allowed'
 24 |     c.arg_name 'TODO_FILE'
 25 |     c.flag %i[in todo]
 26 | 
 27 |     c.desc 'Include @done actions'
 28 |     c.switch %i[done]
 29 | 
 30 |     c.desc 'Specify the file to search for the task'
 31 |     c.arg_name 'PATH'
 32 |     c.flag %i[file]
 33 | 
 34 |     c.desc 'Search for files X directories deep'
 35 |     c.arg_name 'DEPTH'
 36 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
 37 | 
 38 |     c.desc 'Match actions containing tag. Allows value comparisons'
 39 |     c.arg_name 'TAG'
 40 |     c.flag %i[tagged], multiple: true
 41 | 
 42 |     c.desc 'Act on all matches immediately (no menu)'
 43 |     c.switch %i[all], negatable: false
 44 | 
 45 |     c.desc 'Filter results using search terms'
 46 |     c.arg_name 'QUERY'
 47 |     c.flag %i[search find grep], multiple: true
 48 | 
 49 |     c.desc 'Include notes in search'
 50 |     c.switch %i[search_notes], negatable: true, default_value: true
 51 | 
 52 |     c.desc 'Interpret search pattern as regular expression'
 53 |     c.switch %i[e regex], negatable: false
 54 | 
 55 |     c.desc 'Match pattern exactly'
 56 |     c.switch %i[x exact], negatable: false
 57 | 
 58 |     c.action do |global_options, options, args|
 59 |       tags = args.join(',').split(/ *, */)
 60 |       options[:remove] = []
 61 |       options[:tag] = []
 62 |       tags.each do |tag|
 63 |         if tag =~ /^[!-]/
 64 |           options[:remove] << tag.sub(/^[!-]/, '').sub(/^@/, '')
 65 |         elsif tag =~ /^~/
 66 |           options[:remove] << tag.sub(/^~/, '').sub(/\(.*?\)$/, '').sub(/^@/, '')
 67 |           options[:tag] << tag.sub(/^~/, '').sub(/^@/, '')
 68 |         else
 69 |           options[:tag] << tag.sub(/^@/, '')
 70 |         end
 71 |       end
 72 | 
 73 |       if options[:search]
 74 |         tokens = nil
 75 |         if options[:exact]
 76 |           tokens = options[:search]
 77 |         elsif options[:regex]
 78 |           tokens = Regexp.new(options[:search], Regexp::IGNORECASE)
 79 |         else
 80 |           action = options[:search].join(' ')
 81 |           tokens = []
 82 |           all_req = action !~ /[+!-]/ && !options[:or]
 83 | 
 84 |           action.split(/ /).each do |arg|
 85 |             m = arg.match(/^(?[+\-!])?(?.*?)$/)
 86 |             tokens.push({
 87 |                           token: m['tok'],
 88 |                           required: all_req || (!m['req'].nil? && m['req'] == '+'),
 89 |                           negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
 90 |                         })
 91 |           end
 92 |         end
 93 |       end
 94 | 
 95 |       all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
 96 |       tags = []
 97 |       options[:tagged].join(',').split(/ *, */).each do |arg|
 98 |         m = arg.match(/^(?[+!-])?(?[^ =<>$~\^]+?) *(?:(?[=<>~]{1,2}|[*$\^]=) *(?.*?))?$/)
 99 | 
100 |         tags.push({
101 |                     tag: m['tag'].wildcard_to_rx,
102 |                     comp: m['op'],
103 |                     value: m['val'],
104 |                     required: all_req || (!m['req'].nil? && m['req'] == '+'),
105 |                     negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
106 |                   })
107 |       end
108 | 
109 |       add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
110 |       remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
111 | 
112 |       if options[:file]
113 |         file = File.expand_path(options[:file])
114 |         NA.notify("#{NA.theme[:error]}File not found", exit_code: 1) unless File.exist?(file)
115 | 
116 |         targets = [file]
117 |       elsif options[:todo]
118 |         todo = []
119 |         options[:todo].split(/ *, */).each do |a|
120 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
121 |           todo.push({
122 |                       token: m['tok'],
123 |                       required: all_req || (!m['req'].nil? && m['req'] == '+'),
124 |                       negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
125 |                     })
126 |         end
127 |         dirs = NA.match_working_dir(todo)
128 | 
129 |         if dirs.count == 1
130 |           targets = [dirs[0]]
131 |         elsif dirs.count.positive?
132 |           targets = NA.select_file(dirs, multiple: true)
133 |           NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless targets && targets.count.positive?
134 |         else
135 |           NA.notify("#{NA.theme[:error]}Todo not found", exit_code: 1) unless targets && targets.count.positive?
136 | 
137 |         end
138 |       else
139 |         files = NA.find_files_matching({
140 |                                          depth: options[:depth],
141 |                                          done: options[:done],
142 |                                          regex: options[:regex],
143 |                                          require_na: false,
144 |                                          search: tokens,
145 |                                          tag: tags
146 |                                        })
147 |         NA.notify("#{NA.theme[:error]}No todo file found", exit_code: 1) if files.count.zero?
148 | 
149 |         targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
150 |         NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless files.count.positive?
151 | 
152 |       end
153 | 
154 |       NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
155 | 
156 |       targets.each do |target|
157 |         NA.update_action(target, tokens,
158 |                          search_note: options[:search_notes],
159 |                          add_tag: add_tags,
160 |                          all: options[:all],
161 |                          done: options[:done],
162 |                          remove_tag: remove_tags,
163 |                          tagged: tags)
164 |       end
165 |     end
166 |   end
167 | end
168 | 


--------------------------------------------------------------------------------
/bin/commands/tagged.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc "Find actions matching a tag"
  6 |   long_desc 'Finds actions with tags matching the arguments. An action is shown if it
  7 |   contains all of the tags listed. Add a + before a tag to make it required
  8 |   and others optional. You can specify values using TAG=VALUE pairs.
  9 |   Use <, >, and = for numeric comparisons, and *=, ^=, $=, or =~ (regex) for text comparisons.
 10 |   Date comparisons use natural language (`na tagged "due<=today"`) and
 11 |   are detected automatically.'
 12 |   arg_name "TAG[=VALUE]"
 13 |   command %i[tagged] do |c|
 14 |     c.example "na tagged maybe", desc: "Show all actions tagged @maybe"
 15 |     c.example 'na tagged -d 3 "feature, idea"', desc: "Show all actions tagged @feature AND @idea, recurse 3 levels"
 16 |     c.example 'na tagged --or "feature, idea"', desc: "Show all actions tagged @feature OR @idea"
 17 |     c.example 'na tagged "priority>=4"', desc: "Show actions with @priority(4) or @priority(5)"
 18 |     c.example 'na tagged "due[+!-])?(?[^ =<>$~\^]+?) *(?:(?[=<>~]{1,2}|[*$\^]=) *(?.*?))?$/)
 91 |         next if m.nil?
 92 | 
 93 |         tags.push({
 94 |                     tag: m["tag"].sub(/^@/, "").wildcard_to_rx,
 95 |                     comp: m["op"],
 96 |                     value: m["val"],
 97 |                     required: all_req || (!m["req"].nil? && m["req"] == "+"),
 98 |                     negate: !m["req"].nil? && m["req"] =~ /[!-]/,
 99 |                   })
100 |       end
101 | 
102 |       search_for_done = false
103 |       tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
104 |       tags.push({ tag: "done", value: nil, negate: true }) unless search_for_done || options[:done]
105 |       options[:done] = true if search_for_done
106 | 
107 |       tokens = nil
108 |       if options[:search]
109 |         if options[:exact]
110 |           tokens = options[:search].join(" ")
111 |         elsif options[:regex]
112 |           tokens = Regexp.new(options[:search].join(" "), Regexp::IGNORECASE)
113 |         else
114 |           tokens = []
115 |           all_req = options[:search].join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
116 | 
117 |           options[:search].join(" ").split(/ /).each do |arg|
118 |             m = arg.match(/^(?[+\-!])?(?.*?)$/)
119 |             tokens.push({
120 |                           token: m["tok"],
121 |                           required: all_req || (!m["req"].nil? && m["req"] == "+"),
122 |                           negate: !m["req"].nil? && m["req"] =~ /[!-]/,
123 |                         })
124 |           end
125 |         end
126 |       end
127 | 
128 |       todos = nil
129 |       if options[:in]
130 |         todos = []
131 |         all_req = options[:in] !~ /(?<=[, ])[+!-]/ && !options[:or]
132 |         options[:in].split(/ *, */).each do |a|
133 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
134 |           todos.push({
135 |                        token: m["tok"],
136 |                        required: all_req || (!m["req"].nil? && m["req"] == "+"),
137 |                        negate: !m["req"].nil? && m["req"] =~ /[!-]/,
138 |                      })
139 |         end
140 |       end
141 | 
142 |       NA.notify("#{NA.theme[:error]}No actions matched search", exit_code: 1) if tags.empty? && tokens.empty?
143 | 
144 |       todo = NA::Todo.new({ depth: depth,
145 |                             done: options[:done],
146 |                             query: todos,
147 |                             search: tokens,
148 |                             search_note: options[:search_notes],
149 |                             tag: tags,
150 |                             negate: options[:invert],
151 |                             project: options[:project],
152 |                             require_na: false })
153 | 
154 |       regexes = if tokens.is_a?(Array)
155 |           tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
156 |         else
157 |           [tokens]
158 |         end
159 |       todo.actions.output(depth,
160 |                           { files: todo.files,
161 |                             regexes: regexes,
162 |                             notes: options[:notes],
163 |                             nest: options[:nest],
164 |                             nest_projects: options[:omnifocus],
165 |                             no_files: options[:no_file] })
166 |     end
167 |   end
168 | end
169 | 


--------------------------------------------------------------------------------
/bin/commands/todos.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Show list of known todo files'
 6 |   long_desc 'Arguments will be interpreted as a query against which the
 7 |   list of todos will be fuzzy matched. Separate directories with
 8 |   /, :, or a space, e.g. `na todos code/marked`'
 9 |   arg_name 'QUERY', optional: true
10 |   command %i[todos] do |c|
11 |     c.desc 'Open the todo database in an editor for manual modification'
12 |     c.switch %i[e edit]
13 | 
14 |     c.action do |_global_options, options, args|
15 |       if options[:edit]
16 |         system("#{NA::Editor.default_editor(prefer_git_editor: false)} #{NA.database_path}")
17 |         editor = NA::Editor.default_editor(prefer_git_editor: false).highlight_filename
18 |         database = NA.database_path.highlight_filename
19 |         NA.notify("{b}#{NA.theme[:success]}Opened #{database}#{NA.theme[:success]} in #{editor}")
20 |       else
21 |         if args.count.positive?
22 |           all_req = args.join(' ') !~ /(?<=[, ])[+!-]/
23 | 
24 |           tokens = [{ token: '*', required: all_req, negate: false }]
25 |           args.each do |arg|
26 |             arg.split(/ *, */).each do |a|
27 |               m = a.match(/^(?[+!-])?(?.*?)$/)
28 |               tokens.push({
29 |                             token: m['tok'],
30 |                             required: all_req || (!m['req'].nil? && m['req'] == '+'),
31 |                             negate: (!m['req'].nil? && m['req'] =~ /[!-]/) ? true : false
32 |                           })
33 |             end
34 |           end
35 |         end
36 | 
37 |         NA.list_todos(query: tokens)
38 |       end
39 |     end
40 |   end
41 | end
42 | 


--------------------------------------------------------------------------------
/bin/commands/undo.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class App
 4 |   extend GLI::App
 5 |   desc 'Undo the last change'
 6 |   long_desc 'Run without argument to undo most recent change'
 7 |   arg_name 'FILE', optional: true, multiple: true
 8 |   command %i[undo] do |c|
 9 |     c.desc 'Select from available undo files'
10 |     c.switch %i[s select choose]
11 | 
12 |     c.example 'na undo', desc: 'Undo the last change'
13 |     c.example 'na undo myproject', desc: 'Undo the last change to a file matching "myproject"'
14 | 
15 |     c.action do |_global_options, options, args|
16 |       if options[:select]
17 |         options = IO.read(NA.database_path(file: 'last_modified.txt')).strip.split(/\n/)
18 |         res = NA.choose_from(options, sorted: false)
19 |         NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless res
20 | 
21 |         NA.restore_modified_file(res)
22 |       elsif args.empty?
23 |         NA.restore_last_modified_file
24 |       else
25 |         args.each do |arg|
26 |           NA.restore_last_modified_file(search: arg)
27 |         end
28 |       end
29 |     end
30 |   end
31 | end
32 | 


--------------------------------------------------------------------------------
/bin/commands/update.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | class App
  4 |   extend GLI::App
  5 |   desc 'Update an existing action'
  6 |   long_desc 'Provides an easy way to complete, prioritize, and tag existing actions.
  7 | 
  8 |   If multiple todo files are found in the current directory, a menu will
  9 |   allow you to pick which file to act on.'
 10 |   arg_name 'ACTION'
 11 |   command %i[update] do |c|
 12 |     c.example 'na update --remove na "An existing task"',
 13 |               desc: 'Find "An existing task" action and remove the @na tag from it'
 14 |     c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
 15 |               desc: 'Find "A bug..." action, add @waiting, add/update @priority(4), and prompt for an additional note'
 16 |     c.example 'na update --archive My cool action',
 17 |               desc: 'Add @done to "My cool action" and immediately move to Archive'
 18 | 
 19 |     c.desc 'Prompt for additional notes. Input will be appended to any existing note.
 20 |     If STDIN input (piped) is detected, it will be used as a note.'
 21 |     c.switch %i[n note], negatable: false
 22 | 
 23 |     c.desc 'Overwrite note instead of appending'
 24 |     c.switch %i[o overwrite], negatable: false
 25 | 
 26 |     c.desc 'Add/change a priority level 1-5'
 27 |     c.arg_name 'PRIO'
 28 |     c.flag %i[p priority], must_match: /[1-5]/, type: :integer, default_value: 0
 29 | 
 30 |     c.desc 'When moving task, add at [s]tart or [e]nd of target project'
 31 |     c.arg_name 'POSITION'
 32 |     c.flag %i[at], must_match: /^[sbea].*?$/i
 33 | 
 34 |     c.desc 'Move action to specific project'
 35 |     c.arg_name 'PROJECT'
 36 |     c.flag %i[to move]
 37 | 
 38 |     c.desc 'Affect actions from a specific project'
 39 |     c.arg_name 'PROJECT[/SUBPROJECT]'
 40 |     c.flag %i[proj project]
 41 | 
 42 |     c.desc 'Use a known todo file, partial matches allowed'
 43 |     c.arg_name 'TODO_FILE'
 44 |     c.flag %i[in todo]
 45 | 
 46 |     c.desc 'Include @done actions'
 47 |     c.switch %i[done]
 48 | 
 49 |     c.desc 'Add a tag to the action, @tag(values) allowed, use multiple times or combine multiple tags with a comma'
 50 |     c.arg_name 'TAG'
 51 |     c.flag %i[t tag], multiple: true
 52 | 
 53 |     c.desc 'Remove a tag from the action, use multiple times or combine multiple tags with a comma,
 54 |             wildcards (* and ?) allowed'
 55 |     c.arg_name 'TAG'
 56 |     c.flag %i[r remove], multiple: true
 57 | 
 58 |     c.desc 'Use with --find to find and replace with new text. Enables --exact when used'
 59 |     c.arg_name 'TEXT'
 60 |     c.flag %i[replace]
 61 | 
 62 |     c.desc 'Add a @done tag to action'
 63 |     c.switch %i[f finish], negatable: false
 64 | 
 65 |     c.desc 'Add a @done tag to action and move to Archive'
 66 |     c.switch %i[a archive], negatable: false
 67 | 
 68 |     c.desc 'Remove @done tag from action'
 69 |     c.switch %i[restore], negatable: false
 70 | 
 71 |     c.desc 'Delete an action'
 72 |     c.switch %i[delete], negatable: false
 73 | 
 74 |     c.desc "Open action in editor (#{NA::Editor.default_editor}).
 75 |             Natural language dates will be parsed and converted in date-based tags."
 76 |     c.switch %i[edit], negatable: false
 77 | 
 78 |     c.desc 'Specify the file to search for the task'
 79 |     c.arg_name 'PATH'
 80 |     c.flag %i[file]
 81 | 
 82 |     c.desc 'Search for files X directories deep'
 83 |     c.arg_name 'DEPTH'
 84 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
 85 | 
 86 |     c.desc 'Filter results using search terms'
 87 |     c.arg_name 'QUERY'
 88 |     c.flag %i[search find grep], multiple: true
 89 | 
 90 |     c.desc 'Include notes in search'
 91 |     c.switch %i[search_notes], negatable: true, default_value: true
 92 | 
 93 |     c.desc 'Match actions containing tag. Allows value comparisons'
 94 |     c.arg_name 'TAG'
 95 |     c.flag %i[tagged], multiple: true
 96 | 
 97 |     c.desc 'Act on all matches immediately (no menu)'
 98 |     c.switch %i[all], negatable: false
 99 | 
100 |     c.desc 'Interpret search pattern as regular expression'
101 |     c.switch %i[e regex], negatable: false
102 | 
103 |     c.desc 'Match pattern exactly'
104 |     c.switch %i[x exact], negatable: false
105 | 
106 |     c.action do |global_options, options, args|
107 |       reader = TTY::Reader.new
108 | 
109 |       args.concat(options[:search]) unless options[:search].nil?
110 | 
111 |       append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
112 | 
113 |       if options[:restore] || (!options[:remove].nil? && options[:remove].include?('done'))
114 |         options[:done] = true
115 |         options[:tagged] << '+done'
116 |       elsif !options[:remove].nil? && !options[:remove].empty?
117 |         options[:tagged].concat(options[:remove])
118 |       elsif options[:finish] && !options[:done]
119 |         options[:tagged] << '-done'
120 |       end
121 | 
122 |       options[:exact] = true unless options[:replace].nil?
123 | 
124 |       action = if args.count.positive?
125 |                  args.join(' ').strip
126 |                else
127 |                  NA.request_input(options, prompt: 'Enter a task to search for')
128 |                end
129 |       if action
130 |         tokens = nil
131 |         if options[:exact]
132 |           tokens = action
133 |         elsif options[:regex]
134 |           tokens = Regexp.new(action, Regexp::IGNORECASE)
135 |         else
136 |           tokens = []
137 |           all_req = action !~ /[+!-]/ && !options[:or]
138 | 
139 |           action.split(/ /).each do |arg|
140 |             m = arg.match(/^(?[+\-!])?(?.*?)$/)
141 |             tokens.push({
142 |                           token: m['tok'],
143 |                           required: all_req || (!m['req'].nil? && m['req'] == '+'),
144 |                           negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
145 |                         })
146 |           end
147 |         end
148 |       end
149 | 
150 |       if (action.nil? || action.empty?) && options[:tagged].empty?
151 |         NA.notify("#{NA.theme[:error]}Empty input, cancelled", exit_code: 1)
152 |       end
153 | 
154 |       all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
155 |       tags = []
156 |       options[:tagged].join(',').split(/ *, */).each do |arg|
157 |         m = arg.match(/^(?[+!-])?(?[^ =<>$~\^]+?) *(?:(?[=<>~]{1,2}|[*$\^]=) *(?.*?))?$/)
158 | 
159 |         tags.push({
160 |                     tag: m['tag'].wildcard_to_rx,
161 |                     comp: m['op'],
162 |                     value: m['val'],
163 |                     required: all_req || (!m['req'].nil? && m['req'] == '+'),
164 |                     negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
165 |                   })
166 |       end
167 | 
168 |       priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
169 |       add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
170 |       remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
171 |       remove_tags << 'done' if options[:restore]
172 | 
173 |       stdin_note = NA.stdin ? NA.stdin.split("\n") : []
174 | 
175 |       line_note = if options[:note] && $stdin.isatty
176 |                     puts stdin_note unless stdin_note.nil?
177 |                     if TTY::Which.exist?('gum')
178 |                       args = ['--placeholder "Enter a note, CTRL-d to save"']
179 |                       args << '--char-limit 0'
180 |                       args << '--width $(tput cols)'
181 |                       gum = TTY::Which.which('gum')
182 |                       `#{gum} write #{args.join(' ')}`.strip.split("\n")
183 |                     else
184 |                       NA.notify("#{NA.theme[:prompt]}Enter a note, {bw}CTRL-d#{NA.theme[:prompt]} to end editing:#{NA.theme[:action]}")
185 |                       reader.read_multiline
186 |                     end
187 |                   end
188 | 
189 |       note = stdin_note.empty? ? [] : stdin_note
190 |       note.concat(line_note) unless line_note.nil? || line_note.empty?
191 | 
192 |       target_proj = if options[:move]
193 |                       options[:move]
194 |                     elsif NA.cwd_is == :project
195 |                       NA.cwd
196 |                     end
197 | 
198 |       if options[:file]
199 |         file = File.expand_path(options[:file])
200 |         NA.notify("#{NA.theme[:error]}File not found", exit_code: 1) unless File.exist?(file)
201 | 
202 |         targets = [file]
203 |       elsif options[:todo]
204 |         todo = []
205 |         options[:todo].split(/ *, */).each do |a|
206 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
207 |           todo.push({
208 |                       token: m['tok'],
209 |                       required: all_req || (!m['req'].nil? && m['req'] == '+'),
210 |                       negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
211 |                     })
212 |         end
213 |         dirs = NA.match_working_dir(todo)
214 | 
215 |         if dirs.count == 1
216 |           targets = [dirs[0]]
217 |         elsif dirs.count.positive?
218 |           targets = NA.select_file(dirs, multiple: true)
219 |           NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless targets && targets.count.positive?
220 |         else
221 |           NA.notify("#{NA.theme[:error]}Todo not found", exit_code: 1) unless targets && targets.count.positive?
222 | 
223 |         end
224 |       else
225 |         files = NA.find_files_matching({
226 |                                          depth: options[:depth],
227 |                                          done: options[:done],
228 |                                          project: options[:project],
229 |                                          regex: options[:regex],
230 |                                          require_na: false,
231 |                                          search: tokens,
232 |                                          tag: tags
233 |                                        })
234 |         NA.notify("#{NA.theme[:error]}No todo file found", exit_code: 1) if files.count.zero?
235 | 
236 |         targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
237 |         NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless files.count.positive?
238 | 
239 |       end
240 | 
241 |       if options[:archive]
242 |         options[:finish] = true
243 |         options[:move] = 'Archive'
244 |       end
245 | 
246 |       NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
247 | 
248 |       targets.each do |target|
249 |         NA.update_action(target, tokens,
250 |                          add_tag: add_tags,
251 |                          all: options[:all],
252 |                          append: append,
253 |                          delete: options[:delete],
254 |                          done: options[:done],
255 |                          edit: options[:edit],
256 |                          finish: options[:finish],
257 |                          move: target_proj,
258 |                          note: note,
259 |                          overwrite: options[:overwrite],
260 |                          priority: priority,
261 |                          project: options[:project],
262 |                          remove_tag: remove_tags,
263 |                          replace: options[:replace],
264 |                          search_note: options[:search_notes],
265 |                          tagged: tags)
266 |       end
267 |     end
268 |   end
269 | end
270 | 


--------------------------------------------------------------------------------
/bin/na:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env ruby
  2 | # frozen_string_literal: true
  3 | 
  4 | $LOAD_PATH.unshift File.join(__dir__, '..', 'lib')
  5 | require 'gli'
  6 | require 'na/help_monkey_patch'
  7 | require 'na'
  8 | require 'fcntl'
  9 | require 'tempfile'
 10 | 
 11 | # Search for XDG compliant config first. Default to ~/.na.rc for compatibility
 12 | def self.find_config_file
 13 |   home = ENV['HOME']
 14 |   xdg_config_home = ENV['XDG_CONFIG_HOME'] || File.join(home, '.config')
 15 | 
 16 |   rc_paths = [
 17 |     File.join(xdg_config_home, 'na', 'na.rc'),  # Check $XDG_CONFIG_HOME/na/na.rc first
 18 |     File.join(xdg_config_home, 'na.rc'),        # Then check $XDG_CONFIG_HOME/na.rc
 19 |     File.join(home, '.na.rc')                   # Finally check ~/.na.rc for compatibility
 20 |   ]
 21 | 
 22 |   # Return the first path that exists
 23 |   existing_path = rc_paths.find { |path| File.exist?(path) }
 24 | 
 25 |   # If none exist, return XDG-compliant path for creation
 26 |   existing_path || File.join(xdg_config_home, 'na', 'na.rc')
 27 | end
 28 | 
 29 | 
 30 | # Main application
 31 | class App
 32 |   extend GLI::App
 33 | 
 34 |   program_desc 'Add and list next actions for the current project'
 35 | 
 36 |   version Na::VERSION
 37 |   hide_commands_without_desc true
 38 |   autocomplete_commands false
 39 |   wrap_help_text :one_line unless $stdout.isatty
 40 | 
 41 |   config_file '.na.rc'
 42 | 
 43 |   desc 'File extension to consider a todo file'
 44 |   default_value 'taskpaper'
 45 |   arg_name 'EXT'
 46 |   flag :ext
 47 | 
 48 |   desc 'Include file extension in display'
 49 |   switch :include_ext, default_value: false, negatable: false
 50 | 
 51 |   desc 'Tag to consider a next action'
 52 |   default_value 'na'
 53 |   arg_name 'TAG'
 54 |   flag %i[t na_tag]
 55 | 
 56 |   desc 'Enable pagination'
 57 |   switch %i[pager], default_value: true, negatable: true
 58 | 
 59 |   default_command :next
 60 | 
 61 |   NA::Color.coloring = $stdin.isatty
 62 |   NA::Pager.paginate = $stdin.isatty
 63 | 
 64 |   desc 'Add a next action (deprecated, for backwards compatibility)'
 65 |   switch %i[a add], negatable: false
 66 | 
 67 |   desc 'Colorize output'
 68 |   switch %i[color], negatable: true, default_value: true
 69 | 
 70 |   desc 'Set a priority 0-5 (deprecated, for backwards compatibility)'
 71 |   arg_name 'PRIORITY'
 72 |   flag %i[p priority]
 73 | 
 74 |   desc 'Use a single file as global todo, use initconfig to make permanent'
 75 |   arg_name 'PATH'
 76 |   flag %i[f file]
 77 | 
 78 |   desc 'Use a taskpaper file named after the git repository'
 79 |   arg_name 'REPO'
 80 |   switch %i[repo], negatable: true, default_value: true
 81 | 
 82 |   desc 'Provide a template for new/blank todo files, use initconfig to make permanent'
 83 |   flag %[template]
 84 | 
 85 |   desc 'Use current working directory as [p]roject, [t]ag, or [n]one'
 86 |   arg_name 'TYPE'
 87 |   flag %i[cwd_as], must_match: /^[ptn].*?$/i, default_value: 'none'
 88 | 
 89 |   desc 'Add all new/moved entries at [s]tart or [e]nd of target project'
 90 |   arg_name 'POSITION'
 91 |   flag %i[add_at], default_value: 'start'
 92 | 
 93 |   desc 'Prompt for additional notes (deprecated, for backwards compatibility)'
 94 |   switch %i[n note], negatable: false
 95 | 
 96 |   desc 'Recurse 3 directories deep (deprecated, for backwards compatability)'
 97 |   switch %i[r recurse], default_value: false, negatable: true
 98 | 
 99 |   desc 'Recurse to depth'
100 |   arg_name 'DEPTH'
101 |   default_value 1
102 |   flag %i[d depth], type: :integer, must_match: /^[1-9]$/
103 | 
104 |   desc 'Display verbose output'
105 |   switch %i[debug], default_value: false
106 | 
107 |   Dir.glob(File.join(File.dirname(__FILE__), 'commands/*.rb')).each do |cmd|
108 |     require_relative "commands/#{File.basename(cmd, '.rb')}"
109 |   end
110 | 
111 |   pre do |global, _command, _options, _args|
112 |     NA.move_deprecated_backups
113 |     NA.verbose = global[:debug]
114 |     NA::Pager.paginate = global[:pager] && $stdout.isatty
115 |     NA::Color.coloring = global[:color] && $stdout.isatty
116 |     NA.extension = global[:ext]
117 |     NA.include_ext = global[:include_ext]
118 |     NA.na_tag = global[:na_tag]
119 |     NA.global_file = global[:file]
120 |     NA.cwd = File.basename(ENV['PWD'])
121 |     NA.cwd_is = if global[:cwd_as] =~ /^n/
122 |                   :none
123 |                 else
124 |                   global[:cwd_as] =~ /^p/ ? :project : :tag
125 |                 end
126 | 
127 |     # start of git repo addition ==================================
128 |     # defaut to git repo if in a git managed directory
129 |     if global[:repo]
130 |       begin
131 |         require 'git'
132 | 
133 |         # Check if we're in a git repo first
134 |         in_git_repo = system('git rev-parse --is-inside-work-tree >/dev/null 2>&1')
135 | 
136 |         if in_git_repo
137 |           g = Git.open('.', log: Logger.new(File::NULL)) # Silence Git logs
138 |           repo_root = g.dir.path
139 |           repo_name = File.basename(repo_root)
140 |           taskpaper_file = File.join(repo_root, "#{repo_name}.#{NA.extension}")
141 |           NA.notify("Using repository taskpaper file: #{taskpaper_file}", debug: true)
142 |           NA.global_file = taskpaper_file
143 |           # Add this block to create the file if it doesn't exist
144 |           unless File.exist?(taskpaper_file)
145 |             res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Repository file not found, create #{taskpaper_file}"), default: true)
146 |             if res
147 |               NA.create_todo(taskpaper_file, repo_name, template: global[:template])
148 |             else
149 |               NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
150 |             end
151 |           end
152 |         else
153 |           NA.notify("#{NA.theme[:warning]}Not in a git repository, using default file location logic.", debug: true)
154 |         end
155 |       rescue LoadError
156 |         NA.notify("#{NA.theme[:error]}Git gem not installed. Run 'gem install git' to use --repo option.", exit_code: 1)
157 |       end
158 |     end
159 |     # end of git repo addition ====================================
160 | 
161 |     NA.weed_cache_file
162 |     NA.notify("{dw}{ globals: #{NA.globals}, command_line: #{NA.command_line}, command: #{NA.command}}", debug: true)
163 |     true
164 |   end
165 | 
166 |   post do |global, command, options, args|
167 |     # post actions
168 |   end
169 | 
170 |   on_error do |exception|
171 |     case exception
172 |     when GLI::UnknownCommand
173 |       if NA.command_line.count == 1
174 |         cmd = ['saved']
175 |         cmd.concat(ARGV.unshift(NA.command_line[0]))
176 | 
177 |         exit run(cmd)
178 |       elsif NA.globals.include?('-a') || NA.globals.include?('--add')
179 |         cmd = ['add']
180 |         cmd.concat(NA.command_line)
181 |         NA.globals.delete('-a')
182 |         NA.globals.delete('--add')
183 |         cmd.unshift(*NA.globals)
184 | 
185 |         exit run(cmd)
186 |       end
187 |       true
188 |     when SystemExit
189 |       false
190 |     else
191 |       true
192 |     end
193 |   end
194 | end
195 | 
196 | NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
197 | NA.stdin = nil unless NA.stdin && NA.stdin.length.positive?
198 | 
199 | NA.globals = []
200 | NA.command_line = []
201 | in_globals = true
202 | ARGV.each do |arg|
203 |   if arg =~ /^-/ && in_globals
204 |     NA.globals.push(arg)
205 |   else
206 |     NA.command_line.push(arg)
207 |     in_globals = false
208 |   end
209 | end
210 | NA.command = NA.command_line[0]
211 | 
212 | exit App.run(ARGV)
213 | 


--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
 1 | FROM ruby:3.1.3-slim
 2 | RUN mkdir /na
 3 | WORKDIR /na
 4 | RUN gem install bundler:2.2
 5 | COPY ./docker/sources.list /etc/apt/sources.list
 6 | RUN apt-get update -y --allow-insecure-repositories || true
 7 | RUN apt-get install -y less vim
 8 | COPY ./docker/inputrc /root/.inputrc
 9 | COPY ./docker/bash_profile /root/.bash_profile
10 | CMD ["/na/scripts/runtests.sh"]
11 | 


--------------------------------------------------------------------------------
/docker/Dockerfile-2.6:
--------------------------------------------------------------------------------
 1 | FROM ruby:2.6
 2 | RUN mkdir /na
 3 | WORKDIR /na
 4 | RUN gem install bundler:2.2
 5 | COPY ./docker/sources.list /etc/apt/sources.list
 6 | RUN apt-get update -y --allow-insecure-repositories || true
 7 | RUN apt-get install -y sudo || true
 8 | RUN sudo apt-get install -y less vim || true
 9 | COPY ./docker/inputrc /root/.inputrc
10 | COPY ./docker/bash_profile /root/.bash_profile
11 | CMD ["/na/scripts/runtests.sh"]
12 | 


--------------------------------------------------------------------------------
/docker/Dockerfile-2.7:
--------------------------------------------------------------------------------
 1 | FROM ruby:2.7
 2 | RUN mkdir /na
 3 | WORKDIR /na
 4 | RUN gem install bundler:2.2
 5 | COPY ./docker/sources.list /etc/apt/sources.list
 6 | RUN apt-get update -y --allow-insecure-repositories || true
 7 | RUN apt-get install -y sudo || true
 8 | RUN sudo apt-get install -y less vim || true
 9 | COPY ./docker/inputrc /root/.inputrc
10 | COPY ./docker/bash_profile /root/.bash_profile
11 | CMD ["/na/scripts/runtests.sh"]
12 | 


--------------------------------------------------------------------------------
/docker/Dockerfile-3.0:
--------------------------------------------------------------------------------
 1 | FROM ruby:3.0.0
 2 | RUN mkdir /na
 3 | WORKDIR /na
 4 | RUN gem install bundler:2.2
 5 | COPY ./docker/sources.list /etc/apt/sources.list
 6 | RUN apt-get update -y --allow-insecure-repositories || true
 7 | RUN apt-get install -y sudo || true
 8 | RUN sudo apt-get install -y less vim || true
 9 | COPY ./docker/inputrc /root/.inputrc
10 | COPY ./docker/bash_profile /root/.bash_profile
11 | CMD ["/na/scripts/runtests.sh"]
12 | 


--------------------------------------------------------------------------------
/docker/Dockerfile-3.3:
--------------------------------------------------------------------------------
 1 | FROM ruby:3.3.0
 2 | # RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
 3 | RUN mkdir /na
 4 | WORKDIR /na
 5 | RUN gem install bundler:2.2
 6 | COPY ./docker/sources.list /etc/apt/sources.list
 7 | RUN apt-get update -y --allow-insecure-repositories || true
 8 | RUN apt-get install -y sudo || true
 9 | RUN sudo apt-get install -y less vim || true
10 | COPY ./docker/inputrc /root/.inputrc
11 | COPY ./docker/bash_profile /root/.bash_profile
12 | CMD ["/na/scripts/runtests.sh"]
13 | 


--------------------------------------------------------------------------------
/docker/bash_profile:
--------------------------------------------------------------------------------
 1 | #!/bin/bash
 2 | export GLI_DEBUG=true
 3 | export EDITOR="/usr/bin/vim"
 4 | alias b="bundle exec bin/na"
 5 | alias be="bundle exec"
 6 | alias quit="exit"
 7 | 
 8 | shopt -s nocaseglob
 9 | shopt -s histappend
10 | shopt -s histreedit
11 | shopt -s histverify
12 | shopt -s cmdhist
13 | 
14 | cd /na
15 | bundle update
16 | gem update --system
17 | gem install pkg/*.gem
18 | 


--------------------------------------------------------------------------------
/docker/inputrc:
--------------------------------------------------------------------------------
 1 | "\e[3~": delete-char
 2 | "\ex": 'cd !$ \015ls\015'
 3 | "\ez": 'cd -\015'
 4 | "\e\C-m": '\C-a "$(\C-e|fzf)"\C-a'
 5 | "\e/": '"$(!!|fzf)"\C-a \C-m\C-m'
 6 | # these allow you to use alt+left/right arrow keys
 7 | # to jump the cursor over words
 8 | "\e[1;5C": forward-word
 9 | "\e[1;5D": backward-word
10 | # "\e[D": backward-word
11 | # "\e[C": forward-word
12 | "\ea": menu-complete
13 | # TAB: menu-complete
14 | # "\e[Z": "\e-1\C-i"
15 | 
16 | "\e\C-l": history-and-alias-expand-line
17 | 
18 | # these allow you to start typing a command and
19 | # use the up/down arrow to auto complete from
20 | # commands in your history
21 | "\e[B": history-search-forward
22 | "\e[A": history-search-backward
23 | "\ew": history-search-backward
24 | "\es": history-search-forward
25 | # this lets you hit tab to auto-complete a file or
26 | # directory name ignoring case
27 | set completion-ignore-case On
28 | set mark-symlinked-directories On
29 | set completion-prefix-display-length 2
30 | set bell-style none
31 | # set bell-style visible
32 | set meta-flag on
33 | set convert-meta off
34 | set input-meta on
35 | set output-meta on
36 | set show-all-if-ambiguous on
37 | set show-all-if-unmodified on
38 | set completion-map-case on
39 | set visible-stats on
40 | 
41 | # Do history expansion when space entered?
42 | $if bash
43 | 	Space: magic-space
44 | $endif
45 | 
46 | # Show extra file information when completing, like `ls -F` does
47 | set visible-stats on
48 | 
49 | # Be more intelligent when autocompleting by also looking at the text after
50 | # the cursor. For example, when the current line is "cd ~/src/mozil", and
51 | # the cursor is on the "z", pressing Tab will not autocomplete it to "cd
52 | # ~/src/mozillail", but to "cd ~/src/mozilla". (This is supported by the
53 | # Readline used by Bash 4.)
54 | set skip-completed-text on
55 | 
56 | # Use Alt/Meta + Delete to delete the preceding word
57 | "\e[3;3~": kill-word
58 | 


--------------------------------------------------------------------------------
/docker/sources.list:
--------------------------------------------------------------------------------
 1 | deb http://archive.ubuntu.com/ubuntu/ focal main restricted
 2 | deb http://archive.ubuntu.com/ubuntu/ focal-updates main restricted
 3 | deb http://archive.ubuntu.com/ubuntu/ focal universe
 4 | deb http://archive.ubuntu.com/ubuntu/ focal-updates universe
 5 | deb http://archive.ubuntu.com/ubuntu/ focal multiverse
 6 | deb http://archive.ubuntu.com/ubuntu/ focal-updates multiverse
 7 | deb http://archive.ubuntu.com/ubuntu/ focal-backports main restricted universe multiverse
 8 | 
 9 | deb http://security.ubuntu.com/ubuntu focal-security main restricted
10 | deb http://security.ubuntu.com/ubuntu focal-security universe
11 | deb http://security.ubuntu.com/ubuntu focal-security multiversesudo apt update
12 | 


--------------------------------------------------------------------------------
/lib/na.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require 'na/version'
 4 | require 'na/pager'
 5 | require 'time'
 6 | require 'fileutils'
 7 | require 'shellwords'
 8 | require 'chronic'
 9 | require 'tty-screen'
10 | require 'tty-reader'
11 | require 'tty-which'
12 | require 'na/hash'
13 | require 'na/colors'
14 | require 'na/string'
15 | require 'na/array'
16 | require 'na/theme'
17 | require 'na/todo'
18 | require 'na/actions'
19 | require 'na/project'
20 | require 'na/action'
21 | require 'na/editor'
22 | require 'na/next_action'
23 | require 'na/prompt'
24 | 


--------------------------------------------------------------------------------
/lib/na/action.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module NA
  4 |   class Action < Hash
  5 |     attr_reader :file, :project, :parent, :tags, :line
  6 | 
  7 |     attr_accessor :action, :note
  8 | 
  9 |     def initialize(file, project, parent, action, idx, note = [])
 10 |       super()
 11 | 
 12 |       @file = file
 13 |       @project = project
 14 |       @parent = parent
 15 |       @action = action.gsub(/\{/, '\\{')
 16 |       @tags = scan_tags
 17 |       @line = idx
 18 |       @note = note
 19 |     end
 20 | 
 21 |     def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
 22 |       string = @action.dup
 23 | 
 24 |       if priority&.positive?
 25 |         string.gsub!(/(?<=\A| )@priority\(\d+\)/, '').strip!
 26 |         string += " @priority(#{priority})"
 27 |       end
 28 | 
 29 |       remove_tag.each do |tag|
 30 |         string.gsub!(/(?<=\A| )@#{tag.gsub(/([()*?])/, '\\\\1')}(\(.*?\))?/, '')
 31 |         string.strip!
 32 |       end
 33 | 
 34 |       add_tag.each do |tag|
 35 |         string.gsub!(/(?<=\A| )@#{tag.gsub(/([()*?])/, '\\\\1')}(\(.*?\))?/, '')
 36 |         string.strip!
 37 |         string += " @#{tag}"
 38 |       end
 39 | 
 40 |       string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /(?<=\A| )@done/
 41 | 
 42 |       @action = string.expand_date_tags
 43 |       @note = note unless note.empty?
 44 |     end
 45 | 
 46 |     def to_s
 47 |       note = if @note.count.positive?
 48 |                "\n#{@note.join("\n")}"
 49 |              else
 50 |                ''
 51 |              end
 52 |       "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}#{note}"
 53 |     end
 54 | 
 55 |     def inspect
 56 |       <<~EOINSPECT
 57 |       @file: #{@file}
 58 |       @project: #{@project}
 59 |       @parent: #{@parent.join('>')}
 60 |       @action: #{@action}
 61 |       @tags: #{@tags}
 62 |       @note: #{@note}
 63 |       EOINSPECT
 64 |     end
 65 | 
 66 |     ##
 67 |     ## Pretty print an action
 68 |     ##
 69 |     ## @param      extension  [String] The file extension
 70 |     ## @param      template   [Hash] The template to use for
 71 |     ##                        colorization
 72 |     ## @param      regexes    [Array] The regexes to
 73 |     ##                        highlight (searches)
 74 |     ## @param      notes      [Boolean] Include notes
 75 |     ##
 76 |     def pretty(extension: 'taskpaper', template: {}, regexes: [], notes: false, detect_width: true)
 77 |       theme = NA::Theme.load_theme
 78 |       template = theme.merge(template)
 79 | 
 80 |       # Create the hierarchical parent string
 81 |       parents = @parent.map do |par|
 82 |         NA::Color.template("{x}#{template[:parent]}#{par}")
 83 |       end.join(NA::Color.template(template[:parent_divider]))
 84 |       parents = "#{NA.theme[:bracket]}[#{NA.theme[:error]}#{parents}#{NA.theme[:bracket]}]{x} "
 85 | 
 86 |       # Create the project string
 87 |       project = NA::Color.template("#{template[:project]}#{@project}{x} ")
 88 | 
 89 |       # Create the source filename string, substituting ~ for HOME and removing extension
 90 |       file = @file.sub(%r{^\./}, '').sub(/#{ENV['HOME']}/, '~')
 91 |       file = file.sub(/\.#{extension}$/, '') unless NA.include_ext
 92 |       # colorize the basename
 93 |       file = file.highlight_filename
 94 |       file_tpl = "#{template[:file]}#{file} {x}"
 95 |       filename = NA::Color.template(file_tpl)
 96 | 
 97 |       # colorize the action and highlight tags
 98 |       @action.gsub!(/\{(.*?)\}/, '\\{\1\\}')
 99 |       action = NA::Color.template("#{template[:action]}#{@action.sub(/ @#{NA.na_tag}\b/, '')}{x}")
100 |       action = action.highlight_tags(color: template[:tags],
101 |                                      parens: template[:value_parens],
102 |                                      value: template[:values],
103 |                                      last_color: template[:action])
104 | 
105 |       if detect_width
106 |         width = TTY::Screen.columns
107 |         prefix = NA::Color.uncolor(pretty(template: { templates: { output: template[:templates][:output].sub(/%action/, '').sub(/%note/, '') } }, detect_width: false))
108 |         indent = prefix.length
109 | 
110 |         # Add notes if needed
111 |         note = if notes && @note.count.positive?
112 |                  NA::Color.template(@note.wrap(width, indent, template[:note]))
113 |                elsif !notes && @note.count.positive?
114 |                  action += "#{template[:note]}*"
115 |                else
116 |                  ''
117 |                end
118 | 
119 |         action = action.wrap(width, indent)
120 |       else
121 |         note = if notes && @note.count.positive?
122 |                  NA::Color.template("\n#{@note.map { |l| "  #{template[:note]}• #{l.wrap(width, indent)}{x}" }.join("\n")}")
123 |                elsif !notes && @note.count.positive?
124 |                  action += "#{template[:note]}*"
125 |                else
126 |                  ''
127 |                end
128 |       end
129 | 
130 |       # Replace variables in template string and output colorized
131 |       NA::Color.template(template[:templates][:output].gsub(/%filename/, filename)
132 |                           .gsub(/%project/, project)
133 |                           .gsub(/%parents?/, parents)
134 |                           .gsub(/%action/, action.highlight_search(regexes))
135 |                           .gsub(/%note/, note)).gsub(/\\\{/, '{')
136 |     end
137 | 
138 |     def tags_match?(any: [], all: [], none: [])
139 |       tag_matches_any(any) && tag_matches_all(all) && tag_matches_none(none)
140 |     end
141 | 
142 |     def search_match?(any: [], all: [], none: [], include_note: true)
143 |       search_matches_any(any, include_note: include_note) &&
144 |         search_matches_all(all, include_note: include_note) &&
145 |         search_matches_none(none, include_note: include_note)
146 |     end
147 | 
148 |     private
149 | 
150 |     def search_matches_none(regexes, include_note: true)
151 |       regexes.each do |rx|
152 |         note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
153 |         return false if @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
154 |       end
155 |       true
156 |     end
157 | 
158 |     def search_matches_any(regexes, include_note: true)
159 |       return true if regexes.empty?
160 | 
161 |       regexes.each do |rx|
162 |         note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
163 |         return true if @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
164 |       end
165 |       false
166 |     end
167 | 
168 |     def search_matches_all(regexes, include_note: true)
169 |       regexes.each do |rx|
170 |         note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
171 |         return false unless @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
172 |       end
173 |       true
174 |     end
175 | 
176 |     def tag_matches_none(tags)
177 |       tags.each do |tag|
178 |         return false if compare_tag(tag)
179 |       end
180 |       true
181 |     end
182 | 
183 |     def tag_matches_any(tags)
184 |       return true if tags.empty?
185 | 
186 |       tags.each do |tag|
187 |         return true if compare_tag(tag)
188 |       end
189 |       false
190 |     end
191 | 
192 |     def tag_matches_all(tags)
193 |       tags.each do |tag|
194 |         return false unless compare_tag(tag)
195 |       end
196 |       true
197 |     end
198 | 
199 |     def compare_tag(tag)
200 |       keys = @tags.keys.delete_if { |k| k !~ Regexp.new(tag[:tag], Regexp::IGNORECASE) }
201 |       return false if keys.empty?
202 | 
203 |       key = keys[0]
204 |       return true if tag[:comp].nil?
205 | 
206 |       tag_val = @tags[key]
207 |       val = tag[:value]
208 | 
209 |       return false if tag_val.nil?
210 | 
211 |       begin
212 |         tag_date = Time.parse(tag_val)
213 |         date = Chronic.parse(val)
214 | 
215 |         raise ArgumentError if date.nil?
216 | 
217 |         unless val =~ /(\d:\d|a[mp]|now)/i
218 |           tag_date = Time.parse(tag_date.strftime('%Y-%m-%d 12:00'))
219 |           date = Time.parse(date.strftime('%Y-%m-%d 12:00'))
220 |         end
221 | 
222 |         case tag[:comp]
223 |         when /^>$/
224 |           tag_date > date
225 |         when /^<$/
226 |           tag_date < date
227 |         when /^<=$/
228 |           tag_date <= date
229 |         when /^>=$/
230 |           tag_date >= date
231 |         when /^==?$/
232 |           tag_date == date
233 |         when /^\$=$/
234 |           tag_val =~ /#{val.wildcard_to_rx}/i
235 |         when /^\*=$/
236 |           tag_val =~ /#{val.wildcard_to_rx}/i
237 |         when /^\^=$/
238 |           tag_val =~ /^#{val.wildcard_to_rx}/
239 |         else
240 |           false
241 |         end
242 |       rescue ArgumentError
243 |         case tag[:comp]
244 |         when /^>$/
245 |           tag_val.to_f > val.to_f
246 |         when /^<$/
247 |           tag_val.to_f < val.to_f
248 |         when /^<=$/
249 |           tag_val.to_f <= val.to_f
250 |         when /^>=$/
251 |           tag_val.to_f >= val.to_f
252 |         when /^==?$/
253 |           tag_val =~ /^#{val.wildcard_to_rx}$/
254 |         when /^=~$/
255 |           tag_val =~ Regexp.new(val, Regexp::IGNORECASE)
256 |         when /^\$=$/
257 |           tag_val =~ /#{val.wildcard_to_rx}$/i
258 |         when /^\*=$/
259 |           tag_val =~ /.*?#{val.wildcard_to_rx}.*?/i
260 |         when /^\^=$/
261 |           tag_val =~ /^#{val.wildcard_to_rx}/i
262 |         else
263 |           false
264 |         end
265 |       end
266 |     end
267 | 
268 |     def scan_tags
269 |       tags = {}
270 |       rx = /(?<= |^)@(?\S+?)(?:\((?.*?)\))?(?= |$)/
271 |       all_tags = []
272 |       @action.scan(rx) { all_tags << Regexp.last_match }
273 |       all_tags.each do |m|
274 |         tag = m.named_captures.symbolize_keys
275 |         tags[tag[:tag]] = tag[:value]
276 |       end
277 | 
278 |       tags
279 |     end
280 |   end
281 | end
282 | 


--------------------------------------------------------------------------------
/lib/na/actions.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module NA
 4 |   # Actions controller
 5 |   class Actions < Array
 6 |     def initialize(actions = [])
 7 |       super
 8 |       concat(actions)
 9 |     end
10 | 
11 |     ##
12 |     ## Pretty print a list of actions
13 |     ##
14 |     ## @param depth [Integer] The depth of the action
15 |     ## @param config [Hash] The configuration options
16 |     ##
17 |     ## @option config [Array] :files The files to include in the output
18 |     ## @option config [Array] :regexes The regexes to match against
19 |     ## @option config [Boolean] :notes Whether to include notes in the output
20 |     ## @option config [Boolean] :nest Whether to nest the output
21 |     ## @option config [Boolean] :nest_projects Whether to nest projects in the output
22 |     ## @option config [Boolean] :no_files Whether to include files in the output
23 |     ##
24 |     ## @return [String] The output string
25 |     ##
26 |     def output(depth, config = {})
27 |       defaults = {
28 |         files: nil,
29 |         regexes: [],
30 |         notes: false,
31 |         nest: false,
32 |         nest_projects: false,
33 |         no_files: false,
34 |       }
35 |       config = defaults.merge(config)
36 | 
37 |       return if config[:files].nil?
38 | 
39 |       if config[:nest]
40 |         template = NA.theme[:templates][:default]
41 |         template = NA.theme[:templates][:no_file] if config[:no_files]
42 | 
43 |         parent_files = {}
44 |         out = []
45 | 
46 |         if config[:nest_projects]
47 |           each do |action|
48 |             parent_files[action.file] ||= []
49 |             parent_files[action.file].push(action)
50 |           end
51 | 
52 |           parent_files.each do |file, acts|
53 |             projects = NA.project_hierarchy(acts)
54 |             out.push("#{file.sub(%r{^./}, "").shorten_path}:")
55 |             out.concat(NA.output_children(projects, 0))
56 |           end
57 |         else
58 |           template = NA.theme[:templates][:default]
59 |           template = NA.theme[:templates][:no_file] if config[:no_files]
60 | 
61 |           each do |action|
62 |             parent_files[action.file] ||= []
63 |             parent_files[action.file].push(action)
64 |           end
65 | 
66 |           parent_files.each do |file, acts|
67 |             out.push("#{file.sub(%r{^\./}, "")}:")
68 |             acts.each do |a|
69 |               out.push("\t- [#{a.parent.join("/")}] #{a.action}")
70 |               out.push("\t\t#{a.note.join("\n\t\t")}") unless a.note.empty?
71 |             end
72 |           end
73 |         end
74 |         NA::Pager.page out.join("\n")
75 |       else
76 |         template = if config[:no_files]
77 |             NA.theme[:templates][:no_file]
78 |           elsif config[:files].count.positive?
79 |             config[:files].count == 1 ? NA.theme[:templates][:single_file] : NA.theme[:templates][:multi_file]
80 |           elsif NA.find_files(depth: depth).count > 1
81 |             depth > 1 ? NA.theme[:templates][:multi_file] : NA.theme[:templates][:single_file]
82 |           else
83 |             NA.theme[:templates][:default]
84 |           end
85 |         template += "%note" if config[:notes]
86 | 
87 |         config[:files].map { |f| NA.notify(f, debug: true) } if config[:files]
88 | 
89 |         output = map { |action| action.pretty(template: { templates: { output: template } }, regexes: config[:regexes], notes: config[:notes]) }
90 |         NA::Pager.page(output.join("\n"))
91 |       end
92 |     end
93 |   end
94 | end
95 | 


--------------------------------------------------------------------------------
/lib/na/array.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class ::Array
 4 |   ##
 5 |   ## Like Array#compact -- removes nil items, but also
 6 |   ## removes empty strings, zero or negative numbers and FalseClass items
 7 |   ##
 8 |   ## @return     [Array] Array without "bad" elements
 9 |   ##
10 |   def remove_bad
11 |     compact.map { |x| x.is_a?(String) ? x.strip : x }.select(&:good?)
12 |   end
13 | 
14 |   def wrap(width, indent, color)
15 |     return map { |l| "#{color}  #{l.wrap(width, 2)}" } if width < 60
16 | 
17 |     map! do |l|
18 |       "#{color}#{' ' * indent }• #{l.wrap(width, indent)}{x}"
19 |     end
20 |     "\n#{join("\n")}"
21 |   end
22 | end
23 | 


--------------------------------------------------------------------------------
/lib/na/colors.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | # Cribbed from 
  4 | module NA
  5 |   # Terminal output color functions.
  6 |   module Color
  7 |     # Regexp to match excape sequences
  8 |     ESCAPE_REGEX = /(?<=\[)(?:(?:(?:[349]|10)[0-9]|[0-9])?;?)+(?=m)/
  9 | 
 10 |     # All available color names. Available as methods and string extensions.
 11 |     #
 12 |     # @example Use a color as a method. Color reset will be added to end of string.
 13 |     #   Color.yellow('This text is yellow') => "\e[33mThis text is yellow\e[0m"
 14 |     #
 15 |     # @example Use a color as a string extension. Color reset added automatically.
 16 |     #   'This text is green'.green => "\e[1;32mThis text is green\e[0m"
 17 |     #
 18 |     # @example Send a text string as a color
 19 |     #   Color.send('red') => "\e[31m"
 20 |     ATTRIBUTES = [
 21 |       [:clear,               0], # String#clear is already used to empty string in Ruby 1.9
 22 |       [:reset,               0], # synonym for :clear
 23 |       [:bold,                1],
 24 |       [:dark,                2],
 25 |       [:italic,              3], # not widely implemented
 26 |       [:underline,           4],
 27 |       [:underscore,          4], # synonym for :underline
 28 |       [:blink,               5],
 29 |       [:rapid_blink,         6], # not widely implemented
 30 |       [:negative,            7], # no reverse because of String#reverse
 31 |       [:concealed,           8],
 32 |       [:strikethrough,       9], # not widely implemented
 33 |       [:strike,              9], # not widely implemented
 34 |       [:black,              30],
 35 |       [:red,                31],
 36 |       [:green,              32],
 37 |       [:yellow,             33],
 38 |       [:blue,               34],
 39 |       [:magenta,            35],
 40 |       [:purple,             35],
 41 |       [:cyan,               36],
 42 |       [:white,              37],
 43 |       [:bgblack,            40],
 44 |       [:bgred,              41],
 45 |       [:bggreen,            42],
 46 |       [:bgyellow,           43],
 47 |       [:bgblue,             44],
 48 |       [:bgmagenta,          45],
 49 |       [:bgpurple,           45],
 50 |       [:bgcyan,             46],
 51 |       [:bgwhite,            47],
 52 |       [:boldblack,          90],
 53 |       [:boldred,            91],
 54 |       [:boldgreen,          92],
 55 |       [:boldyellow,         93],
 56 |       [:boldblue,           94],
 57 |       [:boldmagenta,        95],
 58 |       [:boldpurple,         95],
 59 |       [:boldcyan,           96],
 60 |       [:boldwhite,          97],
 61 |       [:boldbgblack,       100],
 62 |       [:boldbgred,         101],
 63 |       [:boldbggreen,       102],
 64 |       [:boldbgyellow,      103],
 65 |       [:boldbgblue,        104],
 66 |       [:boldbgmagenta,     105],
 67 |       [:boldbgpurple,      105],
 68 |       [:boldbgcyan,        106],
 69 |       [:boldbgwhite,       107],
 70 |       [:softpurple,  '0;35;40'],
 71 |       [:hotpants,    '7;34;40'],
 72 |       [:knightrider, '7;30;40'],
 73 |       [:flamingo,    '7;31;47'],
 74 |       [:yeller,      '1;37;43'],
 75 |       [:whiteboard,  '1;30;47'],
 76 |       [:chalkboard,  '1;37;40'],
 77 |       [:led,         '0;32;40'],
 78 |       [:redacted,    '0;30;40'],
 79 |       [:alert,       '1;31;43'],
 80 |       [:error,       '1;37;41'],
 81 |       [:default, '0;39']
 82 |     ].map(&:freeze).freeze
 83 | 
 84 |     # Array of attribute keys only
 85 |     ATTRIBUTE_NAMES = ATTRIBUTES.transpose.first
 86 | 
 87 |     # Returns true if Color supports the +feature+.
 88 |     #
 89 |     # The feature :clear, that is mixing the clear color attribute into String,
 90 |     # is only supported on ruby implementations, that do *not* already
 91 |     # implement the String#clear method. It's better to use the reset color
 92 |     # attribute instead.
 93 |     def support?(feature)
 94 |       case feature
 95 |       when :clear
 96 |         !String.instance_methods(false).map(&:to_sym).include?(:clear)
 97 |       end
 98 |     end
 99 | 
100 |     # Template coloring
101 |     class ::String
102 |       ##
103 |       ## Extract the longest valid %color name from a string.
104 |       ##
105 |       ## Allows %colors to bleed into other text and still
106 |       ## be recognized, e.g. %greensomething still finds
107 |       ## %green.
108 |       ##
109 |       ## @return     [String] a valid color name
110 |       ##
111 |       def validate_color
112 |         valid_color = nil
113 |         compiled = ''
114 |         normalize_color.split('').each do |char|
115 |           compiled += char
116 |           valid_color = compiled if Color.attributes.include?(compiled.to_sym) || compiled =~ /^([fb]g?)?#([a-f0-9]{6})$/i
117 |         end
118 | 
119 |         valid_color
120 |       end
121 | 
122 |       ##
123 |       ## Normalize a color name, removing underscores,
124 |       ## replacing "bright" with "bold", and converting
125 |       ## bgbold to boldbg
126 |       ##
127 |       ## @return     [String] Normalized color name
128 |       ##
129 |       def normalize_color
130 |         gsub(/_/, '').sub(/bright/i, 'bold').sub(/bgbold/, 'boldbg')
131 |       end
132 | 
133 |       # Get the calculated ANSI color at the end of the
134 |       # string
135 |       #
136 |       # @return     ANSI escape sequence to match color
137 |       #
138 |       def last_color_code
139 |         m = scan(ESCAPE_REGEX)
140 | 
141 |         em = ['0']
142 |         fg = nil
143 |         bg = nil
144 |         rgbf = nil
145 |         rgbb = nil
146 | 
147 |         m.each do |c|
148 |           case c
149 |           when '0'
150 |             em = ['0']
151 |             fg, bg, rgbf, rgbb = nil
152 |           when /^[34]8/
153 |             case c
154 |             when /^3/
155 |               fg = nil
156 |               rgbf = c
157 |             when /^4/
158 |               bg = nil
159 |               rgbb = c
160 |             end
161 |           else
162 |             c.split(/;/).each do |i|
163 |               x = i.to_i
164 |               if x <= 9
165 |                 em << x
166 |               elsif x >= 30 && x <= 39
167 |                 rgbf = nil
168 |                 fg = x
169 |               elsif x >= 40 && x <= 49
170 |                 rgbb = nil
171 |                 bg = x
172 |               elsif x >= 90 && x <= 97
173 |                 rgbf = nil
174 |                 fg = x
175 |               elsif x >= 100 && x <= 107
176 |                 rgbb = nil
177 |                 bg = x
178 |               end
179 |             end
180 |           end
181 |         end
182 | 
183 |         escape = "\e[#{em.join(';')}m"
184 |         escape += "\e[#{rgbb}m" if rgbb
185 |         escape += "\e[#{rgbf}m" if rgbf
186 |         escape + "\e[#{[fg, bg].delete_if(&:nil?).join(';')}m"
187 |       end
188 |     end
189 | 
190 |     class << self
191 |       # Returns true if the coloring function of this module
192 |       # is switched on, false otherwise.
193 |       def coloring?
194 |         @coloring
195 |       end
196 | 
197 |       attr_writer :coloring
198 | 
199 |       ##
200 |       ## Enables colored output
201 |       ##
202 |       ## @example Turn color on or off based on TTY
203 |       ##   NA::Color.coloring = STDOUT.isatty
204 |       def coloring
205 |         @coloring ||= true
206 |       end
207 | 
208 |       ##
209 |       ## Convert a template string to a colored string.
210 |       ## Colors are specified with single letters inside
211 |       ## curly braces. Uppercase changes background color.
212 |       ##
213 |       ## w: white, k: black, g: green, l: blue, y: yellow,
214 |       ## c: cyan, m: magenta, r: red, b: bold, u: underline,
215 |       ## i: italic, x: reset (remove background, color,
216 |       ## emphasis)
217 |       ##
218 |       ## Also accepts {#RGB} and {#RRGGBB} strings. Put a b
219 |       ## before the hash to make it a background color
220 |       ##
221 |       ## @example    Convert a templated string
222 |       ## Color.template('{Rwb}Warning:{x} {w}you look a
223 |       ## little {g}ill{x}')
224 |       ##
225 |       ## @example    Convert using RGB colors
226 |       ## Color.template('{#f0a}This is an RGB color')
227 |       ##
228 |       ## @param      input  [String, Array] The template
229 |       ##                    string. If this is an array, the
230 |       ##                    elements will be joined with a
231 |       ##                    space.
232 |       ##
233 |       ## @return     [String] Colorized string
234 |       ##
235 |       def template(input)
236 |         input = input.join(' ') if input.is_a? Array
237 |         return input.gsub(/(?s" }.join('')
247 |         end
248 | 
249 |         colors = { w: white, k: black, g: green, l: blue,
250 |                    y: yellow, c: cyan, m: magenta, r: red,
251 |                    W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
252 |                    Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
253 |                    d: dark, b: bold, u: underline, i: italic, x: reset }
254 | 
255 |         format(fmt, colors)
256 |       end
257 |     end
258 | 
259 |     ATTRIBUTES.each do |c, v|
260 |       new_method = <<-EOSCRIPT
261 |         # Color string as #{c}
262 |         def #{c}(string = nil)
263 |           result = ''
264 |           result << "\e[#{v}m" if NA::Color.coloring?
265 |           if block_given?
266 |             result << yield
267 |           elsif string.respond_to?(:to_str)
268 |             result << string.to_str
269 |           elsif respond_to?(:to_str)
270 |             result << to_str
271 |           else
272 |             return result #only switch on
273 |           end
274 |           result << "\e[0m" if NA::Color.coloring?
275 |           result
276 |         end
277 |       EOSCRIPT
278 | 
279 |       module_eval(new_method)
280 | 
281 |       next unless c =~ /bold/
282 | 
283 |       # Accept brightwhite in addition to boldwhite
284 |       new_method = <<-EOSCRIPT
285 |         # color string as #{c}
286 |         def #{c.to_s.sub(/bold/, 'bright')}(string = nil)
287 |           result = ''
288 |           result << "\e[#{v}m" if NA::Color.coloring?
289 |           if block_given?
290 |             result << yield
291 |           elsif string.respond_to?(:to_str)
292 |             result << string.to_str
293 |           elsif respond_to?(:to_str)
294 |             result << to_str
295 |           else
296 |             return result #only switch on
297 |           end
298 |           result << "\e[0m" if NA::Color.coloring?
299 |           result
300 |         end
301 |       EOSCRIPT
302 | 
303 |       module_eval(new_method)
304 |     end
305 | 
306 |     ##
307 |     ## Generate escape codes for hex colors
308 |     ##
309 |     ## @param      hex   [String] The hexadecimal color code
310 |     ##
311 |     ## @return     [String] ANSI escape string
312 |     ##
313 |     def rgb(hex)
314 |       is_bg = hex.match(/^bg?#/) ? true : false
315 |       hex_string = hex.sub(/^([fb]g?)?#/, '')
316 | 
317 |       if hex_string.length == 3
318 |         parts = hex_string.match(/(?.)(?.)(?.)/)
319 | 
320 |         t = []
321 |         %w[r g b].each do |e|
322 |           t << parts[e]
323 |           t << parts[e]
324 |         end
325 |         hex_string = t.join('')
326 |       end
327 | 
328 |       parts = hex_string.match(/(?..)(?..)(?..)/)
329 |       t = []
330 |       %w[r g b].each do |e|
331 |         t << parts[e].hex
332 |       end
333 | 
334 |       "\e[#{is_bg ? '48' : '38'};2;#{t.join(';')}m"
335 |     end
336 | 
337 |     # Regular expression that is used to scan for ANSI-sequences while
338 |     # uncoloring strings.
339 |     COLORED_REGEXP = /\e\[(?:(?:(?:[349]|10)[0-9]|[0-9])?;?)+m/
340 | 
341 |     # Returns an uncolored version of the string, that is all
342 |     # ANSI-sequences are stripped from the string.
343 |     def uncolor(string = nil) # :yields:
344 |       if block_given?
345 |         yield.to_str.gsub(COLORED_REGEXP, '')
346 |       elsif string.respond_to?(:to_str)
347 |         string.to_str.gsub(COLORED_REGEXP, '')
348 |       elsif respond_to?(:to_str)
349 |         to_str.gsub(COLORED_REGEXP, '')
350 |       else
351 |         ''
352 |       end
353 |     end
354 | 
355 |     # Returns an array of all NA::Color attributes as symbols.
356 |     def attributes
357 |       ATTRIBUTE_NAMES
358 |     end
359 |     extend self
360 |   end
361 | end
362 | 


--------------------------------------------------------------------------------
/lib/na/editor.rb:
--------------------------------------------------------------------------------
  1 | module NA
  2 |   module Editor
  3 |     class << self
  4 |       def default_editor(prefer_git_editor: true)
  5 |         if prefer_git_editor
  6 |           editor ||= ENV['NA_EDITOR'] || ENV['GIT_EDITOR'] || ENV['EDITOR']
  7 |         else
  8 |           editor ||= ENV['NA_EDITOR'] || ENV['EDITOR'] || ENV['GIT_EDITOR']
  9 |         end
 10 | 
 11 |         return editor if editor&.good? && TTY::Which.exist?(editor)
 12 | 
 13 |         NA.notify("No EDITOR environment variable, testing available editors", debug: true)
 14 |         editors = %w[vim vi code subl mate mvim nano emacs]
 15 |         editors.each do |ed|
 16 |           try = TTY::Which.which(ed)
 17 |           if try
 18 |             NA.notify("Using editor #{try}", debug: true)
 19 |             return try
 20 |           end
 21 |         end
 22 | 
 23 |         NA.notify("#{NA.theme[:error]}No editor found", exit_code: 5)
 24 | 
 25 |         nil
 26 |       end
 27 | 
 28 |       def editor_with_args
 29 |         args_for_editor(default_editor)
 30 |       end
 31 | 
 32 |       def args_for_editor(editor)
 33 |         return editor if editor =~ /-\S/
 34 | 
 35 |         args = case editor
 36 |                when /^(subl|code|mate)$/
 37 |                  ['-w']
 38 |                when /^(vim|mvim)$/
 39 |                  ['-f']
 40 |                else
 41 |                  []
 42 |                end
 43 |         "#{editor} #{args.join(' ')}"
 44 |       end
 45 | 
 46 |       ##
 47 |       ## Create a process for an editor and wait for the file handle to return
 48 |       ##
 49 |       ## @param      input  [String] Text input for editor
 50 |       ##
 51 |       def fork_editor(input = '', message: :default)
 52 |         # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
 53 | 
 54 |         NA.notify("#{NA.theme[:error]}No EDITOR variable defined in environment", exit_code: 5) if default_editor.nil?
 55 | 
 56 |         tmpfile = Tempfile.new(['na_temp', '.na'])
 57 | 
 58 |         File.open(tmpfile.path, 'w+') do |f|
 59 |           f.puts input
 60 |           unless message.nil?
 61 |             f.puts message == :default ? '# First line is the action, lines after are added as a note' : message
 62 |           end
 63 |         end
 64 | 
 65 |         pid = Process.fork { system("#{editor_with_args} #{tmpfile.path}") }
 66 | 
 67 |         trap('INT') do
 68 |           begin
 69 |             Process.kill(9, pid)
 70 |           rescue StandardError
 71 |             Errno::ESRCH
 72 |           end
 73 |           tmpfile.unlink
 74 |           tmpfile.close!
 75 |           exit 0
 76 |         end
 77 | 
 78 |         Process.wait(pid)
 79 | 
 80 |         begin
 81 |           if $?.exitstatus == 0
 82 |             input = IO.read(tmpfile.path)
 83 |           else
 84 |             exit_now! 'Cancelled'
 85 |           end
 86 |         ensure
 87 |           tmpfile.close
 88 |           tmpfile.unlink
 89 |         end
 90 | 
 91 |         input.split(/\n/).delete_if(&:ignore?).join("\n")
 92 |       end
 93 | 
 94 |       ##
 95 |       ## Takes a multi-line string and formats it as an entry
 96 |       ##
 97 |       ## @param      input  [String] The string to parse
 98 |       ##
 99 |       ## @return     [Array] [[String]title, [Note]note]
100 |       ##
101 |       def format_input(input)
102 |         NA.notify("#{NA.theme[:error]}No content in entry", exit_code: 1) if input.nil? || input.strip.empty?
103 | 
104 |         input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
105 |         title = input_lines[0]&.strip
106 |         NA.notify("#{NA.theme[:error]}No content in first line", exit_code: 1) if title.nil? || title.strip.empty?
107 | 
108 |         title = title.expand_date_tags
109 | 
110 |         note = if input_lines.length > 1
111 |                  input_lines[1..-1]
112 |                else
113 |                  []
114 |                end
115 | 
116 |         unless note.empty?
117 |           note.map!(&:strip)
118 |           note.delete_if { |l| l =~ /^\s*$/ || l =~ /^#/ }
119 |         end
120 | 
121 |         [title, note]
122 |       end
123 |     end
124 |   end
125 | end
126 | 


--------------------------------------------------------------------------------
/lib/na/hash.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | class ::Hash
 4 |   def symbolize_keys
 5 |     each_with_object({}) { |(k, v), hsh| hsh[k.to_sym] = v.is_a?(Hash) ? v.symbolize_keys : v }
 6 |   end
 7 | 
 8 |   ##
 9 |   ## Freeze all values in a hash
10 |   ##
11 |   ## @return     Hash with all values frozen
12 |   ##
13 |   def deep_freeze
14 |     chilled = {}
15 |     each do |k, v|
16 |       chilled[k] = v.is_a?(Hash) ? v.deep_freeze : v.freeze
17 |     end
18 | 
19 |     chilled.freeze
20 |   end
21 | 
22 |   def deep_freeze!
23 |     replace deep_thaw.deep_freeze
24 |   end
25 | 
26 |   def deep_thaw
27 |     chilled = {}
28 |     each do |k, v|
29 |       chilled[k] = v.is_a?(Hash) ? v.deep_thaw : v.dup
30 |     end
31 | 
32 |     chilled.dup
33 |   end
34 | 
35 |   def deep_thaw!
36 |     replace deep_thaw
37 |   end
38 | 
39 | 	def deep_merge(second)
40 | 	    merger = proc { |_, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : Array === v1 && Array === v2 ? v1 | v2 : [:undefined, nil, :nil].include?(v2) ? v1 : v2 }
41 | 	    merge(second.to_h, &merger)
42 | 	end
43 | end
44 | 


--------------------------------------------------------------------------------
/lib/na/help_monkey_patch.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module GLI
 4 |   module Commands
 5 |     # Help Command Monkeypatch for paginated output
 6 |     class Help < Command
 7 |       def show_help(global_options, options, arguments, out, error)
 8 |         NA::Pager.paginate = true
 9 | 
10 |         command_finder = HelpModules::CommandFinder.new(@app, arguments, error)
11 |         if options[:c]
12 |           help_output = HelpModules::HelpCompletionFormat.new(@app, command_finder, arguments).format
13 |           out.puts help_output unless help_output.nil?
14 |         elsif arguments.empty? || options[:c]
15 |           NA::Pager.page HelpModules::GlobalHelpFormat.new(@app, @sorter, @text_wrapping_class).format
16 |         else
17 |           name = arguments.shift
18 |           command = command_finder.find_command(name)
19 |           unless command.nil?
20 |             NA::Pager.page HelpModules::CommandHelpFormat.new(
21 |               command,
22 |               @app,
23 |               @sorter,
24 |               @synopsis_formatter_class,
25 |               @text_wrapping_class
26 |             ).format
27 |           end
28 |         end
29 |       end
30 |     end
31 |   end
32 | end
33 | 


--------------------------------------------------------------------------------
/lib/na/pager.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require 'pathname'
 4 | 
 5 | module NA
 6 |   # Pagination
 7 |   module Pager
 8 |     class << self
 9 |       # Boolean determines whether output is paginated
10 |       def paginate
11 |         @paginate ||= false
12 |       end
13 | 
14 |       # Enable/disable pagination
15 |       #
16 |       # @param      should_paginate  [Boolean] true to paginate
17 |       def paginate=(should_paginate)
18 |         @paginate = should_paginate
19 |       end
20 | 
21 |       # Page output. If @paginate is false, just dump to
22 |       # STDOUT
23 |       #
24 |       # @param      text  [String] text to paginate
25 |       #
26 |       def page(text)
27 |         unless @paginate
28 |           puts text
29 |           return
30 |         end
31 | 
32 |         pager = which_pager
33 | 
34 |         read_io, write_io = IO.pipe
35 | 
36 |         input = $stdin
37 | 
38 |         pid = Kernel.fork do
39 |           write_io.close
40 |           input.reopen(read_io)
41 |           read_io.close
42 | 
43 |           # Wait until we have input before we start the pager
44 |           IO.select [input]
45 | 
46 |           begin
47 |             NA.notify("#{NA.theme[:debug]}Pager #{pager}", debug: true)
48 |             exec(pager)
49 |           rescue SystemCallError => e
50 |             raise Errors::DoingStandardError, "Pager error, #{e}"
51 |           end
52 |         end
53 | 
54 |         begin
55 |           read_io.close
56 |           write_io.write(text)
57 |           write_io.close
58 |         rescue SystemCallError # => e
59 |           # raise Errors::DoingStandardError, "Pager error, #{e}"
60 |         end
61 | 
62 |         _, status = Process.waitpid2(pid)
63 |         status.success?
64 |       end
65 | 
66 |       private
67 | 
68 |       def git_pager
69 |         TTY::Which.exist?('git') ? `#{TTY::Which.which('git')} config --get-all core.pager` : nil
70 |       end
71 | 
72 |       def pagers
73 |         [
74 |           ENV['PAGER'],
75 |           'less -FXr',
76 |           ENV['GIT_PAGER'],
77 |           git_pager,
78 |           'more -r'
79 |         ].remove_bad
80 |       end
81 | 
82 |       def find_executable(*commands)
83 |         execs = commands.empty? ? pagers : commands
84 |         execs
85 |           .remove_bad.uniq
86 |           .find { |cmd| TTY::Which.exist?(cmd.split.first) }
87 |       end
88 | 
89 |       def which_pager
90 |         @which_pager ||= find_executable(*pagers)
91 |       end
92 |     end
93 |   end
94 | end
95 | 


--------------------------------------------------------------------------------
/lib/na/project.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module NA
 4 |   class Project < Hash
 5 |     attr_accessor :project, :indent, :line, :last_line
 6 | 
 7 |     def initialize(project, indent = 0, line = 0, last_line = 0)
 8 |       super()
 9 |       @project = project
10 |       @indent = indent
11 |       @line = line
12 |       @last_line = last_line
13 |     end
14 | 
15 |     def to_s
16 |       { project: @project, indent: @indent, line: @line, last_line: @last_line }.to_s
17 |     end
18 | 
19 |     def inspect
20 |       [
21 |         "@project: #{@project}",
22 |         "@indent: #{@indent}",
23 |         "@line: #{@line}",
24 |         "@last_line: #{@last_line}"
25 |       ].join(" ")
26 |     end
27 |   end
28 | end
29 | 


--------------------------------------------------------------------------------
/lib/na/prompt.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module NA
  4 |   # Prompt Hooks
  5 |   module Prompt
  6 |     class << self
  7 |       def prompt_hook(shell)
  8 |         case shell
  9 |         when :zsh
 10 |           cmd = if NA.global_file
 11 |                   case NA.cwd_is
 12 |                   when :project
 13 |                     'na next --proj $(basename "$PWD")'
 14 |                   when :tag
 15 |                     'na tagged $(basename "$PWD")'
 16 |                   else
 17 |                     NA.notify("#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1)
 18 |                   end
 19 |                 else
 20 |                   'na next'
 21 |                 end
 22 |           <<~EOHOOK
 23 |             # zsh prompt hook for na
 24 |             chpwd() { #{cmd} }
 25 |           EOHOOK
 26 |         when :fish
 27 |           cmd = if NA.global_file
 28 |                   case NA.cwd_is
 29 |                   when :project
 30 |                     'na next --proj (basename "$PWD")'
 31 |                   when :tag
 32 |                     'na tagged (basename "$PWD")'
 33 |                   else
 34 |                     NA.notify("#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1)
 35 |                   end
 36 |                 else
 37 |                   'na next'
 38 |                 end
 39 |           <<~EOHOOK
 40 |             # Fish Prompt Command
 41 |             function __should_na --on-variable PWD
 42 |               test -s (basename $PWD)".#{NA.extension}" && #{cmd}
 43 |             end
 44 |           EOHOOK
 45 |         when :bash
 46 |           cmd = if NA.global_file
 47 |                   case NA.cwd_is
 48 |                   when :project
 49 |                     'na next --proj $(basename "$PWD")'
 50 |                   when :tag
 51 |                     'na tagged $(basename "$PWD")'
 52 |                   else
 53 |                     NA.notify("#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1)
 54 |                   end
 55 |                 else
 56 |                   'na next'
 57 |                 end
 58 | 
 59 |           <<~EOHOOK
 60 |             # Bash PROMPT_COMMAND for na
 61 |             last_command_was_cd() {
 62 |               [[ $(history 1|sed -e "s/^[ ]*[0-9]*[ ]*//") =~ ^((cd|z|j|jump|g|f|pushd|popd|exit)([ ]|$)) ]] && #{cmd}
 63 |             }
 64 |             if [[ -z "$PROMPT_COMMAND" ]]; then
 65 |               PROMPT_COMMAND="eval 'last_command_was_cd'"
 66 |             else
 67 |               echo $PROMPT_COMMAND | grep -v -q "last_command_was_cd" && PROMPT_COMMAND="$PROMPT_COMMAND;"'eval "last_command_was_cd"'
 68 |             fi
 69 |           EOHOOK
 70 |         end
 71 |       end
 72 | 
 73 |       def prompt_file(shell)
 74 |         files = {
 75 |           zsh: '~/.zshrc',
 76 |           fish: '~/.config/fish/conf.d/na.fish',
 77 |           bash: '~/.bash_profile'
 78 |         }
 79 | 
 80 |         files[shell]
 81 |       end
 82 | 
 83 |       def show_prompt_hook(shell)
 84 |         file = prompt_file(shell)
 85 | 
 86 |         NA.notify("#{NA.theme[:warning]}# Add this to #{NA.theme[:filename]}#{file}")
 87 |         puts prompt_hook(shell)
 88 |       end
 89 | 
 90 |       def install_prompt_hook(shell)
 91 |         file = prompt_file(shell)
 92 | 
 93 |         File.open(File.expand_path(file), 'a') { |f| f.puts prompt_hook(shell) }
 94 |         NA.notify("#{NA.theme[:success]}Added #{NA.theme[:filename]}#{shell}{x}#{NA.theme[:success]} prompt hook to #{NA.theme[:filename]}#{file}#{NA.theme[:success]}.")
 95 |         NA.notify("#{NA.theme[:warning]}You may need to close the current terminal and open a new one to enable the script.")
 96 |       end
 97 |     end
 98 |   end
 99 | end
100 | 


--------------------------------------------------------------------------------
/lib/na/string.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | REGEX_DAY = /^(mon|tue|wed|thur?|fri|sat|sun)(\w+(day)?)?$/i.freeze
  4 | REGEX_CLOCK = '(?:\d{1,2}+(?::\d{1,2}+)?(?: *(?:am|pm))?|midnight|noon)'
  5 | REGEX_TIME = /^#{REGEX_CLOCK}$/i.freeze
  6 | 
  7 | # String helpers
  8 | class ::String
  9 |   ##
 10 |   ## Insert a comment character at the start of every line
 11 |   ##
 12 |   ## @param      char  [String] The character to insert (default #)
 13 |   ##
 14 |   def comment(char = "#")
 15 |     split(/\n/).map { |l| "# #{l}" }.join("\n")
 16 |   end
 17 | 
 18 |   ##
 19 |   ## Tests if object is nil or empty
 20 |   ##
 21 |   ## @return     [Boolean] true if object is defined and
 22 |   ##             has content
 23 |   ##
 24 |   def good?
 25 |     !strip.empty?
 26 |   end
 27 | 
 28 |   ##
 29 |   ## Test if line should be ignored
 30 |   ##
 31 |   ## @return     [Boolean] line is empty or comment
 32 |   ##
 33 |   def ignore?
 34 |     line = self
 35 |     line =~ /^#/ || line.strip.empty?
 36 |   end
 37 | 
 38 |   def read_file
 39 |     file = File.expand_path(self)
 40 |     raise "Missing file #{file}" unless File.exist?(file)
 41 | 
 42 |     if File.directory?(file)
 43 |       if File.exist?("#{file}.#{NA.extension}")
 44 |         file = "#{file}.#{NA.extension}"
 45 |       elsif File.exist?("#{file}/#{File.basename(file)}.#{NA.extension}")
 46 |         file = "#{file}/#{File.basename(file)}.#{NA.extension}"
 47 |       else
 48 |         NA.notify("#{NA.theme[:error]}#{file} is a directory", exit_code: 2)
 49 |       end
 50 |     end
 51 | 
 52 |     # IO.read(file).force_encoding('ASCII-8BIT').encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
 53 |     IO.read(file).force_encoding('utf-8')
 54 |   end
 55 | 
 56 |   ##
 57 |   ## Determine indentation level of line
 58 |   ##
 59 |   ## @return     [Number] number of indents detected
 60 |   ##
 61 |   def indent_level
 62 |     prefix = match(/(^[ \t]+)/)
 63 |     return 0 if prefix.nil?
 64 | 
 65 |     prefix[1].gsub(/    /, "\t").scan(/\t/).count
 66 |   end
 67 | 
 68 |   def action?
 69 |     self =~ /^[ \t]*- /
 70 |   end
 71 | 
 72 |   def blank?
 73 |     strip =~ /^$/
 74 |   end
 75 | 
 76 |   def project?
 77 |     !action? && self =~ /:( +@\S+(\([^)]*\))?)*$/
 78 |   end
 79 | 
 80 |   def project
 81 |     m = match(/^([ \t]*)([^\-][^@:]*?): *(@\S+ *)*$/)
 82 |     m ? m[2] : nil
 83 |   end
 84 | 
 85 |   def action
 86 |     sub(/^[ \t]*- /, '')
 87 |   end
 88 | 
 89 |   def done?
 90 |     self =~ /@done/
 91 |   end
 92 | 
 93 |   def na?
 94 |     self =~ /@#{NA.na_tag}\b/
 95 |   end
 96 | 
 97 |   ##
 98 |   ## Colorize the dirname and filename of a path
 99 |   ##
100 |   ## @return     Colorized string
101 |   ##
102 |   def highlight_filename
103 |     dir = File.dirname(self).shorten_path.trunc_middle(TTY::Screen.columns / 3)
104 |     file = NA.include_ext ? File.basename(self) : File.basename(self, ".#{NA.extension}")
105 |     "#{NA.theme[:dirname]}#{dir}/#{NA.theme[:filename]}#{file}{x}"
106 |   end
107 | 
108 |   ##
109 |   ## Colorize @tags with ANSI escapes
110 |   ##
111 |   ## @param      color       [String] color (see #Color)
112 |   ## @param      value       [String] The value color
113 |   ##                         template
114 |   ## @param      parens      [String] The parens color
115 |   ##                         template
116 |   ## @param      last_color  [String] Color to restore after
117 |   ##                         tag highlight
118 |   ##
119 |   ## @return     [String] string with @tags highlighted
120 |   ##
121 |   def highlight_tags(color: NA.theme[:tags], value: NA.theme[:value], parens: NA.theme[:value_parens], last_color: NA.theme[:action])
122 |     tag_color = NA::Color.template(color)
123 |     paren_color = NA::Color.template(parens)
124 |     value_color = NA::Color.template(value)
125 |     gsub(/(?
\s|m)(?@[^ ("']+)(?:(?\()(?.*?)(?\)))?/) do
126 |       m = Regexp.last_match
127 |       if m['val']
128 |         "#{m['pre']}#{tag_color}#{m['tag']}#{paren_color}(#{value_color}#{m['val']}#{paren_color})#{last_color}"
129 |       else
130 |         "#{m['pre']}#{tag_color}#{m['tag']}#{last_color}"
131 |       end
132 |     end
133 |   end
134 | 
135 |   ##
136 |   ## Highlight search results
137 |   ##
138 |   ## @param      regexes     [Array] The regexes for the
139 |   ##                         search
140 |   ## @param      color       [String] The highlight color
141 |   ##                         template
142 |   ## @param      last_color  [String] Color to restore after
143 |   ##                         highlight
144 |   ##
145 |   def highlight_search(regexes, color: NA.theme[:search_highlight], last_color: NA.theme[:action])
146 |     string = dup
147 |     color = NA::Color.template(color.dup)
148 |     regexes.each do |rx|
149 |       next if rx.nil?
150 |       rx = Regexp.new(rx, Regexp::IGNORECASE) if rx.is_a?(String)
151 | 
152 |       string.gsub!(rx) do
153 |         m = Regexp.last_match
154 |         last = m.pre_match.last_color
155 |         "#{color}#{m[0]}#{NA::Color.template(last)}"
156 |       end
157 |     end
158 |     string
159 |   end
160 | 
161 |   def trunc_middle(max)
162 |     return self unless length > max
163 | 
164 |     half = (max / 2).floor - 3
165 |     chars = split('')
166 |     pre = chars.slice(0, half)
167 |     post = chars.reverse.slice(0, half).reverse
168 |     "#{pre.join('')}[...]#{post.join('')}"
169 |   end
170 | 
171 |   def wrap(width, indent)
172 |     return "\n#{self}" if width <= 80
173 | 
174 |     output = []
175 |     line = []
176 |     length = indent
177 |     gsub!(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
178 | 
179 |     split(' ').each do |word|
180 |       uncolored = NA::Color.uncolor(word)
181 |       if (length + uncolored.length + 1) < width
182 |         line << word
183 |         length += uncolored.length + 1
184 |       else
185 |         output << line.join(' ')
186 |         line = [word]
187 |         length = indent + uncolored.length + 1
188 |       end
189 |     end
190 |     output << line.join(' ')
191 |     output.join("\n" + ' ' * (indent + 2)).gsub(/†/, ' ')
192 |   end
193 | 
194 |   # Returns the last escape sequence from a string.
195 |   #
196 |   # @note       Actually returns all escape codes, with the
197 |   #             assumption that the result of inserting them
198 |   #             will generate the same color as was set at
199 |   #             the end of the string. Because you can send
200 |   #             modifiers like dark and bold separate from
201 |   #             color codes, only using the last code may
202 |   #             not render the same style.
203 |   #
204 |   # @return     [String]  All escape codes in string
205 |   #
206 |   def last_color
207 |     scan(/\e\[[\d;]+m/).join('').gsub(/\e\[0m/, '')
208 |   end
209 | 
210 |   ##
211 |   ## Convert a directory path to a regular expression
212 |   ##
213 |   ## @note       Splits at / or :, adds variable distance
214 |   ##             between characters, joins segments with
215 |   ##             slashes and requires that last segment
216 |   ##             match last segment of target path
217 |   ##
218 |   ## @param      distance      The distance allowed between characters
219 |   ## @param      require_last  Require match to be last element in path
220 |   ##
221 |   def dir_to_rx(distance: 1, require_last: true)
222 |     "#{split(%r{[/:]}).map { |comp| comp.split('').join(".{0,#{distance}}").gsub(/\*/, '[^ ]*?') }.join('.*?/.*?')}#{require_last ? '[^/]*?$' : ''}"
223 |   end
224 | 
225 |   def dir_matches(any: [], all: [], none: [], require_last: true, distance: 1)
226 |     any_rx = any.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
227 |     all_rx = all.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
228 |     none_rx = none.map { |q| q.dir_to_rx(distance: distance, require_last: false) }
229 |     matches_any(any_rx) && matches_all(all_rx) && matches_none(none_rx)
230 |   end
231 | 
232 |   def matches(any: [], all: [], none: [])
233 |     matches_any(any) && matches_all(all) && matches_none(none)
234 |   end
235 | 
236 |   ##
237 |   ## Convert wildcard characters to regular expressions
238 |   ##
239 |   ## @return     [String] Regex string
240 |   ##
241 |   def wildcard_to_rx
242 |     gsub(/\./, '\\.').gsub(/\?/, '.').gsub(/\*/, '[^ ]*?')
243 |   end
244 | 
245 |   def cap_first!
246 |     replace cap_first
247 |   end
248 | 
249 |   ##
250 |   ## Capitalize first character, leaving other
251 |   ## capitalization in place
252 |   ##
253 |   ## @return     [String] capitalized string
254 |   ##
255 |   def cap_first
256 |     sub(/^([a-z])(.*)$/) do
257 |       m = Regexp.last_match
258 |       m[1].upcase << m[2]
259 |     end
260 |   end
261 | 
262 |   ##
263 |   ## Replace home directory with tilde
264 |   ##
265 |   ## @return     [String] shortened path
266 |   ##
267 |   def shorten_path
268 |     sub(/^#{ENV['HOME']}/, '~')
269 |   end
270 | 
271 |   ##
272 |   ## Convert (chronify) natural language dates
273 |   ## within configured date tags (tags whose value is
274 |   ## expected to be a date). Modifies string in place.
275 |   ##
276 |   ## @param      additional_tags  [Array] An array of
277 |   ##                              additional tags to
278 |   ##                              consider date_tags
279 |   ##
280 |   def expand_date_tags(additional_tags = nil)
281 |     iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
282 | 
283 |     watch_tags = [
284 |       'due',
285 |       'start(?:ed)?',
286 |       'beg[ia]n',
287 |       'done',
288 |       'finished',
289 |       'completed?',
290 |       'waiting',
291 |       'defer(?:red)?'
292 |     ]
293 | 
294 |     if additional_tags
295 |       date_tags = additional_tags
296 |       date_tags = date_tags.split(/ *, */) if date_tags.is_a?(String)
297 |       date_tags.map! do |tag|
298 |         tag.sub(/^@/, '').gsub(/\((?!\?:)(.*?)\)/, '(?:\1)').strip
299 |       end
300 |       watch_tags.concat(date_tags).uniq!
301 |     end
302 | 
303 |     done_rx = /(?<=^| )@(?#{watch_tags.join('|')})\((?.*?)\)/i
304 | 
305 |     dup.gsub(done_rx) do
306 |       m = Regexp.last_match
307 |       t = m['tag']
308 |       d = m['date']
309 |       future = t =~ /^(done|complete)/ ? false : true
310 |       parsed_date = d =~ iso_rx ? Time.parse(d) : d.chronify(guess: :begin, future: future)
311 |       parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
312 |     end
313 |   end
314 | 
315 |   ##
316 |   ## Converts input string into a Time object when input
317 |   ## takes on the following formats:
318 |   ##             - interval format e.g. '1d2h30m', '45m'
319 |   ##               etc.
320 |   ##             - a semantic phrase e.g. 'yesterday
321 |   ##               5:30pm'
322 |   ##             - a strftime e.g. '2016-03-15 15:32:04
323 |   ##               PDT'
324 |   ##
325 |   ## @param      options  Additional options
326 |   ##
327 |   ## @option options :future [Boolean] assume future date
328 |   ##                                   (default: false)
329 |   ##
330 |   ## @option options :guess  [Symbol] :begin or :end to
331 |   ##                                   assume beginning or end of
332 |   ##                                   arbitrary time range
333 |   ##
334 |   ## @return     [DateTime] result
335 |   ##
336 |   def chronify(**options)
337 |     now = Time.now
338 |     raise StandardError, "Invalid time expression #{inspect}" if to_s.strip == ''
339 | 
340 |     secs_ago = if match(/^(\d+)$/)
341 |                  # plain number, assume minutes
342 |                  Regexp.last_match(1).to_i * 60
343 |                elsif (m = match(/^(?:(?\d+)d)? *(?:(?\d+)h)? *(?:(?\d+)m)?$/i))
344 |                  # day/hour/minute format e.g. 1d2h30m
345 |                  [[m['day'], 24 * 3600],
346 |                   [m['hour'], 3600],
347 |                   [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
348 |                end
349 | 
350 |     if secs_ago
351 |       res = now - secs_ago
352 |       notify(%(date/time string "#{self}" interpreted as #{res} (#{secs_ago} seconds ago)), debug: true)
353 |     else
354 |       date_string = dup
355 |       date_string = 'today' if date_string.match(REGEX_DAY) && now.strftime('%a') =~ /^#{Regexp.last_match(1)}/i
356 |       date_string = "#{options[:context].to_s} #{date_string}" if date_string =~ REGEX_TIME && options[:context]
357 | 
358 |       res = Chronic.parse(date_string, {
359 |                             guess: options.fetch(:guess, :begin),
360 |                             context: options.fetch(:future, false) ? :future : :past,
361 |                             ambiguous_time_range: 8
362 |                           })
363 | 
364 |       NA.notify(%(date/time string "#{self}" interpreted as #{res}), debug: true)
365 |     end
366 | 
367 |     res
368 |   end
369 | 
370 |   private
371 | 
372 |   def matches_none(regexes)
373 |     regexes.each do |rx|
374 |       return false if match(Regexp.new(rx, Regexp::IGNORECASE))
375 |     end
376 |     true
377 |   end
378 | 
379 |   def matches_any(regexes)
380 |     regexes.each do |rx|
381 |       return true if match(Regexp.new(rx, Regexp::IGNORECASE))
382 |     end
383 |     false
384 |   end
385 | 
386 |   def matches_all(regexes)
387 |     regexes.each do |rx|
388 |       return false unless match(Regexp.new(rx, Regexp::IGNORECASE))
389 |     end
390 |     true
391 |   end
392 | end
393 | 


--------------------------------------------------------------------------------
/lib/na/theme.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | module NA
 4 |   module Theme
 5 |     class << self
 6 |       def template_help
 7 |         <<~EOHELP
 8 |           Use {X} placeholders to apply colors. Available colors are:
 9 | 
10 |           w: white, k: black, g: green, l: blue,
11 |           y: yellow, c: cyan, m: magenta, r: red,
12 |           W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
13 |           Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
14 |           d: dark, b: bold, u: underline, i: italic, x: reset
15 | 
16 |           Multiple placeholders can be combined in a single {} pair.
17 | 
18 |           You can also use {#RGB} and {#RRGGBB} to specify hex colors.
19 |           Add a b before the # to make the hex a background color ({b#fa0}).
20 | 
21 | 
22 |         EOHELP
23 |       end
24 | 
25 |       def load_theme(template: {})
26 |         # Default colorization, can be overridden with full or partial template variable
27 |         default_template = {
28 |           parent: '{c}',
29 |           bracket: '{dc}',
30 |           parent_divider: '{xw}/',
31 |           action: '{bg}',
32 |           project: '{xbk}',
33 |           tags: '{m}',
34 |           value_parens: '{m}',
35 |           values: '{c}',
36 |           search_highlight: '{y}',
37 |           note: '{dw}',
38 |           dirname: '{xdw}',
39 |           filename: '{xb}{#eccc87}',
40 |           prompt: '{m}',
41 |           success: '{bg}',
42 |           error: '{b}{#b61d2a}',
43 |           warning: '{by}',
44 |           debug: '{dw}',
45 |           templates: {
46 |             output: '%filename%parents| %action',
47 |             default: '%parent%action',
48 |             single_file: '%parent%action',
49 |             multi_file: '%filename%parent%action',
50 |             no_file: '%parent%action'
51 |           }
52 |         }
53 | 
54 |         # Load custom theme
55 |         theme_file = NA.database_path(file: 'theme.yaml')
56 |         theme = if File.exist?(theme_file)
57 |                   YAML.load(IO.read(theme_file)) || {}
58 |                 else
59 |                   {}
60 |                 end
61 |         theme = default_template.deep_merge(theme)
62 | 
63 |         File.open(theme_file, 'w') do |f|
64 |           f.puts template_help.comment
65 |           f.puts YAML.dump(theme)
66 |         end
67 | 
68 |         theme.merge(template)
69 |       end
70 |     end
71 |   end
72 | end
73 | 


--------------------------------------------------------------------------------
/lib/na/todo.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module NA
  4 |   class Todo
  5 |     attr_accessor :actions, :projects, :files
  6 | 
  7 |     def initialize(options = {})
  8 |       @files, @actions, @projects = parse(options)
  9 |     end
 10 | 
 11 |     ##
 12 |     ## Read a todo file and create a list of actions
 13 |     ##
 14 |     ## @param      options  The options
 15 |     ##
 16 |     ## @option      depth       [Number] The directory depth to
 17 |     ##                         search for files
 18 |     ## @option      done        [Boolean] include @done actions
 19 |     ## @option      query       [Hash] The todo file query
 20 |     ## @option      tag         [Array] Tags to search for
 21 |     ## @option      search      [String] A search string
 22 |     ## @option      negate      [Boolean] Invert results
 23 |     ## @option      regex       [Boolean] Interpret as regular
 24 |     ##                         expression
 25 |     ## @option      project     [String] The project
 26 |     ## @option      require_na  [Boolean] Require @na tag
 27 |     ## @option      file_path   [String] file path to parse
 28 |     ##
 29 |     def parse(options)
 30 |       defaults = {
 31 |         depth: 1,
 32 |         done: false,
 33 |         file_path: nil,
 34 |         negate: false,
 35 |         project: nil,
 36 |         query: nil,
 37 |         regex: false,
 38 |         require_na: true,
 39 |         search: nil,
 40 |         search_note: true,
 41 |         tag: nil
 42 |       }
 43 | 
 44 |       settings = defaults.merge(options)
 45 | 
 46 |       actions = NA::Actions.new
 47 |       required = []
 48 |       optional = []
 49 |       negated = []
 50 |       required_tag = []
 51 |       optional_tag = []
 52 |       negated_tag = []
 53 |       projects = []
 54 | 
 55 |       NA.notify("Tags: #{settings[:tag]}", debug:true)
 56 |       NA.notify("Search: #{settings[:search]}", debug:true)
 57 | 
 58 |       settings[:tag]&.each do |t|
 59 |         unless t[:tag].nil?
 60 |           if settings[:negate]
 61 |             optional_tag.push(t) if t[:negate]
 62 |             required_tag.push(t) if t[:required] && t[:negate]
 63 |             negated_tag.push(t) unless t[:negate]
 64 |           else
 65 |             optional_tag.push(t) unless t[:negate] || t[:required]
 66 |             required_tag.push(t) if t[:required] && !t[:negate]
 67 |             negated_tag.push(t) if t[:negate]
 68 |           end
 69 |         end
 70 |       end
 71 | 
 72 |       unless settings[:search].nil? || settings[:search].empty?
 73 |         if settings[:regex] || settings[:search].is_a?(String)
 74 |           if settings[:negate]
 75 |             negated.push(settings[:search])
 76 |           else
 77 |             optional.push(settings[:search])
 78 |             required.push(settings[:search])
 79 |           end
 80 |         else
 81 |           settings[:search].each do |t|
 82 |             opt, req, neg = parse_search(t, settings[:negate])
 83 |             optional.concat(opt)
 84 |             required.concat(req)
 85 |             negated.concat(neg)
 86 |           end
 87 |         end
 88 |       end
 89 | 
 90 |       files = if !settings[:file_path].nil?
 91 |                 [settings[:file_path]]
 92 |               elsif settings[:query].nil?
 93 |                 NA.find_files(depth: settings[:depth])
 94 |               else
 95 |                 NA.match_working_dir(settings[:query])
 96 |               end
 97 | 
 98 |       NA.notify("Files: #{files.join(', ')}", debug: true)
 99 |       files.each do |file|
100 |         NA.save_working_dir(File.expand_path(file))
101 |         content = file.read_file
102 |         indent_level = 0
103 |         parent = []
104 |         in_yaml = false
105 |         in_action = false
106 |         content.split(/\n/).each.with_index do |line, idx|
107 |           if in_yaml && line !~ /^(---|~~~)\s*$/
108 |             NA.notify("YAML: #{line}", debug: true)
109 |           elsif line =~ /^(---|~~~)\s*$/
110 |             in_yaml = !in_yaml
111 |           elsif line.project? && !in_yaml
112 |             in_action = false
113 |             proj = line.project
114 |             indent = line.indent_level
115 | 
116 |             if indent.zero? # top level project
117 |               parent = [proj]
118 |             elsif indent <= indent_level # if indent level is same or less, split parent before indent level and append
119 |               parent.slice!(indent, parent.count - indent)
120 |               parent.push(proj)
121 |             else # if indent level is greater, append project to parent
122 |               parent.push(proj)
123 |             end
124 | 
125 |             projects.push(NA::Project.new(parent.join(':'), indent, idx, idx))
126 | 
127 |             indent_level = indent
128 |           elsif line.blank?
129 |             in_action = false # Comment out to allow line breaks in of notes, which isn't TaskPaper-compatible
130 |           elsif line.action?
131 |             in_action = false
132 | 
133 |             action = line.action
134 |             new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
135 | 
136 |             projects[-1].last_line = idx if projects.count.positive?
137 | 
138 |             next if line.done? && !settings[:done]
139 | 
140 |             next if settings[:require_na] && !line.na?
141 | 
142 |             if settings[:project]
143 |               rx = settings[:project].split(%r{[/:]}).join('.*?/')
144 |               next unless parent.join('/') =~ Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
145 |             end
146 | 
147 |             has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
148 |             next if has_tag && !new_action.tags_match?(any: optional_tag,
149 |                                                        all: required_tag,
150 |                                                        none: negated_tag)
151 | 
152 |             actions.push(new_action)
153 |             in_action = true
154 |           elsif in_action
155 |             actions[-1].note.push(line.strip) if actions.count.positive?
156 |             projects[-1].last_line = idx if projects.count.positive?
157 |           end
158 |         end
159 |         projects = projects.dup
160 |       end
161 | 
162 |       actions.delete_if do |new_action|
163 |         has_search = !optional.empty? || !required.empty? || !negated.empty?
164 |         has_search && !new_action.search_match?(any: optional,
165 |                                                 all: required,
166 |                                                 none: negated,
167 |                                                 include_note: settings[:search_note])
168 |       end
169 | 
170 |       [files, actions, projects]
171 |     end
172 | 
173 |     def parse_search(tag, negate)
174 |       required = []
175 |       optional = []
176 |       negated = []
177 |       new_rx = tag[:token].to_s.wildcard_to_rx
178 | 
179 |       if negate
180 |         optional.push(new_rx) if tag[:negate]
181 |         required.push(new_rx) if tag[:required] && tag[:negate]
182 |         negated.push(new_rx) unless tag[:negate]
183 |       else
184 |         optional.push(new_rx) unless tag[:negate]
185 |         required.push(new_rx) if tag[:required] && !tag[:negate]
186 |         negated.push(new_rx) if tag[:negate]
187 |       end
188 | 
189 |       [optional, required, negated]
190 |     end
191 |   end
192 | end
193 | 


--------------------------------------------------------------------------------
/lib/na/version.rb:
--------------------------------------------------------------------------------
1 | module Na
2 |   VERSION = '1.2.77'
3 | end
4 | 


--------------------------------------------------------------------------------
/na.gemspec:
--------------------------------------------------------------------------------
 1 | # Ensure we require the local version and not one we might have installed already
 2 | require './lib/na/version.rb'
 3 | 
 4 | spec = Gem::Specification.new do |s|
 5 |   s.name = 'na'
 6 |   s.version = Na::VERSION
 7 |   s.author = 'Brett Terpstra'
 8 |   s.email = 'me@brettterpstra.com'
 9 |   s.homepage = 'https://brettterpstra.com/projects/na/'
10 |   s.platform = Gem::Platform::RUBY
11 |   s.summary = 'A command line tool for adding and listing project todos'
12 |   s.description = [
13 |     'A tool for managing a TaskPaper file of project todos for the current directory.',
14 |     'Easily create "next actions" to come back to, add tags and priorities, and notes.',
15 |     'Add prompt hooks to display your next actions automatically when cd\'ing into a directory.'
16 |   ].join(' ')
17 |   s.license = 'MIT'
18 |   s.files =`git ls-files -z`.split("\x0").reject { |f| f.strip =~ %r{^((test|spec|features)/|\.git|buildnotes|.*\.taskpaper)} }
19 |   s.require_paths << 'lib'
20 |   s.extra_rdoc_files = ['README.md', 'na.rdoc']
21 |   s.rdoc_options << '--title' << 'na' << '--main' << 'README.md' << '--markup' << 'markdown'
22 |   s.bindir = 'bin'
23 |   s.executables << 'na'
24 |   s.add_development_dependency('minitest', '~> 5.14')
25 |   s.add_development_dependency('rdoc', '~> 4.3')
26 |   s.add_runtime_dependency('chronic', '~> 0.10', '>= 0.10.2')
27 |   s.add_runtime_dependency('gli','~> 2.21.0')
28 |   s.add_runtime_dependency('mdless', '~> 1.0', '>= 1.0.32')
29 |   s.add_runtime_dependency('tty-reader', '~> 0.9', '>= 0.9.0')
30 |   s.add_runtime_dependency('tty-screen', '~> 0.8', '>= 0.8.1')
31 |   s.add_runtime_dependency('tty-which', '~> 0.5', '>= 0.5.0')
32 | 	s.add_runtime_dependency('git', '~> 3.0.0')
33 |   s.add_development_dependency('tty-spinner', '~> 0.9', '>= 0.9.0')
34 | end
35 | 


--------------------------------------------------------------------------------
/na.rdoc:
--------------------------------------------------------------------------------
  1 | == na - Add and list next actions for the current project
  2 | 
  3 | v1.0.2
  4 | 
  5 | === Global Options
  6 | === -d|--depth DEPTH
  7 | 
  8 | Recurse to depth
  9 | 
 10 | [Default Value] 1
 11 | [Must Match] (?-mix:^\d+$)
 12 | 
 13 | 
 14 | === --ext FILE_EXTENSION
 15 | 
 16 | File extension to consider a todo file
 17 | 
 18 | [Default Value] taskpaper
 19 | 
 20 | 
 21 | === --na_tag TAG
 22 | 
 23 | Tag to consider a next action
 24 | 
 25 | [Default Value] na
 26 | 
 27 | 
 28 | === -p|--priority PRIORITY
 29 | 
 30 | Set a priority 0-5 (deprecated, for backwards compatibility)
 31 | 
 32 | [Default Value] None
 33 | 
 34 | 
 35 | === -a|--[no-]add
 36 | Add a next action (deprecated, for backwards compatibility)
 37 | 
 38 | 
 39 | 
 40 | === --help
 41 | Show this message
 42 | 
 43 | 
 44 | 
 45 | === -n|--[no-]note
 46 | Prompt for additional notes (deprecated, for backwards compatibility)
 47 | 
 48 | 
 49 | 
 50 | === -r|--[no-]recurse
 51 | Recurse 3 directories deep (deprecated, for backwards compatability)
 52 | 
 53 | 
 54 | 
 55 | === --version
 56 | Display the program version
 57 | 
 58 | 
 59 | 
 60 | === Commands
 61 | ==== Command: add  TASK
 62 | Add a new next action
 63 | 
 64 | Provides an easy way to store todos while you work. Add quick reminders and (if you set up Prompt Hooks)
 65 |   they'll automatically display next time you enter the directory.
 66 | 
 67 |   If multiple todo files are found in the current directory, a menu will allow you to pick to which
 68 |   file the action gets added.
 69 | ===== Options
 70 | ===== -f|--file PATH
 71 | 
 72 | Specify the file to which the task should be added
 73 | 
 74 | [Default Value] None
 75 | 
 76 | 
 77 | ===== -p|--priority arg
 78 | 
 79 | Add a priority level 1-5
 80 | 
 81 | [Default Value] 0
 82 | [Must Match] (?-mix:[1-5])
 83 | 
 84 | 
 85 | ===== -t|--tag TAG
 86 | 
 87 | Use a tag other than the default next action tag
 88 | 
 89 | [Default Value] None
 90 | 
 91 | 
 92 | ===== -n|--[no-]note
 93 | Prompt for additional notes
 94 | 
 95 | 
 96 | 
 97 | ==== Command: find  PATTERN
 98 | Find actions matching a search pattern
 99 | 
100 | Search tokens are separated by spaces. Actions matching any token in the pattern will be shown
101 |   (partial matches allowed). Add a + before a token to make it required, e.g. `na find +feature +maybe`
102 | ===== Options
103 | ===== -d|--depth DEPTH
104 | 
105 | Recurse to depth
106 | 
107 | [Default Value] 1
108 | [Must Match] (?-mix:^\d+$)
109 | 
110 | 
111 | ===== -x|--[no-]exact
112 | Match pattern exactly
113 | 
114 | 
115 | 
116 | ==== Command: help  command
117 | Shows a list of commands or help for one command
118 | 
119 | Gets help for the application or its commands. Can also list the commands in a way helpful to creating a bash-style completion function
120 | ===== Options
121 | ===== -c
122 | List commands one per line, to assist with shell completion
123 | 
124 | 
125 | 
126 | ==== Command: initconfig 
127 | Initialize the config file using current global options
128 | 
129 | Initializes a configuration file where you can set default options for command line flags, both globally and on a per-command basis.  These defaults override the built-in defaults and allow you to omit commonly-used command line flags when invoking this program
130 | ===== Options
131 | ===== --[no-]force
132 | force overwrite of existing config file
133 | 
134 | 
135 | 
136 | ==== Command: next|show  OPTIONAL_QUERY
137 | Show next actions
138 | 
139 | 
140 | ===== Options
141 | ===== -d|--depth DEPTH
142 | 
143 | Recurse to depth
144 | 
145 | [Default Value] None
146 | [Must Match] (?-mix:^\d+$)
147 | 
148 | 
149 | ===== -t|--tag arg
150 | 
151 | Alternate tag to search for
152 | 
153 | [Default Value] na
154 | 
155 | 
156 | ==== Command: tagged  TAG [VALUE]
157 | Find actions matching a tag
158 | 
159 | Finds actions with tags matching the arguments. An action is shown if it
160 |   contains any of the tags listed. Add a + before a tag to make it required,
161 |   e.g. `na tagged feature +maybe`
162 | ===== Options
163 | ===== -d|--depth DEPTH
164 | 
165 | Recurse to depth
166 | 
167 | [Default Value] 1
168 | [Must Match] (?-mix:^\d+$)
169 | 
170 | 
171 | [Default Command] next
172 | 


--------------------------------------------------------------------------------
/na.taskpaper:
--------------------------------------------------------------------------------
 1 | ---
 2 | title: NA Todos
 3 | ---
 4 | Testing:
 5 | 	New Commands:
 6 | Inbox: @inbox
 7 | 	- OmniFocus linking and sync @na
 8 | 		Link directory to OmniFocus project
 9 | 		option to merge in output or mirror projects
10 | 	- Accept (h)igh (m)edium (l)ow as arguments to next -p @priority(2) @na @done
11 | 		just translates l=1 m=3 h=5
12 | 	- Would be nice if file matching preferred exact matches, ignoring other matches if there is one @priority(4) @na
13 | na:
14 | 	New Features:
15 | 	Ideas:
16 | 		- Onlyin flag to search only in a specified directory @priority(3) @na
17 | 	Bugs:
18 | 		- add --in "na" adds to Bunch, needs more exact matching @priority(4) @na
19 | 		- Need to add some tests @priority(5) @na
20 | Archive:
21 | 	- File matching is working on FILE/FILE matches, but now it's doubling output @priority(4) @na @done(2024-06-23 10:59) @project(Inbox)
22 | 		Try `na next na` to see.
23 | 	- command to archive all done tasks @na @priority(2) @maybe @done(2023-09-02 15:17) @project(Inbox)
24 | 	- No todo file found when using archive command in curlyq project @na @done(2024-01-17 10:06) @project(na / Bugs)
25 | 	- Ability to remove na tag, mark done, or archive @idea @maybe @done (23-06-14 18:42)
26 | 		Searching for "na" should not select .../na_gem/na/marked, only na_gem/na/na
27 | 	- test the new add command @na @done(2023-06-05 10:22)
28 | 		Searching for "na" should not select .../na_gem/na/marked, only na_gem/na/na
29 | 	- Allow custom template for creating new files @priority(4) @na @done(2023-08-21 07:58)
30 | 		Searching for "na" should not select .../na_gem/na/marked, only na_gem/na/na
31 | 	- ability to add to a specific project @maybe @na @done (22-10-15 11:59) @project(na / Ideas)
32 | 	- Easy matching search should require last argument to be in last position of match @na @done (22-10-15 12:00) @project(Inbox)
33 | Search Definitions:
34 | 	Top Priority @search(@priority = 5 and not @done)
35 | 	High Priority @search(@priority > 3 and not @done)
36 | 	Maybe @search(@maybe)
37 | 	Next @search(@na and not @done and not project = "Archive")
38 | 


--------------------------------------------------------------------------------
/na_gem.taskpaper:
--------------------------------------------------------------------------------
 1 | ---
 2 | title: NA Todos
 3 | ---
 4 | Testing:
 5 | 	New Commands:
 6 | Inbox: @inbox
 7 | 	- OmniFocus linking and sync @na
 8 | 		Link directory to OmniFocus project
 9 | 		option to merge in output or mirror projects
10 | 	- Accept (h)igh (m)edium (l)ow as arguments to next -p @priority(2) @na @done
11 | 		just translates l=1 m=3 h=5
12 | 	- Would be nice if file matching preferred exact matches, ignoring other matches if there is one @priority(4) @na
13 | na:
14 | 	New Features:
15 | 	Ideas:
16 | 		- Onlyin flag to search only in a specified directory @priority(3) @na
17 | 	Bugs:
18 | 		- add --in "na" adds to Bunch, needs more exact matching @priority(4) @na
19 | 		- Need to add some tests @priority(5) @na
20 | Archive:
21 | 	- File matching is working on FILE/FILE matches, but now it's doubling output @priority(4) @na @done(2024-06-23 10:59) @project(Inbox)
22 | 		Try `na next na` to see.
23 | 	- command to archive all done tasks @na @priority(2) @maybe @done(2023-09-02 15:17) @project(Inbox)
24 | 	- No todo file found when using archive command in curlyq project @na @done(2024-01-17 10:06) @project(na / Bugs)
25 | 	- Ability to remove na tag, mark done, or archive @idea @maybe @done (23-06-14 18:42)
26 | 		Searching for "na" should not select .../na_gem/na/marked, only na_gem/na/na
27 | 	- test the new add command @na @done(2023-06-05 10:22)
28 | 		Searching for "na" should not select .../na_gem/na/marked, only na_gem/na/na
29 | 	- Allow custom template for creating new files @priority(4) @na @done(2023-08-21 07:58)
30 | 		Searching for "na" should not select .../na_gem/na/marked, only na_gem/na/na
31 | 	- ability to add to a specific project @maybe @na @done (22-10-15 11:59) @project(na / Ideas)
32 | 	- Easy matching search should require last argument to be in last position of match @na @done (22-10-15 12:00) @project(Inbox)
33 | Search Definitions:
34 | 	Top Priority @search(@priority = 5 and not @done)
35 | 	High Priority @search(@priority > 3 and not @done)
36 | 	Maybe @search(@maybe)
37 | 	Next @search(@na and not @done and not project = "Archive")
38 | 


--------------------------------------------------------------------------------
/scripts/fixreadme.rb:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env ruby
 2 | # frozen_string_literal: true
 3 | 
 4 | current_ver = `rake cver`
 5 | src = 'src/_README.md'
 6 | dest = 'README.md'
 7 | 
 8 | readme = IO.read(src).force_encoding('ASCII-8BIT').encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
 9 | 
10 | content = readme.match(/(?<=\)(.*?)(?=\)/m)[0]
11 | 
12 | content.gsub!(/(.*?)/, current_ver)
13 | content.gsub!(/(.*?)/m, '\1')
14 | content.gsub!(//m, '')
15 | 
16 | content.gsub!(/^@cli\((.*?)\)/) do
17 |   cmd = Regexp.last_match(1)
18 |   `#{cmd}`.strip.gsub(/\n{2,}/, "\n\n")
19 | end
20 | 
21 | File.open(dest, 'w') { |f| f.puts(content) }
22 | 
23 | Process.exit 0
24 | 


--------------------------------------------------------------------------------
/scripts/generate-fish-completions.rb:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env ruby
  2 | require 'tty-progressbar'
  3 | require 'shellwords'
  4 | 
  5 | class ::String
  6 |   def short_desc
  7 |     split(/[,.]/)[0].sub(/ \(.*?\)?$/, '').strip
  8 |   end
  9 | 
 10 |   def ltrunc(max)
 11 |     if length > max
 12 |       sub(/^.*?(.{#{max - 3}})$/, '...\1')
 13 |     else
 14 |       self
 15 |     end
 16 |   end
 17 | 
 18 |   def ltrunc!(max)
 19 |     replace ltrunc(max)
 20 |   end
 21 | end
 22 | 
 23 | class FishCompletions
 24 | 
 25 |   attr_accessor :commands, :global_options
 26 | 
 27 |   def generate_helpers
 28 |     <<~EOFUNCTIONS
 29 |       function __fish_na_needs_command
 30 |         # Figure out if the current invocation already has a command.
 31 | 
 32 |         set -l opts a-add add_at= color cwd_as= d-depth= debug ext= f-file= help include_ext n-note p-priority= pager f-recurse t-na_tag= template= version
 33 |         set cmd (commandline -opc)
 34 |         set -e cmd[1]
 35 |         argparse -s $opts -- $cmd 2>/dev/null
 36 |         or return 0
 37 |         # These flags function as commands, effectively.
 38 |         if set -q argv[1]
 39 |           # Also print the command, so this can be used to figure out what it is.
 40 |           echo $argv[1]
 41 |           return 1
 42 |         end
 43 |         return 0
 44 |       end
 45 | 
 46 |       function __fish_na_using_command
 47 |         set -l cmd (__fish_na_needs_command)
 48 |         test -z "$cmd"
 49 |         and return 1
 50 |         contains -- $cmd $argv
 51 |         and return 0
 52 |       end
 53 | 
 54 |       function __fish_na_subcommands
 55 |         na help -c
 56 |       end
 57 | 
 58 |       complete -c na -f
 59 |       complete -xc na -n '__fish_na_needs_command' -a '(__fish_na_subcommands)'
 60 | 
 61 |       complete -xc na -n '__fish_seen_subcommand_from help; and not __fish_seen_subcommand_from (na help -c)' -a "(na help -c)"
 62 |     EOFUNCTIONS
 63 |   end
 64 | 
 65 |   def get_help_sections(command = '')
 66 |     res = `na help #{command}`.strip
 67 |     scanned = res.scan(/(?m-i)^([A-Z ]+)\n([\s\S]*?)(?=\n+[A-Z]+|\Z)/)
 68 |     sections = {}
 69 |     scanned.each do |sect|
 70 |       title = sect[0].downcase.strip.gsub(/ +/, '_').to_sym
 71 |       content = sect[1].split(/\n/).map(&:strip).delete_if(&:empty?)
 72 |       sections[title] = content
 73 |     end
 74 |     sections
 75 |   end
 76 | 
 77 |   def parse_option(option)
 78 |     res = option.match(/(?:-(?\w), )?(?:--(?:\[no-\])?(?w+)(?:=(?\w+))?)\s+- (?.*?)$/)
 79 |     return nil unless res
 80 |     {
 81 |       short: res['short'],
 82 |       long: res['long'],
 83 |       arg: res[:arg],
 84 |       description: res['desc'].short_desc
 85 |     }
 86 |   end
 87 | 
 88 |   def parse_options(options)
 89 |     options.map { |opt| parse_option(opt) }
 90 |   end
 91 | 
 92 |   def parse_command(command)
 93 |     res = command.match(/^(?[^, \t]+)(?(?:, [^, \t]+)*)?\s+- (?.*?)$/)
 94 |     commands = [res['cmd']]
 95 |     commands.concat(res['alias'].split(/, /).delete_if(&:empty?)) if res['alias']
 96 | 
 97 |     {
 98 |       commands: commands,
 99 |       description: res['desc'].short_desc
100 |     }
101 |   end
102 | 
103 |   def parse_commands(commands)
104 |     commands.map { |cmd| parse_command(cmd) }
105 |   end
106 | 
107 |   def generate_subcommand_completions
108 |     out = []
109 |     @commands.each_with_index do |cmd, i|
110 |       out << "complete -xc na -n '__fish_na_needs_command' -a '#{cmd[:commands].join(' ')}' -d #{Shellwords.escape(cmd[:description])}"
111 |     end
112 | 
113 |     out.join("\n")
114 |   end
115 | 
116 |   def generate_subcommand_option_completions
117 | 
118 |     out = []
119 |     need_export = []
120 | 
121 |     @commands.each_with_index do |cmd, i|
122 |       @bar.advance
123 |       data = get_help_sections(cmd[:commands].first)
124 | 
125 |       if data[:synopsis].join(' ').strip.split(/ /).last =~ /(path|file)/i
126 |         out << "complete -c na -F -n '__fish_na_using_command #{cmd[:commands].join(" ")}'"
127 |       end
128 | 
129 |       if data[:command_options]
130 |         parse_options(data[:command_options]).each do |option|
131 |           next if option.nil?
132 | 
133 |           arg = option[:arg] ? '-r' : ''
134 |           short = option[:short] ? "-s #{option[:short]}" : ''
135 |           long = option[:long] ? "-l #{option[:long]}" : ''
136 |           out << "complete -c na #{long} #{short} -f #{arg} -n '__fish_na_using_command #{cmd[:commands].join(' ')}' -d #{Shellwords.escape(option[:description])}"
137 | 
138 |           need_export.concat(cmd[:commands]) if option[:long] == 'output'
139 |         end
140 |       end
141 |     end
142 | 
143 |     unless need_export.empty?
144 |       out << "complete -f -c na -s o -l output -x -n '__fish_na_using_command #{need_export.join(' ')}' -a '(__fish_na_export_plugins)'"
145 |     end
146 | 
147 |     # clear
148 |     out.join("\n")
149 |   end
150 | 
151 |   def initialize
152 |     data = get_help_sections
153 |     @global_options = parse_options(data[:global_options])
154 |     @commands = parse_commands(data[:commands])
155 |     @bar = TTY::ProgressBar.new("\033[0;0;33mGenerating Fish completions: \033[0;35;40m[:bar]\033[0m", total: @commands.count, bar_format: :blade)
156 |     @bar.resize(25)
157 |   end
158 | 
159 |   def generate_completions
160 |     @bar.start
161 |     out = []
162 |     out << generate_helpers
163 |     out << generate_subcommand_completions
164 |     out << generate_subcommand_option_completions
165 |     @bar.finish
166 |     out.join("\n")
167 |   end
168 | end
169 | 
170 | puts FishCompletions.new.generate_completions
171 | 


--------------------------------------------------------------------------------
/scripts/runtests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | 
3 | cd /planter
4 | bundle update
5 | rake test
6 | 


--------------------------------------------------------------------------------
/src/_README.md:
--------------------------------------------------------------------------------
  1 | # na
  2 | 
  3 | [![Gem](https://img.shields.io/gem/v/na.svg)](https://rubygems.org/gems/na)
  4 | [![Travis](https://app.travis-ci.com/ttscoff/na_gem.svg?branch=main)](https://travis-ci.org/makenew/na_gem)
  5 | [![GitHub license](https://img.shields.io/github/license/ttscoff/na_gem.svg)](./LICENSE.txt)
  6 | 
  7 | **A command line tool for adding and listing per-project todos.**
  8 | 
  9 | _If you're one of the rare people like me who find this useful, feel free to
 10 | [buy me some coffee][donate]._
 11 | 
 12 | The current version of `na` is 1.2.76.
 13 | 
 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 | 
 16 | Used with Taskpaper files, it can add new action items quickly from the command line, automatically tagging them as next actions. It can also mark actions as completed, delete them, archive them, and move them between projects.
 17 | 
 18 | It can also auto-display next actions when you enter a project directory, automatically locating any todo files and listing their next actions when you `cd` to the project (optionally recursive). See the [Prompt Hooks](#prompt-hooks) section for details.
 19 | 
 20 | ### Installation
 21 | 
 22 | Assuming you have Ruby and RubyGems installed, you can just run `gem install na`. If you run into errors, try `gem install --user-install na`, or use `sudo gem install na`.
 23 | 
 24 | If you're using Homebrew, you have the option to install via [brew-gem](https://github.com/sportngin/brew-gem):
 25 | 
 26 |     brew install brew-gem
 27 |     brew gem install na
 28 | 
 29 | If you don't have Ruby/RubyGems, you can install them pretty easily with Homebrew, rvm, or asdf. I can't swear this tool is worth the time, but there _are_ a lot of great gems available...
 30 | 
 31 | 
 32 | 
 33 | ### Optional Dependencies
 34 | 
 35 | 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.
 36 | 
 37 | ### Features
 38 | 
 39 | You can list next actions in files in the current directory by typing `na`. By default, `na` looks for `*.taskpaper` files and extracts items tagged `@na` and not `@done`. This can be modified to work with a single global file, and all of these options can be changed in the configuration.
 40 | 
 41 | #### Easy matching
 42 | 
 43 | `na` features intelligent project matching. Every time it locates a todo file, it adds the project to the database. Once a project is recorded, you can list its actions by using any portion of the parent directories or file names. If your project is in `~/Sites/dev/markedapp`, you could quickly list its next actions by typing `na next dev/mark`. Creat paths by separating with / or :, separate multiple queries with spaces. na will always look for the shortest match for a path.
 44 | 
 45 | #### Recursion
 46 | 
 47 | `na` can also recurse subdirectories to find all todo files in child folders as well. Use the `-d X` to search X levels deep from the current directory. `na -r` with no arguments will recurse from your current location, looking for todo files 3 directories deep.
 48 | 
 49 | #### Adding todos
 50 | 
 51 | You can also quickly add todo items from the command line with the `add` subcommand. The script will look for a file in the current directory with a `.taskpaper` extension (configurable). 
 52 | 
 53 | If found, it will try to locate an `Inbox:` project, or create one if it doesn't exist. Any arguments after `add` will be combined to create a new task in TaskPaper format. They will automatically be assigned as next actions (tagged `@na`) and will show up when `na` lists the tasks for the project.
 54 | 
 55 | #### Updating todos
 56 | 
 57 | You can mark todos as complete, delete them, add and remove tags, change priority, and even move them between projects with the `na update` command.
 58 | 
 59 | ### Terminology
 60 | 
 61 | **Todo**: Refers to a todo file, usually a TaskPaper document
 62 | 
 63 | **Project**: Refers to a project within the TaskPaper document, specified by an alphanumeric name (spaces allowed) followed by a colon. Projects can be nested by indenting a tab beyond the parent projects indentation.
 64 | 
 65 | **Action**: Refers to an individual task, specified by a line starting with a hyphen (`-`)
 66 | 
 67 | **Note**: Refers to lines appearing between action lines that start without hyphens. The note is attached to the preceding action regardless of indentation.
 68 | 
 69 | ### Usage
 70 | 
 71 | ```
 72 | @cli(bundle exec bin/na help)
 73 | ```
 74 | 
 75 | #### Commands
 76 | 
 77 | ##### add
 78 | 
 79 | Example: `na add This feature @idea I have`
 80 | 
 81 | If you run the `add` command with no arguments, you'll be asked for input on the command line.
 82 | 
 83 | ###### Adding notes
 84 | 
 85 | Use the `--note` switch to add a note. If STDIN (piped) input is present when this switch is used, it will be included in the note. A prompt will be displayed for adding additional notes, which will be appended to any STDIN note passed. Press CTRL-d to end editing and save the note. 
 86 | 
 87 | Notes are not displayed by the `next/tagged/find` commands unless `--notes` is specified.
 88 | 
 89 | ```
 90 | @cli(bundle exec bin/na help add)
 91 | ```
 92 | 
 93 | ##### edit
 94 | 
 95 | ```
 96 | @cli(bundle exec bin/na help edit)
 97 | ```
 98 | 
 99 | ##### find
100 | 
101 | Example: `na find cool feature idea`
102 | 
103 | Unless `--exact` is specified, search is tokenized and combined with AND, so `na find cool feature idea` translates to `cool AND feature AND idea`, matching any string that contains all of the words. To make a token required and others optional, add a `+` before it (e.g. `cool +feature idea` is `(cool OR idea) AND feature`). Wildcards allowed (`*` and `?`), use `--regex` to interpret the search as a regular expression. Use `-v` to invert the results (display non-matching actions only).
104 | 
105 | ```
106 | @cli(bundle exec bin/na help find)
107 | ```
108 | 
109 | ##### init, create
110 | 
111 | ```
112 | @cli(bundle exec bin/na help init)
113 | ```
114 | 
115 | ##### move
116 | 
117 | Move an action between projects. Argument is a search term, if left blank a prompt will allow you to enter terms. If no `--to` project is specified, a menu will be shown of projects in the target file.
118 | 
119 | Examples:
120 | 
121 | - `na move` (enter a search term, select a file/destination)
122 | - `na move "Bug description"` (find matching action and show a menu of project destinations)
123 | - `na move "Bug description" --to Bugs (move matching action to Bugs project)
124 | 
125 | ```
126 | @cli(bundle exec bin/na help move)
127 | ```
128 | 
129 | ##### next, show
130 | 
131 | Examples:
132 | 
133 | - `na next` (list all next actions in the current directory)
134 | - `na next -d 3` (list all next actions in the current directory and look for additional files 3 levels deep from there)
135 | - `na next marked2` (show next actions from another directory you've previously used na on)
136 | 
137 | To see all next actions across all known todos, use `na next "*"`. You can combine multiple arguments to see actions across multiple todos, e.g. `na next marked nvultra`.
138 | 
139 | ```
140 | @cli(bundle exec bin/na help next)
141 | ```
142 | 
143 | ##### projects
144 | 
145 | 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.
146 | 
147 | ```
148 | @cli(bundle exec bin/na help projects)
149 | ```
150 | 
151 | ##### saved
152 | 
153 | 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.
154 | 
155 | 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).
156 | 
157 | Run `na saved` without an argument to list your saved searches.
158 | 
159 | > As a shortcut, if `na` is run with one argument that matches the name of a saved search, it will execute that search, so running `na maybe` is the same as running `na saved maybe`.
160 | 
161 | 
162 | ```
163 | @cli(bundle exec bin/na help saved)
164 | ```
165 | 
166 | ##### tagged
167 | 
168 | Example: `na tagged feature +maybe`.
169 | 
170 | 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.
171 | 
172 | 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 You can see all available global options by running `na help`.
275 | 
276 | 
277 | Example: `na --ext md --na_tag next initconfig --force`
278 | 
279 | When this command is run, it doesn't include options for subcommands, but inserts placeholders for them. If you want to permanently set an option for a subcommand, you'll need to edit `~/.na.rc`. For example, if you wanted the `next` command to always recurse 2 levels deep, you could edit it to look like this:
280 | 
281 | ```yaml
282 | ---
283 | :ext: taskpaper
284 | :na_tag: na
285 | :d: 1
286 | commands:
287 |   :next:
288 |     :depth: 2
289 |   :add: {}
290 |   :find: {}
291 |   :tagged: {}
292 | ```
293 | 
294 | Note that I created a new YAML dictionary inside of the `:next:` command, and added a `:depth:` key that matches the setting I want to make permanent.
295 | 
296 | > **WARNING** Don't touch most of the settings at the top of the auto-generated file. Setting any of them to true will alter the way na interprets the commands you're running. Most of those options are there for backwards compatibility with the bash version of this tool and will eventually be removed.
297 | 
298 | 
299 | #### Working with a single global file
300 | 
301 | na is designed to work with one or more TaskPaper files in each project directory, but if you prefer to use a single global TaskPaper file, you can add `--file PATH` as a global option and specify a single file. This will bypass the detection of any files in the current directory. Make it permanent by including the `--file` flag when running `initconfig`.
302 | 
303 | When using a global file, you can additionally include `--cwd_as TYPE` to determine whether the current working directory is used as a tag or a project (default is neither). If you add `--cwd_as tag` to the global options (before the command), the last element of the current working directory will be appended as an @tag (e.g. if you're in ~/Code/project/doing, the action would be tagged @doing). If you use `--cwd_as project` the action will be put into a project with the same name as the current directory (e.g. `Doing:` from the previous example).
304 | 
305 | #### Add tasks at the end of a project
306 | 
307 | By default, tasks are added at the top of the target project (Inbox, etc.). If you prefer new tasks to go at the end of the project by default, include `--add_at end` as a global option when running `initconfig`.
308 | 
309 | ### Prompt Hooks
310 | 
311 | You can add a prompt command to your shell to have na automatically list your next actions when you `cd` into a directory. To install a prompt command for your current shell, just run `na prompt install`. It works with Zsh, Bash, and Fish. If you'd rather make the changes to your startup file yourself, run `na prompt show` to get the hook and instructions printed out for copying.
312 | 
313 | If you're using a single global file, you'll need `--cwd_as` to be `tag` or `project` for a prompt command to work. na will detect which system you're using and provide a prompt command that lists actions based on the current directory using either project or tag.
314 | 
315 | > You can also get output for shells other than the one you're currently using by adding "bash", "zsh", or "fish" to the show or install command.
316 | 
317 | 
318 | > You can add `-r` to any of the calls to na to automatically recurse 3 directories deep, or just set the depth config permanently
319 | 
320 | 
321 | After installing a hook, you'll need to close your terminal and start a new session to initialize the new commands.
322 | 
323 | 
324 | [fzf]: https://github.com/junegunn/fzf
325 | [gum]: https://github.com/charmbracelet/gum
326 | [donate]: http://brettterpstra.com/donate/
327 | [github]: https://github.com/ttscoff/na_gem/
328 | 
329 | 
330 | PayPal link: [paypal.me/ttscoff](https://paypal.me/ttscoff)
331 | 
332 | ## Changelog
333 | 
334 | See [CHANGELOG.md](https://github.com/ttscoff/na_gem/blob/master/CHANGELOG.md)
335 | 
336 | 


--------------------------------------------------------------------------------
/test.md:
--------------------------------------------------------------------------------
 1 | ---
 2 | comment: 2023-09-03
 3 | keywords:
 4 | ---
 5 | Other New Project:
 6 | 	- testing @na @butter
 7 | Brand New Project:
 8 | 	- testing @na
 9 | 		A multi line (multiline) note
10 | 		with a line break
11 | 	- testing @na
12 | Project0:
13 | 
14 | - Test1
15 | 
16 | - Test2
17 | 
18 | Project1:
19 | - Test4
20 | - Test5
21 | - Test6
22 | 


--------------------------------------------------------------------------------
/test/default_test.rb:
--------------------------------------------------------------------------------
 1 | require_relative "test_helper"
 2 | 
 3 | class DefaultTest < Minitest::Test
 4 |   def setup
 5 |     create_temp_files
 6 |   end
 7 | 
 8 |   def teardown
 9 |     clean_up_temp_files
10 |   end
11 | 
12 |   # def test_add
13 |   #   NA.add_action('test.taskpaper', 'Inbox', 'Test Action @testing', [], finish: false, append: false)
14 |   #   files, actions, = NA.parse_actions(depth: 1,
15 |   #                                      done: false,
16 |   #                                      query: [],
17 |   #                                      tag: [{ tag: 'testing', value: nil }],
18 |   #                                      search: [],
19 |   #                                      project: 'Inbox',
20 |   #                                      require_na: false)
21 | 
22 |   #   assert actions.count == 1
23 |   # end
24 | 
25 |   # def test_update
26 |   #   NA.add_action('test.taskpaper', 'Inbox', 'Test Action @testing')
27 | 
28 |   #   tags = []
29 |   #   all_req = true
30 |   #   ['testing'].join(',').split(/ *, */).each do |arg|
31 |   #     m = arg.match(/^(?[+\-!])?(?[^ =<>$\^]+?)(?:(?[=<>]{1,2}|[*$\^]=)(?.*?))?$/)
32 | 
33 |   #     tags.push({
34 |   #                 tag: m['tag'].wildcard_to_rx,
35 |   #                 comp: m['op'],
36 |   #                 value: m['val'],
37 |   #                 required: all_req || (!m['req'].nil? && m['req'] == '+'),
38 |   #                 negate: !m['req'].nil? && m['req'] =~ /[!\-]/
39 |   #               })
40 |   #   end
41 | 
42 |   #   NA.update_action('test.taskpaper', nil,
43 |   #                    priority: 5,
44 |   #                    add_tag: ['testing2'],
45 |   #                    remove_tag: ['testing'],
46 |   #                    finish: false,
47 |   #                    project: nil,
48 |   #                    delete: false,
49 |   #                    note: [],
50 |   #                    overwrite: false,
51 |   #                    tagged: tags,
52 |   #                    all: true,
53 |   #                    done: true,
54 |   #                    append: false)
55 | 
56 |   #   files, actions, = NA.parse_actions(file_path: 'test.taskpaper',
57 |   #                                      done: false,
58 |   #                                      tag: [{ tag: 'testing2', value: nil }],
59 |   #                                      project: 'Inbox')
60 |   #   assert actions.count == 1
61 |   # end
62 | end
63 | 


--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
 1 | require "minitest/autorun"
 2 | $LOAD_PATH.unshift File.join(__dir__, '..', 'lib')
 3 | require 'na'
 4 | require 'fileutils'
 5 | # Add test libraries you want to use here, e.g. mocha
 6 | # Add helper classes or methods here, too
 7 | 
 8 | def create_temp_files
 9 |   NA.create_todo('test.taskpaper', 'test')
10 |   NA.create_todo('test2.taskpaper', 'test2')
11 | end
12 | 
13 | def clean_up_temp_files
14 |   FileUtils.rm('test.taskpaper')
15 |   FileUtils.rm('test2.taskpaper')
16 | end
17 | 


--------------------------------------------------------------------------------
/test2.txt:
--------------------------------------------------------------------------------
1 | ---
2 | comment: 2023
3 | keywords:
4 | ---
5 | 
6 | Inbox:
7 | 	- Test @na
8 | 


--------------------------------------------------------------------------------