Class: NA::Todo

Inherits:
Object
  • Object
show all
Defined in:
lib/na/todo.rb

Overview

Represents a parsed todo file, including actions, projects, and file management.

Examples:

Parse a todo file

todo = NA::Todo.new(file_path: 'todo.txt')

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ void

Initialize a Todo object and parse actions/projects/files

Examples:

todo = NA::Todo.new(file_path: 'todo.txt')

Parameters:

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

    Options for parsing todo files



17
18
19
# File 'lib/na/todo.rb', line 17

def initialize(options = {})
  @files, @actions, @projects = parse(options)
end

Instance Attribute Details

#actionsObject

Returns the value of attribute actions.



9
10
11
# File 'lib/na/todo.rb', line 9

def actions
  @actions
end

#filesObject

Returns the value of attribute files.



9
10
11
# File 'lib/na/todo.rb', line 9

def files
  @files
end

#projectsObject

Returns the value of attribute projects.



9
10
11
# File 'lib/na/todo.rb', line 9

def projects
  @projects
end

Instance Method Details

#parse(options) ⇒ Array

Read a todo file and create a list of actions

Examples:

files, actions, projects = todo.parse(file_path: 'todo.txt')

Parameters:

  • options (Hash)

    The options

Options Hash (options):

  • :depth (Number)

    The directory depth to search for files

  • :done (Boolean)

    include @done actions

  • :query (Hash)

    The todo file query

  • :tag (Array)

    Tags to search for

  • :search (String)

    A search string

  • :negate (Boolean)

    Invert results

  • :regex (Boolean)

    Interpret as regular expression

  • :project (String)

    The project

  • :require_na (Boolean)

    Require @na tag

  • :file_path (String)

    file path to parse

Returns:

  • (Array)

    files, actions, projects



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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/na/todo.rb', line 37

