na 1.2.32 → 1.2.33

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 440413f1b22a8745d119a8b55098ad3fd9c36096f930ad5f25d6091a9c9cb496
4
- data.tar.gz: 02e42b27ea06069b669fdded2929b4351990b9e3be35add61cd734a50d2fa28a
3
+ metadata.gz: d77588ce09a5a3cfc680b1866081dba5f56e75c8b51301cf0e1efee60c32f8f7
4
+ data.tar.gz: 06d73082ad5371cda4760df90c929c74963d76843b7b979dde59e46fdb933389
5
5
  SHA512:
6
- metadata.gz: 3fae03d1566494bff4c792fa6921d975e94e9984688873e05e8936e97a3a0dc1f2134f6677218f66d53d6be87481100835fd66d4e30a27e1db42bf3cb09805c8
7
- data.tar.gz: 3d3c6482675e0c880c51d7f0ea460225c869376db8d84a245c296ec23dceabd9bf481387fac05c06c88d73e914529beb4effd540ea90b9d558e30be1e1ffcc9d
6
+ metadata.gz: 07ee85d5f2f95ba2542ff15ca3b6c3736bd85d4a5b1bd9c76a7099296b3976d9b0e2478d6e2b8b84a824787c9fc05050b89153c316b7878c9743e3cd4cad3a8a
7
+ data.tar.gz: 0af252e545f390a539279747f7d89e87b5efaceb82db3adfa57ce037cd6e4b139e18aa2d90340dec14d9eb90f78c3b8963c5f7e92d59258f1711ea1dbb76462c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ### 1.2.33
2
+
3
+ 2023-08-29 13:45
4
+
5
+ #### NEW
6
+
7
+ - `na update --edit` flag to open a single task in your default $EDITOR for modification and notes
8
+ - Expand natural language dates in recognized date-based tags (@due, @start, @deferred, etc.)
9
+ - Edit command now opens editor on matched action, previous edit
10
+
11
+ #### IMPROVED
12
+
13
+ - Allow multiple add or remove tags using comma-separated list
14
+
15
+ #### FIXED
16
+
17
+ - `na update --restore` irregularities
18
+ - Failing if using gum to input search string
19
+
1
20
  ### 1.2.32
2
21
 
3
22
  2023-08-29 10:59
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- na (1.2.32)
4
+ na (1.2.33)
5
5
  chronic (~> 0.10, >= 0.10.2)
6
6
  gli (~> 2.21.0)
7
7
  mdless (~> 1.0, >= 1.0.32)
data/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is 1.2.32
12
+ The current version of `na` is 1.2.33
13
13
  .
14
14
 
15
15
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
@@ -77,7 +77,7 @@ SYNOPSIS
77
77
  na [global options] command [command options] [arguments...]
78
78
 
79
79
  VERSION
80
- 1.2.32
80
+ 1.2.33
81
81
 
82
82
  GLOBAL OPTIONS
83
83
  -a, --add - Add a next action (deprecated, for backwards compatibility)
@@ -103,12 +103,13 @@ COMMANDS
103
103
  changes, changelog - Display the changelog
104
104
  complete, finish - Find and mark an action as @done
105
105
  completed, finished - Display completed actions
106
- edit - Open a todo file in the default editor
106
+ edit - Edit an existing action
107
107
  find, grep - Find actions matching a search pattern
108
108
  help - Shows a list of commands or help for one command
109
109
  init, create - Create a new todo file in the current directory
110
110
  initconfig - Initialize the config file using current global options
111
111
  next, show - Show next actions
112
+ open - Open a todo file in the default editor
112
113
  projects - Show list of projects for a file
113
114
  prompt - Show or install prompt hooks for the current shell
114
115
  saved - Execute a saved search
@@ -170,28 +171,28 @@ EXAMPLES
170
171
 
