Module: NA

Extended by:
Editor
Defined in:
lib/na/colors.rb,
lib/na.rb,
lib/na/todo.rb,
lib/na/pager.rb,
lib/na/theme.rb,
lib/na/types.rb,
lib/na/action.rb,
lib/na/editor.rb,
lib/na/prompt.rb,
lib/na/actions.rb,
lib/na/plugins.rb,
lib/na/project.rb,
lib/na/benchmark.rb,
lib/na/next_action.rb

Overview

Next Action methods

Defined Under Namespace

Modules: Benchmark, Color, Editor, Pager, Plugins, Prompt, Theme, Types Classes: Action, Actions, Project, Todo

Class Attribute Summary collapse

Class Method Summary collapse

Methods included from Editor

args_for_editor, default_editor, editor_with_args, fork_editor, format_input, format_multi_action_input, parse_multi_action_output

Class Attribute Details

.commandObject

Returns the value of attribute command.



99
100
101
# File 'lib/na/next_action.rb', line 99

def command
  @command
end

.command_lineObject

Returns the value of attribute command_line.



99
100
101
# File 'lib/na/next_action.rb', line 99

def command_line
  @command_line
end

.cwdObject

Returns the value of attribute cwd.



99
100
101
# File 'lib/na/next_action.rb', line 99

def cwd
  @cwd
end

.cwd_isObject

Returns the value of attribute cwd_is.



99
100
101
# File 'lib/na/next_action.rb', line 99

def cwd_is
  @cwd_is
end

.extensionObject

Returns the value of attribute extension.



99
100
101
# File 'lib/na/next_action.rb', line 99

def extension
  @extension
end

.global_fileObject

Returns the value of attribute global_file.



99
100
101
# File 'lib/na/next_action.rb', line 99

def global_file
  @global_file
end

.globalsObject

Returns the value of attribute globals.



99
100
101
# File 'lib/na/next_action.rb', line 99

def globals
  @globals
end

.include_extObject

Returns the value of attribute include_ext.



99
100
101
# File 'lib/na/next_action.rb', line 99

def include_ext
  @include_ext
end

.na_tagObject

Returns the value of attribute na_tag.



99
100
101
# File 'lib/na/next_action.rb', line 99

def na_tag
  @na_tag
end

.show_cwd_indicatorObject

Returns the value of attribute show_cwd_indicator.



99
100
101
# File 'lib/na/next_action.rb', line 99

def show_cwd_indicator
  @show_cwd_indicator
end

.stdinObject

Returns the value of attribute stdin.



99
100
101
# File 'lib/na/next_action.rb', line 99

def stdin
  @stdin
end

.verboseObject

Returns the value of attribute verbose.



99
100
101
# File 'lib/na/next_action.rb', line 99

def verbose
  @verbose
end

Class Method Details

.add_action(file, project, action, note = [], priority: 0, finish: false, append: false, started_at: nil, done_at: nil, duration_seconds: nil) ⇒ void

This method returns an undefined value.

Add an action to a todo file

Parameters:

  • file (String)

    Path to the todo file

  • project (String)

    Project name

  • action (String)

    Action text

  • note (Array<String>) (defaults to: [])

    Notes

  • priority (Integer) (defaults to: 0)

    Priority value

  • finish (Boolean) (defaults to: false)

    Mark as finished

  • append (Boolean) (defaults to: false)

    Append to project



701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
# File 'lib/na/next_action.rb', line 701

def add_action(file, project, action, note = [], priority: 0, finish: false, append: false, started_at: nil, done_at: nil, duration_seconds: nil)
  parent = project.split(%r{[:/]})
  file_project = File.basename(file, ".#{NA.extension}")

  if NA.global_file
    if NA.cwd_is == :tag
      add_tag = [NA.cwd]
    else
      project = NA.cwd
    end
  end

  action = Action.new(file, file_project, parent, action, nil, note)

  update_action(file, nil,
                add: action,
                project: project,
                add_tag: add_tag,
                priority: priority,
                finish: finish,
                append: append,
                started_at: started_at,
                done_at: done_at,
                duration_seconds: duration_seconds)
end

.apply_plugin_result(io_hash) ⇒ Object

Apply a plugin result hash back to the underlying file

  • Move if parents changed (project path differs)

  • Update text/note/tags



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/na/next_action.rb', line 25

def apply_plugin_result(io_hash)
  file = io_hash['file_path']
  line = io_hash['line'].to_i
  parents = Array(io_hash['parents']).map(&:to_s)
  text = io_hash['text'].to_s
  note = io_hash['note'].to_s
  tags = Array(io_hash['tags']).to_h { |t| [t['name'].to_s, t['value'].to_s] }
  action_block = io_hash['action'] || { 'action' => 'UPDATE', 'arguments' => [] }
  action_name = action_block['action'].to_s.upcase
  action_args = Array(action_block['arguments'])

  # Load current action
  _projects, actions = find_actions(file, nil, nil, all: true, done: true, project: nil, search_note: true, target_line: line)
  action = actions&.first
  return unless action

  # Determine new project path from parents array
  new_project = ''
  new_parent_chain = []
  if parents.any?
    new_project = parents.first.to_s
    new_parent_chain = parents[1..] || []
  end

  case action_name
  when 'DELETE'
    update_action(file, { target_line: line }, delete: true, all: true)
    return
  when 'COMPLETE'
    update_action(file, { target_line: line }, finish: true, all: true)
    return
  when 'RESTORE'
    update_action(file, { target_line: line }, restore: true, all: true)
    return
  when 'ARCHIVE'
    update_action(file, { target_line: line }, finish: true, move: 'Archive', all: true)
    return
  when 'ADD_TAG'
    add_tags = action_args.map { |t| t.sub(/^@/, '') }
    update_action(file, { target_line: line }, add: action, add_tag: add_tags, all: true)
    return
  when 'DELETE_TAG', 'REMOVE_TAG'
    remove_tags = action_args.map { |t| t.sub(/^@/, '') }
    update_action(file, { target_line: line }, add: action, remove_tag: remove_tags, all: true)
    return
  when 'MOVE'
    move_to = action_args.first.to_s
    update_action(file, { target_line: line }, add: action, move: move_to, all: true, suppress_prompt: true)
    return
  end

  # Replace content on the existing action then write back in-place
  original_line = action.file_line
  original_project = action.project
  original_parent_chain = action.parent.dup

  # Update action content
  action.action = text
  action.note = note.to_s.split("\n")
  action.action.gsub!(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
  unless tags.empty?
    tag_str = tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
    action.action = action.action.strip + (tag_str.empty? ? "" : " #{tag_str}")
  end

  # Check if parents changed
  parents_changed = new_project.to_s.strip != original_project || new_parent_chain != original_parent_chain
  move_to = parents_changed ? ([new_project] + new_parent_chain).join(':') : nil

  # Update in-place (with move if parents changed)
  update_action(file, { target_line: original_line }, add: action, move: move_to, all: true, suppress_prompt: true)
end

.backup_file(target) ⇒ void

This method returns an undefined value.

Create a backup file

Parameters:

  • target (String)

    The file to back up



1261
1262
1263
1264
1265
# File 'lib/na/next_action.rb', line 1261

def backup_file(target)
  FileUtils.cp(target, backup_path(target))
  save_modified_file(target)
  NA.notify("#{NA.theme[:warning]}Backup file created for #{target.highlight_filename}", debug: true)
end

.backup_filesArray<String>

Get list of backed up files

Returns:



1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
# File 'lib/na/next_action.rb', line 1000

def backup_files
  db = database_path(file: 'last_modified.txt')
  if File.exist?(db)
    File.read(db).strip.split("\n").map(&:strip)
  else
    NA.notify("#{NA.theme[:error]}Backup database not found")
    File.open(db, 'w', &:puts)
    []
  end
end

.backup_path(file) ⇒ String

Get the backup file path for a file

Parameters:

Returns:

  • (String)

    Backup file path



1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
# File 'lib/na/next_action.rb', line 1035

def backup_path(file)
  backup_home = File.expand_path('~/.local/share/na/backup')
  backup = old_backup_path(file)
  backup_dir = File.join(backup_home, File.dirname(backup))
  FileUtils.mkdir_p(backup_dir) unless File.directory?(backup_dir)

  backup_target = File.join(backup_home, backup)
  FileUtils.mv(backup, backup_target) if File.exist?(backup)
  backup_target
end

.choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: []) ⇒ String, Array

