├── .cursor └── commands │ ├── priority35m36m335m32m.md │ └── changelog.md ├── scripts ├── runtests.sh ├── fixreadme.rb └── generate-fish-completions.rb ├── test2.txt ├── .github └── FUNDING.yml ├── README.rdoc ├── .gitignore ├── lib ├── na │ ├── version.rb │ ├── array.rb │ ├── project.rb │ ├── help_monkey_patch.rb │ ├── hash.rb │ ├── benchmark.rb │ ├── theme.rb │ ├── pager.rb │ ├── prompt.rb │ ├── types.rb │ ├── editor.rb │ └── actions.rb └── na.rb ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── test.md ├── na ├── test.md └── Test.todo.markdown ├── docker ├── Dockerfile ├── bash_profile ├── Dockerfile-2.6 ├── Dockerfile-2.7 ├── Dockerfile-3.0 ├── Dockerfile-3.3 ├── sources.list └── inputrc ├── bin ├── commands │ ├── changes.rb │ ├── undo.rb │ ├── init.rb │ ├── projects.rb │ ├── todos.rb │ ├── open.rb │ ├── prompt.rb │ ├── restore.rb │ ├── archive.rb │ ├── complete.rb │ ├── scan.rb │ ├── saved.rb │ ├── completed.rb │ ├── edit.rb │ ├── tag.rb │ ├── move.rb │ └── add.rb └── na ├── Test.todo.markdown ├── test ├── test_helper.rb ├── hash_test.rb ├── array_test.rb ├── filename_indicator_test.rb ├── depth_test.rb ├── string_test.rb ├── actions_test.rb ├── item_path_test.rb ├── plugin_flow_test.rb ├── default_test.rb ├── action_test.rb ├── time_output_test.rb ├── colors_test.rb ├── todo_test.rb ├── time_features_test.rb ├── taskpaper_search_item_path_test.rb ├── prompt_test.rb ├── plugins_test.rb ├── time_tracking_test.rb └── editor_test.rb ├── LICENSE.txt ├── test_performance.rb ├── na.gemspec ├── na.rdoc ├── Gemfile.lock ├── plugins.md ├── .rubocop_todo.yml └── Rakefile /.cursor/commands/priority35m36m335m32m.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /planter 4 | bundle update 5 | rake test 6 | -------------------------------------------------------------------------------- /test2.txt: -------------------------------------------------------------------------------- 1 | --- 2 | comment: 2023 3 | keywords: 4 | --- 5 | 6 | Inbox: 7 | - Test @na 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ttscoff] 2 | custom: ['https://brettterpstra.com/support/', 'https://brettterpstra.com/donate/'] 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | html/ 2 | *.bak 3 | na*.gem 4 | *~ 5 | .*~ 6 | .vscode 7 | time-tracking.md 8 | line-tracking.md 9 | *.plan.md 10 | commit_message.txt 11 | *.taskpaper 12 | 2025-10-29-one-more-na-update.md 13 | -------------------------------------------------------------------------------- /lib/na/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Main module for the na gem, providing version information. 5 | module Na 6 | ## 7 | # Current version of the na gem. 8 | VERSION = '1.2.95' 9 | end 10 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | NewCops: enable 5 | Include: 6 | - 'lib/na/**/*' 7 | Exclude: 8 | - 'pkg/**/*' 9 | - 'test/**/*' 10 | - 'bin/**/*' 11 | -------------------------------------------------------------------------------- /.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 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | gemspec 5 | gem 'rake' 6 | 7 | gem 'simplecov', '~> 0.22.0', group: :development 8 | 9 | group :development do 10 | gem 'rubocop', '~> 1.66' 11 | gem 'rubocop-performance', '~> 1.21' 12 | end 13 | gem 'chronic' 14 | gem 'csv' 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /na/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /na/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 | Subproject: 11 | - Bollocks @na 12 | Subsub: 13 | - Hey, I think it's all working @na 14 | - Is this at the end? @na 15 | - This better work @na 16 | 2023-09-08: 17 | Project2: 18 | - new_task @na 19 | - new_task @na 20 | - test task @na 21 | Project0: 22 | - other task @na 23 | - other task @na 24 | - There, that's better @na 25 | Subproject: 26 | - new_task 2 @na 27 | - new_task @na 28 | - new_task 2 @na 29 | Project1: 30 | - Test4 31 | - Test5 32 | - Test6 33 | -------------------------------------------------------------------------------- /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 | 'glow', 13 | 'bat', 14 | ENV['PAGER'], 15 | 'less -FXr', 16 | ENV['GIT_PAGER'], 17 | 'more -r' 18 | ] 19 | pager = pagers.find { |cmd| TTY::Which.exist?(cmd.split.first) } 20 | system %(#{pager} "#{changelog}") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter '/test/' 4 | add_group 'Library', 'lib/na' 5 | end 6 | 7 | require "minitest/autorun" 8 | $LOAD_PATH.unshift File.join(__dir__, '..', 'lib') 9 | require 'na' 10 | require 'fileutils' 11 | # Add test libraries you want to use here, e.g. mocha 12 | # Add helper classes or methods here, too 13 | 14 | def create_temp_files 15 | NA.extension = 'taskpaper' 16 | NA.create_todo('test.taskpaper', 'test') 17 | NA.create_todo('test2.taskpaper', 'test2') 18 | end 19 | 20 | def clean_up_temp_files 21 | FileUtils.rm('test.taskpaper') if File.exist?('test.taskpaper') 22 | FileUtils.rm('test2.taskpaper') if File.exist?('test2.taskpaper') 23 | # Also clean up any remaining files from previous tests 24 | Dir.glob('*.taskpaper').each { |f| FileUtils.rm(f) if File.exist?(f) } 25 | end 26 | -------------------------------------------------------------------------------- /test/hash_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "na/hash" 3 | 4 | class HashExtTest < Minitest::Test 5 | def test_symbolize_keys_recursively 6 | h = { "a" => 1, "b" => { "c" => 2 } } 7 | result = h.symbolize_keys 8 | assert_equal({ a: 1, b: { c: 2 } }, result) 9 | end 10 | 11 | def test_deep_freeze_and_thaw 12 | h = { a: { b: [1, 2] }, c: "str" } 13 | frozen = h.deep_freeze 14 | assert frozen.frozen? 15 | assert frozen[:a].frozen? 16 | assert frozen[:c].frozen? 17 | thawed = frozen.deep_thaw 18 | refute thawed.frozen? 19 | refute thawed[:a].frozen? 20 | refute thawed[:c].frozen? 21 | end 22 | 23 | def test_deep_merge_combines_hashes_and_arrays 24 | h1 = { a: { b: [1, 2] }, c: 1 } 25 | h2 = { a: { b: [2, 3] }, c: 2, d: 3 } 26 | merged = h1.deep_merge(h2) 27 | assert_equal [1, 2, 3], merged[:a][:b] 28 | assert_equal 2, merged[:c] 29 | assert_equal 3, merged[:d] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/na.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'na/benchmark' if ENV['NA_BENCHMARK'] 4 | # Define a dummy Benchmark if not available for tests 5 | unless defined?(NA::Benchmark) 6 | module NA 7 | module Benchmark 8 | def self.measure(_label) 9 | yield 10 | end 11 | end 12 | end 13 | end 14 | require 'na/version' 15 | require 'na/pager' 16 | require 'time' 17 | require 'fileutils' 18 | require 'shellwords' 19 | # Lazy load heavy gems - only load when needed 20 | # require 'chronic' # Loaded in action.rb and string.rb when needed 21 | require 'tty-screen' 22 | require 'tty-reader' 23 | require 'tty-which' 24 | require 'na/hash' 25 | require 'na/colors' 26 | require 'na/string' 27 | require 'na/array' 28 | require 'yaml' 29 | require 'na/theme' 30 | require 'na/todo' 31 | require 'na/actions' 32 | require 'na/project' 33 | require 'na/action' 34 | require 'na/types' 35 | require 'na/editor' 36 | require 'na/next_action' 37 | require 'na/prompt' 38 | require 'na/plugins' 39 | -------------------------------------------------------------------------------- /test/array_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "na/array" 3 | 4 | class ArrayExtTest < Minitest::Test 5 | def test_remove_bad_removes_nil_and_false_and_empty 6 | arr = [nil, "", " ", false, 0, -1, "good", " ok ", 42] 7 | # Patch good? for non-String types to avoid NoMethodError 8 | [FalseClass, Integer, NilClass].each { |cls| cls.class_eval { def good?; false; end } } 9 | result = arr.remove_bad 10 | assert_includes result, "good" 11 | assert_includes result, "ok" 12 | refute_includes result, 42 13 | refute_includes result, nil 14 | refute_includes result, "" 15 | refute_includes result, false 16 | refute_includes result, 0 17 | refute_includes result, -1 18 | end 19 | 20 | def test_wrap_returns_wrapped_and_colorized 21 | arr = ["This is a long line that should wrap nicely.", "Short line."] 22 | result = arr.wrap(50, 2, "[color]") 23 | assert result.is_a?(Array) || result.is_a?(String) 24 | assert result.first.include?("[color]") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.cursor/commands/changelog.md: -------------------------------------------------------------------------------- 1 | Review all staged and unstaged files in the repo. Write a commmit message that uses @ labels to specify what type of change each line is. Apply @new, @fixed, @changed, @improved, and @breaking as appropriate to each line. Only add @ labels to changes that affect the user, not technical details. Technical details can be included in the commit, just don't add @ labels to those lines. Be sure to include a general description (< 60 characters) as the first line, followed by a line break. 2 | 3 | Do not add @tags to notes about documentation updates. Always focus on actual code changes we've made since the last commit when generating the commit message. 4 | 5 | Always use straight quotes and ascii punctuation, never curl quotes. Don't use emoji. 6 | 7 | Always include a blank line after the first line (commit message) before the note. 8 | 9 | Save this commit message to commit_message.txt. Overwrite existing contents. 10 | 11 | Save this commit message to commit_message.txt{% if args.reset or args.replace %}. Overwrite existing contents.{% else %}. Update the file, merging changes, if file exists, otherwise create new.{% endif %} -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/filename_indicator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class FilenameIndicatorTest < Minitest::Test 6 | SUBDIR = 'sub' 7 | 8 | def setup 9 | create_temp_files 10 | FileUtils.mkdir_p(SUBDIR) 11 | NA.create_todo(File.join(SUBDIR, 'sub.taskpaper'), 'Sub') 12 | end 13 | 14 | def teardown 15 | clean_up_temp_files 16 | FileUtils.rm_rf(SUBDIR) 17 | end 18 | 19 | def build_action(path) 20 | NA::Action.new(path, File.basename(path, ".#{NA.extension}"), ['Proj'], 'Do it', 1) 21 | end 22 | 23 | def test_cwd_indicator_hidden_when_only_root_files 24 | NA.show_cwd_indicator = false 25 | a = build_action('test.taskpaper') 26 | s = a.pretty(template: { templates: { output: '%filename%action' } }, detect_width: false) 27 | refute_match(%r{\./}, s, 'should not include ./ when flag is false and file in cwd') 28 | end 29 | 30 | def test_cwd_indicator_shown_when_subdir_present 31 | NA.show_cwd_indicator = true 32 | a = build_action('test.taskpaper') 33 | s = a.pretty(template: { templates: { output: '%filename%action' } }, detect_width: false) 34 | assert_match(%r{\./}, s, 'should include ./ when flag is true') 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/na/array.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Extensions to Ruby's Array class for todo management and formatting. 5 | # 6 | # @example Remove bad elements from an array 7 | # ['foo', '', nil, 0, false, 'bar'].remove_bad #=> ['foo', 'bar'] 8 | class ::Array 9 | # Like Array#compact -- removes nil items, but also 10 | # removes empty strings, zero or negative numbers and FalseClass items 11 | # 12 | # @return [Array] Array without "bad" elements 13 | # @example 14 | # ['foo', '', nil, 0, false, 'bar'].remove_bad #=> ['foo', 'bar'] 15 | def remove_bad 16 | compact.map { |x| x.is_a?(String) ? x.strip : x }.select(&:good?) 17 | end 18 | 19 | # Wrap each string in the array to the given width and indent, with color 20 | # 21 | # @param width [Integer] Maximum line width 22 | # @param indent [Integer] Indentation spaces 23 | # @param color [String] Color code to apply 24 | # @return [Array, String] Wrapped and colorized lines 25 | # @example 26 | # ['foo', 'bar'].wrap(80, 2, '{g}') #=> "\n{g} • foo{x}\n{g} • bar{x}" 27 | def wrap(width, indent, color) 28 | return map { |l| "#{color} #{l.wrap(width, 2)}" } if width < 60 29 | 30 | map! do |l| 31 | "#{color}#{' ' * indent}• #{l.wrap(width, indent)}{x}" 32 | end 33 | "\n#{join("\n")}" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/na/project.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NA 4 | # Represents a project section in a todo file, with indentation and line tracking. 5 | # 6 | # @example Create a new project 7 | # project = NA::Project.new('Inbox', 0, 1, 5) 8 | class Project < Hash 9 | attr_accessor :project, :indent, :line, :last_line 10 | 11 | # Initialize a Project object 12 | # 13 | # @param project [String] Project name 14 | # @param indent [Integer] Indentation level 15 | # @param line [Integer] Starting line number 16 | # @param last_line [Integer] Ending line number 17 | # @return [void] 18 | # @example 19 | # project = NA::Project.new('Inbox', 0, 1, 5) 20 | def initialize(project, indent = 0, line = 0, last_line = 0) 21 | super() 22 | @project = project 23 | @indent = indent 24 | @line = line 25 | @last_line = last_line 26 | end 27 | 28 | # String representation of the project 29 | # 30 | # @return [String] 31 | # @example 32 | # project.to_s #=> "{ project: 'Inbox', ... }" 33 | def to_s 34 | { project: @project, indent: @indent, line: @line, last_line: @last_line }.to_s 35 | end 36 | 37 | # Inspect the project object 38 | # 39 | # @return [String] 40 | def inspect 41 | [ 42 | "@project: #{@project}", 43 | "@indent: #{@indent}", 44 | "@line: #{@line}", 45 | "@last_line: #{@last_line}" 46 | ].join(' ') 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/depth_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'test_helper' 4 | 5 | class DepthTest < Minitest::Test 6 | SUBDIR = 'test_subdir' 7 | HIDDEN_SUBDIR = '.hidden_subdir' 8 | FILE = File.join(SUBDIR, 'test.taskpaper') 9 | HIDDEN_FILE = File.join(HIDDEN_SUBDIR, 'hidden.taskpaper') 10 | 11 | def setup 12 | create_temp_files 13 | FileUtils.mkdir_p(SUBDIR) 14 | NA.create_todo(FILE, 'DepthTest') 15 | FileUtils.mkdir_p(HIDDEN_SUBDIR) 16 | NA.create_todo(HIDDEN_FILE, 'HiddenDepthTest') 17 | end 18 | 19 | def teardown 20 | clean_up_temp_files 21 | FileUtils.rm_rf(SUBDIR) if Dir.exist?(SUBDIR) 22 | FileUtils.rm_rf(HIDDEN_SUBDIR) if Dir.exist?(HIDDEN_SUBDIR) 23 | end 24 | 25 | def test_find_files_depth_1_does_not_include_subdir 26 | files = NA.find_files(depth: 1) 27 | assert files.include?('test.taskpaper'), 'root file should be present at depth 1' 28 | refute files.include?(FILE), 'subdir file should not be present at depth 1' 29 | end 30 | 31 | def test_find_files_depth_3_includes_subdir 32 | files = NA.find_files(depth: 3) 33 | assert files.include?(FILE), 'subdir file should be found when depth>=2' 34 | end 35 | 36 | def test_hidden_dirs_excluded_by_default 37 | files = NA.find_files(depth: 3) 38 | refute files.include?(HIDDEN_FILE), 'hidden subdir file should not be present by default' 39 | end 40 | 41 | def test_hidden_dirs_included_when_requested 42 | files = NA.find_files(depth: 3, include_hidden: true) 43 | assert files.include?(HIDDEN_FILE), 'hidden subdir file should be present when include_hidden is true' 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/string_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "na/string" 3 | 4 | class StringExtTest < Minitest::Test 5 | def test_wrap_splits_long_lines_and_indents 6 | s = "This is a very long line that should wrap at a certain width and indent. " * 5 7 | wrapped = s.wrap(50, 2) 8 | 9 | # Should contain line breaks and indentation 10 | # Only lines after the first are indented 11 | assert wrapped.lines[1..].any? { |line| line =~ /^ / }, 'should have indented lines after wrapping (after first line)' 12 | assert wrapped.lines.count > 1, 'should wrap to multiple lines' 13 | # Should preserve all words 14 | s.split.each { |word| assert_match(/#{word}/, wrapped) } 15 | end 16 | def test_comment_prepends_hash 17 | s = "line1\nline2" 18 | commented = s.comment 19 | assert_match(/^# line1/, commented) 20 | assert_match(/^# line2/, commented) 21 | end 22 | 23 | def test_good_returns_true_for_nonempty 24 | assert "ok".good? 25 | refute " ".good? 26 | refute "".good? 27 | end 28 | 29 | def test_ignore_returns_true_for_comments_and_blank 30 | assert "# comment".ignore? 31 | assert " ".ignore? 32 | refute "not ignored".ignore? 33 | end 34 | 35 | def test_indent_level_detects_tabs_and_spaces 36 | assert_equal 0, "noindent".indent_level 37 | assert_equal 1, "\tindented".indent_level 38 | assert_equal 2, " \tmore".indent_level 39 | end 40 | 41 | def test_action_and_blank 42 | assert " - do something".action? 43 | assert " ".blank? 44 | refute "not blank".blank? 45 | end 46 | 47 | def test_highlight_filename_with_nil 48 | assert_equal '', nil.highlight_filename 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/actions_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require 'ostruct' 3 | require 'na/actions' 4 | 5 | class ActionsTest < Minitest::Test 6 | def test_initialize_empty 7 | actions = NA::Actions.new 8 | assert_instance_of NA::Actions, actions 9 | assert_equal 0, actions.size 10 | end 11 | 12 | def test_initialize_with_array 13 | dummy_action = OpenStruct.new(file: "file.taskpaper", parent: ["Inbox"], action: "Test Action", note: []) 14 | def dummy_action.pretty(*); "pretty output"; end 15 | actions = NA::Actions.new([dummy_action]) 16 | assert_equal 1, actions.size 17 | assert_equal dummy_action, actions.first 18 | end 19 | 20 | def test_output_returns_nil_with_no_files 21 | actions = NA::Actions.new 22 | assert_nil actions.output(1, {}) 23 | end 24 | 25 | def test_output_with_nest_projects 26 | dummy_action = OpenStruct.new(file: "file.taskpaper", parent: ["Inbox"], action: "Test Action", note: []) 27 | def dummy_action.pretty(*); "pretty output"; end 28 | actions = NA::Actions.new([dummy_action]) 29 | config = { files: ["file.taskpaper"], nest: true, nest_projects: true } 30 | # Should not raise error, returns nil (Pager.page is stubbed in real usage) 31 | assert_nil actions.output(1, config) 32 | end 33 | 34 | def test_output_with_notes 35 | dummy_action = OpenStruct.new(file: "file.taskpaper", parent: ["Inbox"], action: "Test Action", note: ["A note"]) 36 | def dummy_action.pretty(*); "pretty output"; end 37 | actions = NA::Actions.new([dummy_action]) 38 | config = { files: ["file.taskpaper"], notes: true } 39 | # Should not raise error, returns nil (Pager.page is stubbed in real usage) 40 | assert_nil actions.output(1, config) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /test_performance.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Simple performance test script that doesn't require bundler 5 | require_relative 'lib/na/benchmark' 6 | 7 | # Mock the required dependencies 8 | module NA 9 | module Color 10 | def self.template(input) 11 | input.to_s # Simple mock 12 | end 13 | end 14 | 15 | module Theme 16 | def self.load_theme 17 | { 18 | parent: '{c}', 19 | bracket: '{dc}', 20 | parent_divider: '{xw}/', 21 | action: '{bg}', 22 | project: '{xbk}', 23 | templates: { 24 | output: '%parent%action', 25 | default: '%parent%action' 26 | } 27 | } 28 | end 29 | end 30 | 31 | def self.theme 32 | @theme ||= Theme.load_theme 33 | end 34 | 35 | def self.notify(msg, debug: false) 36 | puts msg if debug 37 | end 38 | end 39 | 40 | # Initialize benchmark 41 | NA::Benchmark.init 42 | 43 | # Test the optimizations 44 | puts 'Testing performance optimizations...' 45 | 46 | # Test 1: Theme caching 47 | NA::Benchmark.measure('Theme loading (first time)') do 48 | NA::Theme.load_theme 49 | end 50 | 51 | NA::Benchmark.measure('Theme loading (cached)') do 52 | NA.theme 53 | end 54 | 55 | # Test 2: Color template caching 56 | NA::Benchmark.measure('Color template (first time)') do 57 | NA::Color.template('{bg}Test action{x}') 58 | end 59 | 60 | NA::Benchmark.measure('Color template (cached)') do 61 | NA::Color.template('{bg}Test action{x}') 62 | end 63 | 64 | # Test 3: Multiple operations 65 | NA::Benchmark.measure('Multiple theme calls') do 66 | 100.times do 67 | NA.theme 68 | end 69 | end 70 | 71 | NA::Benchmark.measure('Multiple color templates') do 72 | 100.times do 73 | NA::Color.template("{bg}Action {c}#{rand(1000)}{x}") 74 | end 75 | end 76 | 77 | # Report results 78 | NA::Benchmark.report 79 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/na/help_monkey_patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Monkeypatches for GLI CLI framework to support paginated help output. 5 | # 6 | # @example Show help for a command 7 | # GLI::Commands::Help.new.show_help({}, {}, [], $stdout, $stderr) 8 | module GLI 9 | ## 10 | # Command extensions for GLI CLI framework. 11 | module Commands 12 | # Help Command Monkeypatch for paginated output 13 | class Help < Command 14 | # Show help output for GLI commands with paginated output 15 | # 16 | # @param _global_options [Hash] Global CLI options 17 | # @param options [Hash] Command-specific options 18 | # @param arguments [Array] Command arguments 19 | # @param out [IO] Output stream 20 | # @param error [IO] Error stream 21 | # @return [void] 22 | # @example 23 | # GLI::Commands::Help.new.show_help({}, {}, [], $stdout, $stderr) 24 | def show_help(_global_options, options, arguments, out, error) 25 | NA::Pager.paginate = true 26 | 27 | command_finder = HelpModules::CommandFinder.new(@app, arguments, error) 28 | if options[:c] 29 | help_output = HelpModules::HelpCompletionFormat.new(@app, command_finder, arguments).format 30 | out.puts help_output unless help_output.nil? 31 | elsif arguments.empty? || options[:c] 32 | NA::Pager.page HelpModules::GlobalHelpFormat.new(@app, @sorter, @text_wrapping_class).format 33 | else 34 | name = arguments.shift 35 | command = command_finder.find_command(name) 36 | unless command.nil? 37 | NA::Pager.page HelpModules::CommandHelpFormat.new( 38 | command, 39 | @app, 40 | @sorter, 41 | @synopsis_formatter_class, 42 | @text_wrapping_class 43 | ).format 44 | end 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /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('csv', '>= 3.2') 28 | s.add_runtime_dependency('git', '~> 3.0.0') 29 | s.add_runtime_dependency('gli','~> 2.21.0') 30 | s.add_runtime_dependency('mdless', '~> 1.0', '>= 1.0.32') 31 | s.add_runtime_dependency('ostruct', '~> 0.6', '>= 0.6.1') 32 | s.add_runtime_dependency('tty-reader', '~> 0.9', '>= 0.9.0') 33 | s.add_runtime_dependency('tty-screen', '~> 0.8', '>= 0.8.1') 34 | s.add_runtime_dependency('tty-which', '~> 0.5', '>= 0.5.0') 35 | s.add_development_dependency('tty-spinner', '~> 0.9', '>= 0.9.0') 36 | s.add_development_dependency 'rspec', '~> 3.0' 37 | s.add_development_dependency 'bump', '~> 0.6.0' 38 | end 39 | -------------------------------------------------------------------------------- /test/item_path_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'test_helper' 4 | require 'na/next_action' 5 | require 'ostruct' 6 | 7 | class ItemPathTest < Minitest::Test 8 | def test_parse_item_path_simple_child 9 | steps = NA.parse_item_path('/Inbox/New Videos') 10 | assert_equal 2, steps.length 11 | assert_equal :child, steps[0][:axis] 12 | assert_equal 'Inbox', steps[0][:text] 13 | assert_equal :child, steps[1][:axis] 14 | assert_equal 'New Videos', steps[1][:text] 15 | end 16 | 17 | def test_resolve_item_path_child 18 | projects = [ 19 | NA::Project.new('Inbox', 0, 0, 10), 20 | NA::Project.new('Inbox:New Videos', 1, 1, 3), 21 | NA::Project.new('Inbox:Bugs', 1, 4, 6), 22 | NA::Project.new('Archive', 0, 11, 15) 23 | ] 24 | 25 | steps = NA.parse_item_path('/Inbox/New Videos') 26 | result_projects = NA.resolve_path_in_projects(projects, steps) 27 | paths = result_projects.map(&:project) 28 | assert_includes paths, 'Inbox:New Videos' 29 | end 30 | 31 | def test_resolve_item_path_descendant 32 | projects = [ 33 | NA::Project.new('Inbox', 0, 0, 10), 34 | NA::Project.new('Inbox:New Videos', 1, 1, 3), 35 | NA::Project.new('Inbox:Bugs', 1, 4, 6), 36 | NA::Project.new('Archive', 0, 11, 15) 37 | ] 38 | 39 | steps = NA.parse_item_path('/Inbox//Bugs') 40 | result_projects = NA.resolve_path_in_projects(projects, steps) 41 | 42 | paths = result_projects.map(&:project) 43 | assert_includes paths, 'Inbox:Bugs' 44 | end 45 | 46 | def test_resolve_item_path_wildcard_root 47 | projects = [ 48 | NA::Project.new('Inbox', 0, 0, 10), 49 | NA::Project.new('Inbox:New Videos', 1, 1, 3), 50 | NA::Project.new('Archive', 0, 11, 15) 51 | ] 52 | 53 | steps = NA.parse_item_path('/*') 54 | result_projects = NA.resolve_path_in_projects(projects, steps) 55 | paths = result_projects.map(&:project) 56 | assert_includes paths, 'Inbox' 57 | assert_includes paths, 'Archive' 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/plugin_flow_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'tmpdir' 5 | require_relative 'test_helper' 6 | require_relative '../lib/na' 7 | 8 | class PluginFlowTest < Minitest::Test 9 | def with_temp_todo 10 | Dir.mktmpdir do |dir| 11 | file = File.join(dir, 'todo.taskpaper') 12 | File.write(file, <<~TP) 13 | Inbox: 14 | - Do a thing @na 15 | TP 16 | # Ensure NA defaults for tests 17 | NA.extension = 'taskpaper' 18 | NA.na_tag = 'na' 19 | yield file 20 | end 21 | end 22 | 23 | def test_update_action_in_place 24 | with_temp_todo do |file| 25 | todo = NA::Todo.new(file_path: file, require_na: false) 26 | action = todo.actions.find { |a| a.na? } || todo.actions.first 27 | refute_nil action 28 | # Build plugin return to add @foo tag 29 | io = { 30 | 'file_path' => action.file_path, 31 | 'line' => action.file_line, 32 | 'parents' => [action.project] + action.parent, 33 | 'text' => (action.action + ' @foo'), 34 | 'note' => '', 35 | 'tags' => [{ 'name' => 'foo', 'value' => '' }], 36 | 'action' => { 'action' => 'UPDATE', 'arguments' => [] } 37 | } 38 | NA.apply_plugin_result(io) 39 | content = File.read(file) 40 | assert_match(/@foo/, content) 41 | # Ensure not duplicated action line 42 | assert_equal 1, content.lines.grep(/- Do a thing/).size 43 | end 44 | end 45 | 46 | def test_move_action_when_parents_change 47 | with_temp_todo do |file| 48 | todo = NA::Todo.new(file_path: file, require_na: false) 49 | action = todo.actions.find { |a| a.na? } || todo.actions.first 50 | new_parents = ['Inbox', 'Moved'] 51 | io = { 52 | 'file_path' => action.file_path, 53 | 'line' => action.file_line, 54 | 'parents' => new_parents, 55 | 'text' => action.action, 56 | 'note' => '', 57 | 'tags' => [], 58 | 'action' => { 'action' => 'MOVE', 'arguments' => ['Inbox:Moved'] } 59 | } 60 | NA.apply_plugin_result(io) 61 | content = File.read(file) 62 | assert_match(/^\tMoved:/, content) 63 | end 64 | end 65 | end 66 | 67 | 68 | -------------------------------------------------------------------------------- /lib/na/hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Extensions to Ruby's Hash class for symbolizing keys and deep freezing values. 5 | # 6 | # @example Symbolize all keys in a hash 7 | # { 'foo' => 1, 'bar' => { 'baz' => 2 } }.symbolize_keys #=> { :foo => 1, :bar => { :baz => 2 } } 8 | class ::Hash 9 | # Convert all keys in the hash to symbols recursively 10 | # 11 | # @return [Hash] Hash with symbolized keys 12 | # @example 13 | # { 'foo' => 1, 'bar' => { 'baz' => 2 } }.symbolize_keys #=> { :foo => 1, :bar => { :baz => 2 } } 14 | def symbolize_keys 15 | each_with_object({}) { |(k, v), hsh| hsh[k.to_sym] = v.is_a?(Hash) ? v.symbolize_keys : v } 16 | end 17 | 18 | # 19 | # Freeze all values in a hash 20 | # 21 | # @return Hash with all values frozen 22 | # @example 23 | # { foo: { bar: 'baz' } }.deep_freeze 24 | def deep_freeze 25 | chilled = {} 26 | each do |k, v| 27 | chilled[k] = v.is_a?(Hash) ? v.deep_freeze : v.freeze 28 | end 29 | 30 | chilled.freeze 31 | end 32 | 33 | # Freeze all values in a hash in place 34 | # 35 | # @return [Hash] Hash with all values frozen 36 | def deep_freeze! 37 | replace deep_thaw.deep_freeze 38 | end 39 | 40 | # Recursively duplicate all values in a hash 41 | # 42 | # @return [Hash] Hash with all values duplicated 43 | def deep_thaw 44 | chilled = {} 45 | each do |k, v| 46 | chilled[k] = v.is_a?(Hash) ? v.deep_thaw : v.dup 47 | end 48 | 49 | chilled.dup 50 | end 51 | 52 | # Recursively duplicate all values in a hash in place 53 | # 54 | # @return [Hash] Hash with all values duplicated 55 | def deep_thaw! 56 | replace deep_thaw 57 | end 58 | 59 | # Recursively merge two hashes, combining arrays and preferring non-nil values 60 | # 61 | # @param second [Hash] The hash to merge with 62 | # @return [Hash] The merged hash 63 | def deep_merge(second) 64 | merger = proc { |_, v1, v2| 65 | if v1.is_a?(Hash) && v2.is_a?(Hash) 66 | v1.merge(v2, &merger) 67 | elsif v1.is_a?(Array) && v2.is_a?(Array) 68 | v1 | v2 69 | else 70 | [:undefined, nil, :nil].include?(v2) ? v1 : v2 71 | end 72 | } 73 | merge(second.to_h, &merger) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/na/benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NA 4 | # Provides benchmarking utilities for measuring code execution time. 5 | # 6 | # @example Measure a block of code 7 | # NA::Benchmark.measure('sleep') { sleep(1) } 8 | module Benchmark 9 | class << self 10 | attr_accessor :enabled, :timings 11 | 12 | # Initialize benchmarking state 13 | # 14 | # @return [void] 15 | def init 16 | @enabled = %w[1 true].include?(ENV.fetch('NA_BENCHMARK', nil)) 17 | @timings = [] 18 | @start_time = Time.now 19 | end 20 | 21 | # Measure the execution time of a block 22 | # 23 | # @param label [String] Label for the measurement 24 | # @return [Object] Result of the block 25 | # @example 26 | # NA::Benchmark.measure('sleep') { sleep(1) } 27 | def measure(label) 28 | return yield unless @enabled 29 | 30 | start = Time.now 31 | result = yield 32 | duration = ((Time.now - start) * 1000).round(2) 33 | @timings << { label: label, duration: duration, timestamp: (start - @start_time) * 1000 } 34 | result 35 | end 36 | 37 | # Output a performance report to STDERR 38 | # 39 | # @return [void] 40 | # @example 41 | # NA::Benchmark.report 42 | def report 43 | return unless @enabled 44 | 45 | total = @timings.sum { |t| t[:duration] } 46 | warn "\n#{NA::Color.template('{y}=== NA Performance Report ===')}" 47 | warn NA::Color.template("{dw}Total: {bw}#{total.round(2)}ms{x}") 48 | warn NA::Color.template("{dw}GC Count: {bw}#{GC.count}{x}") if defined?(GC) 49 | if defined?(GC) 50 | warn NA::Color.template("{dw}Memory: {bw}#{(GC.stat[:heap_live_slots] * 40 / 1024.0).round(1)}KB{x}") 51 | end 52 | warn '' 53 | 54 | @timings.each do |timing| 55 | pct = total.positive? ? ((timing[:duration] / total) * 100).round(1) : 0 56 | bar = '█' * [(pct / 2).round, 50].min 57 | warn NA::Color.template( 58 | "{dw}[{y}#{bar.ljust(25)}{dw}] {bw}#{timing[:duration].to_s.rjust(7)}ms {dw}(#{pct.to_s.rjust(5)}%) {x}#{timing[:label]}" 59 | ) 60 | end 61 | warn NA::Color.template("{y}#{'=' * 50}{x}\n") 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/action_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "na/action" 3 | require "tty-screen" 4 | 5 | class ActionTest < Minitest::Test 6 | def test_pretty_with_nil_file 7 | action = NA::Action.new(nil, "Project", ["Project"], "Test Action", 1) 8 | TTY::Screen.stub(:width, 80) do 9 | TTY::Screen.stub(:size, [80, 24]) do 10 | assert_silent do 11 | result = action.pretty 12 | assert result.is_a?(String) 13 | end 14 | end 15 | end 16 | end 17 | 18 | def test_file_stores_path_line_format 19 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", 42) 20 | assert_equal "/path/to/file.taskpaper:42", action.file 21 | assert_equal 42, action.line 22 | end 23 | 24 | def test_file_parts_extraction 25 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", 42) 26 | file_path, line = action.file_line_parts 27 | assert_equal "/path/to/file.taskpaper", file_path 28 | assert_equal 42, line 29 | end 30 | 31 | def test_file_path_method 32 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", 42) 33 | assert_equal "/path/to/file.taskpaper", action.file_path 34 | end 35 | 36 | def test_file_line_method 37 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", 42) 38 | assert_equal 42, action.file_line 39 | end 40 | 41 | def test_file_line_parts_without_line_number 42 | # Test backward compatibility with nil line 43 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", nil) 44 | file_path, line = action.file_line_parts 45 | assert_equal "/path/to/file.taskpaper", file_path 46 | assert_nil line 47 | end 48 | 49 | def test_file_stores_path_only_when_no_line 50 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", nil) 51 | assert_equal "/path/to/file.taskpaper", action.file 52 | assert_nil action.line 53 | end 54 | 55 | def test_to_s_includes_path_line 56 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", 42, ["Note 1"]) 57 | output = action.to_s 58 | assert_match(/\(.*file\.taskpaper:42\)/, output) 59 | assert_match(/Test Action/, output) 60 | assert_match(/Note 1/, output) 61 | end 62 | 63 | def test_to_s_pretty_includes_line_number 64 | action = NA::Action.new('/path/to/file.taskpaper', "Project", ["Project"], "Test Action", 42) 65 | output = action.to_s_pretty 66 | assert_match(/42/, output) 67 | end 68 | end -------------------------------------------------------------------------------- /test/time_output_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'test_helper' 4 | require 'json' 5 | 6 | class TimeOutputTest < Minitest::Test 7 | def setup 8 | clean_up_temp_files 9 | create_temp_files 10 | @file = File.expand_path('test.taskpaper') 11 | # Seed timed actions 12 | now = Time.now 13 | NA.add_action(@file, 'Inbox', 'A @tag1 @tag2', [], started_at: now - 3600, done_at: now) 14 | NA.add_action(@file, 'Inbox', 'B @tag2', [], started_at: now - 1800, done_at: now) 15 | # Untimed action 16 | NA.add_action(@file, 'Inbox', 'C @tag3', []) 17 | end 18 | 19 | def teardown 20 | clean_up_temp_files 21 | end 22 | 23 | def with_stubbed_screen 24 | TTY::Screen.stub(:width, 80) do 25 | TTY::Screen.stub(:size, [80, 24]) do 26 | yield 27 | end 28 | end 29 | end 30 | 31 | def test_only_timed_filters_actions 32 | todo = NA::Todo.new(file_path: @file, require_na: false, done: true) 33 | actions = todo.actions 34 | NA::Pager.paginate = false 35 | out = nil 36 | with_stubbed_screen do 37 | out = capture_io do 38 | actions.output(1, files: [@file], only_timed: true, times: true) 39 | end.first 40 | end 41 | # Should not include untimed 'C' 42 | refute_match(/\bC\b/, out) 43 | # Count only per-action duration tokens (exclude totals/footer) 44 | per_action_tokens = out.scan(/(?=, 0) 28 | assert_operator(Time.now - t, :<=, 86_400) 29 | end 30 | end 31 | 32 | # Types.parse_duration_seconds extended forms 33 | def test_parse_duration_seconds_extended 34 | assert_equal 9_000, NA::Types.parse_duration_seconds('-2h30m') 35 | assert_equal 7_500, NA::Types.parse_duration_seconds('2:05 ago') 36 | assert_equal 9_000, NA::Types.parse_duration_seconds('2 hours 30 minutes ago') 37 | assert_equal 1_800, NA::Types.parse_duration_seconds('30m') 38 | assert_equal 86_400 + 3_600, NA::Types.parse_duration_seconds('1d1h') 39 | end 40 | 41 | # String#expand_date_tags on @started/@done natural language 42 | def test_expand_date_tags_normalizes_started_and_done 43 | s = 'Task @started(2 hours ago) @done(now)' 44 | out = s.expand_date_tags 45 | assert_match(/@started\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)/, out) 46 | assert_match(/@done\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)/, out) 47 | end 48 | 49 | # NA.add_action injects @started and @done 50 | def test_add_action_injects_started_and_done 51 | started_at = Time.now - 3_600 52 | done_at = Time.now 53 | NA.add_action(@file, 'Inbox', 'Injected times', [], started_at: started_at, done_at: done_at) 54 | 55 | content = File.read(@file) 56 | assert_match(/- Injected times .*@started\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)/, content) 57 | assert_match(/@done\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)/, content) 58 | end 59 | 60 | # update_action respects started_at/done_at when adding an existing Action 61 | def test_update_action_respects_started_and_done 62 | # Seed a simple action first 63 | NA.add_action(@file, 'Inbox', 'Seed', []) 64 | 65 | # Parse the file to find the action object we just added 66 | todo = NA::Todo.new(file_path: @file, require_na: false) 67 | action = todo.actions.find { |a| a.action =~ /Seed/ } 68 | refute_nil action 69 | 70 | started_at = Time.now - 1_800 71 | done_at = Time.now 72 | NA.update_action(@file, nil, 73 | add: action, 74 | project: 'Inbox', 75 | started_at: started_at, 76 | done_at: done_at) 77 | 78 | content = File.read(@file) 79 | assert_match(/- Seed .*@started\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)/, content) 80 | assert_match(/@done\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\)/, content) 81 | end 82 | 83 | # Using duration to backfill started from end 84 | def test_add_action_duration_backfills_started 85 | done_at = Time.now 86 | NA.add_action(@file, 'Inbox', 'Backfill', [], done_at: done_at, 87 | duration_seconds: 2 * 3600 + 30 * 60) 88 | content = File.read(@file) 89 | assert_match(/- Backfill .*@started\(\d{4}-\d{2}-\d{2} \d{2}:\d{2}\).*@done\(/, content) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /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 | # 11 | # @return [Boolean] true if paginated 12 | def paginate 13 | @paginate ||= false 14 | end 15 | 16 | # Enable/disable pagination 17 | # 18 | # @return [void] 19 | attr_writer :paginate 20 | 21 | # Page output. If @paginate is false, just dump to STDOUT 22 | # 23 | # @param text [String] text to paginate 24 | # @return [Boolean, nil] true if paged, false if not, nil if no pager 25 | def page(text) 26 | unless @paginate 27 | puts text 28 | return 29 | end 30 | 31 | # Skip pagination for small outputs (faster than starting a pager) 32 | if text.length < 2000 && text.lines.count < 50 33 | puts text 34 | return 35 | end 36 | 37 | pager = which_pager 38 | return false unless pager 39 | 40 | # Optimized pager execution - use spawn instead of fork+exec 41 | read_io, write_io = IO.pipe 42 | 43 | # Use spawn for better performance than fork+exec 44 | pid = spawn(pager, in: read_io, out: $stdout, err: $stderr) 45 | read_io.close 46 | 47 | begin 48 | # Write data to pager 49 | write_io.write(text) 50 | write_io.close 51 | 52 | # Wait for pager to complete 53 | _, status = Process.waitpid2(pid) 54 | status.success? 55 | rescue SystemCallError 56 | # Clean up on error 57 | begin 58 | write_io.close 59 | rescue StandardError 60 | nil 61 | end 62 | begin 63 | Process.kill('TERM', pid) 64 | rescue StandardError 65 | nil 66 | end 67 | begin 68 | Process.waitpid(pid) 69 | rescue StandardError 70 | nil 71 | end 72 | false 73 | end 74 | end 75 | 76 | private 77 | 78 | # Get the git pager command if available 79 | # 80 | # @return [String, nil] git pager command 81 | def git_pager 82 | TTY::Which.exist?('git') ? `#{TTY::Which.which('git')} config --get-all core.pager` : nil 83 | end 84 | 85 | # List of possible pager commands 86 | # 87 | # @return [Array] pager commands 88 | def pagers 89 | [ 90 | ENV.fetch('PAGER', nil), 91 | 'less -FXr', 92 | ENV.fetch('GIT_PAGER', nil), 93 | git_pager, 94 | 'more -r' 95 | ].remove_bad 96 | end 97 | 98 | # Find the first available executable pager command 99 | # 100 | # @param commands [Array] commands to check 101 | # @return [String, nil] first available command 102 | def find_executable(*commands) 103 | execs = commands.empty? ? pagers : commands 104 | execs 105 | .remove_bad.uniq 106 | .find { |cmd| TTY::Which.exist?(cmd.split.first) } 107 | end 108 | 109 | # Determine which pager to use 110 | # 111 | # @return [String, nil] pager command 112 | def which_pager 113 | @which_pager ||= find_executable(*pagers) 114 | end 115 | 116 | # Clear pager cache (useful for testing) 117 | def clear_pager_cache 118 | @which_pager = nil 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/taskpaper_search_item_path_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'test_helper' 4 | require 'na/next_action' 5 | 6 | class TaskpaperSearchItemPathTest < Minitest::Test 7 | def setup 8 | @file = 'taskpaper_search_item_path_test.taskpaper' 9 | File.write(@file, <<~TP) 10 | Inbox: 11 | \tProject A: 12 | \t\t- Task in A @na 13 | \tProject B: 14 | \t\t- Task in B @na @done(2025-01-01) 15 | TP 16 | end 17 | 18 | def teardown 19 | FileUtils.rm_f(@file) 20 | end 21 | 22 | def test_parse_taskpaper_search_clauses_with_item_path 23 | clauses = NA.parse_taskpaper_search_clauses('@search(/Inbox//Project A and @na and not @done)') 24 | refute_empty clauses 25 | clause = clauses.first 26 | assert_includes clause[:item_paths], '/Inbox//Project A' 27 | assert(clause[:tags].any? { |t| t[:tag] =~ /na/ }) 28 | end 29 | 30 | def test_run_taskpaper_search_filters_to_item_path_subtree 31 | # Extend fixture to include a Bugs subtree and an Archive project 32 | File.write(@file, <<~TP) 33 | Inbox: 34 | \tProject A: 35 | \t\t- Task in A @na 36 | \tBugs: 37 | \t\t- Bug 1 @na 38 | \t\t- Bug 2 @na @done(2025-01-01) 39 | Archive: 40 | \tOld: 41 | \t\t- Old task @na 42 | TP 43 | 44 | NA::Pager.paginate = false 45 | 46 | output = capture_io do 47 | NA.run_taskpaper_search( 48 | '@search(/Inbox//Bugs and @na and not @done)', 49 | file: @file, 50 | options: { 51 | depth: 1, 52 | notes: false, 53 | nest: false, 54 | omnifocus: false, 55 | no_file: false, 56 | times: false, 57 | human: false, 58 | search_notes: true, 59 | invert: false, 60 | regex: false, 61 | project: nil, 62 | done: false, 63 | require_na: false 64 | } 65 | ) 66 | end.first 67 | 68 | # Should include only the Bugs subtree (as a project) and exclude others 69 | assert_match(/Inbox:Bugs/, output) 70 | refute_match(/Project A/, output) 71 | refute_match(/Archive/, output) 72 | end 73 | 74 | def test_project_shortcut_expands_to_project_equals 75 | clauses = NA.parse_taskpaper_search_clauses('@search(project Inbox)') 76 | refute_empty clauses 77 | clause = clauses.first 78 | assert_equal 'Inbox', clause[:project] 79 | end 80 | 81 | def test_slice_applied_to_entire_expression 82 | # Two @na, not-done actions and one done; slice [0] should return only the 83 | # first matching action. 84 | file = 'slice_test.taskpaper' 85 | File.write(file, <<~TP) 86 | Inbox: 87 | \t- First @na 88 | \t- Second @na 89 | \t- Third @na @done(2025-01-01) 90 | TP 91 | 92 | NA.debug = true 93 | NA.verbose = true 94 | 95 | actions, = NA.evaluate_taskpaper_search( 96 | '@search((project Inbox and @na and not @done)[0])', 97 | file: file, 98 | options: { 99 | depth: 1, 100 | notes: false, 101 | nest: false, 102 | omnifocus: false, 103 | no_file: false, 104 | times: false, 105 | human: false, 106 | search_notes: true, 107 | invert: false, 108 | regex: false, 109 | project: nil, 110 | done: false, 111 | require_na: false 112 | } 113 | ) 114 | 115 | assert_equal 1, actions.size 116 | assert_includes actions.first.action, 'First' 117 | refute_includes actions.first.action, 'Second' 118 | ensure 119 | FileUtils.rm_f(file) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /bin/commands/scan.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class App 4 | extend GLI::App 5 | desc 'Scan a directory tree for todo files and cache them' 6 | long_desc 'Searches PATH (default: current directory) for files matching the current NA.extension 7 | and adds their absolute paths to the tdlist.txt cache. Avoids duplicates. Optionally prunes 8 | non-existent entries from the cache.' 9 | arg_name 'PATH', optional: true 10 | command %i[scan] do |c| 11 | c.example 'na scan', desc: 'Scan current directory up to default depth (5)' 12 | c.example 'na scan -d 3 ~/Projects', desc: 'Scan a specific path up to depth 3' 13 | c.example 'na scan -d inf', desc: 'Scan current directory recursively with no depth limit' 14 | c.example 'na scan --prune', desc: 'Prune non-existent entries from the cache (in addition to scanning)' 15 | 16 | c.desc 'Recurse to depth (1..N or i/inf for infinite)' 17 | c.arg_name 'DEPTH' 18 | c.default_value '5' 19 | c.flag %i[d depth], must_match: /^(\d+|i\w*)$/i 20 | 21 | c.desc 'Prune removed files from cache after scan' 22 | c.switch %i[p prune], negatable: false, default_value: false 23 | 24 | c.desc 'Include hidden directories and files while scanning' 25 | c.switch %i[hidden], negatable: false, default_value: false 26 | 27 | c.desc 'Show what would be added/pruned, but do not write tdlist.txt' 28 | c.switch %i[n dry-run], negatable: false, default_value: false 29 | 30 | c.action do |_global_options, options, args| 31 | base = args.first || Dir.pwd 32 | ext = NA.extension 33 | 34 | # Parse depth: numeric or starts-with-i for infinite 35 | depth_arg = (options[:depth] || '5').to_s 36 | infinite = depth_arg =~ /^i/i ? true : false 37 | depth = infinite ? nil : depth_arg.to_i 38 | depth = 5 if depth.nil? && !infinite 39 | 40 | # Prepare existing cache 41 | db = NA.database_path 42 | existing = if File.exist?(db) 43 | File.read(db).split(/\n/).map(&:strip) 44 | else 45 | [] 46 | end 47 | 48 | found = [] 49 | Dir.chdir(base) do 50 | patterns = if infinite 51 | ["*.#{ext}", "**/*.#{ext}"] 52 | else 53 | (1..[depth, 1].max).map { |d| (d > 1 ? ('*/' * (d - 1)) : '') + "*.#{ext}" } 54 | end 55 | pattern = patterns.length == 1 ? patterns.first : "{#{patterns.join(',')}}" 56 | files = Dir.glob(pattern, File::FNM_DOTMATCH) 57 | # Exclude hidden dirs/files (any segment starting with '.') unless --hidden 58 | files.reject! { |f| f.split('/').any? { |seg| seg.start_with?('.') && seg !~ /^\.\.?$/ } } unless options[:hidden] 59 | found = files.map { |f| File.expand_path(f) } 60 | end 61 | 62 | merged = (existing + found).map(&:strip).uniq.sort 63 | merged.select! { |f| File.exist?(f) } if options[:prune] 64 | 65 | added_files = (merged - existing) 66 | pruned_files = options[:prune] ? (existing - merged) : [] 67 | added = added_files.count 68 | pruned = pruned_files.count 69 | 70 | if options[:dry_run] 71 | msg = "#{NA.theme[:success]}Dry run: would add #{added} file#{added == 1 ? '' : 's'}" 72 | msg << ", prune #{pruned} file#{pruned == 1 ? '' : 's'}" if options[:prune] 73 | NA.notify(msg) 74 | NA.notify("{bw}Would add:{x}\n#{added_files.join("\n")}") if added_files.any? 75 | NA.notify("{bw}Would prune:{x}\n#{pruned_files.join("\n")}") if options[:prune] && pruned_files.any? 76 | else 77 | File.open(db, 'w') { |f| f.puts merged.join("\n") } 78 | msg = "#{NA.theme[:success]}Scan complete: #{NA.theme[:filename]}#{added}{x}#{NA.theme[:success]} added" 79 | msg << ", #{NA.theme[:filename]}#{pruned}{x}#{NA.theme[:success]} pruned" if options[:prune] 80 | NA.notify(msg) 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /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 | yaml_searches = NA.load_searches 31 | taskpaper_searches = NA.load_taskpaper_searches(depth: 1) 32 | NA.notify("#{NA.theme[:success]}Saved searches stored in #{NA.database_path(file: 'saved_searches.yml').highlight_filename}") 33 | lines = yaml_searches.map do |k, v| 34 | "#{NA.theme[:filename]}#{k}: #{NA.theme[:values]}#{v}" 35 | end 36 | unless taskpaper_searches.empty? 37 | lines << "#{NA.theme[:prompt]}TaskPaper saved searches:" 38 | lines.concat( 39 | taskpaper_searches.map do |k, v| 40 | "#{NA.theme[:filename]}#{k}: #{NA.theme[:values]}#{v[:expr]} #{NA.theme[:note]}(#{File.basename(v[:file])})" 41 | end 42 | ) 43 | end 44 | NA.notify(lines.join("\n")) 45 | else 46 | NA.delete_search(args.join(',').split(/[ ,]/)) if options[:delete] 47 | 48 | if options[:select] 49 | yaml_searches = NA.load_searches 50 | taskpaper_searches = NA.load_taskpaper_searches(depth: 1) 51 | combined = {} 52 | yaml_searches.each { |k, v| combined[k] = { source: :yaml, value: v } } 53 | taskpaper_searches.each { |k, v| combined[k] ||= { source: :taskpaper, value: v } } 54 | 55 | res = NA.choose_from( 56 | combined.map do |k, info| 57 | val = info[:source] == :yaml ? info[:value] : info[:value][:expr] 58 | "#{NA.theme[:filename]}#{k} #{NA.theme[:value]}(#{val})" 59 | end, 60 | multiple: true 61 | ) 62 | NA.notify("#{NA.theme[:error]}Nothing selected", exit_code: 0) if res&.empty? 63 | args = res.map { |r| r.match(/(\S+)(?= \()/)[1] } 64 | end 65 | 66 | args.each do |arg| 67 | yaml_searches = NA.load_searches 68 | taskpaper_searches = NA.load_taskpaper_searches(depth: 1) 69 | all_keys = (yaml_searches.keys + taskpaper_searches.keys).uniq 70 | 71 | keys = all_keys.delete_if { |k| k !~ /#{arg.wildcard_to_rx}/ } 72 | NA.notify("#{NA.theme[:error]}Search #{arg} not found", exit_code: 1) if keys.empty? 73 | 74 | keys.each do |key| 75 | NA.notify("#{NA.theme[:prompt]}Saved search #{NA.theme[:filename]}#{key}#{NA.theme[:warning]}:") 76 | if yaml_searches.key?(key) 77 | value = yaml_searches[key] 78 | if value.to_s.strip =~ /\A@search\(.+\)\s*\z/ 79 | NA.run_taskpaper_search(value) 80 | else 81 | cmd = Shellwords.shellsplit(value) 82 | run(cmd) 83 | end 84 | elsif taskpaper_searches.key?(key) 85 | info = taskpaper_searches[key] 86 | NA.run_taskpaper_search(info[:expr], file: info[:file]) 87 | end 88 | end 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/prompt_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "na/prompt" 3 | 4 | class PromptTest < Minitest::Test 5 | def setup 6 | @orig_global_file = NA.global_file 7 | @orig_cwd_is = NA.cwd_is 8 | @orig_extension = NA.extension 9 | NA.extension = "taskpaper" 10 | @theme = { error: "[ERROR]", warning: "[WARN]", filename: "[FILE]", success: "[SUCCESS]" } 11 | NA.stub(:theme, @theme) do; end 12 | end 13 | 14 | def teardown 15 | NA.global_file = @orig_global_file 16 | NA.cwd_is = @orig_cwd_is 17 | NA.extension = @orig_extension 18 | end 19 | 20 | def test_prompt_hook_zsh_project 21 | NA.global_file = true 22 | NA.cwd_is = :project 23 | result = NA::Prompt.prompt_hook(:zsh) 24 | assert_includes result, 'na next --proj $(basename "$PWD")' 25 | end 26 | 27 | def test_prompt_hook_zsh_tag 28 | NA.global_file = true 29 | NA.cwd_is = :tag 30 | result = NA::Prompt.prompt_hook(:zsh) 31 | assert_includes result, 'na tagged $(basename "$PWD")' 32 | end 33 | 34 | def test_prompt_hook_zsh_default 35 | NA.global_file = false 36 | result = NA::Prompt.prompt_hook(:zsh) 37 | assert_includes result, 'na next' 38 | end 39 | 40 | def test_prompt_hook_zsh_error 41 | NA.global_file = true 42 | NA.cwd_is = :other 43 | called = false 44 | NA.stub(:notify, ->(msg, exit_code: nil) { called = true; msg }) do 45 | NA::Prompt.prompt_hook(:zsh) 46 | end 47 | assert called 48 | end 49 | 50 | def test_prompt_hook_fish_project 51 | NA.global_file = true 52 | NA.cwd_is = :project 53 | result = NA::Prompt.prompt_hook(:fish) 54 | assert_includes result, 'na next --proj (basename "$PWD")' 55 | end 56 | 57 | def test_prompt_hook_fish_tag 58 | NA.global_file = true 59 | NA.cwd_is = :tag 60 | result = NA::Prompt.prompt_hook(:fish) 61 | assert_includes result, 'na tagged (basename "$PWD")' 62 | end 63 | 64 | def test_prompt_hook_fish_default 65 | NA.global_file = false 66 | result = NA::Prompt.prompt_hook(:fish) 67 | assert_includes result, 'na next' 68 | end 69 | 70 | def test_prompt_hook_fish_error 71 | NA.global_file = true 72 | NA.cwd_is = :other 73 | called = false 74 | NA.stub(:notify, ->(msg, exit_code: nil) { called = true; msg }) do 75 | NA::Prompt.prompt_hook(:fish) 76 | end 77 | assert called 78 | end 79 | 80 | def test_prompt_hook_bash_project 81 | NA.global_file = true 82 | NA.cwd_is = :project 83 | result = NA::Prompt.prompt_hook(:bash) 84 | assert_includes result, 'na next --proj $(basename "$PWD")' 85 | end 86 | 87 | def test_prompt_hook_bash_tag 88 | NA.global_file = true 89 | NA.cwd_is = :tag 90 | result = NA::Prompt.prompt_hook(:bash) 91 | assert_includes result, 'na tagged $(basename "$PWD")' 92 | end 93 | 94 | def test_prompt_hook_bash_default 95 | NA.global_file = false 96 | result = NA::Prompt.prompt_hook(:bash) 97 | assert_includes result, 'na next' 98 | end 99 | 100 | def test_prompt_hook_bash_error 101 | NA.global_file = true 102 | NA.cwd_is = :other 103 | called = false 104 | NA.stub(:notify, ->(msg, exit_code: nil) { called = true; msg }) do 105 | NA::Prompt.prompt_hook(:bash) 106 | end 107 | assert called 108 | end 109 | 110 | def test_prompt_file 111 | assert_equal '~/.zshrc', NA::Prompt.prompt_file(:zsh) 112 | assert_equal '~/.config/fish/conf.d/na.fish', NA::Prompt.prompt_file(:fish) 113 | assert_equal '~/.bash_profile', NA::Prompt.prompt_file(:bash) 114 | end 115 | 116 | def test_show_prompt_hook 117 | called = false 118 | NA.stub(:notify, ->(msg) { called = true; msg }) do 119 | NA::Prompt.show_prompt_hook(:zsh) 120 | end 121 | assert called 122 | end 123 | 124 | def test_install_prompt_hook 125 | file = File.expand_path('~/.zshrc') 126 | mock_file = Object.new 127 | def mock_file.puts(*args); end 128 | File.stub(:open, ->(f, mode, &block) { assert_equal file, f; block&.call(mock_file) if block }) do 129 | called = false 130 | NA.stub(:notify, ->(msg) { called = true; msg }) do 131 | NA::Prompt.install_prompt_hook(:zsh) 132 | end 133 | assert called 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | na (1.2.95) 5 | chronic (~> 0.10, >= 0.10.2) 6 | csv (>= 3.2) 7 | git (~> 3.0.0) 8 | gli (~> 2.21.0) 9 | mdless (~> 1.0, >= 1.0.32) 10 | ostruct (~> 0.6, >= 0.6.1) 11 | tty-reader (~> 0.9, >= 0.9.0) 12 | tty-screen (~> 0.8, >= 0.8.1) 13 | tty-which (~> 0.5, >= 0.5.0) 14 | 15 | GEM 16 | remote: https://rubygems.org/ 17 | specs: 18 | activesupport (8.1.1) 19 | base64 20 | bigdecimal 21 | concurrent-ruby (~> 1.0, >= 1.3.1) 22 | connection_pool (>= 2.2.5) 23 | drb 24 | i18n (>= 1.6, < 2) 25 | json 26 | logger (>= 1.4.2) 27 | minitest (>= 5.1) 28 | securerandom (>= 0.3) 29 | tzinfo (~> 2.0, >= 2.0.5) 30 | uri (>= 0.13.1) 31 | addressable (2.8.8) 32 | public_suffix (>= 2.0.2, < 8.0) 33 | ast (2.4.3) 34 | base64 (0.3.0) 35 | bigdecimal (3.3.1) 36 | bump (0.6.1) 37 | chronic (0.10.2) 38 | concurrent-ruby (1.3.5) 39 | connection_pool (2.5.5) 40 | csv (3.3.5) 41 | diff-lcs (1.6.2) 42 | docile (1.4.1) 43 | drb (2.2.3) 44 | git (3.0.2) 45 | activesupport (>= 5.0) 46 | addressable (~> 2.8) 47 | process_executer (~> 1.3) 48 | rchardet (~> 1.9) 49 | gli (2.21.5) 50 | i18n (1.14.7) 51 | concurrent-ruby (~> 1.0) 52 | json (2.16.0) 53 | language_server-protocol (3.17.0.5) 54 | lint_roller (1.1.0) 55 | logger (1.7.0) 56 | mdless (1.0.37) 57 | minitest (5.26.2) 58 | ostruct (0.6.3) 59 | parallel (1.27.0) 60 | parser (3.3.10.0) 61 | ast (~> 2.4.1) 62 | racc 63 | prism (1.6.0) 64 | process_executer (1.3.0) 65 | public_suffix (7.0.0) 66 | racc (1.8.1) 67 | rainbow (3.1.1) 68 | rake (13.3.1) 69 | rchardet (1.10.0) 70 | rdoc (4.3.0) 71 | regexp_parser (2.11.3) 72 | rspec (3.13.2) 73 | rspec-core (~> 3.13.0) 74 | rspec-expectations (~> 3.13.0) 75 | rspec-mocks (~> 3.13.0) 76 | rspec-core (3.13.6) 77 | rspec-support (~> 3.13.0) 78 | rspec-expectations (3.13.5) 79 | diff-lcs (>= 1.2.0, < 2.0) 80 | rspec-support (~> 3.13.0) 81 | rspec-mocks (3.13.7) 82 | diff-lcs (>= 1.2.0, < 2.0) 83 | rspec-support (~> 3.13.0) 84 | rspec-support (3.13.6) 85 | rubocop (1.81.7) 86 | json (~> 2.3) 87 | language_server-protocol (~> 3.17.0.2) 88 | lint_roller (~> 1.1.0) 89 | parallel (~> 1.10) 90 | parser (>= 3.3.0.2) 91 | rainbow (>= 2.2.2, < 4.0) 92 | regexp_parser (>= 2.9.3, < 3.0) 93 | rubocop-ast (>= 1.47.1, < 2.0) 94 | ruby-progressbar (~> 1.7) 95 | unicode-display_width (>= 2.4.0, < 4.0) 96 | rubocop-ast (1.48.0) 97 | parser (>= 3.3.7.2) 98 | prism (~> 1.4) 99 | rubocop-performance (1.26.1) 100 | lint_roller (~> 1.1) 101 | rubocop (>= 1.75.0, < 2.0) 102 | rubocop-ast (>= 1.47.1, < 2.0) 103 | ruby-progressbar (1.13.0) 104 | securerandom (0.4.1) 105 | simplecov (0.22.0) 106 | docile (~> 1.1) 107 | simplecov-html (~> 0.11) 108 | simplecov_json_formatter (~> 0.1) 109 | simplecov-html (0.13.2) 110 | simplecov_json_formatter (0.1.4) 111 | tty-cursor (0.7.1) 112 | tty-reader (0.9.0) 113 | tty-cursor (~> 0.7) 114 | tty-screen (~> 0.8) 115 | wisper (~> 2.0) 116 | tty-screen (0.8.2) 117 | tty-spinner (0.9.3) 118 | tty-cursor (~> 0.7) 119 | tty-which (0.5.0) 120 | tzinfo (2.0.6) 121 | concurrent-ruby (~> 1.0) 122 | unicode-display_width (3.2.0) 123 | unicode-emoji (~> 4.1) 124 | unicode-emoji (4.1.0) 125 | uri (1.1.1) 126 | wisper (2.0.1) 127 | 128 | PLATFORMS 129 | aarch64-linux-gnu 130 | aarch64-linux-musl 131 | arm-linux-gnu 132 | arm-linux-musl 133 | arm64-darwin 134 | ruby 135 | x86-linux-gnu 136 | x86-linux-musl 137 | x86_64-darwin 138 | x86_64-linux-gnu 139 | x86_64-linux-musl 140 | 141 | DEPENDENCIES 142 | bump (~> 0.6.0) 143 | chronic 144 | csv 145 | minitest (~> 5.14) 146 | na! 147 | rake 148 | rdoc (~> 4.3) 149 | rspec (~> 3.0) 150 | rubocop (~> 1.66) 151 | rubocop-performance (~> 1.21) 152 | simplecov (~> 0.22.0) 153 | tty-spinner (~> 0.9, >= 0.9.0) 154 | 155 | BUNDLED WITH 156 | 2.6.6 157 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/na/prompt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NA 4 | # Prompt Hooks 5 | module Prompt 6 | class << self 7 | # Generate the shell prompt hook script for na 8 | # 9 | # @param shell [Symbol] Shell type (:zsh, :fish, :bash) 10 | # @return [String] Shell script for prompt hook 11 | def prompt_hook(shell) 12 | case shell 13 | when :zsh 14 | cmd = if NA.global_file 15 | case NA.cwd_is 16 | when :project 17 | 'na next --proj $(basename "$PWD")' 18 | when :tag 19 | 'na tagged $(basename "$PWD")' 20 | else 21 | NA.notify( 22 | "#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1 23 | ) 24 | end 25 | else 26 | 'na next' 27 | end 28 | <<~EOHOOK 29 | # zsh prompt hook for na 30 | chpwd() { #{cmd} } 31 | EOHOOK 32 | when :fish 33 | cmd = if NA.global_file 34 | case NA.cwd_is 35 | when :project 36 | 'na next --proj (basename "$PWD")' 37 | when :tag 38 | 'na tagged (basename "$PWD")' 39 | else 40 | NA.notify( 41 | "#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1 42 | ) 43 | end 44 | else 45 | 'na next' 46 | end 47 | <<~EOHOOK 48 | # Fish Prompt Command 49 | function __should_na --on-variable PWD 50 | test -s (basename $PWD)".#{NA.extension}" && #{cmd} 51 | end 52 | EOHOOK 53 | when :bash 54 | cmd = if NA.global_file 55 | case NA.cwd_is 56 | when :project 57 | 'na next --proj $(basename "$PWD")' 58 | when :tag 59 | 'na tagged $(basename "$PWD")' 60 | else 61 | NA.notify( 62 | "#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1 63 | ) 64 | end 65 | else 66 | 'na next' 67 | end 68 | 69 | <<~EOHOOK 70 | # Bash PROMPT_COMMAND for na 71 | last_command_was_cd() { 72 | [[ $(history 1|sed -e "s/^[ ]*[0-9]*[ ]*//") =~ ^((cd|z|j|jump|g|f|pushd|popd|exit)([ ]|$)) ]] && #{cmd} 73 | } 74 | if [[ -z "$PROMPT_COMMAND" ]]; then 75 | PROMPT_COMMAND="eval 'last_command_was_cd'" 76 | else 77 | echo $PROMPT_COMMAND | grep -v -q "last_command_was_cd" && PROMPT_COMMAND="$PROMPT_COMMAND;"'eval "last_command_was_cd"' 78 | fi 79 | EOHOOK 80 | end 81 | end 82 | 83 | # Get the configuration file path for the given shell 84 | # 85 | # @param shell [Symbol] Shell type 86 | # @return [String] Path to shell config file 87 | def prompt_file(shell) 88 | files = { 89 | zsh: '~/.zshrc', 90 | fish: '~/.config/fish/conf.d/na.fish', 91 | bash: '~/.bash_profile' 92 | } 93 | 94 | files[shell] 95 | end 96 | 97 | # Display the prompt hook script and notify user of config file 98 | # 99 | # @param shell [Symbol] Shell type 100 | # @return [void] 101 | def show_prompt_hook(shell) 102 | file = prompt_file(shell) 103 | 104 | NA.notify("#{NA.theme[:warning]}# Add this to #{NA.theme[:filename]}#{file}") 105 | puts prompt_hook(shell) 106 | end 107 | 108 | # Install the prompt hook script into the shell config file 109 | # 110 | # @param shell [Symbol] Shell type 111 | # @return [void] 112 | def install_prompt_hook(shell) 113 | file = prompt_file(shell) 114 | 115 | File.open(File.expand_path(file), 'a') { |f| f.puts prompt_hook(shell) } 116 | NA.notify("#{NA.theme[:success]}Added #{NA.theme[:filename]}#{shell}{x}#{NA.theme[:success]} prompt hook to #{NA.theme[:filename]}#{file}#{NA.theme[:success]}.") 117 | NA.notify("#{NA.theme[:warning]}You may need to close the current terminal and open a new one to enable the script.") 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/plugins_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'tmpdir' 5 | require_relative '../lib/na' 6 | 7 | class PluginsTest < Minitest::Test 8 | def with_tmp_plugins 9 | Dir.mktmpdir do |dir| 10 | orig = NA::Plugins.method(:plugins_home) 11 | NA::Plugins.define_singleton_method(:plugins_home) { dir } 12 | begin 13 | yield dir 14 | ensure 15 | NA::Plugins.define_singleton_method(:plugins_home, &orig) 16 | end 17 | end 18 | end 19 | 20 | def test_metadata_parsing_from_comment_block 21 | with_tmp_plugins do |dir| 22 | path = File.join(dir, 'Meta.sh') 23 | File.write(path, <<~SH) 24 | #!/usr/bin/env bash 25 | # NAME: Test Meta 26 | # INPUT: YAML 27 | # Output: json 28 | echo 29 | SH 30 | meta = NA::Plugins.parse_plugin_metadata(path) 31 | assert_equal 'Test Meta', meta['name'] 32 | assert_equal 'yaml', meta['input'] 33 | assert_equal 'json', meta['output'] 34 | end 35 | end 36 | 37 | def test_text_roundtrip_with_action_and_args 38 | action = { 39 | 'action' => { 'action' => 'MOVE', 'arguments' => ['Work:Feature'] }, 40 | 'file_path' => '/t/todo.taskpaper', 41 | 'line' => 5, 42 | 'parents' => %w[Work Backlog], 43 | 'text' => '- Example', 44 | 'note' => 'Line1\\nLine2', 45 | 'tags' => [{ 'name' => 'na', 'value' => '' }] 46 | } 47 | txt = NA::Plugins.serialize_actions([action], format: 'text', divider: '||') 48 | parsed = NA::Plugins.parse_actions(txt, format: 'text', divider: '||') 49 | assert_equal 1, parsed.size 50 | p1 = parsed.first 51 | assert_equal '/t/todo.taskpaper', p1['file_path'] 52 | assert_equal 5, p1['line'] 53 | assert_equal %w[Work Backlog], p1['parents'] 54 | assert_equal '- Example', p1['text'] 55 | assert_equal "Line1\nLine2", p1['note'] 56 | assert_equal({ 'action' => 'MOVE', 'arguments' => ['Work:Feature'] }, p1['action']) 57 | end 58 | 59 | def test_csv_roundtrip 60 | action = { 61 | 'action' => { 'action' => 'ADD_TAG', 'arguments' => ['bar'] }, 62 | 'file_path' => '/t/todo.taskpaper', 63 | 'line' => 7, 64 | 'parents' => ['Inbox'], 65 | 'text' => '- Add tag', 66 | 'note' => '', 67 | 'tags' => [] 68 | } 69 | csv = NA::Plugins.serialize_actions([action], format: 'csv') 70 | parsed = NA::Plugins.parse_actions(csv, format: 'csv') 71 | p1 = parsed.first 72 | assert_equal 'ADD_TAG', p1['action']['action'] 73 | assert_equal ['bar'], p1['action']['arguments'] 74 | assert_equal 7, p1['line'] 75 | end 76 | 77 | def test_run_plugin_echo_json 78 | with_tmp_plugins do |dir| 79 | path = File.join(dir, 'Echo.py') 80 | File.write(path, <<~PY) 81 | #!/usr/bin/env python3 82 | # input: json 83 | # output: json 84 | import sys, json 85 | data = json.load(sys.stdin) 86 | # mutate: add @foo 87 | for a in data: 88 | tags = a.get('tags', []) 89 | tags.append({'name':'foo','value':''}) 90 | a['tags'] = tags 91 | json.dump(data, sys.stdout) 92 | PY 93 | File.chmod(0o755, path) 94 | input = [{ 'file_path' => '/t/t.todo', 'line' => 1, 'parents' => [], 'text' => '- T', 'note' => '', 'tags' => [] }] 95 | stdin = NA::Plugins.serialize_actions(input, format: 'json') 96 | out = NA::Plugins.run_plugin(path, stdin) 97 | parsed = NA::Plugins.parse_actions(out, format: 'json') 98 | assert_equal 'foo', parsed.first['tags'].last['name'] 99 | end 100 | end 101 | 102 | def test_enable_disable_and_samples 103 | with_tmp_plugins do |_dir| 104 | # ensure creates disabled samples 105 | NA::Plugins.ensure_plugins_home 106 | disabled = NA::Plugins.list_plugins_disabled 107 | assert disabled.keys.size >= 1, 'expected sample plugins in plugins_disabled' 108 | 109 | # enable one 110 | name = File.basename(disabled.values.first) 111 | enabled_path = NA::Plugins.enable_plugin(name) 112 | assert File.dirname(enabled_path) == NA::Plugins.plugins_home 113 | # disable back 114 | disabled_path = NA::Plugins.disable_plugin(File.basename(enabled_path)) 115 | assert File.dirname(disabled_path) == NA::Plugins.plugins_disabled_home 116 | end 117 | end 118 | 119 | def test_create_plugin_infers_shebang 120 | with_tmp_plugins do |_dir| 121 | path = NA::Plugins.create_plugin('TestTool.rb') 122 | first = File.open(path, 'r', &:readline).strip 123 | assert_match(/ruby/, first) 124 | end 125 | end 126 | end 127 | 128 | 129 | -------------------------------------------------------------------------------- /test/time_tracking_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class TimeTrackingTest < Minitest::Test 6 | def test_action_process_with_start_time 7 | action = NA::Action.new("test.taskpaper", "Project", ["Project"], "Test Action", 1) 8 | start_time = Time.parse("2024-01-15 14:30") 9 | action.process(started_at: start_time) 10 | 11 | assert_match(/@started\(2024-01-15 14:30\)/, action.action) 12 | assert_equal("2024-01-15 14:30", action.tags["started"]) 13 | end 14 | 15 | def test_action_process_with_done_time 16 | action = NA::Action.new("test.taskpaper", "Project", ["Project"], "Test Action", 1) 17 | done_time = Time.parse("2024-01-15 15:45") 18 | action.process(done_at: done_time) 19 | 20 | assert_match(/@done\(2024-01-15 15:45\)/, action.action) 21 | assert_equal("2024-01-15 15:45", action.tags["done"]) 22 | end 23 | 24 | def test_action_process_with_start_and_done 25 | action = NA::Action.new("test.taskpaper", "Project", ["Project"], "Test Action", 1) 26 | start_time = Time.parse("2024-01-15 14:30") 27 | done_time = Time.parse("2024-01-15 15:45") 28 | action.process(started_at: start_time, done_at: done_time) 29 | 30 | assert_match(/@started\(2024-01-15 14:30\)/, action.action) 31 | assert_match(/@done\(2024-01-15 15:45\)/, action.action) 32 | end 33 | 34 | def test_action_process_with_duration_only 35 | action = NA::Action.new("test.taskpaper", "Project", ["Project"], "Test Action", 1) 36 | duration_seconds = 90 * 60 # 90 minutes 37 | action.process(duration_seconds: duration_seconds, finish: true) 38 | 39 | # Should have @done and @started (90 minutes before done) 40 | assert_match(/@done\(/, action.action) 41 | assert_match(/@started\(/, action.action) 42 | 43 | # Verify tags were updated 44 | assert action.tags["done"] 45 | assert action.tags["started"] 46 | 47 | # Verify @started is ~90 minutes before @done 48 | start_time = Time.parse(action.tags["started"]) 49 | done_time = Time.parse(action.tags["done"]) 50 | diff = done_time - start_time 51 | assert_in_delta(90 * 60, diff, 60) # Allow 1 minute tolerance 52 | end 53 | 54 | def test_action_process_with_duration_and_end 55 | action = NA::Action.new("test.taskpaper", "Project", ["Project"], "Test Action", 1) 56 | end_time = Time.parse("2024-01-15 15:00") 57 | duration_seconds = 30 * 60 # 30 minutes 58 | action.process(duration_seconds: duration_seconds, done_at: end_time) 59 | 60 | assert_match(/@done\(2024-01-15 15:00\)/, action.action) 61 | assert_match(/@started\(2024-01-15 14:30\)/, action.action) 62 | 63 | start_time = Time.parse(action.tags["started"]) 64 | done_time = Time.parse(action.tags["done"]) 65 | diff = done_time - start_time 66 | assert_in_delta(30 * 60, diff, 60) 67 | end 68 | 69 | def test_action_process_with_start_and_duration 70 | action = NA::Action.new("test.taskpaper", "Project", ["Project"], "Test Action", 1) 71 | start_time = Time.parse("2024-01-15 14:00") 72 | duration_seconds = 45 * 60 # 45 minutes 73 | action.process(started_at: start_time, duration_seconds: duration_seconds) 74 | 75 | assert_match(/@started\(2024-01-15 14:00\)/, action.action) 76 | assert_match(/@done\(2024-01-15 14:45\)/, action.action) 77 | end 78 | 79 | def test_types_parse_date_begin 80 | # ISO format 81 | time = NA::Types.parse_date_begin("2024-01-15 14:30") 82 | assert_instance_of(Time, time) 83 | assert_equal(2024, time.year) 84 | assert_equal(1, time.month) 85 | assert_equal(15, time.day) 86 | 87 | # Natural language - verify it parses correctly 88 | time = NA::Types.parse_date_begin("30 minutes ago") 89 | # Just verify we get a Time object - the exact value depends on implementation 90 | assert_instance_of(Time, time) if time 91 | end 92 | 93 | def test_types_parse_date_end 94 | # ISO format 95 | time = NA::Types.parse_date_end("2024-01-15 15:00") 96 | assert_instance_of(Time, time) 97 | 98 | # Natural language 99 | time = NA::Types.parse_date_end("in 2 hours") 100 | assert_instance_of(Time, time) 101 | end 102 | 103 | def test_types_parse_duration_seconds 104 | # Plain number (minutes) 105 | assert_equal(90 * 60, NA::Types.parse_duration_seconds("90")) 106 | 107 | # Minutes 108 | assert_equal(45 * 60, NA::Types.parse_duration_seconds("45m")) 109 | 110 | # Hours 111 | assert_equal(2 * 3600, NA::Types.parse_duration_seconds("2h")) 112 | 113 | # Days 114 | assert_equal(1 * 86_400, NA::Types.parse_duration_seconds("1d")) 115 | 116 | # Combined 117 | assert_equal(86_400 + (2 * 3600) + (30 * 60), NA::Types.parse_duration_seconds("1d2h30m")) 118 | end 119 | end 120 | 121 | -------------------------------------------------------------------------------- /plugins.md: -------------------------------------------------------------------------------- 1 | I would like to add a plugin architecture to na_gem. It should allow the user to add plugins to ~/.local/share/na/plugins/. These plugins can be any shell script (with a shebang). They can be run with `na plugin NAME`, which accepts the plugin filename with or without an extension, and with or without spaces (so that `plugin AddFoo` will run `Add Foo.sh` if found, but the user can also use `plugin "Add Foo"`). 2 | 3 | A plugin will be a shell script that takes input on STDIN. The input should be an action as a JSON object, with the file path, line number, action text, note, and array of tags/values (`tags: [{ name: "done", value: "2025-10-29 03:00"}, { name: "na", value: ""}]`). That should be the default. 4 | 5 | The `plugin` command should accept a `--input TYPE` flag that accepts `json`, `yaml` or `text`. The YAML should be the same as the JSON (but as YAML), and the text should just be the file_path:line_number, action text, and note, split with "||" (newlines in the note replaced with \n, and filename and line number are combined with : not the divider), with no colorization. One action per line. The "||" in `--input text` should also be a flag `--divider "STRING"` that defaults to "||", but allows the user to specify a different string to split the parts on. 6 | 7 | The plugin will need to return output (on STDOUT) in the same format as the input (yaml, json, or text with specified divider), unless `--output FORMAT` is specified with a different type. The `plugin` command will execute the script for every command passed to it, and update the actions based on the returned output. 8 | 9 | The `plugin` command should accept all the same filter flags as `finish` or other actions that update commands. 10 | 11 | For the `update` command, it should accept a `--plugin NAME` flag, and if it's using interactive menus, a list of plugin names (basename minus extension) should be added to the list of available operations. 12 | 13 | Also add a `--plugin NAME`, `--input TYPE`, and `--output TYPE` flag to all search and display commands (next, grep, tagged, etc.). That way the user can filter output with any command and run the result through the plugin. 14 | 15 | In lieu of the `--input` and `--output` commands, the plugin itself can have a comment block after the shebang with `key: value` pairs. When reading a plugin, check for a comment block with `input: JSON` `output: YAML` (case insensitive). The user can also define a `name` or `title` (interchangeable) in this block, which will be used instead of the base name if provided. We need to ignore leading characters when scanning for this comment block (e.g. # or //). The block can have blank lines before it. The only keys we read are input, output, and name/title. Parsing stops at the first blank line or after all three keys are populated. Other keys might exist, like `author` or `description`, which should be ignored. 16 | 17 | The plugins shouldn't need to be executable, the hashbang should be read and used to execute the script. 18 | 19 | When `na` runs, it should check for the existence of the `plugins` directory, creating it if it's missing, and adding a `~/.local/share/na/plugsin/README.md` file with plugin instructions if one doesn't exist. Any `.md` or `.bak` file in the plugins directory should be ignored. In fact, let's have a helper validate the files in the directory by checking for a shebang and ignoring if none exists, and also ignoring any '.bak' or '.md' files. 20 | 21 | Have NA also create 2 sample plugins in the `~/.local/share/na/plugins` folder when creating it (do not create plugins if the folder already exists). Have the sample plugins be a Python script and a Bash script. The sample plugins should just do something benign like add a tag with a dynamic value to the passed actions. In the README.md note that the user can delete the sample plugins. Give the sample plugins names "Add Foo.py" and "Add Bar.sh" and have them add @foo and @bar, respectively. 22 | 23 | ### Summary ### 24 | 25 | - plugins are script files in ~/.local/share/na/plugins 26 | - plugins require a shebang, which is used to execute them 27 | - plugin base names (without extension) becomes the command name (spaces are handled) 28 | - Ignore 29 | - `plugin` subcommand 30 | - accepts plugin name as argument 31 | - has a `--input TYPE` flag that determines the input type (yaml, json, or text) 32 | - has a `--output TYPE` (yaml, json, or text) 33 | - has a `--divider` flag that determines the divider when `--input text` is used 34 | - `update` subcommand 35 | - accepts a `--plugin NAME` flag 36 | - Adds plugin names to interactive menu when no action is specified 37 | - main script parses the output of the plugin, stripping whitespace and reading it as YAML, JSON, or text split on the divider (based on `--output` and defaulting to the value of `--input`), then updates each action in the result. Line numbers should be passed on both input and output and used to update the specific actions. 38 | - Generate README and scripts -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2025-12-03 13:48:16 UTC using RuboCop version 1.81.7. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 3 10 | # Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch. 11 | Lint/DuplicateBranch: 12 | Exclude: 13 | - 'lib/na/action.rb' 14 | - 'lib/na/colors.rb' 15 | 16 | # Offense count: 2 17 | # This cop supports safe autocorrection (--autocorrect). 18 | # Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. 19 | # NotImplementedExceptions: NotImplementedError 20 | Lint/UnusedMethodArgument: 21 | Exclude: 22 | - 'lib/na/string.rb' 23 | 24 | # Offense count: 58 25 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 26 | Metrics/AbcSize: 27 | Max: 309 28 | 29 | # Offense count: 14 30 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 31 | # AllowedMethods: refine 32 | Metrics/BlockLength: 33 | Max: 186 34 | 35 | # Offense count: 7 36 | # Configuration parameters: CountBlocks, CountModifierForms. 37 | Metrics/BlockNesting: 38 | Max: 4 39 | 40 | # Offense count: 6 41 | # Configuration parameters: CountComments, CountAsOne. 42 | Metrics/ClassLength: 43 | Max: 1492 44 | 45 | # Offense count: 41 46 | # Configuration parameters: AllowedMethods, AllowedPatterns. 47 | Metrics/CyclomaticComplexity: 48 | Max: 91 49 | 50 | # Offense count: 63 51 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 52 | Metrics/MethodLength: 53 | Max: 239 54 | 55 | # Offense count: 5 56 | # Configuration parameters: CountComments, CountAsOne. 57 | Metrics/ModuleLength: 58 | Max: 1494 59 | 60 | # Offense count: 5 61 | # Configuration parameters: CountKeywordArgs, MaxOptionalParameters. 62 | Metrics/ParameterLists: 63 | Max: 23 64 | 65 | # Offense count: 39 66 | # Configuration parameters: AllowedMethods, AllowedPatterns. 67 | Metrics/PerceivedComplexity: 68 | Max: 104 69 | 70 | # Offense count: 1 71 | # Configuration parameters: ForbiddenDelimiters. 72 | # ForbiddenDelimiters: (?i-mx:(^|\s)(EO[A-Z]{1}|END)(\s|$)) 73 | Naming/HeredocDelimiterNaming: 74 | Exclude: 75 | - 'lib/na/editor.rb' 76 | 77 | # Offense count: 3 78 | # This cop supports unsafe autocorrection (--autocorrect-all). 79 | Security/YAMLLoad: 80 | Exclude: 81 | - 'lib/na/next_action.rb' 82 | - 'lib/na/theme.rb' 83 | 84 | # Offense count: 1 85 | # This cop supports unsafe autocorrection (--autocorrect-all). 86 | # Configuration parameters: MinBranchesCount. 87 | Style/CaseLikeIf: 88 | Exclude: 89 | - 'lib/na/next_action.rb' 90 | 91 | # Offense count: 2 92 | # This cop supports safe autocorrection (--autocorrect). 93 | Style/IfUnlessModifier: 94 | Exclude: 95 | - 'lib/na/benchmark.rb' 96 | - 'lib/na/colors.rb' 97 | 98 | # Offense count: 2 99 | # This cop supports safe autocorrection (--autocorrect). 100 | # Configuration parameters: EnforcedStyle. 101 | # SupportedStyles: line_count_dependent, lambda, literal 102 | Style/Lambda: 103 | Exclude: 104 | - 'lib/na/next_action.rb' 105 | 106 | # Offense count: 1 107 | # This cop supports unsafe autocorrection (--autocorrect-all). 108 | # Configuration parameters: EnforcedStyle, Autocorrect. 109 | # SupportedStyles: module_function, extend_self, forbidden 110 | Style/ModuleFunction: 111 | Exclude: 112 | - 'lib/na/colors.rb' 113 | 114 | # Offense count: 4 115 | # This cop supports safe autocorrection (--autocorrect). 116 | # Configuration parameters: AllowMethodComparison, ComparisonsThreshold. 117 | Style/MultipleComparison: 118 | Exclude: 119 | - 'lib/na/next_action.rb' 120 | 121 | # Offense count: 1 122 | # This cop supports safe autocorrection (--autocorrect). 123 | # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. 124 | # SupportedStyles: single_quotes, double_quotes 125 | Style/StringLiterals: 126 | Exclude: 127 | - 'lib/na/next_action.rb' 128 | 129 | # Offense count: 1 130 | # This cop supports safe autocorrection (--autocorrect). 131 | # Configuration parameters: EnforcedStyle. 132 | # SupportedStyles: single_quotes, double_quotes 133 | Style/StringLiteralsInInterpolation: 134 | Exclude: 135 | - 'lib/na/action.rb' 136 | 137 | # Offense count: 1 138 | # This cop supports safe autocorrection (--autocorrect). 139 | Style/YAMLFileRead: 140 | Exclude: 141 | - 'lib/na/theme.rb' 142 | 143 | # Offense count: 38 144 | # This cop supports safe autocorrection (--autocorrect). 145 | # Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. 146 | # URISchemes: http, https 147 | Layout/LineLength: 148 | Max: 293 149 | -------------------------------------------------------------------------------- /scripts/generate-fish-completions.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'tty-progressbar' 5 | require 'shellwords' 6 | 7 | class ::String 8 | def short_desc 9 | split(/[,.]/)[0].sub(/ \(.*?\)?$/, '').strip 10 | end 11 | 12 | def ltrunc(max) 13 | if length > max 14 | sub(/^.*?(.{#{max - 3}})$/, '...\1') 15 | else 16 | self 17 | end 18 | end 19 | 20 | def ltrunc!(max) 21 | replace ltrunc(max) 22 | end 23 | end 24 | 25 | class FishCompletions 26 | attr_accessor :commands, :global_options 27 | 28 | def generate_helpers 29 | <<~EOFUNCTIONS 30 | function __fish_na_needs_command 31 | # Figure out if the current invocation already has a command. 32 | 33 | 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 34 | set cmd (commandline -opc) 35 | set -e cmd[1] 36 | argparse -s $opts -- $cmd 2>/dev/null 37 | or return 0 38 | # These flags function as commands, effectively. 39 | if set -q argv[1] 40 | # Also print the command, so this can be used to figure out what it is. 41 | echo $argv[1] 42 | return 1 43 | end 44 | return 0 45 | end 46 | 47 | function __fish_na_using_command 48 | set -l cmd (__fish_na_needs_command) 49 | test -z "$cmd" 50 | and return 1 51 | contains -- $cmd $argv 52 | and return 0 53 | end 54 | 55 | function __fish_na_subcommands 56 | na help -c 57 | end 58 | 59 | complete -c na -f 60 | complete -xc na -n '__fish_na_needs_command' -a '(__fish_na_subcommands)' 61 | 62 | complete -xc na -n '__fish_seen_subcommand_from help; and not __fish_seen_subcommand_from (na help -c)' -a "(na help -c)" 63 | EOFUNCTIONS 64 | end 65 | 66 | def get_help_sections(command = '') 67 | res = `na help #{command}`.strip 68 | scanned = res.scan(/(?m-i)^([A-Z ]+)\n([\s\S]*?)(?=\n+[A-Z]+|\Z)/) 69 | sections = {} 70 | scanned.each do |sect| 71 | title = sect[0].downcase.strip.gsub(/ +/, '_').to_sym 72 | content = sect[1].split("\n").map(&:strip).delete_if(&:empty?) 73 | sections[title] = content 74 | end 75 | sections 76 | end 77 | 78 | def parse_option(option) 79 | res = option.match(/(?:-(?\w), )?(?:--(?:\[no-\])?(?w+)(?:=(?\w+))?)\s+- (?.*?)$/) 80 | return nil unless res 81 | 82 | { 83 | short: res['short'], 84 | long: res['long'], 85 | arg: res[:arg], 86 | description: res['desc'].short_desc 87 | } 88 | end 89 | 90 | def parse_options(options) 91 | options.map { |opt| parse_option(opt) } 92 | end 93 | 94 | def parse_command(command) 95 | res = command.match(/^(?[^, \t]+)(?(?:, [^, \t]+)*)?\s+- (?.*?)$/) 96 | commands = [res['cmd']] 97 | commands.concat(res['alias'].split(', ').delete_if(&:empty?)) if res['alias'] 98 | 99 | { 100 | commands: commands, 101 | description: res['desc'].short_desc 102 | } 103 | end 104 | 105 | def parse_commands(commands) 106 | commands.map { |cmd| parse_command(cmd) } 107 | end 108 | 109 | def generate_subcommand_completions 110 | out = [] 111 | @commands.each_with_index do |cmd, _i| 112 | out << "complete -xc na -n '__fish_na_needs_command' -a '#{cmd[:commands].join(' ')}' -d #{Shellwords.escape(cmd[:description])}" 113 | end 114 | 115 | out.join("\n") 116 | end 117 | 118 | def generate_subcommand_option_completions 119 | out = [] 120 | need_export = [] 121 | 122 | @commands.each_with_index do |cmd, _i| 123 | @bar.advance 124 | data = get_help_sections(cmd[:commands].first) 125 | 126 | out << "complete -c na -F -n '__fish_na_using_command #{cmd[:commands].join(' ')}'" if data[:synopsis].join(' ').strip.split(/ /).last =~ /(path|file)/i 127 | 128 | next unless data[:command_options] 129 | 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 | 142 | out << "complete -f -c na -s o -l output -x -n '__fish_na_using_command #{need_export.join(' ')}' -a '(__fish_na_export_plugins)'" unless need_export.empty? 143 | 144 | # clear 145 | out.join("\n") 146 | end 147 | 148 | def initialize 149 | data = get_help_sections 150 | @global_options = parse_options(data[:global_options]) 151 | @commands = parse_commands(data[:commands]) 152 | @bar = TTY::ProgressBar.new("\033[0;0;33mGenerating Fish completions: \033[0;35;40m[:bar]\033[0m", total: @commands.count, bar_format: :blade) 153 | @bar.resize(25) 154 | end 155 | 156 | def generate_completions 157 | @bar.start 158 | out = [] 159 | out << generate_helpers 160 | out << generate_subcommand_completions 161 | out << generate_subcommand_option_completions 162 | @bar.finish 163 | out.join("\n") 164 | end 165 | end 166 | 167 | puts FishCompletions.new.generate_completions 168 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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 mise-managed 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 = `mise current ruby`.match(/(\d.\d+.\d+)/)[1] 66 | 67 | `mise list ruby`.split.map { |ruby| ruby.strip.sub(/^*/, '') }.each do |ruby| 68 | `mise shell ruby #{ruby}` 69 | puts `gem install #{file}` 70 | end 71 | 72 | `mise 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 | 
211 | desc 'Run tests with coverage'
212 | task :coverage do
213 |   ENV['COVERAGE'] = 'true'
214 |   Rake::Task[:test].invoke
215 | end
216 | 