171
172
  ```
172
173
  NAME
173
- edit - Open a todo file in the default editor
174
+ edit - Edit an existing action
174
175
 
175
176
  SYNOPSIS
176
177
 
177
- na [global options] edit [command options]
178
+ na [global options] edit [command options] ACTION
178
179
 
179
180
  DESCRIPTION
180
- Let the system choose the defualt, (e.g. TaskPaper), or specify a command line utility (e.g. vim). If more than one todo file is found, a menu is displayed.
181
+ Open a matching action in your default $EDITOR. If multiple todo files are found in the current directory, a menu will allow you to pick which file to act on. Natural language dates are expanded in known date-based tags.
181
182
 
182
183
  COMMAND OPTIONS
183
- -a, --app=EDITOR - Specify a Mac app (default: none)
184
- -d, --depth=DEPTH - Recurse to depth (default: 1)
185
- -e, --editor=EDITOR - Specify an editor CLI (default: none)
186
-
187
- EXAMPLES
188
-
189
- # Open the main todo file in the default editor
190
- na edit
191
-
192
- # Display a menu of all todo files three levels deep from the
193
- current directory, open selection in vim.
194
- na edit -d 3 -a vim
184
+ -d, --depth=DEPTH - Search for files X directories deep (default: 1)
185
+ --[no-]done - Include @done actions
186
+ -e, --regex - Interpret search pattern as regular expression
187
+ --file=PATH - Specify the file to search for the task (default: none)
188
+ --in, --todo=TODO_FILE - Use a known todo file, partial matches allowed (default: none)
189
+ --tagged=TAG - Match actions containing tag. Allows value comparisons (may be used more than once, default: none)
190
+ -x, --exact - Match pattern exactly
191
+
192
+ EXAMPLE
193
+
194
+ # Find "An existing task" action and open it for editing
195
+ na edit "An existing task"
195
196
  ```
196
197
 
197
198
  ##### find
@@ -484,15 +485,16 @@ COMMAND OPTIONS
484
485
  --delete - Delete an action
485
486
  --[no-]done - Include @done actions
486
487
  -e, --regex - Interpret search pattern as regular expression
488
+ --edit - Open action in editor (vim). Natural language dates will be parsed and converted in date-based tags.
487
489
  -f, --finish - Add a @done tag to action
488
490
  --file=PATH - Specify the file to search for the task (default: none)
489
491
  --in, --todo=TODO_FILE - Use a known todo file, partial matches allowed (default: none)
490
492
  -n, --note - Prompt for additional notes. Input will be appended to any existing note. If STDIN input (piped) is detected, it will be used as a note.
491
493
  -o, --overwrite - Overwrite note instead of appending
492
494
  -p, --priority=PRIO - Add/change a priority level 1-5 (default: 0)
493
- -r, --remove=TAG - Remove a tag to the action (may be used more than once, default: none)
495
+ -r, --remove=TAG - Remove a tag from the action, use multiple times or combine multiple tags with a comma, wildcards (* and ?) allowed (may be used more than once, default: none)
494
496
  --restore - Remove @done tag from action
495
- -t, --tag=TAG - Add a tag to the action, @tag(values) allowed (may be used more than once, default: none)
497
+ -t, --tag=TAG - Add a tag to the action, @tag(values) allowed, use multiple times or combine multiple tags with a comma (may be used more than once, default: none)
496
498
  --tagged=TAG - Match actions containing tag. Allows value comparisons (may be used more than once, default: none)
497
499
  --to, --project, --proj=PROJECT - Move action to specific project (default: none)
498
500
  -x, --exact - Match pattern exactly
data/bin/commands/edit.rb CHANGED
@@ -2,46 +2,149 @@
2
2
 
3
3
  class App
4
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.'
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'
8
13
  command %i[edit] do |c|
9
- c.example 'na edit', desc: 'Open the main todo file in the default editor'
10
- c.example 'na edit -d 3 -a vim', desc: 'Display a menu of all todo files three levels deep from the
11
- current directory, open selection in vim.'
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]
12
23
 
13
- c.desc 'Recurse to depth'
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'
14
29
  c.arg_name 'DEPTH'