Generate a menu of options and allow user selection

Parameters:

  • options (Array)

    The options from which to choose

  • prompt (String) (defaults to: 'Make a selection: ')

    The prompt

  • multiple (Boolean) (defaults to: false)

    If true, allow multiple selections

  • sorted (Boolean) (defaults to: true)

    If true, sort selections alphanumerically

  • fzf_args (Array) (defaults to: [])

    Additional fzf arguments

Returns:



1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
# File 'lib/na/next_action.rb', line 1297

def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
  return nil unless $stdout.isatty

  options.sort! if sorted

  res = if TTY::Which.exist?('fzf')
          default_args = [%(--prompt="#{prompt}"), "--height=#{options.count + 2}", '--info=inline']
          default_args << '--multi' if multiple
          default_args << '--bind ctrl-a:select-all' if multiple
          header = "esc: cancel,#{' tab: multi-select, ctrl-a: select all,' if multiple} return: confirm"
          default_args << %(--header="#{header}")
          default_args.concat(fzf_args)
          options = NA::Color.uncolor(NA::Color.template(options.join("\n")))
          `echo #{Shellwords.escape(options)}|#{TTY::Which.which('fzf')} #{default_args.join(' ')}`.strip
        elsif TTY::Which.exist?('gum')
          args = [
            '--cursor.foreground="151"',
            '--item.foreground=""'
          ]
          args.push '--no-limit' if multiple
          puts NA::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")
          options = NA::Color.uncolor(NA::Color.template(options.join("\n")))
          `echo #{Shellwords.escape(options)}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`.strip
        else
          reader = TTY::Reader.new
          puts
          options.each.with_index do |f, i|
            puts NA::Color.template(format(
                                      "#{NA.theme[:prompt]}%<idx> 2d{xw}) #{NA.theme[:filename]}%<action>s{x}\n", idx: i + 1, action: f
                                    ))
          end
          result = reader.read_line(NA::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")).strip
          if multiple
            mult_res = []
            result = result.gsub(',', ' ').gsub(/ +/, ' ').split(/ /)
            result.each do |r|
              mult_res << options[r.to_i - 1] if r.to_i.positive?
            end
            mult_res.join("\n")
          else
            result.to_i.positive? ? options[result.to_i - 1] : nil
          end
        end

  return false if res&.strip&.empty?

  # pp NA::Color.uncolor(NA::Color.template(res))
  multiple ? NA::Color.uncolor(NA::Color.template(res)).split("\n") : NA::Color.uncolor(NA::Color.template(res))
end

.color_single_options(choices = %w[y n])) ⇒ String

Helper function to colorize the Y/N prompt

Parameters:

  • choices (Array) (defaults to: %w[y n]))

    The choices with default capitalized

Returns:

  • (String)

    colorized string



168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/na/next_action.rb', line 168

def color_single_options(choices = %w[y n])
  out = []
  choices.each do |choice|
    case choice
    when /[A-Z]/
      out.push(NA::Color.template("{bw}#{choice}{x}"))
    else
      out.push(NA::Color.template("{dw}#{choice}{xg}"))
    end
  end
  NA::Color.template("{xg}[#{out.join('/')}{xg}]{x}")
end

.create_todo(target, basename, template: nil) ⇒ Object

Create a new todo file

Parameters:

  • target (String)

    The target path

  • basename (String)

    The project base name



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/na/next_action.rb', line 187

