na 1.2.94 → 1.2.95

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