--------------------------------------------------------------------------------
/lib/na/types.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require 'na/string'
  4 | 
  5 | module NA
  6 |   # Custom types for GLI
  7 |   # Provides natural language date/time and duration parsing
  8 |   # Uses chronify gem for parsing
  9 |   module Types
 10 |     module_function
 11 | 
 12 |     # Normalize shorthand relative durations to phrases Chronic can parse.
 13 |     # Examples:
 14 |     #  - "30m ago"    => "30 minutes ago"
 15 |     #  - "-30m"       => "30 minutes ago"
 16 |     #  - "2h30m"      => "2 hours 30 minutes ago" (when default_past)
 17 |     #  - "2h 30m ago" => "2 hours 30 minutes ago"
 18 |     #  - "2:30 ago"   => "2 hours 30 minutes ago"
 19 |     #  - "-2:30"      => "2 hours 30 minutes ago"
 20 |     # Accepts d,h,m units; hours:minutes pattern; optional leading '-'; optional 'ago'.
 21 |     # @param value [String] the duration string to normalize
 22 |     # @param default_past [Boolean] whether to default to past tense
 23 |     # @return [String] the normalized duration string
 24 |     def normalize_relative_duration(value, default_past: false)
 25 |       return value if value.nil?
 26 | 
 27 |       s = value.to_s.strip
 28 |       return s if s.empty?
 29 | 
 30 |       has_ago = s =~ /\bago\b/i
 31 |       negative = s.start_with?('-')
 32 | 
 33 |       text = s.sub(/^[-+]/, '')
 34 | 
 35 |       # hours:minutes pattern (e.g., 2:30, 02:30)
 36 |       if (m = text.match(/^(\d{1,2}):(\d{1,2})(?:\s*ago)?$/i))
 37 |         hours = m[1].to_i
 38 |         minutes = m[2].to_i
 39 |         parts = []
 40 |         parts << "#{hours} hours" if hours.positive?
 41 |         parts << "#{minutes} minutes" if minutes.positive?
 42 |         return "#{parts.join(' ')} ago"
 43 |       end
 44 | 
 45 |       # Compound d/h/m (order independent, allow spaces): e.g., 1d2h30m, 2h 30m, 30m
 46 |       days = hours = minutes = 0
 47 |       found = false
 48 |       if (dm = text.match(/(?:(\d+)\s*d)/i))
 49 |         days = dm[1].to_i
 50 |         found = true
 51 |       end
 52 |       if (hm = text.match(/(?:(\d+)\s*h)/i))
 53 |         hours = hm[1].to_i
 54 |         found = true
 55 |       end
 56 |       if (mm = text.match(/(?:(\d+)\s*m)/i))
 57 |         minutes = mm[1].to_i
 58 |         found = true
 59 |       end
 60 | 
 61 |       if found
 62 |         parts = []
 63 |         parts << "#{days} days" if days.positive?
 64 |         parts << "#{hours} hours" if hours.positive?
 65 |         parts << "#{minutes} minutes" if minutes.positive?
 66 |         # Determine if we should make it past-tense
 67 |         return "#{parts.join(' ')} ago" if negative || has_ago || default_past
 68 | 
 69 |         return parts.join(' ')
 70 | 
 71 |       end
 72 | 
 73 |       # Fall through: not a shorthand we handle
 74 |       s
 75 |     end
 76 | 
 77 |     # Parse a natural-language/iso date string for a start time
 78 |     # @param value [String] the date string to parse
 79 |     # @return [Time] the parsed date, or nil if parsing fails
 80 |     def parse_date_begin(value)
 81 |       return nil if value.nil? || value.to_s.strip.empty?
 82 | 
 83 |       # Prefer explicit ISO first (only if the value looks ISO-like)
 84 |       iso_rx = /\A\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\z/
 85 |       if value.to_s.strip =~ iso_rx
 86 |         begin
 87 |           return Time.parse(value)
 88 |         rescue StandardError
 89 |           # fall through to chronify
 90 |         end
 91 |       end
 92 | 
 93 |       # Fallback to chronify with guess begin
 94 |       begin
 95 |         # Normalize shorthand (e.g., 2h30m, -2:30, 30m ago)
 96 |         txt = normalize_relative_duration(value.to_s, default_past: true)
 97 |         # Bias to past for expressions like "ago", "yesterday", or "last ..."
 98 |         future = txt !~ /(\bago\b|yesterday|\blast\b)/i
 99 |         result = txt.chronify(guess: :begin, future: future)