def create_todo(target, basename, template: nil)
  File.open(target, 'w') do |f|
    content = if template && File.exist?(template)
                File.read(template)
              else
                <<~ENDCONTENT
                  Inbox:
                  #{basename}:
                  \tFeature Requests:
                  \tIdeas:
                  \tBugs:
                  Archive:
                  Search Definitions:
                  \tTop Priority @search(@priority = 5 and not @done)
                  \tHigh Priority @search(@priority > 3 and not @done)
                  \tMaybe @search(@maybe)
                  \tNext @search(@#{NA.na_tag} and not @done and not project = "Archive")
                ENDCONTENT
              end
    f.puts(content)
  end
  save_working_dir(target)
  notify("#{NA.theme[:warning]}Created #{NA.theme[:file]}#{target}")
end

.database_path(file: 'tdlist.txt') ⇒ String

Get path to database of known todo files

Parameters:

  • file (String) (defaults to: 'tdlist.txt')

    The database filename (default: ‘tdlist.txt’)

Returns:



1080
1081
1082
1083
1084
1085
# File 'lib/na/next_action.rb', line 1080

def database_path(file: 'tdlist.txt')
  db_dir = File.expand_path('~/.local/share/na')
  # Create directory if needed
  FileUtils.mkdir_p(db_dir) unless File.directory?(db_dir)
  File.join(db_dir, file)
end

.delete_search(strings = nil) ⇒ void

This method returns an undefined value.

Delete saved search definitions by name

Parameters:

  • strings (Array<String>, String, nil) (defaults to: nil)

    Names of searches to delete



1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
# File 'lib/na/next_action.rb', line 1214

def delete_search(strings = nil)
  NA.notify("#{NA.theme[:error]}Name of search required", exit_code: 1) if strings.nil? || strings.empty?

  file = database_path(file: 'saved_searches.yml')
  NA.notify("#{NA.theme[:error]}No search definitions file found", exit_code: 1) unless File.exist?(file)

  strings = [strings] unless strings.is_a? Array

  searches = YAML.load(file.read_file)
  keys = searches.keys.delete_if { |k| k !~ /(#{strings.map(&:wildcard_to_rx).join('|')})/ }

  NA.notify("#{NA.theme[:error]}No search named #{strings.join(', ')} found", exit_code: 1) if keys.empty?

  res = yn(NA::Color.template(%(#{NA.theme[:warning]}Remove #{keys.count > 1 ? 'searches' : 'search'} #{NA.theme[:filename]}"#{keys.join(', ')}"{x})),
           default: false)

  NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless res

  searches.delete_if { |k| keys.include?(k) }

  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }

  NA.notify(
    "#{NA.theme[:warning]}Deleted {bw}#{keys.count}{x}#{NA.theme[:warning]} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0
  )
end

.edit_file(file: nil, app: nil) ⇒ void

This method returns an undefined value.

Open a file in the specified editor/application

Parameters:

  • file (String, nil) (defaults to: nil)

    Path to the file

  • app (String, nil) (defaults to: nil)

    Application to use



788
789
790
# File 'lib/na/next_action.rb', line 788

def edit_file(file: nil, app: nil)
  os_open(file, app: app) if file && File.exist?(file)
end

.edit_searchesvoid

This method returns an undefined value.

Edit saved search definitions in the default editor



1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
# File 'lib/na/next_action.rb', line 1244

def edit_searches
  file = database_path(file: 'saved_searches.yml')
  searches = load_searches

  NA.notify("#{NA.theme[:error]}No search definitions found", exit_code: 1) unless searches.any?

  editor = NA.default_editor
  NA.notify("#{NA.theme[:error]}No $EDITOR defined", exit_code: 1) unless editor && TTY::Which.exist?(editor)

  system %(#{editor} "#{file}")
  NA.notify("#{NA.theme[:success]}Opened #{file} in #{editor}", exit_code: 0)
end

.find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true, target_line: nil) ⇒ Array

Find actions in a todo file matching criteria

Parameters:

  • target (String)

    Path to the todo file

  • search (String, nil)

    Search string

  • tagged (String, nil) (defaults to: nil)

    Tag to filter

  • all (Boolean) (defaults to: false)

    Return all actions

  • done (Boolean) (defaults to: false)

    Include done actions

  • project (String, nil) (defaults to: nil)

    Project name

  • search_note (Boolean) (defaults to: true)

    Search notes

  • target_line (Integer) (defaults to: nil)

    Specific line number to target

Returns:

  • (Array)

    Projects and actions



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/na/next_action.rb', line 266

def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true, target_line: nil)
  todo = NA::Todo.new({ search: search,
                        search_note: search_note,
                        require_na: false,
                        file_path: target,
                        project: project,
                        tag: tagged,
                        done: done })

  unless todo.actions.any?
    NA.notify("#{NA.theme[:error]}No matching actions found in #{File.basename(target,
                                                                               ".#{NA.extension}").highlight_filename}")
    return [todo.projects, NA::Actions.new]
  end

  return [todo.projects, todo.actions] if todo.actions.one? || all

  # If target_line is specified, find the action at that specific line
  if target_line
    matching_action = todo.actions.find { |a| a.line == target_line }
    return [todo.projects, NA::Actions.new([matching_action])] if matching_action

    NA.notify("#{NA.theme[:error]}No action found at line #{target_line}", exit_code: 1)
    return [todo.projects, NA::Actions.new]

  end

  options = todo.actions.map { |action| "#{action.file} : #{action.action}" }
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)

  unless res&.length&.positive?
    NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
    return [todo.projects, NA::Actions.new]
  end

  selected = NA::Actions.new
  res.each do |result|
    # Extract file:line from result (e.g., "./todo.taskpaper:21 : action text")
    match = result.match(/^(.+?):(\d+) : /)
    next unless match

    file_path = match[1]
    line_num = match[2].to_i
    action = todo.actions.select { |a| a.file_path == file_path && a.file_line == line_num }.first
    selected.push(action) if action
  end
  [todo.projects, selected]
end

.find_exact_dir(dirs, search) ⇒ Array<String>

Find a directory with an exact match from a list.

Parameters:

Returns:



930
931
932
933
934
935
936
937
938
939
940
# File 'lib/na/next_action.rb', line 930

def find_exact_dir(dirs, search)
  terms = search.filter { |s| !s[:negate] }.map { |t| t[:token] }.join(' ')
  out = dirs
  dirs.each do |dir|
    if File.basename(dir).sub(/\.#{NA.extension}$/, '') =~ /^#{terms}$/
      out = [dir]
      break
    end
  end
  out
end

.find_files(depth: 1, include_hidden: false) ⇒ Array<String>

Locate files matching NA.extension up to a given depth

Parameters:

  • depth (Integer) (defaults to: 1)

    The depth at which to search

  • include_hidden (Boolean) (defaults to: false)

    Whether to include hidden directories/files

Returns:



797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
# File 'lib/na/next_action.rb', line 797

def find_files(depth: 1, include_hidden: false)
  NA::Benchmark.measure("find_files (depth=#{depth})") do
    return [NA.global_file] if NA.global_file

    # Build a brace-expanded pattern list covering 1..depth levels, e.g.:
    # depth=1 -> "*.ext"
    # depth=3 -> "{*.ext,*/*.ext,*/*/*.ext}"
    ext = NA.extension
    patterns = (1..[depth.to_i, 1].max).map do |d|
      prefix = d > 1 ? ('*/' * (d - 1)) : ''
      "#{prefix}*.#{ext}"
    end
    pattern = patterns.length == 1 ? patterns.first : "{#{patterns.join(',')}}"

    files = Dir.glob(pattern, File::FNM_DOTMATCH)
    # Exclude hidden directories/files unless explicitly requested
    unless include_hidden
      files.reject! do |f|
        # reject any path segment beginning with '.' (excluding '.' and '..')
        f.split('/').any? { |seg| seg.start_with?('.') && seg !~ /^\.\.?$/ }
      end
    end
    files.map! { |f| f.sub(%r{\A\./}, '') }
    files.each { |f| save_working_dir(File.expand_path(f)) }
    files.uniq
  end
end

.find_files_matching(options = {}) ⇒ Array<String>

Find files matching criteria and containing actions.

Parameters:

  • options (Hash) (defaults to: {})

    Options for file search

Options Hash (options):

  • :depth (Integer)

    Search depth

  • :done (Boolean)

    Include done actions

  • :file_path (String)

    File path

  • :negate (Boolean)

    Negate search

  • :hidden (Boolean)

    Include hidden files

  • :project (String)

    Project name

  • :query (String)

    Query string

  • :regex (Boolean)

    Use regex

  • :search (String)

    Search string

  • :tag (String)

    Tag to filter

Returns:



838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
# File 'lib/na/next_action.rb', line 838

def find_files_matching(options = {})
  defaults = {
    depth: 1,
    done: false,
    file_path: nil,
    negate: false,
    hidden: false,
    project: nil,
    query: nil,
    regex: false,
    search: nil,
    tag: nil
  }
  options = defaults.merge(options)
  files = find_files(depth: options[:depth], include_hidden: options[:hidden])
  return [] if files.nil? || files.empty?

  files.delete_if do |file|
    cmd_options = {
      depth: options[:depth],
      done: options[:done],
      file_path: file,
      negate: options[:negate],
      project: options[:project],
      query: options[:query],
      regex: options[:regex],
      require_na: options[:require_na],
      search: options[:search],
      tag: options[:tag]
    }
    todo = NA::Todo.new(cmd_options)
    todo.actions.empty?
  end

  files
end

.find_projects(target) ⇒ Array<NA::Project>

Find all projects in a todo file

Parameters:

  • target (String)

    Path to the todo file

Returns:



250
251
252
253
# File 'lib/na/next_action.rb', line 250

def find_projects(target)
  todo = NA::Todo.new(require_na: false, file_path: target)
  todo.projects
end

.insert_project(target, project) ⇒ NA::Project

Insert a new project into a todo file

Parameters:

  • target (String)

    Path to the todo file

  • project (String)

    Project name

Returns:



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/na/next_action.rb', line 320

def insert_project(target, project)
  path = project.split(%r{[:/]})
  todo = NA::Todo.new(file_path: target)
  built = []
  last_match = nil
  final_match = nil
  new_path = []
  matches = nil
  path.each_with_index do |part, i|
    built.push(part)
    built_path = built.join(':')
    matches = todo.projects.select { |proj| proj.project =~ /^#{Regexp.escape(built_path)}/i }
    exact_match = matches.find { |proj| proj.project.casecmp(built_path).zero? }
    if exact_match
      last_match = exact_match
    else
      final_match = last_match
      new_path = path.slice(i, path.count - i)
      break
    end
  end

  content = target.read_file
  if final_match.nil?
    indent = 0
    input = []

    new_path.each do |part|
      input.push("#{"\t" * indent}#{part.cap_first}:")
      indent += 1
    end

    if new_path.join =~ /Archive/i
      line = todo.projects.last&.last_line || 0
      content = content.split("\n").insert(line, input.join("\n")).join("\n")
    else
      split = content.split("\n")
      line = todo.projects.first&.line || 0
      before = split.slice(0, line).join("\n")
      after = split.slice(line, split.count - 0).join("\n")
      content = "#{before}\n#{input.join("\n")}\n#{after}"
    end

    new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line, line)
  else
    line = final_match.last_line + 1
    indent = final_match.indent + 1
    input = []
    new_path.each do |part|
      input.push("#{"\t" * indent}#{part.cap_first}:")
      indent += 1
    end
    content = content.split("\n").insert(line, input.join("\n")).join("\n")
    new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1,
                                  line + input.count - 1)
  end

  File.open(target, 'w') do |f|
    f.puts content
  end

  new_project
end

.last_modified_file(search: nil) ⇒ String?

Get the last modified file from the database

Parameters:

  • search (String, nil) (defaults to: nil)

    Optional search string

Returns:

  • (String, nil)

    Last modified file path



978
979
980
981
982
# File 'lib/na/next_action.rb', line 978

def last_modified_file(search: nil)
  files = backup_files
  files.delete_if { |f| f !~ Regexp.new(search.dir_to_rx(require_last: true)) } if search
  files.last
end

.list_projects(query: [], file_path: nil, depth: 1, paths: true) ⇒ void

This method returns an undefined value.

List projects in a todo file or matching query.

Parameters:

  • query (Array) (defaults to: [])

    Query tokens

  • file_path (String, nil) (defaults to: nil)

    File path

  • depth (Integer) (defaults to: 1)

    Search depth

  • paths (Boolean) (defaults to: true)

    Show full paths



1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
# File 'lib/na/next_action.rb', line 1124

def list_projects(query: [], file_path: nil, depth: 1, paths: true)
  files = if NA.global_file
            [NA.global_file]
          elsif !file_path.nil?
            [file_path]
          elsif query.nil?
            NA.find_files(depth: depth)
          else
            match_working_dir(query)
          end

  target = files.count > 1 ? NA.select_file(files) : files[0]
  return if target.nil?

  projects = find_projects(target)
  projects.each do |proj|
    parts = proj.project.split(':')
    output = if paths
               "{bg}#{parts.join('{bw}/{bg}')}{x}"
             else
               parts.fill('{bw}—{bg}', 0..-2)
               "{bg}#{parts.join(' ')}{x}"
             end

    puts NA::Color.template(output)
  end
end

.list_todos(query: []) ⇒ void

This method returns an undefined value.

List todo files matching a query.

Parameters:

  • query (Array) (defaults to: [])

    Query tokens



1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
# File 'lib/na/next_action.rb', line 1155

def list_todos(query: [])
  dirs = if query
           match_working_dir(query, distance: 2, require_last: false)
         else
           file = database_path
           content = File.exist?(file) ? file.read_file.strip : ''
           notify("#{NA.theme[:error]}Database empty", exit_code: 1) if content.empty?

           content.split("\n")
         end

  dirs.map!(&:highlight_filename)

  puts NA::Color.template(dirs.join("\n"))
end

.load_searchesHash

Load saved search definitions from YAML file

Returns:

  • (Hash)

    Hash of saved searches



1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
# File 'lib/na/next_action.rb', line 1194

def load_searches
  file = database_path(file: 'saved_searches.yml')
  if File.exist?(file)
    searches = YAML.load(file.read_file)
  else
    searches = {
      'soon' => 'tagged "due<in 2 days,due>yesterday"',
      'overdue' => 'tagged "due<now"',
      'high' => 'tagged "prio>3"',
      'maybe' => 'tagged "maybe"'
    }
    File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
  end
  searches
end

.match_working_dir(search, distance: 1, require_last: true) ⇒ Array<String>

Find a matching path using semi-fuzzy matching. Search tokens can include ! and + to negate or make required.

Parameters:

  • search (Array<Hash>)

    Search tokens to match

  • distance (Integer) (defaults to: 1)

    Allowed distance between characters

  • require_last (Boolean) (defaults to: true)

    Require regex to match last element of path

Returns:

  • (Array<String>)

    Array of matching directories/todo files



882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
# File 'lib/na/next_action.rb', line 882

def match_working_dir(search, distance: 1, require_last: true)
  file = database_path
  NA.notify("#{NA.theme[:error]}No na database found", exit_code: 1) unless File.exist?(file)

  dirs = file.read_file.split("\n")

  optional = search.filter { |s| !s[:negate] }.map { |t| t[:token] }
  required = search.filter { |s| s[:required] && !s[:negate] }.map { |t| t[:token] }
  negated = search.filter { |s| s[:negate] }.map { |t| t[:token] }

  optional.push('*') if optional.none? && required.none? && negated.any?
  if optional == negated
    required = ['*']
    optional = ['*']
  end

  NA.notify("Optional directory regex: {x}#{optional.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
  NA.notify("Required directory regex: {x}#{required.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
  NA.notify("Negated directory regex: {x}#{negated.map do |t|
    t.dir_to_rx(distance: distance, require_last: false)
  end}", debug: true)

  if require_last
    dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
  else
    dirs.delete_if do |d|
      !d.sub(/\.#{NA.extension}$/, '')
        .dir_matches?(any: optional, all: required, none: negated, distance: 2, require_last: false)
    end
  end

  dirs = dirs.sort_by { |d| File.basename(d) }.uniq

  dirs = find_exact_dir(dirs, search) unless optional == ['*']

  if dirs.empty? && require_last
    NA.notify("#{NA.theme[:warning]}No matches, loosening search", debug: true)
    match_working_dir(search, distance: 2, require_last: false)
  else
    NA.notify("Matched files: {x}#{dirs.join(', ')}", debug: true)
    dirs
  end
end

.move_deprecated_backupsvoid

This method returns an undefined value.

Move deprecated backup files to new backup folder



1014
1015
1016
1017
1018
1019
1020
1021
# File 'lib/na/next_action.rb', line 1014

def move_deprecated_backups
  backup_files.each do |file|
    if File.exist?(old_backup_path(file))
      NA.notify("Moving deprecated backup to new backup folder (#{file})", debug: true)
      backup_path(file)
    end
  end
end

.notify(msg, exit_code: false, debug: false) ⇒ void

This method returns an undefined value.

Print a message to stderr, optionally exit or debug.

Parameters:

  • msg (String)

    The message to print

  • exit_code (Integer, Boolean) (defaults to: false)

    Exit code or false for no exit

  • debug (Boolean) (defaults to: false)

    Only print if verbose



113
114
115
116
117
118
119
120
121
122
# File 'lib/na/next_action.rb', line 113

def notify(msg, exit_code: false, debug: false)
  return if debug && !NA.verbose

  if debug
    warn NA::Color.template("{x}#{NA.theme[:debug]}#{msg}{x}")
  else
    warn NA::Color.template("{x}#{msg}{x}")
  end
  Process.exit exit_code if exit_code
end

.old_backup_path(file) ⇒ String

Get the old backup file path for a file

Parameters:

Returns:

  • (String)

    Old backup file path



1027
1028
1029
# File 'lib/na/next_action.rb', line 1027

def old_backup_path(file)
  File.join(File.dirname(file), ".#{File.basename(file)}.bak")
end

.os_open(file, app: nil) ⇒ void

This method returns an undefined value.

Platform-agnostic open command

Parameters:

  • file (String)

    The file to open

  • app (String, nil) (defaults to: nil)

    Optional application to use



1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
# File 'lib/na/next_action.rb', line 1092

def os_open(file, app: nil)
  os = RbConfig::CONFIG['target_os']
  case os
  when /darwin.*/i
    darwin_open(file, app: app)
  when /mingw|mswin/i
    win_open(file)
  else
    linux_open(file)
  end
end

.output_children(children, level = 1) ⇒ Object

Output an Omnifocus-friendly action list

Parameters:

  • children

    The children

  • level (defaults to: 1)

    The indent level



751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
# File 'lib/na/next_action.rb', line 751

def output_children(children, level = 1)
  out = []
  indent = "\t" * level
  return out if children.nil? || children.empty?

  children.each do |k, v|
    if k.to_s =~ /actions/
      indent += "\t"
      v&.each do |a|
        item = "#{indent}- #{a.action}"
        unless a.tags.nil? || a.tags.empty?
          tags = []
          a.tags.each do |key, val|
            next if key =~ /^(due|flagged|done)$/

            tag = key
            tag += "-#{val}" unless val.nil? || val.empty?
            tags.push(tag)
          end
          item += " @tags(#{tags.join(',')})" unless tags.empty?
        end
        item += "\n#{indent}\t#{a.note.join("\n#{indent}\t")}" unless a.note.empty?
        out.push(item)
      end
    else
      out.push("#{indent}#{k}:")
      out.concat(output_children(v, level + 1))
    end
  end
  out
end

.priority_mapHash{String=>Integer}

Returns a map of priority levels to numeric values.

Returns:



126
127
128
129
130
131
132
# File 'lib/na/next_action.rb', line 126

def priority_map
  {
    'h' => 5,
    'm' => 3,
    'l' => 1
  }
end

.project_hierarchy(actions) ⇒ Hash

Build a nested hash representing project hierarchy from actions

Parameters:

Returns:

  • (Hash)

    Nested hierarchy



731
732
733
734
735
736
737
738
739
740
741
742
743
744
# File 'lib/na/next_action.rb', line 731

def project_hierarchy(actions)
  parents = { actions: [] }
  actions.each do |a|
    parent = a.parent
    current_parent = parents
    parent.each do |par|
      current_parent[par] = { actions: [] } unless current_parent.key?(par)
      current_parent = current_parent[par]
    end

    current_parent[:actions].push(a)
  end
  parents
end

.request_input(options, prompt: 'Enter text') ⇒ Object

Request terminal input from user, readline style

Parameters:

  • options (Hash)

    The options

  • prompt (String) (defaults to: 'Enter text')

    The prompt



1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
# File 'lib/na/next_action.rb', line 1273

def request_input(options, prompt: 'Enter text')
  if $stdin.isatty && TTY::Which.exist?('gum') && (options[:tagged].nil? || options[:tagged].empty?)
    opts = [%(--placeholder "#{prompt}"),
            '--char-limit=500',
            "--width=#{TTY::Screen.columns}"]
    `gum input #{opts.join(' ')}`.strip
  elsif $stdin.isatty && options[:tagged].empty?
    NA.notify("#{NA.theme[:prompt]}#{prompt}:")
    reader.read_line(NA::Color.template("#{NA.theme[:filename]}> #{NA.theme[:action]}")).strip
  end
end

.restore_last_modified_file(search: nil) ⇒ void

This method returns an undefined value.

Get last modified file and restore a backup

Parameters:

  • search (String, nil) (defaults to: nil)

    Optional search string



988
989
990
991
992
993
994
995
# File 'lib/na/next_action.rb', line 988

def restore_last_modified_file(search: nil)
  file = last_modified_file(search: search)
  if file
    restore_modified_file(file)
  else
    NA.notify("#{NA.theme[:error]}No matching file found")
  end
end

.restore_modified_file(file) ⇒ void

This method returns an undefined value.

Restore a file from backup

Parameters:



1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
# File 'lib/na/next_action.rb', line 1064

def restore_modified_file(file)
  bak_file = backup_path(file)
  if File.exist?(bak_file)
    FileUtils.mv(bak_file, file)
    NA.notify("#{NA.theme[:success]}Backup restored for #{file.highlight_filename}")
  else
    NA.notify("#{NA.theme[:error]}Backup file for #{file.highlight_filename} not found")
  end

  weed_modified_files(file)
end

.save_modified_file(file) ⇒ void

This method returns an undefined value.

Save a backed-up file to the database

Parameters:



961
962
963
964
965
966
967
968
969
970
971
972
# File 'lib/na/next_action.rb', line 961

def save_modified_file(file)
  db = database_path(file: 'last_modified.txt')
  file = File.expand_path(file)
  if File.exist? db
    files = File.read(db).split("\n").map(&:strip)
    files.delete(file)
    files << file
    File.open(db, 'w') { |f| f.puts(files.join("\n")) }
  else
    File.open(db, 'w') { |f| f.puts(file) }
  end
end

.save_search(title, search) ⇒ void

This method returns an undefined value.

Save a search definition to the database.

Parameters:

  • title (String)

    The search title

  • search (String)

    The search string



1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
# File 'lib/na/next_action.rb', line 1175

def save_search(title, search)
  file = database_path(file: 'saved_searches.yml')
  searches = load_searches
  title = title.gsub(/[^a-zA-Z0-9]/, '_').gsub(/_+/, '_').downcase

  if searches.key?(title)
    res = yn('Overwrite existing definition?', default: true)
    notify("#{NA.theme[:error]}Cancelled", exit_code: 0) unless res

  end

  searches[title] = search
  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
  NA.notify("#{NA.theme[:success]}Search #{NA.theme[:filename]}#{title}#{NA.theme[:success]} saved", exit_code: 0)
end

.save_working_dir(todo_file) ⇒ void

This method returns an undefined value.

Save a todo file path to the database

Parameters:

  • todo_file (String)

    The todo file path



946
947
948
949
950
951
952
953
954
955
# File 'lib/na/next_action.rb', line 946

def save_working_dir(todo_file)
  NA::Benchmark.measure('save_working_dir') do
    file = database_path
    content = File.exist?(file) ? file.read_file : ''
    dirs = content.split("\n")
    dirs.push(File.expand_path(todo_file))
    dirs.sort!.uniq!
    File.open(file, 'w') { |f| f.puts dirs.join("\n") }
  end
end

.select_actions(file: nil, depth: 1, search: [], tagged: [], include_done: false) ⇒ Array<NA::Action>

Select actions across files using existing search pipeline

Returns:



8
9
10
11
12
13
14
15
16
17
18
19
20
# File 'lib/na/next_action.rb', line 8

def select_actions(file: nil, depth: 1, search: [], tagged: [], include_done: false)
  files = if file
            [file]
          else
            find_files(depth: depth)
          end
  out = []
  files.each do |f|
    _projects, actions = find_actions(f, search, tagged, done: include_done, all: true)
    out.concat(actions) if actions
  end
  out
end

.select_file(files, multiple: false) ⇒ String+

Select from multiple files

If ‘gum` or `fzf` are available, they’ll be used (in that order).

Parameters:

  • files (Array<String>)

    The files to select from

  • multiple (Boolean) (defaults to: false)

    Allow multiple selections

Returns:



219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/na/next_action.rb', line 219

def select_file(files, multiple: false)
  res = choose_from(files, prompt: multiple ? 'Select files' : 'Select a file', multiple: multiple)
  if res.nil? || res == false || (res.respond_to?(:length) && res.empty?)
    notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1)
    return nil
  end
  if multiple
    res
  else
    res.is_a?(Array) ? res.first : res
  end
end

.shift_index_after(projects, idx, length = 1) ⇒ Array<NA::Project>

Shift project indices after a given index by a length.

Parameters:

  • projects (Array<NA::Project>)

    Projects to shift

  • idx (Integer)

    Index after which to shift

  • length (Integer) (defaults to: 1)

    Amount to shift

Returns:



237
238
239
240
241
242
243
244
# File 'lib/na/next_action.rb', line 237

def shift_index_after(projects, idx, length = 1)
  projects.map do |proj|
    proj.line = proj.line - length if proj.line > idx
    proj.last_line = proj.last_line - length if proj.last_line > idx

    proj
  end
end

.themeHash

Returns the current theme hash for color and style settings.

Returns:

  • (Hash)

    The theme settings



104
105
106
# File 'lib/na/next_action.rb', line 104

def theme
  @theme ||= NA::Theme.load_theme
end

.update_action(target, search, search_note: true, add: nil, add_tag: [], all: false, append: false, delete: false, done: false, edit: false, finish: false, note: [], overwrite: false, priority: 0, project: nil, move: nil, remove_tag: [], replace: nil, tagged: nil, started_at: nil, done_at: nil, duration_seconds: nil, suppress_prompt: false) ⇒ void

This method returns an undefined value.

Update actions in a todo file (add, edit, delete, move, etc.)

Parameters:

  • target (String)

    Path to the todo file

  • search (String, nil)

    Search string

  • search_note (Boolean) (defaults to: true)

    Search notes

  • add (Action, nil) (defaults to: nil)

    Action to add

  • add_tag (Array<String>) (defaults to: [])

    Tags to add

  • all (Boolean) (defaults to: false)

    Update all matching actions

  • append (Boolean) (defaults to: false)

    Append to project

  • delete (Boolean) (defaults to: false)

    Delete matching actions

  • done (Boolean) (defaults to: false)

    Mark as done

  • edit (Boolean) (defaults to: false)

    Edit matching actions

  • finish (Boolean) (defaults to: false)

    Mark as finished

  • note (Array<String>) (defaults to: [])

    Notes to add

  • overwrite (Boolean) (defaults to: false)

    Overwrite notes

  • priority (Integer) (defaults to: 0)

    Priority value

  • project (String, nil) (defaults to: nil)

    Project name

  • move (String, nil) (defaults to: nil)

    Move to project

  • remove_tag (Array<String>) (defaults to: [])

    Tags to remove

  • replace (String, nil) (defaults to: nil)

    Replacement text

  • tagged (String, nil) (defaults to: nil)

    Tag to filter



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
# File 'lib/na/next_action.rb', line 406

def update_action(target,
                  search,
                  search_note: true,
                  add: nil,
                  add_tag: [],
                  all: false,
                  append: false,
                  delete: false,
                  done: false,
                  edit: false,
                  finish: false,
                  note: [],
                  overwrite: false,
                  priority: 0,
                  project: nil,
                  move: nil,
                  remove_tag: [],
                  replace: nil,
                  tagged: nil,
                  started_at: nil,
                  done_at: nil,
                  duration_seconds: nil,
                  suppress_prompt: false)
  # Coerce date/time inputs if passed as strings
  begin
    started_at = NA::Types.parse_date_begin(started_at) if started_at && !started_at.is_a?(Time)
  rescue StandardError
    # leave as-is
  end
  begin
    done_at = NA::Types.parse_date_end(done_at) if done_at && !done_at.is_a?(Time)
  rescue StandardError
    # leave as-is
  end
  NA.notify("UPDATE parsed started_at=#{started_at.inspect} done_at=#{done_at.inspect} duration=#{duration_seconds.inspect}", debug: true)
  # Expand target to absolute path to avoid path resolution issues
  target = File.expand_path(target) unless Pathname.new(target).absolute?

  projects = find_projects(target)
  affected_actions = []

  target_proj = nil

  if move
    move = move.sub(/:$/, '')
    target_proj = projects.select { |pr| pr.project =~ /#{move.gsub(':', '.*?:.*?')}/i }.first
    if target_proj.nil?
      if suppress_prompt || !$stdout.isatty
        target_proj = insert_project(target, move)
        projects << target_proj
      else
        res = NA.yn(
          NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{move}#{NA.theme[:warning]} doesn't exist, add it"), default: true
        )
        if res
          target_proj = insert_project(target, move)
          projects << target_proj
        else
          NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
        end
      end
    end
  end

  contents = target.read_file.split("\n")

  if add.is_a?(Action)
    # NOTE: Edit is handled in the update command before calling update_action
    # So we don't need to handle it here - the action is already edited

    add_tag ||= []
    NA.notify("PROCESS before add.process started_at=#{started_at.inspect} done_at=#{done_at.inspect}", debug: true)
    add.process(priority: priority,
                finish: finish,
                add_tag: add_tag,
                remove_tag: remove_tag,
                started_at: started_at,
                done_at: done_at,
                duration_seconds: duration_seconds)
    NA.notify("PROCESS after add.process action=\"#{add.action}\"", debug: true)

    # Remove the original action and its notes if this is an existing action
    action_line = add.file_line
    note_lines = add.note.is_a?(Array) ? add.note.count : 0
    contents.slice!(action_line, note_lines + 1) if action_line.is_a?(Integer)

    # Prepare updated note
    note = note.to_s.split("\n") unless note.is_a?(Array)
    updated_note = if note.empty?
                     add.note
                   else
                     overwrite ? note : add.note.concat(note)
                   end

    # Prepare indentation
    projects = find_projects(target)
    # If move is set, update add.parent to the target project
    add.parent = target_proj.project.split(':') if move && target_proj
    project_path = add.parent.join(':')
    target_proj ||= projects.select { |proj| proj.project =~ /^#{project_path}$/i }.first

    if target_proj.nil? && !project_path.empty?
      display_path = project_path.tr(':', '/')
      prompt = NA::Color.template(
        "#{NA.theme[:warning]}Project #{NA.theme[:file]}#{display_path}#{NA.theme[:warning]} doesn't exist, create it?"
      )
      should_create = NA.yn(prompt, default: true)
      NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless should_create

      created_proj = insert_project(target, project_path)
      contents = target.read_file.split("\n")
      projects = find_projects(target)
      target_proj = projects.select { |proj| proj.project =~ /^#{project_path}$/i }.first || created_proj
    end

    add.parent = target_proj.project.split(':') if target_proj
    indent = target_proj ? ("\t" * target_proj.indent) : ''

    # Format note for insertion
    note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"

    # If delete was requested in this direct update path, do not re-insert
    if delete
      affected_actions << { action: add, desc: 'deleted' }
    else
      # Insert at correct location
      if target_proj
        insert_line = if append
                        # End of project
                        target_proj.last_line + 1
                      else
                        # Start of project (after project header)
                        target_proj.line + 1
                      end
        # Ensure @started tag persists if provided
        final_action = add.action.dup
        if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
          final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
          final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
        end
        NA.notify("INSERT at #{insert_line} final_action=\"#{final_action}\"", debug: true)
        contents.insert(insert_line, "#{indent}\t- #{final_action}#{note_str}")
      else
        # Fallback: append to end of file
        final_action = add.action.dup
        if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
          final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
          final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
        end
        NA.notify("APPEND final_action=\"#{final_action}\"", debug: true)
        contents << "#{indent}\t- #{final_action}#{note_str}"
      end

      notify(add.pretty)
    end

    # Track affected action and description
    unless delete
      changes = ['updated']
      changes << 'finished' if finish
      changes << "priority=#{priority}" if priority.to_i.positive?
      changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
      changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
      changes << 'note updated' unless note.nil? || note.empty?
      changes << "moved to #{target_proj.project}" if move && target_proj
      affected_actions << { action: add, desc: changes.join(', ') }
    end
  else
    # Check if search is actually target_line
    target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
    _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
                                                      search_note: search_note, target_line: target_line)

    return if actions.nil?

    # Handle edit (single or multi-action)
    if edit
      editor_content = Editor.format_multi_action_input(actions)
      edited_content = Editor.fork_editor(editor_content)
      edited_actions = Editor.parse_multi_action_output(edited_content)

      # Map edited content back to actions
      actions.each do |action|
        # Use file_path:file_line as the key
        key = "#{action.file_path}:#{action.file_line}"
        action.action, action.note = edited_actions[key] if edited_actions[key]
      end
    end

    actions.sort_by(&:file_line).reverse.each do |action|
      contents.slice!(action.file_line, action.note.count + 1)
      if delete
        # Track deletion before skipping re-insert
        affected_actions << { action: action, desc: 'deleted' }
        next
      end

      projects = shift_index_after(projects, action.file_line, action.note.count + 1)

      # If replace is defined, use search to search and replace text in action
      action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace

      action.process(priority: priority,
                     finish: finish,
                     add_tag: add_tag,
                     remove_tag: remove_tag,
                     started_at: started_at,
                     done_at: done_at,
                     duration_seconds: duration_seconds)

      target_proj = if target_proj
                      projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
                    else
                      projects.select { |proj| proj.project =~ /^#{action.parent.join(':')}$/ }.first
                    end

      indent = "\t" * target_proj.indent
      note = note.split("\n") unless note.is_a?(Array)
      note = if note.empty?
               action.note
             else
               overwrite ? note : action.note.concat(note)
             end
      note = note.empty? ? '' : "\n#{indent}\t\t#{note.join("\n#{indent}\t\t").strip}"

      if append
        this_idx = 0
        projects.each_with_index do |proj, idx|
          if proj.line == target_proj.line
            this_idx = idx
            break
          end
        end

        target_line = if this_idx == projects.length - 1
                        contents.count
                      else
                        projects[this_idx].last_line + 1
                      end
      else
        target_line = target_proj.line + 1
      end

      contents.insert(target_line, "#{indent}\t- #{action.action}#{note}")

      notify(action.pretty)

      # Track affected action and description
      changes = []
      changes << 'finished' if finish
      changes << 'edited' if edit
      changes << "priority=#{priority}" if priority.to_i.positive?
      changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
      changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
      changes << 'text replaced' if replace
      changes << "moved to #{target_proj.project}" if target_proj
      changes << 'note updated' unless note.nil? || note.empty?
      changes = ['updated'] if changes.empty?
      affected_actions << { action: action, desc: changes.join(', ') }
    end
  end

  backup_file(target)
  File.open(target, 'w') { |f| f.puts contents.join("\n") }

  if affected_actions.any?
    if affected_actions.all? { |e| e[:desc] =~ /^deleted/ }
      notify("#{NA.theme[:success]}Task deleted in #{NA.theme[:filename]}#{target}")
    elsif add
      notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
    else
      notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
    end

    affected_actions.reverse.each do |entry|
      action_color = delete ? NA.theme[:error] : NA.theme[:success]
      notify("  #{entry[:action].to_s_pretty}#{action_color}#{entry[:desc]}")
    end
  elsif add
    notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
  else
    notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
  end
end

.weed_cache_fileObject

Remove entries from cache database that no longer exist



1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
# File 'lib/na/next_action.rb', line 1107

def weed_cache_file
  db_dir = File.expand_path('~/.local/share/na')
  db_file = 'tdlist.txt'
  file = File.join(db_dir, db_file)
  return unless File.exist?(file)

  dirs = file.read_file.split("\n")
  dirs.delete_if { |f| !File.exist?(f) }
  File.open(file, 'w') { |f| f.puts dirs.join("\n") }
end

.weed_modified_files(file = nil) ⇒ void

This method returns an undefined value.

Remove entries for missing backup files from the database

Parameters:

  • file (String, nil) (defaults to: nil)

    Optional file to filter



1050
1051
1052
1053
1054
1055
1056
1057
1058
# File 'lib/na/next_action.rb', line 1050

def weed_modified_files(file = nil)
  files = backup_files

  files.delete_if { |f| f =~ /#{file}/ } if file

  files.delete_if { |f| !File.exist?(backup_path(f)) }

  File.open(database_path(file: 'last_modified.txt'), 'w') { |f| f.puts files.join("\n") }
end

.yn(prompt, default: true) ⇒ Boolean

Display and read a Yes/No prompt

Parameters:

  • prompt (String)

    The prompt string

  • default (Boolean) (defaults to: true)

    default value if return is pressed or prompt is skipped

Returns:

  • (Boolean)

    result



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/na/next_action.rb', line 144

def yn(prompt, default: true)
  return default if ENV['NA_TEST'] == '1'
  return default unless $stdout.isatty

  tty_state = `stty -g`
  system 'stty raw -echo cbreak isig'
  yn = color_single_options(default ? %w[Y n] : %w[y N])
  $stdout.syswrite "\e[1;37m#{prompt} #{yn}\e[1;37m? \e[0m"
  res = $stdin.sysread 1
  res.chomp!
  puts
  system 'stty cooked'
  system "stty #{tty_state}"
  res.empty? ? default : res =~ /y/i
end