def parse(options)
  NA::Benchmark.measure('Todo.parse') do
    defaults = {
      depth: 1,
      done: false,
      file_path: nil,
      negate: false,
      hidden: false,
      project: nil,
      query: nil,
      regex: false,
      require_na: true,
      search: nil,
      search_note: true,
      tag: nil
    }

    settings = defaults.merge(options)
    # Coerce settings[:search] to a string or nil if it's an integer
    if settings[:search].is_a?(Integer)
      settings[:search] = settings[:search] <= 0 ? nil : settings[:search].to_s
    end
    # Ensure tag is always an Array
    if settings[:tag].nil?
      settings[:tag] = []
    elsif !settings[:tag].is_a?(Array)
      settings[:tag] = [settings[:tag]]
    end

    actions = NA::Actions.new
    required = []
    optional = []
    negated = []
    required_tag = []
    optional_tag = []
    negated_tag = []
    projects = []

    NA.notify("Tags: #{settings[:tag]}", debug: true)
    NA.notify("Search: #{settings[:search]}", debug: true)

    settings[:tag]&.each do |t|
      # If t is a Hash, use its keys; if String, treat as a tag string
      if t.is_a?(Hash)
        unless t[:tag].nil?
          if settings[:negate]
            optional_tag.push(t) if t[:negate]
            required_tag.push(t) if t[:required] && t[:negate]
            negated_tag.push(t) unless t[:negate]
          else
            optional_tag.push(t) unless t[:negate] || t[:required]
            required_tag.push(t) if t[:required] && !t[:negate]
            negated_tag.push(t) if t[:negate]
          end
        end
      elsif t.is_a?(String)
        # Treat string as a simple tag
        optional_tag.push({ tag: t })
      end
    end
    # Track whether strings came from direct path (need escaping) or parse_search (already processed)
    strings_from_direct_path = false
    unless settings[:search].nil? || (settings[:search].respond_to?(:empty?) && settings[:search].empty?)
      if settings[:regex] || settings[:search].is_a?(String)
        strings_from_direct_path = true
        if settings[:negate]
          negated.push(settings[:search])
        else
          optional.push(settings[:search])
          required.push(settings[:search])
        end
      else
        settings[:search].each do |t|
          opt, req, neg = parse_search(t, settings[:negate])
          optional.concat(opt)
          required.concat(req)
          negated.concat(neg)
        end
      end
    end

    # Pre-compile regexes for better performance
    # When regex is false and string came from direct path, escape special characters
    # When regex is true, use as-is (it's already a regex pattern)
    # Strings from parse_search are already processed by wildcard_to_rx, so use as-is
    compile_regex = lambda do |rx|
      if rx.is_a?(Regexp)
        rx
      elsif settings[:regex]
        Regexp.new(rx, Regexp::IGNORECASE)
      elsif strings_from_direct_path
        Regexp.new(Regexp.escape(rx.to_s), Regexp::IGNORECASE)
      else
        # From parse_search, already processed by wildcard_to_rx
        # Try to compile as-is, but if it fails, escape it (handles edge cases with special chars)
        begin
          Regexp.new(rx, Regexp::IGNORECASE)
        rescue RegexpError
          # If compilation fails, escape the string (fallback for edge cases)
          Regexp.new(Regexp.escape(rx.to_s), Regexp::IGNORECASE)
        end
      end
    end

    optional = optional.map(&compile_regex)
    required = required.map(&compile_regex)
    negated = negated.map(&compile_regex)

    files = if !settings[:file_path].nil?
              [settings[:file_path]]
            elsif settings[:query].nil?
              NA.find_files(depth: settings[:depth], include_hidden: settings[:hidden])
            else
              NA.match_working_dir(settings[:query])
            end

    NA.notify("Files: #{files.join(', ')}", debug: true)
    # Cache project regex compilation outside the line loop for better performance
    project_regex = if settings[:project]
                      rx = settings[:project].split(%r{[/:]}).join('.*?/')
                      Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
                    end

    files.each do |file|
      next if File.directory?(file)

      NA::Benchmark.measure("Parse file: #{File.basename(file)}") do
        NA.save_working_dir(File.expand_path(file))
        content = file.read_file
        indent_level = 0
        parent = []
        in_yaml = false
        in_action = false
        content.split("\n").each.with_index do |line, idx|
          if in_yaml && line !~ /^(---|~~~)\s*$/
            NA.notify("YAML: #{line}", debug: true)
          elsif line =~ /^(---|~~~)\s*$/
            in_yaml = !in_yaml
          elsif line.project? && !in_yaml
            in_action = false
            proj = line.project
            indent = line.indent_level

            if indent.zero? # top level project
              parent = [proj]
            elsif indent <= indent_level # if indent level is same or less, split parent before indent level and append
              parent.slice!(indent, parent.count - indent)
              parent.push(proj)
            else # if indent level is greater, append project to parent
              parent.push(proj)
            end

            projects.push(NA::Project.new(parent.join(':'), indent, idx, idx))

            indent_level = indent
          elsif line.blank?
            in_action = false # Comment out to allow line breaks in of notes, which isn't TaskPaper-compatible
          elsif line.action?
            in_action = false

            # Early exits before creating Action object
            next if line.done? && !settings[:done]

            next if settings[:require_na] && !line.na?

            next if project_regex && parent.join('/') !~ project_regex

            # Only create Action if we passed basic filters
            action = line.action
            new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)

            projects[-1].last_line = idx if projects.any?

            # Tag matching
            has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
            next if has_tag && !new_action.tags_match?(any: optional_tag,
                                                       all: required_tag,
                                                       none: negated_tag)

            actions.push(new_action)
            in_action = true
          elsif in_action
            actions[-1].note.push(line.strip) if actions.any?
            projects[-1].last_line = idx if projects.any?
          end
        end
        projects = projects.dup
      end
    end

    NA::Benchmark.measure('Filter actions by search') do
      actions.delete_if do |new_action|
        has_search = !optional.empty? || !required.empty? || !negated.empty?
        has_search && !new_action.search_match?(any: optional,
                                                all: required,
                                                none: negated,
                                                include_note: settings[:search_note])
      end
    end

    [files, actions, projects]
  end
end

#parse_search(tag, negate) ⇒ Array<Array>

Parse a search tag and categorize as optional, required, or negated

Parameters:

  • tag (Hash)

    Search tag with :token, :negate, :required

  • negate (Boolean)

    Invert results

Returns:

  • (Array<Array>)

    Arrays of optional, required, and negated regexes



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/na/todo.rb', line 246

def parse_search(tag, negate)
  required = []
  optional = []
  negated = []
  new_rx = tag[:token].to_s.wildcard_to_rx

  if negate
    optional.push(new_rx) if tag[:negate]
    required.push(new_rx) if tag[:required] && tag[:negate]
    negated.push(new_rx) unless tag[:negate]
  else
    optional.push(new_rx) unless tag[:negate]
    required.push(new_rx) if tag[:required] && !tag[:negate]
    negated.push(new_rx) if tag[:negate]
  end

  [optional, required, negated]
end