100 |         NA.notify("Parsed '#{value}' as #{result}", debug: true) if result
101 |         result
102 |       rescue StandardError
103 |         nil
104 |       end
105 |     end
106 | 
107 |     # Parse a natural-language/iso date string for an end time
108 |     # @param value [String] the date string to parse
109 |     # @return [Time] the parsed date, or nil if parsing fails
110 |     def parse_date_end(value)
111 |       return nil if value.nil? || value.to_s.strip.empty?
112 | 
113 |       # Prefer explicit ISO first (only if the value looks ISO-like)
114 |       iso_rx = /\A\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\z/
115 |       if value.to_s.strip =~ iso_rx
116 |         begin
117 |           return Time.parse(value)
118 |         rescue StandardError
119 |           # fall through to chronify
120 |         end
121 |       end
122 | 
123 |       # Fallback to chronify with guess end
124 |       value.to_s.chronify(guess: :end, future: false)
125 |     end
126 | 
127 |     # Convert duration expressions to seconds
128 |     # Supports: "90" (minutes), "45m", "2h", "1d2h30m", with optional leading '-' or trailing 'ago'
129 |     # Also supports "2:30", "2:30 ago", and word forms like "2 hours 30 minutes (ago)"
130 |     # @param value [String] the duration string to parse
131 |     # @return [Integer] the duration in seconds, or nil if parsing fails
132 |     def parse_duration_seconds(value)
133 |       return nil if value.nil?
134 | 
135 |       s = value.to_s.strip
136 |       return nil if s.empty?
137 | 
138 |       # Strip leading sign and optional 'ago'
139 |       s = s.sub(/^[-+]/, '')
140 |       s = s.sub(/\bago\b/i, '').strip
141 | 
142 |       # H:MM pattern
143 |       m = s.match(/^(\d{1,2}):(\d{1,2})$/)
144 |       if m
145 |         hours = m[1].to_i
146 |         minutes = m[2].to_i
147 |         return (hours * 3600) + (minutes * 60)
148 |       end
149 | 
150 |       # d/h/m compact with letters, order independent (e.g., 1d2h30m, 2h 30m, 30m)
151 |       m = s.match(/^(?:(?\d+)\s*d)?\s*(?:(?\d+)\s*h)?\s*(?:(?\d+)\s*m)?$/i)
152 |       if m && !m[0].strip.empty? && (m['day'] || m['hour'] || m['min'])
153 |         return [[m['day'], 86_400], [m['hour'], 3600], [m['min'], 60]].map { |q, mult| q ? q.to_i * mult : 0 }.sum
154 |       end
155 | 
156 |       # Word forms: e.g., "2 hours 30 minutes", "1 day 2 hours", etc.
157 |       days = 0
158 |       hours = 0
159 |       minutes = 0
160 |       found_word = false
161 |       if (dm = s.match(/(\d+)\s*(?:day|days)\b/i))
162 |         days = dm[1].to_i
163 |         found_word = true
164 |       end
165 |       if (hm = s.match(/(\d+)\s*(?:hour|hours|hr|hrs)\b/i))
166 |         hours = hm[1].to_i
167 |         found_word = true
168 |       end
169 |       if (mm = s.match(/(\d+)\s*(?:minute|minutes|min|mins)\b/i))
170 |         minutes = mm[1].to_i
171 |         found_word = true
172 |       end
173 |       return (days * 86_400) + (hours * 3600) + (minutes * 60) if found_word
174 | 
175 |       # Plain number => minutes
176 |       return s.to_i * 60 if s =~ /^\d+$/
177 | 
178 |       # Last resort: try chronify two points and take delta
179 |       begin
180 |         start = Time.now
181 |         finish = s.chronify(context: 'now', guess: :end, future: false)
182 |         return (finish - start).abs.to_i if finish
183 |       rescue StandardError
184 |         # ignore
185 |       end
186 | 
187 |       nil
188 |     end
189 |   end
190 | end
191 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/test/editor_test.rb:
--------------------------------------------------------------------------------
  1 | require_relative "test_helper"
  2 | require "na/editor"
  3 | require "tempfile"
  4 | 
  5 | class EditorTest < Minitest::Test
  6 |   def test_default_editor_with_git_editor
  7 |     ENV["NA_EDITOR"] = nil
  8 |     ENV["GIT_EDITOR"] = "vim"
  9 |     ENV["EDITOR"] = nil
 10 |     TTY::Which.stub(:exist?, true) do
 11 |       assert_equal "vim", NA::Editor.default_editor(prefer_git_editor: true)
 12 |     end
 13 |   end
 14 | 
 15 |   def test_default_editor_with_editor_only
 16 |     ENV["NA_EDITOR"] = nil
 17 |     ENV["GIT_EDITOR"] = nil
 18 |     ENV["EDITOR"] = "nano"
 19 |     TTY::Which.stub(:exist?, true) do
 20 |       assert_equal "nano", NA::Editor.default_editor(prefer_git_editor: true)
 21 |     end
 22 |   end
 23 | 
 24 |   def test_default_editor_with_na_editor_only
 25 |     ENV["NA_EDITOR"] = "emacs"
 26 |     ENV["GIT_EDITOR"] = nil
 27 |     ENV["EDITOR"] = nil
 28 |     TTY::Which.stub(:exist?, true) do
 29 |       assert_equal "emacs", NA::Editor.default_editor(prefer_git_editor: true)
 30 |     end
 31 |   end
 32 |   def setup
 33 |     @orig_env = ENV.to_hash
 34 |   end
 35 | 
 36 |   def teardown
 37 |     ENV.replace(@orig_env)
 38 |   end
 39 | 
 40 |   def test_args_for_editor
 41 |     assert_equal "vim -f", NA::Editor.args_for_editor("vim")
 42 |     assert_equal "subl -w", NA::Editor.args_for_editor("subl")
 43 |     assert_equal "code -w", NA::Editor.args_for_editor("code")
 44 |     assert_equal "mate -w", NA::Editor.args_for_editor("mate")
 45 |     assert_equal "mvim -f", NA::Editor.args_for_editor("mvim")
 46 |     assert_equal "nano ", NA::Editor.args_for_editor("nano")
 47 |     assert_equal "emacs ", NA::Editor.args_for_editor("emacs")
 48 |     assert_equal "vim -f", NA::Editor.args_for_editor("vim")
 49 |     assert_equal "vim -f", NA::Editor.args_for_editor("vim -f")
 50 |   end
 51 | 
 52 |   def test_format_input_basic
 53 |     input = "Title\nNote line 1\nNote line 2"
 54 |     title, note = NA::Editor.format_input(input)
 55 |     assert_equal "Title", title
 56 |     assert_equal ["Note line 1", "Note line 2"], note
 57 |   end
 58 | 
 59 |   def test_format_input_empty
 60 |     assert_raises(SystemExit) { NA::Editor.format_input("") }
 61 |     assert_raises(SystemExit) { NA::Editor.format_input(nil) }
 62 |   end
 63 | 
 64 |   def test_format_input_removes_comments_and_blank_lines
 65 |     input = "Title\nNote line 1\n# This is a comment\n   \nNote line 2"
 66 |     title, note = NA::Editor.format_input(input)
 67 |     assert_equal "Title", title
 68 |     assert_equal ["Note line 1", "Note line 2"], note
 69 |   end
 70 | 
 71 |   def test_format_input_expands_date_tags
 72 |   input = "Title with {date}\nNote"
 73 |   title, note = NA::Editor.format_input(input)
 74 |   assert_equal "Title with {date}", title
 75 |   assert_equal ["Note"], note
 76 |   end
 77 | 
 78 |   # default_editor and fork_editor require environment and system interaction,
 79 |   # so only basic presence and fallback logic can be tested here.
 80 |   def test_default_editor_returns_env
 81 |     ENV["NA_EDITOR"] = "nano"
 82 |     TTY::Which.stub(:exist?, true) do
 83 |       assert_equal "nano", NA::Editor.default_editor
 84 |     end
 85 |   end
 86 | 
 87 |   def test_default_editor_fallback
 88 |     ENV.delete("NA_EDITOR")
 89 |     ENV.delete("GIT_EDITOR")
 90 |     ENV.delete("EDITOR")
 91 |     TTY::Which.stub(:exist?, false) do
 92 |       TTY::Which.stub(:which, "nano") do
 93 |         NA.stub(:notify, nil) do
 94 |           assert_equal "nano", NA::Editor.default_editor
 95 |         end
 96 |       end
 97 |     end
 98 |   end
 99 | 