15
- c.default_value 1
16
- c.flag %i[d depth], type: :integer, must_match: /^\d+$/
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
17
35
 
18
- c.desc 'Specify an editor CLI'
19
- c.arg_name 'EDITOR'
20
- c.flag %i[e editor]
36
+ c.desc 'Interpret search pattern as regular expression'
37
+ c.switch %i[e regex], negatable: false
21
38
 
22
- c.desc 'Specify a Mac app'
23
- c.arg_name 'EDITOR'
24
- c.flag %i[a app]
39
+ c.desc 'Match pattern exactly'
40
+ c.switch %i[x exact], negatable: false
25
41
 
26
42
  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
+ options[:edit] = true
44
+ action = if args.count.positive?
45
+ args.join(' ').strip
46
+ elsif $stdin.isatty && TTY::Which.exist?('gum') && options[:tagged].empty?
47
+ opts = [
48
+ %(--placeholder "Enter a task to search for"),
49
+ '--char-limit=500',
50
+ "--width=#{TTY::Screen.columns}"
51
+ ]
52
+ `gum input #{opts.join(' ')}`.strip
53
+ elsif $stdin.isatty && options[:tagged].empty?
54
+ puts NA::Color.template('{bm}Enter search string:{x}')
55
+ reader.read_line(NA::Color.template('{by}> {bw}')).strip
56
+ end
57
+
58
+ NA.notify('{br}Empty input{x}', exit_code: 1) unless action
59
+
60
+ if action
61
+ tokens = nil
62
+ if options[:exact]
63
+ tokens = action
64
+ elsif options[:regex]
65
+ tokens = Regexp.new(action, Regexp::IGNORECASE)
66
+ else
67
+ tokens = []
68
+ all_req = action !~ /[+!\-]/ && !options[:or]
69
+
70
+ action.split(/ /).each do |arg|
71
+ m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
72
+ tokens.push({
73
+ token: m['tok'],
74
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
75
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
76
+ })
77
+ end
78
+ end
79
+ end
80
+
81
+ if (action.nil? || action.empty?) && options[:tagged].empty?
82
+ NA.notify('{br}Empty input, cancelled{x}', exit_code: 1)
83
+ end
84
+
85
+ all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
86
+ tags = []
87
+ options[:tagged].join(',').split(/ *, */).each do |arg|
88
+ m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
89
+
90
+ tags.push({
91
+ tag: m['tag'].wildcard_to_rx,
92
+ comp: m['op'],
93
+ value: m['val'],
94
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
95
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
96
+ })
97
+ end
98
+
99
+ target_proj = if NA.cwd_is == :project
100
+ NA.cwd
101
+ else
102
+ nil
103
+ end
104
+
105
+ if options[:file]
106
+ file = File.expand_path(options[:file])
107
+ NA.notify('{r}File not found', exit_code: 1) unless File.exist?(file)
108
+
109
+ targets = [file]
110
+ elsif options[:todo]
111
+ todo = []
112
+ options[:todo].split(/ *, */).each do |a|
113
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
114
+ todo.push({
115
+ token: m['tok'],
116
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
117
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
118
+ })
119
+ end
120
+ dirs = NA.match_working_dir(todo)
121
+
122
+ if dirs.count == 1
123
+ targets = [dirs[0]]
124
+ elsif dirs.count.positive?
125
+ targets = NA.select_file(dirs, multiple: true)
126
+ NA.notify('{r}Cancelled', exit_code: 1) unless targets && targets.count.positive?
127
+ else
128
+ NA.notify('{r}Todo not found', exit_code: 1) unless targets && targets.count.positive?
129
+
130
+ end
43
131
  else
44
- NA.edit_file(file: file, app: options[:app])
132
+ files = NA.find_files(depth: options[:depth])
133
+ NA.notify('{r}No todo file found', exit_code: 1) if files.count.zero?
134
+
135
+ targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
136
+ NA.notify('{r}Cancelled{x}', exit_code: 1) unless files.count.positive?
137
+
138
+ end
139
+
140
+ NA.notify('{r}No search terms provided', exit_code: 1) if tokens.nil? && options[:tagged].empty?
141
+
142
+ targets.each do |target|
143
+ NA.update_action(target, tokens,
144
+ done: options[:done],
145
+ edit: options[:edit],
146
+ project: target_proj,
147
+ tagged: tags)
45
148
  end
