na 1.2.94 → 1.2.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,9 @@
3
3
  # Next Action methods
4
4
  module NA
5
5
  class << self
6
+ attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
7
+ :cwd_is, :cwd, :stdin, :show_cwd_indicator
8
+
6
9
  # Select actions across files using existing search pipeline
7
10
  # @return [Array<NA::Action>]
8
11
  def select_actions(file: nil, depth: 1, search: [], tagged: [], include_done: false)
@@ -96,9 +99,6 @@ module NA
96
99
  end
97
100
  include NA::Editor
98
101
 
99
- attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
100
- :cwd_is, :cwd, :stdin, :show_cwd_indicator
101
-
102
102
  # Returns the current theme hash for color and style settings.
103
103
  # @return [Hash] The theme settings
104
104
  def theme
@@ -902,7 +902,10 @@ module NA
902
902
  end}", debug: true)
903
903
 
904
904
  if require_last
905
- dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
905
+ dirs.delete_if do |d|
906
+ !d.sub(/\.#{NA.extension}$/, '')
907
+ .dir_matches?(any: optional, all: required, none: negated, require_last: true, distance: distance)
908
+ end
906
909
  else
907
910
  dirs.delete_if do |d|
908
911
  !d.sub(/\.#{NA.extension}$/, '')
@@ -1149,6 +1152,170 @@ module NA
1149
1152
  end
1150
1153
  end
1151
1154
 
1155
+ # Resolve a TaskPaper-style item path (subset) into NA project paths.
1156
+ #
1157
+ # Supported subset:
1158
+ # - Child axis: /Project/Sub
1159
+ # - Descendant axis: //Sub (e.g. /Inbox//Bugs)
1160
+ # - Wildcard step: * (matches any project name in that position)
1161
+ #
1162
+ # @param path [String] TaskPaper-style item path (must start with '/')
1163
+ # @param file [String, nil] Optional single file to resolve against
1164
+ # @param depth [Integer] Directory search depth when no file is given
1165
+ # @return [Array<String>] Array of project paths like "Inbox:New Videos"
1166
+ def resolve_item_path(path:, file: nil, depth: 1)
1167
+ return [] if path.nil?
1168
+
1169
+ steps = parse_item_path(path)
1170
+ return [] if steps.empty?
1171
+
1172
+ files = if file
1173
+ [File.expand_path(file)]
1174
+ else
1175
+ find_files(depth: depth)
1176
+ end
1177
+ return [] if files.nil? || files.empty?
1178
+
1179
+ matches = []
1180
+
1181
+ files.each do |f|
1182
+ todo = NA::Todo.new(require_na: false, file_path: f)
1183
+ projects = todo.projects
1184
+ next if projects.empty?
1185
+
1186
+ current = resolve_path_in_projects(projects, steps)
1187
+ current.each do |proj|
1188
+ matches << proj.project
1189
+ end
1190
+ end
1191
+
1192
+ matches.uniq
1193
+ end
1194
+
1195
+ # Parse a TaskPaper-style item path string into steps with axis and text.
1196
+ # Returns an Array of Hashes: { axis: :child|:desc, text: String,
1197
+ # wildcard: Boolean }.
1198
+ def parse_item_path(path)
1199
+ s = path.to_s.strip
1200
+ return [] unless s.start_with?('/')
1201
+
1202
+ steps = []
1203
+ i = 0
1204
+ len = s.length
1205
+
1206
+ while i < len
1207
+ break unless s[i] == '/'
1208
+
1209
+ axis = :child
1210
+ if i + 1 < len && s[i + 1] == '/'
1211
+ axis = :desc
1212
+ i += 1
1213
+ end
1214
+ i += 1
1215
+
1216
+ text = +''
1217
+ quote = nil
1218
+
1219
+ while i < len
1220
+ ch = s[i]
1221
+ if quote
1222
+ text << ch
1223
+ quote = nil if ch == quote
1224
+ i += 1
1225
+ next
1226
+ end
1227
+
1228
+ if ch == '"' || ch == "'"
1229
+ quote = ch
1230
+ text << ch
1231
+ i += 1
1232
+ next
1233
+ end
1234
+
1235
+ break if ch == '/'
1236
+
1237
+ text << ch
1238
+ i += 1
1239
+ end
1240
+
1241
+ t = text.strip
1242
+ wildcard = t.empty? || t == '*'
1243
+ steps << { axis: axis, text: t, wildcard: wildcard }
1244
+ end
1245
+
1246
+ steps
1247
+ end
1248
+
1249
+ # Resolve a parsed item path against a list of NA::Project objects from a
1250
+ # single file.
1251
+ #
1252
+ # @param projects [Array<NA::Project>]
1253
+ # @param steps [Array<Hash>] Parsed steps from parse_item_path
1254
+ # @return [Array<NA::Project>] Matching projects (last step)
1255
+ def resolve_path_in_projects(projects, steps)
1256
+ return [] if steps.empty? || projects.empty?
1257
+
1258
+ # First step: from a virtual root; child axis means top-level projects
1259
+ # (no ':' in path), descendant axis means any project in the file.
1260
+ first = steps.first
1261
+ current = []
1262
+
1263
+ projects.each do |proj|
1264
+ case first[:axis]
1265
+ when :child
1266
+ next unless proj.project.split(':').length == 1
1267
+ when :desc
1268
+ # any project is a descendant of the virtual root
1269
+ end
1270
+
1271
+ current << proj if item_path_step_match?(first, proj)
1272
+ end
1273
+
1274
+ steps[1..].each do |step|
1275
+ next_current = []
1276
+ current.each do |parent|
1277
+ parent_path = parent.project
1278
+ parent_depth = parent_path.split(':').length
1279
+ projects.each do |proj|
1280
+ next if proj.equal?(parent)
1281
+
1282
+ case step[:axis]
1283
+ when :child
1284
+ next unless proj.project.start_with?("#{parent_path}:")
1285
+ next unless proj.project.split(':').length == parent_depth + 1
1286
+ when :desc
1287
+ next unless proj.project.start_with?("#{parent_path}:")
1288
+ end
1289
+
1290
+ next unless item_path_step_match?(step, proj)
1291
+
1292
+ next_current << proj
1293
+ pp next_current.inspect
1294
+ end
1295
+ end
1296
+ current = next_current.uniq
1297
+ break if current.empty?
1298
+ end
1299
+
1300
+ current
1301
+ end
1302
+
1303
+ # Check if a project matches a single item-path step.
1304
+ def item_path_step_match?(step, proj)
1305
+ return true if step[:wildcard]
1306
+
1307
+ name = proj.project.split(':').last.to_s
1308
+ txt = step[:text]
1309
+ return false if txt.nil? || txt.empty?
1310
+
1311
+ if txt =~ /[*?]/
1312
+ rx = Regexp.new(txt.wildcard_to_rx, Regexp::IGNORECASE)
1313
+ !!(name =~ rx)
1314
+ else
1315
+ name.downcase.include?(txt.downcase)
1316
+ end
1317
+ end
1318
+
1152
1319
  # List todo files matching a query.
1153
1320
  # @param query [Array] Query tokens
1154
1321
  # @return [void]
@@ -1188,6 +1355,643 @@ module NA
1188
1355
  NA.notify("#{NA.theme[:success]}Search #{NA.theme[:filename]}#{title}#{NA.theme[:success]} saved", exit_code: 0)
1189
1356
  end
1190
1357
 
1358
+ # Parse a TaskPaper-style @search() expression into NA search components.
1359
+ #
1360
+ # TaskPaper expressions are of the form (subset of full syntax):
1361
+ # @search(@tag, @tag = 1, @tag contains 1, not @tag, project = "Name", not project = "Name", plain, "text")
1362
+ #
1363
+ # Supported operators (mapped from TaskPaper searches, see:
1364
+ # https://guide.taskpaper.com/reference/searches/):
1365
+ # - boolean: and / not (or/parentheses are not yet fully supported)
1366
+ # - @tag, not @tag
1367
+ # - @tag = VALUE, @tag > VALUE, @tag < VALUE, @tag >= VALUE, @tag <= VALUE, @tag =~ VALUE
1368
+ # - @tag contains VALUE, beginswith VALUE, endswith VALUE, matches VALUE
1369
+ # - @text REL VALUE (treated as plain-text search on the line)
1370
+ # - project = "Name", not project = "Name"
1371
+ #
1372
+ # The result can be passed directly to NA::Todo via the returned clause
1373
+ # hashes, which include keys :tokens, :tags, :project, :include_done, and
1374
+ # :exclude_projects.
1375
+ #
1376
+ # @param expr [String] TaskPaper @search() expression or inner content
1377
+ # @return [Hash] Parsed components for a single AND-joined clause
1378
+ def parse_taskpaper_search(expr)
1379
+ clauses = parse_taskpaper_search_clauses(expr)
1380
+ clauses.first || { tokens: [], tags: [], project: nil, include_done: nil, exclude_projects: [] }
1381
+ end
1382
+
1383
+ # Internal: parse a single (AND-joined) TaskPaper clause into search
1384
+ # components.
1385
+ #
1386
+ # @param clause [String] Clause content with no surrounding @search()
1387
+ # @param out [Hash] Accumulator hash (tokens/tags/project/etc.)
1388
+ # @return [Hash] The same +out+ hash
1389
+ def parse_taskpaper_search_clause(clause, out)
1390
+ parts = clause.split(/\band\b/i).map(&:strip).reject(&:empty?)
1391
+
1392
+ parts.each do |raw_part|
1393
+ part = raw_part.dup
1394
+ neg = false
1395
+
1396
+ if part =~ /\Anot\s+(.+)\z/i
1397
+ neg = true
1398
+ part = Regexp.last_match(1).strip
1399
+ end
1400
+
1401
+ # @tag, @tag OP VALUE, or @attribute OP VALUE
1402
+ if part =~ /\A@([A-Za-z0-9_\-:.]+)\s*(?:(=|==|!=|>=|<=|>|<|=~|contains(?:\[[^\]]+\])?|beginswith(?:\[[^\]]+\])?|endswith(?:\[[^\]]+\])?|matches(?:\[[^\]]+\])?)\s*(.+))?\z/i
1403
+ tag = Regexp.last_match(1)
1404
+ op = Regexp.last_match(2)
1405
+ val = Regexp.last_match(3)&.strip
1406
+ val = val[1..-2] if val && ((val.start_with?('"') && val.end_with?('"')) || (val.start_with?("'") && val.end_with?("'")))
1407
+
1408
+ # Handle @text as a plain-text predicate on the line
1409
+ if tag.casecmp('text').zero?
1410
+ if val
1411
+ token_val = val
1412
+ out[:tokens] << {
1413
+ token: token_val,
1414
+ required: !neg,
1415
+ negate: neg
1416
+ }
1417
+ end
1418
+ next
1419
+ end
1420
+
1421
+ if tag.casecmp('done').zero?
1422
+ # Handle done specially via :include_done; do NOT add a tag filter,
1423
+ # otherwise Todo.parse would force include @done actions.
1424
+ out[:include_done] = !neg
1425
+ next
1426
+ end
1427
+
1428
+ # Normalize operator: strip TaskPaper relation modifiers and map
1429
+ # relation names to our internal comparison codes.
1430
+ op = op.to_s.downcase
1431
+ # Strip relation modifiers like [i], [sl], [dn], etc.
1432
+ op = op.sub(/\[.*\]\z/, '')
1433
+
1434
+ # Translate "!=" into a negated equality check
1435
+ if op == '!='
1436
+ op = '='
1437
+ neg = true
1438
+ elsif op == 'contains'
1439
+ op = '*='
1440
+ elsif op == 'beginswith'
1441
+ op = '^='
1442
+ elsif op == 'endswith'
1443
+ op = '$='
1444
+ elsif op == 'matches'
1445
+ op = '=~'
1446
+ end
1447
+
1448
+ tag_hash = {
1449
+ tag: tag.wildcard_to_rx,
1450
+ comp: op,
1451
+ value: val,
1452
+ required: !neg,
1453
+ negate: neg
1454
+ }
1455
+ out[:tags] << tag_hash
1456
+ next
1457
+ end
1458
+
1459
+ # project = "Name", project != "Name"
1460
+ if part =~ /\Aproject\s*(=|==|!=)\s*(.+)\z/i
1461
+ op = Regexp.last_match(1)
1462
+ val = Regexp.last_match(2).strip
1463
+ val = val[1..-2] if (val.start_with?('"') && val.end_with?('"')) || (val.start_with?("'") && val.end_with?("'"))
1464
+
1465
+ if neg || op == '!='
1466
+ out[:exclude_projects] << val
1467
+ else
1468
+ out[:project] = val
1469
+ end
1470
+ next
1471
+ end
1472
+
1473
+ # Fallback: treat as a plain text token
1474
+ token = part
1475
+ token = token[1..-2] if (token.start_with?('"') && token.end_with?('"')) || (token.start_with?("'") && token.end_with?("'"))
1476
+ out[:tokens] << {
1477
+ token: token,
1478
+ required: !neg,
1479
+ negate: neg
1480
+ }
1481
+ end
1482
+
1483
+ out
1484
+ end
1485
+
1486
+ # Parse a TaskPaper-style @search() expression into multiple OR-joined
1487
+ # clauses. Each clause is an AND-joined set of predicates represented as a
1488
+ # hash compatible with NA::Todo options. Supports nested boolean
1489
+ # expressions with parentheses using `and` / `or`. The unary `not`
1490
+ # operator is handled inside individual predicates.
1491
+ #
1492
+ # Also supports an optional leading item path (subset) before predicates, e.g.:
1493
+ # @search(/Inbox//Testing and not @done)
1494
+ # The leading path is exposed on each clause as :item_paths and is later
1495
+ # resolved via resolve_item_path in run_taskpaper_search.
1496
+ #
1497
+ # @param expr [String] TaskPaper @search() expression or inner content
1498
+ # @return [Array<Hash>] Array of clause hashes
1499
+ def parse_taskpaper_search_clauses(expr)
1500
+ return [] if expr.nil?
1501
+
1502
+ NA.notify("TP DEBUG expr: #{expr.inspect}", debug: true) if NA.verbose
1503
+
1504
+ inner = expr.to_s.strip
1505
+ NA.notify("TP DEBUG inner initial: #{inner.inspect}", debug: true) if NA.verbose
1506
+ inner = Regexp.last_match(1).strip if inner =~ /\A@search\((.*)\)\s*\z/i
1507
+ NA.notify("TP DEBUG inner after @search strip: #{inner.inspect}", debug: true) if NA.verbose
1508
+
1509
+ return [] if inner.empty?
1510
+
1511
+ # Extract optional leading item path (must start with '/'). The remaining
1512
+ # content is treated as the boolean expression for predicates. We allow
1513
+ # spaces inside the path and stop at the first unquoted AND/OR keyword.
1514
+ item_path = nil
1515
+ if inner.start_with?('/')
1516
+ i = 0
1517
+ quote = nil
1518
+ sep_index = nil
1519
+ sep_len = nil
1520
+ while i < inner.length
1521
+ ch = inner[i]
1522
+ if quote
1523
+ quote = nil if ch == quote
1524
+ i += 1
1525
+ next
1526
+ end
1527
+
1528
+ if ch == '"' || ch == "'"
1529
+ quote = ch
1530
+ i += 1
1531
+ next
1532
+ end
1533
+
1534
+ # Look for unquoted AND/OR separators
1535
+ rest = inner[i..]
1536
+ if rest =~ /\A\s+and\b/i
1537
+ sep_index = i
1538
+ sep_len = rest.match(/\A\s+and\b/i)[0].length
1539
+ break
1540
+ elsif rest =~ /\A\s+or\b/i
1541
+ sep_index = i
1542
+ sep_len = rest.match(/\A\s+or\b/i)[0].length
1543
+ break
1544
+ end
1545
+
1546
+ i += 1
1547
+ end
1548
+
1549
+ if sep_index
1550
+ item_path = inner[0...sep_index].strip
1551
+ inner = inner[(sep_index + sep_len)..].to_s.strip
1552
+ else
1553
+ item_path = inner.strip
1554
+ inner = ''
1555
+ end
1556
+ end
1557
+ NA.notify("TP DEBUG item_path: #{item_path.inspect} inner now: #{inner.inspect}", debug: true) if NA.verbose
1558
+
1559
+ # Extract optional trailing slice, e.g.:
1560
+ # [index], [start:end], [start:], [:end], [:]
1561
+ # from the entire inner expression (including parenthesized forms like
1562
+ # (expr)[0]).
1563
+ slice = nil
1564
+ if inner =~ /\A(.+)\[(\d*:?(\d*)?)\]\s*\z/m
1565
+ expr_part = Regexp.last_match(1).strip
1566
+ slice_str = Regexp.last_match(2)
1567
+
1568
+ if slice_str.include?(':')
1569
+ start_str, end_str = slice_str.split(':', 2)
1570
+ slice = {
1571
+ start: (start_str.nil? || start_str.empty? ? nil : start_str.to_i),
1572
+ end: (end_str.nil? || end_str.empty? ? nil : end_str.to_i)
1573
+ }
1574
+ else
1575
+ slice = { index: slice_str.to_i }
1576
+ end
1577
+
1578
+ inner = expr_part
1579
+ end
1580
+ NA.notify("TP DEBUG slice: #{slice.inspect} inner after slice: #{inner.inspect}", debug: true) if NA.verbose
1581
+
1582
+ # If the entire expression is wrapped in a single pair of parentheses,
1583
+ # strip them so shortcuts like `project Inbox and @na` can be recognized.
1584
+ if inner.start_with?('(') && inner.end_with?(')')
1585
+ depth = 0
1586
+ balanced = true
1587
+ inner.chars.each_with_index do |ch, idx|
1588
+ depth += 1 if ch == '('
1589
+ depth -= 1 if ch == ')'
1590
+ if depth.zero? && idx < inner.length - 1
1591
+ balanced = false
1592
+ break
1593
+ end
1594
+ end
1595
+ inner = inner[1..-2].strip if balanced
1596
+ end
1597
+
1598
+ # Expand TaskPaper type shortcuts at the start of the predicate expression:
1599
+ # project NAME -> project = "NAME"
1600
+ # task NAME -> NAME (we only search tasks anyway)
1601
+ # note NAME -> NAME (approximate)
1602
+ if inner =~ /\A(project|task|note)\s+(.+)\z/i
1603
+ kind = Regexp.last_match(1).downcase
1604
+ rest = Regexp.last_match(2).strip
1605
+ case kind
1606
+ when 'project'
1607
+ # If this is just `project NAME`, treat it as a project constraint.
1608
+ # If it contains additional boolean logic (and/or), drop the
1609
+ # `project NAME` prefix and leave the rest of the expression
1610
+ # unchanged for normal predicate parsing.
1611
+ if rest =~ /\b(and|or)\b/i
1612
+ # Drop leading "NAME and" and keep the remainder, e.g.
1613
+ # "Inbox and @na and not @done" -> "@na and not @done"
1614
+ # then strip the leading "and" to leave "@na and not @done".
1615
+ inner = if rest =~ /\A(\S+)\s+and\s+(.+)\z/mi
1616
+ Regexp.last_match(2).strip
1617
+ else
1618
+ rest
1619
+ end
1620
+ else
1621
+ name = rest
1622
+ # Strip surrounding quotes if present
1623
+ name = name[1..-2] if (name.start_with?('"') && name.end_with?('"')) || (name.start_with?("'") && name.end_with?("'"))
1624
+ inner = %(project = "#{name}")
1625
+ end
1626
+ when 'task', 'note'
1627
+ # For now, treat as a plain text search on the rest
1628
+ inner = rest
1629
+ end
1630
+ end
1631
+
1632
+ NA.notify("TP DEBUG inner before tokenizing: #{inner.inspect}", debug: true) if NA.verbose
1633
+
1634
+ # Tokenize expression into TEXT, AND, OR, '(', ')', preserving quoted
1635
+ # strings and leaving `not` to be handled inside predicates.
1636
+ tokens = []
1637
+ current = +''
1638
+ quote = nil
1639
+ i = 0
1640
+
1641
+ boundary = lambda do |str, idx, len|
1642
+ before = idx.positive? ? str[idx - 1] : nil
1643
+ after = (idx + len) < str.length ? str[idx + len] : nil
1644
+ before_ok = before.nil? || before =~ /\s|\(/
1645
+ after_ok = after.nil? || after =~ /\s|\)/
1646
+ before_ok && after_ok
1647
+ end
1648
+
1649
+ while i < inner.length
1650
+ ch = inner[i]
1651
+
1652
+ if quote
1653
+ current << ch
1654
+ quote = nil if ch == quote
1655
+ i += 1
1656
+ next
1657
+ end
1658
+
1659
+ if ch == '"' || ch == "'"
1660
+ quote = ch
1661
+ current << ch
1662
+ i += 1
1663
+ next
1664
+ end
1665
+
1666
+ if ch == '(' || ch == ')'
1667
+ tokens << [:TEXT, current] unless current.strip.empty?
1668
+ current = +''
1669
+ tokens << [ch, ch]
1670
+ i += 1
1671
+ next
1672
+ end
1673
+
1674
+ if ch =~ /\s/
1675
+ unless current.strip.empty?
1676
+ tokens << [:TEXT, current]
1677
+ current = +''
1678
+ end
1679
+ i += 1
1680
+ next
1681
+ end
1682
+
1683
+ rest = inner[i..]
1684
+ if rest.downcase.start_with?('and') && boundary.call(inner, i, 3)
1685
+ tokens << [:TEXT, current] unless current.strip.empty?
1686
+ current = +''
1687
+ tokens << [:AND, 'and']
1688
+ i += 3
1689
+ next
1690
+ elsif rest.downcase.start_with?('or') && boundary.call(inner, i, 2)
1691
+ tokens << [:TEXT, current] unless current.strip.empty?
1692
+ current = +''
1693
+ tokens << [:OR, 'or']
1694
+ i += 2
1695
+ next
1696
+ else
1697
+ current << ch
1698
+ i += 1
1699
+ end
1700
+ end
1701
+ tokens << [:TEXT, current] unless current.strip.empty?
1702
+
1703
+ # Recursive-descent parser producing DNF (array of AND-clauses)
1704
+ index = 0
1705
+
1706
+ current_token = lambda { tokens[index] }
1707
+ advance = lambda { index += 1 }
1708
+
1709
+ # Declare parse_or in outer scope so it's visible inside parse_primary
1710
+ parse_or = nil
1711
+
1712
+ parse_primary = lambda do
1713
+ tok = current_token.call
1714
+ return [] unless tok
1715
+
1716
+ type, = tok
1717
+ if type == '('
1718
+ advance.call
1719
+ clauses = parse_or.call
1720
+ advance.call if current_token.call && current_token.call[0] == ')'
1721
+ clauses
1722
+ elsif type == :TEXT
1723
+ parts = []
1724
+ while current_token.call && current_token.call[0] == :TEXT
1725
+ parts << current_token.call[1].strip
1726
+ advance.call
1727
+ end
1728
+ pred = parts.join(' ').strip
1729
+ return [] if pred.empty?
1730
+
1731
+ [parse_taskpaper_search_clause(pred, {
1732
+ tokens: [],
1733
+ tags: [],
1734
+ project: nil,
1735
+ include_done: nil,
1736
+ exclude_projects: [],
1737
+ item_paths: [],
1738
+ slice: slice
1739
+ })]
1740
+ else
1741
+ advance.call
1742
+ []
1743
+ end
1744
+ end
1745
+
1746
+ parse_and = lambda do
1747
+ clauses = parse_primary.call
1748
+ while current_token.call && current_token.call[0] == :AND
1749
+ advance.call
1750
+ right = parse_primary.call
1751
+ combined = []
1752
+ clauses.each do |left_clause|
1753
+ right.each do |right_clause|
1754
+ combined << {
1755
+ tokens: left_clause[:tokens] + right_clause[:tokens],
1756
+ tags: left_clause[:tags] + right_clause[:tags],
1757
+ project: right_clause[:project] || left_clause[:project],
1758
+ include_done: right_clause[:include_done].nil? ? left_clause[:include_done] : right_clause[:include_done],
1759
+ exclude_projects: left_clause[:exclude_projects] + right_clause[:exclude_projects]
1760
+ }
1761
+ end
1762
+ end
1763
+ clauses = combined
1764
+ end
1765
+ clauses
1766
+ end
1767
+
1768
+ parse_or = lambda do
1769
+ clauses = parse_and.call
1770
+ while current_token.call && current_token.call[0] == :OR
1771
+ advance.call
1772
+ right = parse_and.call
1773
+ clauses.concat(right)
1774
+ end
1775
+ clauses
1776
+ end
1777
+
1778
+ clauses = parse_or.call
1779
+
1780
+ # If there was only an item path and no predicates, create a single
1781
+ # empty clause to carry the path.
1782
+ if clauses.empty? && item_path
1783
+ clauses = [{
1784
+ tokens: [],
1785
+ tags: [],
1786
+ project: nil,
1787
+ include_done: nil,
1788
+ exclude_projects: [],
1789
+ item_paths: [],
1790
+ slice: slice
1791
+ }]
1792
+ end
1793
+
1794
+ # Attach leading item path (if any) to all clauses
1795
+ if item_path
1796
+ clauses.each do |clause|
1797
+ clause[:item_paths] ||= []
1798
+ clause[:item_paths] << item_path
1799
+ end
1800
+ end
1801
+
1802
+ clauses
1803
+ end
1804
+
1805
+ # Load TaskPaper-style saved searches from todo files.
1806
+ #
1807
+ # Scans all lines in each file for:
1808
+ # [WHITESPACE]TITLE @search(PARAMS)
1809
+ # regardless of project name or indentation. This allows searches to live
1810
+ # in any project (e.g. "Searches") or even at top level.
1811
+ #
1812
+ # @param depth [Integer] Directory depth to search for files
1813
+ # @return [Hash{String=>Hash}] Map of title to {:expr, :file}
1814
+ def load_taskpaper_searches(depth: 1)
1815
+ searches = {}
1816
+ files = find_files(depth: depth)
1817
+ return searches if files.nil? || files.empty?
1818
+
1819
+ files.each do |file|
1820
+ content = file.read_file
1821
+ next if content.nil? || content.empty?
1822
+
1823
+ content.each_line do |line|
1824
+ next if line.strip.empty?
1825
+ next unless line =~ /^\s*(.+?)\s+@search\((.+)\)\s*$/
1826
+
1827
+ title = Regexp.last_match(1).strip
1828
+ expr = "@search(#{Regexp.last_match(2).strip})"
1829
+ searches[title] = { expr: expr, file: file }
1830
+ end
1831
+ end
1832
+
1833
+ searches
1834
+ end
1835
+
1836
+ # Evaluate a TaskPaper-style @search() expression and return matching
1837
+ # actions and files, without printing.
1838
+ #
1839
+ # @param expr [String] TaskPaper @search() expression
1840
+ # @param file [String,nil] Optional single file to search within
1841
+ # @param options [Hash] Display/search options (subset of find.rb)
1842
+ # @return [Array(NA::Actions, Array<String>, Array<Hash>)] actions, files, clauses
1843
+ def evaluate_taskpaper_search(expr, file: nil, options: {})
1844
+ clauses = parse_taskpaper_search_clauses(expr)
1845
+ NA.notify("TP DEBUG clauses: #{clauses.inspect}", debug: true) if NA.verbose
1846
+ return [NA::Actions.new, [], []] if clauses.empty?
1847
+
1848
+ depth = options[:depth] || 1
1849
+ all_actions = NA::Actions.new
1850
+ all_files = []
1851
+
1852
+ clauses.each do |parsed|
1853
+ search_tokens = parsed[:tokens]
1854
+ tags = parsed[:tags]
1855
+ include_done = parsed[:include_done]
1856
+ exclude_projects = parsed[:exclude_projects] || []
1857
+ project = parsed[:project] || options[:project]
1858
+ slice = parsed[:slice]
1859
+
1860
+ # Resolve any item-path filters declared on this clause
1861
+ item_paths = Array(parsed[:item_paths]).compact
1862
+ resolved_paths = []
1863
+ item_paths.each do |p|
1864
+ resolved_paths.concat(resolve_item_path(path: p, file: file, depth: depth))
1865
+ end
1866
+
1867
+ todo_options = {
1868
+ depth: depth,
1869
+ done: include_done.nil? ? options[:done] : include_done,
1870
+ query: nil,
1871
+ search: search_tokens,
1872
+ search_note: options.fetch(:search_notes, true),
1873
+ tag: tags,
1874
+ negate: options.fetch(:invert, false),
1875
+ regex: options.fetch(:regex, false),
1876
+ project: project,
1877
+ require_na: options.fetch(:require_na, false)
1878
+ }
1879
+ todo_options[:file_path] = file if file
1880
+
1881
+ todo = NA::Todo.new(todo_options)
1882
+
1883
+ # Start from the full action list for this clause
1884
+ clause_actions = todo.actions.to_a
1885
+ if NA.verbose
1886
+ NA.notify("TP DEBUG initial actions count: #{clause_actions.size}", debug: true)
1887
+ clause_actions.each do |a|
1888
+ NA.notify("TP DEBUG action: #{a.action.inspect} parents=#{Array(a.parent).inspect}", debug: true)
1889
+ end
1890
+ end
1891
+
1892
+ # Apply project exclusions (e.g. "not project = \"Archive\"")
1893
+ unless exclude_projects.empty?
1894
+ before = clause_actions.size
1895
+ clause_actions.delete_if do |action|
1896
+ parents = Array(action.parent)
1897
+ last = parents.last.to_s
1898
+ full = parents.join(':')
1899
+ exclude_projects.any? do |proj|
1900
+ proj_rx = Regexp.new(Regexp.escape(proj), Regexp::IGNORECASE)
1901
+ last =~ proj_rx || full =~ /(^|:)#{Regexp.escape(proj)}$/i
1902
+ end
1903
+ end
1904
+ NA.notify("TP DEBUG after exclude_projects: #{clause_actions.size} (was #{before})", debug: true) if NA.verbose
1905
+ end
1906
+
1907
+ # Apply item-path project filters, if any
1908
+ unless resolved_paths.empty?
1909
+ before = clause_actions.size
1910
+ clause_actions.delete_if do |action|
1911
+ parents = Array(action.parent)
1912
+ path = parents.join(':')
1913
+ resolved_paths.none? do |p|
1914
+ path =~ /\A#{Regexp.escape(p)}(?::|\z)/i
1915
+ end
1916
+ end
1917
+ NA.notify("TP DEBUG after item_path filter: #{clause_actions.size} (was #{before})", debug: true) if NA.verbose
1918
+ end
1919
+
1920
+ # Apply slice, if present, to the filtered clause actions
1921
+ if slice
1922
+ before = clause_actions.size
1923
+ if slice[:index]
1924
+ idx = slice[:index].to_i
1925
+ clause_actions = idx.negative? ? [] : [clause_actions[idx]].compact
1926
+ else
1927
+ start_idx = slice[:start] || 0
1928
+ end_idx = slice[:end] || clause_actions.length
1929
+ clause_actions = clause_actions[start_idx...end_idx] || []
1930
+ end
1931
+ NA.notify("TP DEBUG after slice #{slice.inspect}: #{clause_actions.size} (was #{before})", debug: true) if NA.verbose
1932
+ end
1933
+
1934
+ all_files.concat(todo.files)
1935
+ clause_actions.each { |a| all_actions.push(a) }
1936
+ end
1937
+
1938
+ # De-duplicate actions across clauses
1939
+ seen = {}
1940
+ merged_actions = NA::Actions.new
1941
+ all_actions.each do |a|
1942
+ key = "#{a.file_path}:#{a.file_line}"
1943
+ next if seen[key]
1944
+
1945
+ seen[key] = true
1946
+ merged_actions.push(a)
1947
+ end
1948
+
1949
+ if NA.verbose
1950
+ NA.notify("TP DEBUG merged_actions count: #{merged_actions.size}", debug: true)
1951
+ merged_actions.each do |a|
1952
+ NA.notify("TP DEBUG merged action: #{a.file_path}:#{a.file_line} #{a.action.inspect}", debug: true)
1953
+ end
1954
+ end
1955
+
1956
+ [merged_actions, all_files.uniq, clauses]
1957
+ end
1958
+
1959
+ # Execute a TaskPaper-style @search() expression using NA::Todo and output
1960
+ # results with the standard formatting options.
1961
+ #
1962
+ # @param expr [String] TaskPaper @search() expression
1963
+ # @param file [String,nil] Optional single file to search within
1964
+ # @param options [Hash] Display/search options (subset of find.rb)
1965
+ # @return [void]
1966
+ def run_taskpaper_search(expr, file: nil, options: {})
1967
+ actions, files, clauses = evaluate_taskpaper_search(expr, file: file, options: options)
1968
+ depth = options[:depth] || 1
1969
+
1970
+ # Build regexes for highlighting from all positive tokens across clauses
1971
+ regexes = []
1972
+ clauses.each do |parsed|
1973
+ sts = parsed[:tokens]
1974
+ if sts.is_a?(Array)
1975
+ regexes.concat(sts.delete_if { |token| token[:negate] }.map { |token| token[:token].wildcard_to_rx })
1976
+ elsif sts
1977
+ regexes << sts
1978
+ end
1979
+ end
1980
+ regexes.uniq!
1981
+
1982
+ actions.output(depth,
1983
+ {
1984
+ files: files,
1985
+ regexes: regexes,
1986
+ notes: options.fetch(:notes, false),
1987
+ nest: options.fetch(:nest, false),
1988
+ nest_projects: options.fetch(:omnifocus, false),
1989
+ no_files: options.fetch(:no_file, false),
1990
+ times: options.fetch(:times, false),
1991
+ human: options.fetch(:human, false)
1992
+ })
1993
+ end
1994
+
1191
1995
  # Load saved search definitions from YAML file
1192
1996
  #
1193
1997
  # @return [Hash] Hash of saved searches