100 |   def test_format_multi_action_input
101 |     require "na/action"
102 |     actions = [
103 |       NA::Action.new('./test1.taskpaper', "Project1", [], "Action 1", 10, ["Note 1"]),
104 |       NA::Action.new('./test2.taskpaper', "Project2", [], "Action 2", 20)
105 |     ]
106 | 
107 |     content = NA::Editor.format_multi_action_input(actions)
108 | 
109 |     # Check for header comments
110 |     assert_match(/Edit the action text/, content)
111 |     assert_match(/Blank lines/, content)
112 | 
113 |     # Check for file markers
114 |     assert_match(/------ .*taskpaper:10/, content)
115 |     assert_match(/------ .*taskpaper:20/, content)
116 | 
117 |     # Check for action content
118 |     assert_match(/Action 1/, content)
119 |     assert_match(/Action 2/, content)
120 | 
121 |     # Check for notes
122 |     assert_match(/Note 1/, content)
123 |   end
124 | 
125 |   def test_format_multi_action_input_with_notes
126 |     require "na/action"
127 |     actions = [
128 |       NA::Action.new('./test.taskpaper', "Project", [], "Action", 15, ["Note line 1", "Note line 2"])
129 |     ]
130 | 
131 |     content = NA::Editor.format_multi_action_input(actions)
132 | 
133 |     assert_match(/Action/, content)
134 |     assert_match(/Note line 1/, content)
135 |     assert_match(/Note line 2/, content)
136 |   end
137 | 
138 |   def test_parse_multi_action_output
139 |     content = <<~CONTENT
140 |       # Do not edit # comment lines. Add notes on new lines after the action.
141 |       # Blank lines will be ignored
142 | 
143 |       # ------ ./test1.taskpaper:10
144 |       Updated Action 1
145 |       Updated Note 1
146 | 
147 |       # ------ ./test2.taskpaper:20
148 |       Updated Action 2
149 |     CONTENT
150 | 
151 |     results = NA::Editor.parse_multi_action_output(content)
152 | 
153 |     assert_equal 2, results.length
154 | 
155 |     # Check first action
156 |     assert results.has_key?('./test1.taskpaper:10')
157 |     action1, note1 = results['./test1.taskpaper:10']
158 |     assert_equal "Updated Action 1", action1
159 |     assert_equal ["Updated Note 1"], note1
160 | 
161 |     # Check second action
162 |     assert results.has_key?('./test2.taskpaper:20')
163 |     action2, note2 = results['./test2.taskpaper:20']
164 |     assert_equal "Updated Action 2", action2
165 |     assert_empty note2
166 |   end
167 | 
168 |   def test_parse_multi_action_output_ignores_comments_and_blanks
169 |     content = <<~CONTENT
170 |       # Some comment
171 |       # Do not edit # comment lines
172 | 
173 |       # ------ ./test.taskpaper:5
174 |       Action with notes
175 |       Note 1
176 |       Note 2
177 | 
178 |       # Another comment
179 |     CONTENT
180 | 
181 |     results = NA::Editor.parse_multi_action_output(content)
182 | 
183 |     assert_equal 1, results.length
184 |     assert results.has_key?('./test.taskpaper:5')
185 |     action, note = results['./test.taskpaper:5']
186 |     assert_equal "Action with notes", action
187 |     assert_equal ["Note 1", "Note 2"], note
188 |   end
189 | 
190 |   def test_parse_multi_action_output_single_action
191 |     content = <<~CONTENT
192 |       # ------ ./test.taskpaper:1
193 |       Single action
194 |     CONTENT
195 | 
196 |     results = NA::Editor.parse_multi_action_output(content)
197 | 
198 |     assert_equal 1, results.length
199 |     action, note = results['./test.taskpaper:1']
200 |     assert_equal "Single action", action
201 |     assert_empty note
202 |   end
203 | 
204 |   def test_parse_multi_action_output_with_multiple_notes
205 |     content = <<~CONTENT
206 |       # ------ ./test.taskpaper:1
207 |       Main action
208 |       First note
209 |       Second note
210 |       Third note
211 |     CONTENT
212 | 
213 |     results = NA::Editor.parse_multi_action_output(content)
214 |     action, note = results['./test.taskpaper:1']
215 |     assert_equal "Main action", action
216 |     assert_equal ["First note", "Second note", "Third note"], note
217 |   end
218 | end
219 | 