46
149
  end
47
150
  end
@@ -0,0 +1,48 @@
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
@@ -42,11 +42,12 @@ class App
42
42
  c.desc 'Include @done actions'
43
43
  c.switch %i[done]
44
44
 
45
- c.desc 'Add a tag to the action, @tag(values) allowed'
45
+ c.desc 'Add a tag to the action, @tag(values) allowed, use multiple times or combine multiple tags with a comma'
46
46
  c.arg_name 'TAG'
47
47
  c.flag %i[t tag], multiple: true
48
48
 
49
- c.desc 'Remove a tag to the action'
49
+ c.desc 'Remove a tag from the action, use multiple times or combine multiple tags with a comma,
50
+ wildcards (* and ?) allowed'
50
51
  c.arg_name 'TAG'
51
52
  c.flag %i[r remove], multiple: true
52
53
 
@@ -62,6 +63,10 @@ class App
62
63
  c.desc 'Delete an action'
63
64
  c.switch %i[delete], negatable: false
64
65
 
66
+ c.desc "Open action in editor (#{NA.default_editor}).
67
+ Natural language dates will be parsed and converted in date-based tags."
68
+ c.switch %i[edit], negatable: false
69
+
65
70
  c.desc 'Specify the file to search for the task'
66
71
  c.arg_name 'PATH'
67
72
  c.flag %i[file]
@@ -87,17 +92,22 @@ class App
87
92
  reader = TTY::Reader.new
88
93
  append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
89
94
 
90
- options[:done] = true if options[:restore] || options[:remove] =~ /^done/
95
+ if options[:restore] || (!options[:remove].nil? && options[:remove].include?('done'))
96
+ options[:done] = true
97
+ options[:tagged] << '+done'
98
+ elsif !options[:remove].nil? && !options[:remove].empty?
99
+ options[:tagged].concat(options[:remove])
100
+ end
91
101
 
92
102
  action = if args.count.positive?
93
103
  args.join(' ').strip
94
104
  elsif $stdin.isatty && TTY::Which.exist?('gum') && options[:tagged].empty?
95
- options = [
105
+ opts = [
96
106
  %(--placeholder "Enter a task to search for"),
97
107
  '--char-limit=500',
98
108
  "--width=#{TTY::Screen.columns}"
99
109
  ]
100
- `gum input #{options.join(' ')}`.strip
110
+ `gum input #{opts.join(' ')}`.strip
101
111
  elsif $stdin.isatty && options[:tagged].empty?
102
112
  puts NA::Color.template('{bm}Enter search string:{x}')
103
113
  reader.read_line(NA::Color.template('{by}> {bw}')).strip
@@ -144,8 +154,9 @@ class App
144
154
  end
145
155
 
146
156
  priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
147
- add_tags = options[:tag] ? options[:tag].map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
148
- remove_tags = options[:remove] ? options[:remove].map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
157
+ add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
158
+ remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
159
+ remove_tags << 'done' if options[:restore]
149
160
 
150
161
  stdin_note = NA.stdin ? NA.stdin.split("\n") : []
151
162
 
@@ -217,18 +228,19 @@ class App
217
228
 
218
229
  targets.each do |target|
219
230
  NA.update_action(target, tokens,
220
- priority: priority,
221
231
  add_tag: add_tags,
222
- remove_tag: remove_tags,
223
- finish: options[:finish],
224
- project: target_proj,
232
+ all: options[:all],
233
+ append: append,
225
234
  delete: options[:delete],
235
+ done: options[:done],
236
+ edit: options[:edit],
237
+ finish: options[:finish],
226
238
  note: note,
227
239
  overwrite: options[:overwrite],
228
- tagged: tags,
229
- all: options[:all],
230
- done: options[:done],
231
- append: append)
240
+ priority: priority,
241
+ project: target_proj,
242
+ remove_tag: remove_tags,
243
+ tagged: tags)
232
244
  end