--------------------------------------------------------------------------------
/lib/na/editor.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require 'English'
  4 | 
  5 | module NA
  6 |   # Provides editor selection and argument helpers for launching text editors.
  7 |   module Editor
  8 |     class << self
  9 |       # Returns the default editor command, checking environment variables and available editors.
 10 |       # @param prefer_git_editor [Boolean] Prefer GIT_EDITOR over EDITOR
 11 |       # @return [String, nil] Editor command or nil if not found
 12 |       def default_editor(prefer_git_editor: true)
 13 |         editor ||= if prefer_git_editor
 14 |                      ENV['NA_EDITOR'] || ENV['GIT_EDITOR'] || ENV.fetch('EDITOR', nil)
 15 |                    else
 16 |                      ENV['NA_EDITOR'] || ENV['EDITOR'] || ENV.fetch('GIT_EDITOR', nil)
 17 |                    end
 18 | 
 19 |         return editor if editor&.good? && TTY::Which.exist?(editor)
 20 | 
 21 |         NA.notify('No EDITOR environment variable, testing available editors', debug: true)
 22 |         editors = %w[vim vi code subl mate mvim nano emacs]
 23 |         editors.each do |ed|
 24 |           try = TTY::Which.which(ed)
 25 |           if try
 26 |             NA.notify("Using editor #{try}", debug: true)
 27 |             return try
 28 |           end
 29 |         end
 30 | 
 31 |         NA.notify("#{NA.theme[:error]}No editor found", exit_code: 5)
 32 | 
 33 |         nil
 34 |       end
 35 | 
 36 |       # Returns the default editor command with its arguments.
 37 |       # @return [String] Editor command with arguments
 38 |       def editor_with_args
 39 |         args_for_editor(default_editor)
 40 |       end
 41 | 
 42 |       # Returns the editor command with appropriate arguments for file opening.
 43 |       # @param editor [String] Editor command
 44 |       # @return [String] Editor command with arguments
 45 |       def args_for_editor(editor)
 46 |         return editor if editor =~ /-\S/
 47 | 
 48 |         args = case editor
 49 |                when /^(subl|code|mate)$/
 50 |                  ['-w']
 51 |                when /^(vim|mvim)$/
 52 |                  ['-f']
 53 |                else
 54 |                  []
 55 |                end
 56 |         "#{editor} #{args.join(' ')}"
 57 |       end
 58 | 
 59 |       # Create a process for an editor and wait for the file handle to return
 60 |       #
 61 |       # @param input [String] Text input for editor
 62 |       # @return [String] Edited text
 63 |       def fork_editor(input = '', message: :default)
 64 |         # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
 65 | 
 66 |         NA.notify("#{NA.theme[:error]}No EDITOR variable defined in environment", exit_code: 5) if default_editor.nil?
 67 | 
 68 |         tmpfile = Tempfile.new(['na_temp', '.na'])
 69 | 
 70 |         File.open(tmpfile.path, 'w+') do |f|
 71 |           f.puts input
 72 |           unless message.nil?
 73 |             f.puts message == :default ? '# First line is the action, lines after are added as a note' : message
 74 |           end
 75 |         end
 76 | 
 77 |         pid = Process.fork { system("#{editor_with_args} #{tmpfile.path}") }
 78 | 
 79 |         trap('INT') do
 80 |           begin
 81 |             Process.kill(9, pid)
 82 |           rescue StandardError
 83 |             Errno::ESRCH
 84 |           end
 85 |           tmpfile.unlink
 86 |           tmpfile.close!
 87 |           exit 0
 88 |         end
 89 | 
 90 |         Process.wait(pid)
 91 | 
 92 |         begin
 93 |           if $CHILD_STATUS.exitstatus.zero?
 94 |             input = File.read(tmpfile.path)
 95 |           else
 96 |             exit_now! 'Cancelled'
 97 |           end
 98 |         ensure
 99 |           tmpfile.close