233
245
  end
234
246
  end
data/bin/na CHANGED
@@ -6,6 +6,7 @@ require 'gli'
6
6
  require 'na/help_monkey_patch'
7
7
  require 'na'
8
8
  require 'fcntl'
9
+ require 'tempfile'
9
10
 
10
11
  # Main application
11
12
  class App
@@ -45,6 +45,125 @@ module NA
45
45
  res.empty? ? default : res =~ /y/i
46
46
  end
47
47
 
48
+ def default_editor
49
+ editor ||= ENV['NA_EDITOR'] || ENV['GIT_EDITOR'] || ENV['EDITOR']
50
+
51
+ if editor.good? && TTY::Which.exist?(editor)
52
+ return editor
53
+ end
54
+
55
+ notify('No EDITOR environment variable, testing available editors', debug: true)
56
+ editors = %w[vim vi code subl mate mvim nano emacs]
57
+ editors.each do |ed|
58
+ try = TTY::Which.which(ed)
59
+ if try
60
+ notify("Using editor #{try}", debug: true)
61
+ return try
62
+ end
63
+ end
64
+
65
+ notify('{br}No editor found{x}', exit_code: 5)
66
+
67
+ nil
68
+ end
69
+
70
+ def editor_with_args
71
+ args_for_editor(default_editor)
72
+ end
73
+
74
+ def args_for_editor(editor)
75
+ return editor if editor =~ /-\S/
76
+
77
+ args = case editor
78
+ when /^(subl|code|mate)$/
79
+ ['-w']
80
+ when /^(vim|mvim)$/
81
+ ['-f']
82
+ else
83
+ []
84
+ end
85
+ "#{editor} #{args.join(' ')}"
86
+ end
87
+
88
+ ##
89
+ ## Create a process for an editor and wait for the file handle to return
90
+ ##
91
+ ## @param input [String] Text input for editor
92
+ ##
93
+ def fork_editor(input = '', message: :default)
94
+ # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
95
+
96
+ notify('{br}No EDITOR variable defined in environment{x}', exit_code: 5) if default_editor.nil?
97
+
98
+ tmpfile = Tempfile.new(['na_temp', '.na'])
99
+
100
+ File.open(tmpfile.path, 'w+') do |f|
101
+ f.puts input
102
+ unless message.nil?
103
+ f.puts message == :default ? '# First line is the action, lines after are added as a note' : message
104
+ end
105
+ end
106
+
107
+ pid = Process.fork { system("#{editor_with_args} #{tmpfile.path}") }
108
+
109
+ trap('INT') do
110
+ begin
111
+ Process.kill(9, pid)
112
+ rescue StandardError
113
+ Errno::ESRCH
114
+ end
115
+ tmpfile.unlink
116
+ tmpfile.close!
117
+ exit 0
118
+ end
119
+
120
+ Process.wait(pid)
121
+
122
+ begin
123
+ if $?.exitstatus == 0
124
+ input = IO.read(tmpfile.path)
125
+ else
126
+ exit_now! 'Cancelled'
127
+ end
128
+ ensure
129
+ tmpfile.close
130
+ tmpfile.unlink
131
+ end
132
+
133
+ input.split(/\n/).delete_if(&:ignore?).join("\n")
134
+ end
135
+
136
+ ##
137
+ ## Takes a multi-line string and formats it as an entry
138
+ ##
139
+ ## @param input [String] The string to parse
140
+ ##
141
+ ## @return [Array] [[String]title, [Note]note]
142
+ ##
143
+ def format_input(input)
144
+ notify('No content in entry', exit_code: 1) if input.nil? || input.strip.empty?
145
+
146
+ input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
147
+ title = input_lines[0]&.strip
148
+ notify('{br}No content in first line{x}', exit_code: 1) if title.nil? || title.strip.empty?
149
+
150
+ title.expand_date_tags
151
+
152
+ note = if input_lines.length > 1
153
+ input_lines[1..-1]
154
+ else
155
+ []
156
+ end
157
+
158
+
159
+ unless note.empty?
160
+ note.map!(&:strip)
161
+ note.delete_if { |l| l =~ /^\s*$/ || l =~ /^#/ }
162
+ end
163
+
164
+ [title, note]
165
+ end
166
+
48
167
  ##
49
168
  ## Helper function to colorize the Y/N prompt
50
169
  ##
@@ -247,6 +366,8 @@ module NA
247
366
  string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /(?<=\A| )@done/
248
367
 
249
368
  action.action = string
369
+ action.action.expand_date_tags
370
+ action.note = note unless note.empty?
250
371
 
251
372
  action
252
373
  end
@@ -254,18 +375,19 @@ module NA
254
375
  def update_action(target,
255
376
  search,
256
377
  add: nil,
257
- priority: 0,
258
378
  add_tag: [],
259
- remove_tag: [],
260
- finish: false,
261
- project: nil,
379
+ all: false,
380
+ append: false,
262
381
  delete: false,
382
+ done: false,
383
+ edit: false,
384
+ finish: false,
263
385
  note: [],
264
386
  overwrite: false,
265
- tagged: nil,
266
- all: false,
267
- done: false,
268
- append: false)
387
+ priority: 0,
388
+ project: nil,
389
+ remove_tag: [],
390
+ tagged: nil)
269
391
 