100 |           tmpfile.unlink
101 |         end
102 | 
103 |         # Don't strip comments if this looks like multi-action format (has # ------ markers)
104 |         if input.include?('# ------ ')
105 |           input
106 |         else
107 |           input.split("\n").delete_if(&:ignore?).join("\n")
108 |         end
109 |       end
110 | 
111 |       # Takes a multi-line string and formats it as an entry
112 |       #
113 |       # @param input [String] The string to parse
114 |       # @return [Array] [[String]title, [Note]note]
115 |       def format_input(input)
116 |         NA.notify("#{NA.theme[:error]}No content in entry", exit_code: 1) if input.nil? || input.strip.empty?
117 | 
118 |         input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
119 |         title = input_lines[0]&.strip
120 |         NA.notify("#{NA.theme[:error]}No content in first line", exit_code: 1) if title.nil? || title.strip.empty?
121 | 
122 |         title = title.expand_date_tags
123 | 
124 |         note = if input_lines.length > 1
125 |                  input_lines[1..]
126 |                else
127 |                  []
128 |                end
129 | 
130 |         unless note.empty?
131 |           note.map!(&:strip)
132 |           note.delete_if { |l| l =~ /^\s*$/ || l =~ /^#/ }
133 |         end
134 | 
135 |         [title, note]
136 |       end
137 | 
138 |       # Format multiple actions for multi-edit
139 |       # @param actions [Array] Actions to edit
140 |       # @return [String] Formatted editor content
141 |       def format_multi_action_input(actions)
142 |         header = <<~EOF
143 |           # Instructions:
144 |           # - Edit the action text (the lines WITHOUT # comment markers)
145 |           # - DO NOT remove or edit the lines starting with "# ------"
146 |           # - Add notes on new lines after the action
147 |           # - Blank lines are ignored
148 |           #
149 | 
150 |         EOF
151 | 
152 |         # Use + to create a mutable string
153 |         content = +header
154 | 
155 |         actions.each do |action|
156 |           # Use file_path to get the path and file_line to get the line number
157 |           content << "# ------ #{action.file_path}:#{action.file_line}\n"
158 |           content << "#{action.action}\n"
159 |           content << "#{action.note.join("\n")}\n" if action.note.any?
160 |           content << "\n" # Blank line separator
161 |         end
162 | 
163 |         content
164 |       end
165 | 
166 |       # Parse multi-action editor output
167 |       # @param content [String] Editor output
168 |       # @return [Hash] Hash mapping file:line to [action, note]
169 |       def parse_multi_action_output(content)
170 |         results = {}
171 |         current_file = nil
172 |         current_action = nil
173 |         current_note = []
174 | 
175 |         content.lines.each do |line|
176 |           stripped = line.strip
177 | 
178 |           # Check for file marker: # ------ path:line
179 |           match = stripped.match(/^# ------ (.+?):(\d+)$/)
180 |           if match
181 |             # Save previous action if exists
182 |             results[current_file] = [current_action, current_note] if current_file && current_action
183 | 
184 |             # Start new action
185 |             current_file = "#{match[1]}:#{match[2]}"
186 |             current_action = nil
187 |             current_note = []
188 |             next
189 |           end
190 | 
191 |           # Skip other comment lines
192 |           next if stripped.start_with?('#')
193 | 
194 |           # Skip blank lines
195 |           next if stripped.empty?
196 | 
197 |           # Store as action or note based on what we've seen so far
198 |           if current_action.nil?
199 |             current_action = stripped
200 |           else
201 |             # Subsequent lines are notes
202 |             current_note << stripped
203 |           end
204 |         end
205 | 
206 |         # Save last action
207 |         results[current_file] = [current_action, current_note] if current_file && current_action
208 | 
209 |         results
210 |       end
211 |     end
212 |   end
213 | end
214 | 


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


--------------------------------------------------------------------------------
/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/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.desc "Started time (natural language or ISO)"
 15 |     c.arg_name "DATE"
 16 |     c.flag %i[started], type: :date_begin
 17 | 
 18 |     c.desc "End/Finished time (natural language or ISO)"
 19 |     c.arg_name "DATE"
 20 |     c.flag %i[end finished], type: :date_end
 21 | 
 22 |     c.desc "Duration (e.g. 45m, 2h, 1d2h30m, or minutes)"
 23 |     c.arg_name "DURATION"
 24 |     c.flag %i[duration], type: :duration
 25 |     c.example 'na add "A cool feature I thought of @idea"', desc: "Add a new action to the Inbox, including a tag"
 26 |     c.example 'na add "A bug I need to fix" -p 4 -n',
 27 |               desc: "Add a new action to the Inbox, set its @priority to 4, and prompt for an additional note."
 28 |     c.example 'na add "An action item (with a note)"',
 29 |               desc: "A parenthetical at the end of an action is interpreted as a note"
 30 | 
 31 |     c.desc "Prompt for additional notes. STDIN input (piped) will be treated as a note if present."
 32 |     c.switch %i[n note], negatable: false
 33 | 
 34 |     c.desc "Add a priority level 1-5 or h, m, l"
 35 |     c.arg_name "PRIO"
 36 |     c.flag %i[p priority], must_match: /[1-5hml]/, default_value: 0
 37 | 
 38 |     c.desc "Add action to specific project"
 39 |     c.arg_name "PROJECT"
 40 |     c.default_value "Inbox"
 41 |     c.flag %i[to project proj]
 42 | 
 43 |     c.desc "Add task at [s]tart or [e]nd of target project"
 44 |     c.arg_name "POSITION"
 45 |     c.flag %i[at], must_match: /^[sbea].*?$/i
 46 | 
 47 |     c.desc "Add to a known todo file, partial matches allowed"
 48 |     c.arg_name "TODO_FILE"
 49 |     c.flag %i[in todo]
 50 | 
 51 |     c.desc "Use a tag other than the default next action tag"
 52 |     c.arg_name "TAG"
 53 |     c.flag %i[t tag]
 54 | 
 55 |     c.desc 'Don\'t add next action tag to new entry'
 56 |     c.switch %i[x], negatable: false
 57 | 
 58 |     c.desc "Specify the file to which the task should be added"
 59 |     c.arg_name "PATH"
 60 |     c.flag %i[f file]
 61 | 
 62 |     c.desc "Mark task as @done with date"
 63 |     c.switch %i[finish done], negatable: false
 64 | 
 65 |     c.desc "Search for files X directories deep"
 66 |     c.arg_name "DEPTH"
 67 |     c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
 68 | 
 69 |     c.action do |global_options, options, args|
 70 |       reader = TTY::Reader.new
 71 |       append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/
 72 | 
 73 |       priority = options[:priority].to_s || "0"
 74 |       if priority =~ /^[1-5]$/
 75 |         priority = priority.to_i
 76 |       elsif priority =~ /^[hml]$/
 77 |         priority = NA.priority_map[priority]
 78 |       else
 79 |         priority = 0
 80 |       end
 81 | 
 82 |       if NA.global_file
 83 |         target = File.expand_path(NA.global_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[:file]
 94 |         target = File.expand_path(options[:file])
 95 |         unless File.exist?(target)
 96 |           res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Specified file not found, create it"), default: true)
 97 |           if res
 98 |             basename = File.basename(target, ".#{NA.extension}")
 99 |             NA.create_todo(target, basename, template: global_options[:template])
100 |           else
101 |             NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
102 |           end
103 |         end
104 |       elsif options[:todo]
105 |         todo = []
106 |         all_req = options[:todo] !~ /[+!\-]/
107 |         options[:todo].split(/ *, */).each do |a|
108 |           m = a.match(/^(?[+\-!])?(?.*?)$/)
109 |           todo.push({
110 |                       token: m["tok"],
111 |                       required: all_req || (!m["req"].nil? && m["req"] == "+"),
112 |                       negate: !m["req"].nil? && m["req"] =~ /[!\-]/,
113 |                     })
114 |         end
115 |         dirs = NA.match_working_dir(todo)
116 |         if dirs.count.positive?
117 |           target = dirs[0]
118 |         else
119 |           todo = "#{options[:todo].sub(/#{NA.extension}$/, "")}.#{NA.extension}"
120 |           target = File.expand_path(todo)
121 |           unless File.exist?(target)
122 |             res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Specified file not found, create #{todo}"), default: true)
123 |             NA.notify("#{NA.theme[:error]}Cancelled{x}", exit_code: 1) unless res
124 | 
125 |             basename = File.basename(target, ".#{NA.extension}")
126 |             NA.create_todo(target, basename, template: global_options[:template])
127 |           end
128 |         end
129 |       else
130 |         files = NA.find_files(depth: options[:depth])
131 |         if files.count.zero?
132 |           res = NA.yn(NA::Color.template("#{NA.theme[:warning]}No todo file found, create one"), default: true)
133 |           if res
134 |             basename = File.expand_path(".").split("/").last
135 |             target = "#{basename}.#{NA.extension}"
136 |             NA.create_todo(target, basename, template: global_options[:template])
137 |             files = NA.find_files(depth: 1)
138 |           end
139 |         end
140 |         target = files.count > 1 ? NA.select_file(files) : files[0]
141 |         NA.notify("#{NA.theme[:error]}Cancelled{x}", exit_code: 1) unless files.count.positive? && File.exist?(target)
142 |       end
143 | 
144 |       action = if args.count.positive?
145 |           args.join(" ").strip
146 |         else
147 |           NA.request_input(options, prompt: "Enter a task")
148 |         end
149 | 
150 |       if action.nil? || action.empty?
151 |         puts "Empty input, cancelled"
152 |         Process.exit 1
153 |       end
154 | 
155 |       note_rx = /^(.+) \(([^)]+)\)$/
156 |       split_note = if action =~ note_rx
157 |           n = Regexp.last_match(2)
158 |           action.sub!(note_rx, '\1').strip!
159 |           n
160 |         end
161 | 
162 |       if priority&.to_i&.positive?
163 |         action = "#{action.gsub(/@priority\(\d+\)/, "")} @priority(#{priority})"
164 |       end
165 | 
166 |       na_tag = NA.na_tag
167 |       if options[:x]
168 |         na_tag = ""
169 |       else
170 |         na_tag = options[:tag] unless options[:tag].nil?
171 |         na_tag = " @#{na_tag}"
172 |       end
173 | 
174 |       action = "#{action.gsub(/#{na_tag}\b/, "")}#{na_tag}"
175 | 
176 |       stdin_note = NA.stdin ? NA.stdin.split("\n") : []
177 | 
178 |       line_note = if options[:note] && $stdin.isatty
179 |           puts stdin_note unless stdin_note.nil?
180 |           if TTY::Which.exist?("gum")
181 |             args = ['--placeholder "Enter additional note, CTRL-d to save"']
182 |             args << "--char-limit 0"
183 |             args << "--width $(tput cols)"
184 |             `gum write #{args.join(" ")}`.strip.split("\n")
185 |           else
186 |             NA.notify("#{NA.theme[:prompt]}Enter a note, {bw}CTRL-d#{NA.theme[:prompt]} to end editing#{NA.theme[:action]}")
187 |             reader.read_multiline
188 |           end
189 |         end
190 | 
191 |       note = stdin_note.empty? ? [] : stdin_note
192 |       note.<< split_note unless split_note.nil?
193 |       note.concat(line_note) unless line_note.nil?
194 | 
195 |       # Compute started/done based on flags
196 |       started_at = options[:started]
197 |       started_at = NA::Types.parse_date_begin(started_at) if started_at && !started_at.is_a?(Time)
198 |       done_at = options[:end] || options[:finished]
199 |       done_at = NA::Types.parse_date_end(done_at) if done_at && !done_at.is_a?(Time)
200 |       duration_seconds = options[:duration]
201 | 
202 |       NA.notify("ADD parsed started_at=#{started_at.inspect} done_at=#{done_at.inspect} duration=#{duration_seconds.inspect}", debug: true)
203 | 
204 |       # Ensure @started is present in the action text if a start time was provided
205 |       if started_at
206 |         started_str = started_at.strftime('%Y-%m-%d %H:%M')
207 |         # remove any existing @start/@started tag before appending
208 |         action = action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
209 |         action = "#{action} @started(#{started_str})"
210 |       end
211 | 
212 |       # Resolve TaskPaper-style item path in --to/--project if provided
213 |       project = options[:project]
214 |       if project && project.start_with?('/')
215 |         paths = NA.resolve_item_path(path: project, file: target)
216 |         if paths.count > 1
217 |           choices = paths.map { |p| "#{File.basename(target)}: #{p}" }
218 |           res = NA.choose_from(choices, prompt: 'Select target project', multiple: false)
219 |           if res
220 |             m = res.match(/: (.+)\z/)
221 |             project = m ? m[1] : project
222 |           else
223 |             NA.notify("#{NA.theme[:error]}No project selected, cancelled", exit_code: 1)
224 |           end
225 |         elsif paths.count == 1
226 |           project = paths.first
227 |         end
228 |       end
229 | 
230 |       NA.add_action(target, project, action, note,
231 |                     finish: options[:finish], append: append,
232 |                     started_at: started_at, done_at: done_at, duration_seconds: duration_seconds)
233 |     end
234 |   end
235 | end
236 | 


--------------------------------------------------------------------------------
/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 |     end
  9 | 
 10 |     # Pretty print a list of actions
 11 |     #
 12 |     # @param depth [Integer] The depth of the action
 13 |     # @param config [Hash] The configuration options
 14 |     # @option config [Array] :files The files to include in the output
 15 |     # @option config [Array] :regexes The regexes to match against
 16 |     # @option config [Boolean] :notes Whether to include notes in the output
 17 |     # @option config [Boolean] :nest Whether to nest the output
 18 |     # @option config [Boolean] :nest_projects Whether to nest projects in the output
 19 |     # @option config [Boolean] :no_files Whether to include files in the output
 20 |     # @return [String] The output string
 21 |     def output(depth, config = {})
 22 |       NA::Benchmark.measure('Actions.output') do
 23 |         defaults = {
 24 |           files: nil,
 25 |           regexes: [],
 26 |           notes: false,
 27 |           nest: false,
 28 |           nest_projects: false,
 29 |           no_files: false,
 30 |           times: false,
 31 |           human: false,
 32 |           only_timed: false,
 33 |           json_times: false
 34 |         }
 35 |         config = defaults.merge(config)
 36 | 
 37 |         return if config[:files].nil?
 38 | 
 39 |         # Optionally filter to only actions with a computable duration (@started and @done)
 40 |         filtered_actions = if config[:only_timed]
 41 |                              self.select do |a|
 42 |                                t = a.tags
 43 |                                tl = t.transform_keys { |k| k.to_s.downcase }
 44 |                                (tl['started'] || tl['start']) && tl['done']
 45 |                              end
 46 |                            else
 47 |                              self
 48 |                            end
 49 | 
 50 |         if config[:nest]
 51 |           template = NA.theme[:templates][:default]
 52 |           template = NA.theme[:templates][:no_file] if config[:no_files]
 53 | 
 54 |           parent_files = {}
 55 |           out = []
 56 | 
 57 |           if config[:nest_projects]
 58 |             filtered_actions.each do |action|
 59 |               parent_files[action.file] ||= []
 60 |               parent_files[action.file].push(action)
 61 |             end
 62 | 
 63 |             parent_files.each do |file, acts|
 64 |               projects = NA.project_hierarchy(acts)
 65 |               out.push("#{file.sub(%r{^./}, '').shorten_path}:")
 66 |               out.concat(NA.output_children(projects, 0))
 67 |             end
 68 |           else
 69 |             template = NA.theme[:templates][:default]
 70 |             template = NA.theme[:templates][:no_file] if config[:no_files]
 71 | 
 72 |             filtered_actions.each do |action|
 73 |               parent_files[action.file] ||= []
 74 |               parent_files[action.file].push(action)
 75 |             end
 76 | 
 77 |             parent_files.each do |file, acts|
 78 |               out.push("#{file.sub(%r{^\./}, '')}:")
 79 |               acts.each do |a|
 80 |                 out.push("\t- [#{a.parent.join('/')}] #{a.action}")
 81 |                 out.push("\t\t#{a.note.join("\n\t\t")}") unless a.note.empty?
 82 |               end
 83 |             end
 84 |           end
 85 |           NA::Pager.page out.join("\n")
 86 |         else
 87 |           # Optimize template selection
 88 |           template = if config[:no_files]
 89 |                        NA.theme[:templates][:no_file]
 90 |                      elsif config[:files]&.any?
 91 |                        config[:files].one? ? NA.theme[:templates][:single_file] : NA.theme[:templates][:multi_file]
 92 |                      elsif depth > 1
 93 |                        NA.theme[:templates][:multi_file]
 94 |                      else
 95 |                        NA.theme[:templates][:default]
 96 |                      end
 97 |           template += '%note' if config[:notes]
 98 | 
 99 |           # Show './' for current directory only when listing also includes subdir files
100 |           if template == NA.theme[:templates][:multi_file]
101 |             has_subdir = config[:files]&.any? { |f| File.dirname(f) != '.' } || depth > 1
102 |             NA.show_cwd_indicator = !has_subdir.nil?
103 |           else
104 |             NA.show_cwd_indicator = false
105 |           end
106 | 
107 |           # Skip debug output if not verbose
108 |           config[:files]&.each { |f| NA.notify(f, debug: true) } if config[:files] && NA.verbose
109 | 
110 |           # Optimize output generation - compile all output first, then apply regexes
111 |           output = String.new
112 |           total_seconds = 0
113 |           totals_by_tag = Hash.new(0)
114 |           timed_items = []
115 |           NA::Benchmark.measure('Generate action strings') do
116 |             filtered_actions.each_with_index do |action, idx|
117 |               # Generate raw output without regex processing
118 |               line = action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
119 | 
120 |               if config[:times]
121 |                 # compute duration from @started/@done
122 |                 tags = action.tags.transform_keys { |k| k.to_s.downcase }
123 |                 begun = tags['started'] || tags['start']
124 |                 finished = tags['done']
125 |                 if begun && finished
126 |                   begin
127 |                     start_t = Time.parse(begun)
128 |                     end_t = Time.parse(finished)
129 |                     secs = [end_t - start_t, 0].max.to_i
130 |                     total_seconds += secs
131 |                     dur_color = NA.theme[:duration] || '{y}'
132 |                     line << NA::Color.template(" #{dur_color}[#{format_duration(secs, human: config[:human])}]{x}")
133 | 
134 |                     # collect for JSON output
135 |                     timed_items << {
136 |                       action: NA::Color.uncolor(action.action),
137 |                       started: start_t.iso8601,
138 |                       ended: end_t.iso8601,
139 |                       duration: secs
140 |                     }
141 | 
142 |                     # accumulate per-tag durations (exclude time-control tags)
143 |                     tags.each_key do |k|
144 |                       next if k =~ /^(start|started|done)$/i
145 | 
146 |                       totals_by_tag[k.sub(/^@/, '')] += secs
147 |                     end
148 |                   rescue StandardError
149 |                     # ignore parse errors
150 |                   end
151 |                 end
152 |               end
153 | 
154 |               unless config[:only_times]
155 |                 output << line
156 |                 output << "\n" unless idx == filtered_actions.size - 1
157 |               end
158 |             end
159 |           end
160 | 
161 |           # If JSON output requested, emit JSON and return immediately
162 |           if config[:json_times]
163 |             require 'json'
164 |             json = {
165 |               timed: timed_items,
166 |               tags: totals_by_tag.map { |k, v| { tag: k, duration: v } }.sort_by { |h| -h[:duration] },
167 |               total: {
168 |                 seconds: total_seconds,
169 |                 timestamp: format_duration(total_seconds, human: false),
170 |                 human: format_duration(total_seconds, human: true)
171 |               }
172 |             }
173 |             puts JSON.pretty_generate(json)
174 |             return
175 |           end
176 | 
177 |           # Apply regex highlighting to the entire output at once
178 |           if config[:regexes].any?
179 |             NA::Benchmark.measure('Apply regex highlighting') do
180 |               output = output.highlight_search(config[:regexes])
181 |             end
182 |           end
183 | 
184 |           if config[:times] && total_seconds.positive?
185 |             # Build Markdown table of per-tag totals
186 |             if totals_by_tag.empty?
187 |               # No tag totals, just show total line
188 |               dur_color = NA.theme[:duration] || '{y}'
189 |               output << "\n"
190 |               output << NA::Color.template("{x}#{dur_color}Total time: [#{format_duration(total_seconds, human: config[:human])}]{x}")
191 |             else
192 |               rows = totals_by_tag.sort_by { |_, v| -v }.map do |tag, secs|
193 |                 disp = format_duration(secs, human: config[:human])
194 |                 ["@#{tag}", disp]
195 |               end
196 |               # Pre-compute total display for width calculation
197 |               total_disp = format_duration(total_seconds, human: config[:human])
198 |               # Determine column widths, including footer labels/values
199 |               tag_header = 'Tag'
200 |               dur_header = config[:human] ? 'Duration (human)' : 'Duration'
201 |               tag_width = ([tag_header.length, 'Total'.length] + rows.map { |r| r[0].length }).max
202 |               dur_width = ([dur_header.length, total_disp.length] + rows.map { |r| r[1].length }).max
203 | 
204 |               # Header
205 |               output << "\n"
206 |               output << "| #{tag_header.ljust(tag_width)} | #{dur_header.ljust(dur_width)} |\n"
207 |               # Separator for header
208 |               output << "| #{'-' * tag_width} | #{'-' * dur_width} |\n"
209 |               # Body rows
210 |               rows.each do |tag, disp|
211 |                 output << "| #{tag.ljust(tag_width)} | #{disp.ljust(dur_width)} |\n"
212 |               end
213 |               # Footer separator (kramdown footer separator with '=') and footer row
214 |               output << "| #{'=' * tag_width} | #{'=' * dur_width} |\n"
215 |               output << "| #{'Total'.ljust(tag_width)} | #{total_disp.ljust(dur_width)} |\n"
216 |             end
217 |           end
218 | 
219 |           NA::Benchmark.measure('Pager.page call') do
220 |             NA::Pager.page(output)
221 |           end
222 |         end
223 |       end
224 |     end
225 | 
226 |     private
227 | 
228 |     def format_duration(secs, human: false)
229 |       return '' if secs.nil?
230 | 
231 |       secs = secs.to_i
232 |       days = secs / 86_400
233 |       rem = secs % 86_400
234 |       hours = rem / 3600
235 |       rem %= 3600
236 |       minutes = rem / 60
237 |       seconds = rem % 60
238 |       if human
239 |         parts = []
240 |         parts << "#{days} days" if days.positive?
241 |         parts << "#{hours} hours" if hours.positive?
242 |         parts << "#{minutes} minutes" if minutes.positive?
243 |         parts << "#{seconds} seconds" if seconds.positive? || parts.empty?
244 |         parts.join(', ')
245 |       else
246 |         format('%02d:%02d:%02d:%02d', d: days, h: hours, m: minutes, s: seconds)
247 |       end
248 |     end
249 |   end
250 | end
251 | 


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