270
392
  projects = find_projects(target)
271
393
 
@@ -339,6 +461,12 @@ module NA
339
461
 
340
462
  projects = shift_index_after(projects, action.line, action.note.count + 1)
341
463
 
464
+ if edit
465
+ new_action, new_note = format_input(fork_editor("#{action.action}\n#{action.note.join("\n")}"))
466
+ action.action = new_action
467
+ action.note = new_note
468
+ end
469
+
342
470
  action = process_action(action, priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
343
471
 
344
472
  target_proj = if target_proj
data/lib/na/string.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ REGEX_DAY = /^(mon|tue|wed|thur?|fri|sat|sun)(\w+(day)?)?$/i.freeze
4
+ REGEX_CLOCK = '(?:\d{1,2}+(?::\d{1,2}+)?(?: *(?:am|pm))?|midnight|noon)'
5
+ REGEX_TIME = /^#{REGEX_CLOCK}$/i.freeze
6
+
3
7
  # String helpers
4
8
  class ::String
5
9
  ##
@@ -12,6 +16,16 @@ class ::String
12
16
  !strip.empty?
13
17
  end
14
18
 
19
+ ##
20
+ ## Test if line should be ignored
21
+ ##
22
+ ## @return [Boolean] line is empty or comment
23
+ ##
24
+ def ignore?
25
+ line = self
26
+ line =~ /^#/ || line.strip.empty?
27
+ end
28
+
15
29
  def read_file
16
30
  file = File.expand_path(self)
17
31
  raise "Missing file #{file}" unless File.exist?(file)
@@ -186,6 +200,105 @@ class ::String
186
200
  sub(/^#{ENV['HOME']}/, '~')
187
201
  end
188
202
 
203
+ ##
204
+ ## Convert (chronify) natural language dates
205
+ ## within configured date tags (tags whose value is
206
+ ## expected to be a date). Modifies string in place.
207
+ ##
208
+ ## @param additional_tags [Array] An array of
209
+ ## additional tags to
210
+ ## consider date_tags
211
+ ##
212
+ def expand_date_tags(additional_tags = nil)
213
+ iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
214
+
215
+ watch_tags = [
216
+ 'due',
217
+ 'start(?:ed)?',
218
+ 'beg[ia]n',
219
+ 'done',
220
+ 'finished',
221
+ 'completed?',
222
+ 'waiting',
223
+ 'defer(?:red)?'
224
+ ]
225
+
226
+ if additional_tags
227
+ date_tags = additional_tags
228
+ date_tags = date_tags.split(/ *, */) if date_tags.is_a?(String)
229
+ date_tags.map! do |tag|
230
+ tag.sub(/^@/, '').gsub(/\((?!\?:)(.*?)\)/, '(?:\1)').strip
231
+ end
232
+ watch_tags.concat(date_tags).uniq!
233
+ end
234
+
235
+ done_rx = /(?<=^| )@(?<tag>#{watch_tags.join('|')})\((?<date>.*?)\)/i
236
+
237
+ gsub!(done_rx) do
238
+ m = Regexp.last_match
239
+ t = m['tag']
240
+ d = m['date']
241
+ future = t =~ /^(done|complete)/ ? false : true
242
+ parsed_date = d =~ iso_rx ? Time.parse(d) : d.chronify(guess: :begin, future: future)
243
+ parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
244
+ end
245
+ end
246
+
247
+ ##
248
+ ## Converts input string into a Time object when input
249
+ ## takes on the following formats:
250
+ ## - interval format e.g. '1d2h30m', '45m'
251
+ ## etc.
252
+ ## - a semantic phrase e.g. 'yesterday
253
+ ## 5:30pm'
254
+ ## - a strftime e.g. '2016-03-15 15:32:04
255
+ ## PDT'
256
+ ##
257
+ ## @param options Additional options
258
+ ##
259
+ ## @option options :future [Boolean] assume future date
260
+ ## (default: false)
261
+ ##
262
+ ## @option options :guess [Symbol] :begin or :end to
263
+ ## assume beginning or end of
264
+ ## arbitrary time range
265
+ ##
266
+ ## @return [DateTime] result
267
+ ##
268
+ def chronify(**options)
269
+ now = Time.now
270
+ raise StandardError, "Invalid time expression #{inspect}" if to_s.strip == ''
271
+
272
+ secs_ago = if match(/^(\d+)$/)
273
+ # plain number, assume minutes
274
+ Regexp.last_match(1).to_i * 60
275
+ elsif (m = match(/^(?:(?<day>\d+)d)? *(?:(?<hour>\d+)h)? *(?:(?<min>\d+)m)?$/i))
276
+ # day/hour/minute format e.g. 1d2h30m
277
+ [[m['day'], 24 * 3600],
278
+ [m['hour'], 3600],
279
+ [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
280
+ end
281
+
282
+ if secs_ago
283
+ res = now - secs_ago
284
+ notify(%(date/time string "#{self}" interpreted as #{res} (#{secs_ago} seconds ago)), debug: true)
285
+ else
286
+ date_string = dup
287
+ date_string = 'today' if date_string.match(REGEX_DAY) && now.strftime('%a') =~ /^#{Regexp.last_match(1)}/i
288
+ date_string = "#{options[:context].to_s} #{date_string}" if date_string =~ REGEX_TIME && options[:context]
289
+
290
+ res = Chronic.parse(date_string, {
291
+ guess: options.fetch(:guess, :begin),
292
+ context: options.fetch(:future, false) ? :future : :past,
293
+ ambiguous_time_range: 8
294
+ })
295
+
296
+ NA.notify(%(date/time string "#{self}" interpreted as #{res}), debug: true)
297
+ end
298
+
299
+ res
300
+ end
301
+
189
302
  private
190
303
 
191
304
  def matches_none(regexes)
data/lib/na/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Na
2
- VERSION = '1.2.32'
2
+ VERSION = '1.2.33'
3
3
  end
data/src/_README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is <!--VER-->1.2.31<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.32<!--END VER-->.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: na
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.32
4
+ version: 1.2.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -215,6 +215,7 @@ files:
215
215
  - bin/commands/find.rb
216
216
  - bin/commands/init.rb
217
217
  - bin/commands/next.rb
218
+ - bin/commands/open.rb
218
219
  - bin/commands/projects.rb
219
220
  - bin/commands/prompt.rb
220
221
  - bin/commands/saved.rb