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.
- checksums.yaml +4 -4
- data/.cursor/commands/changelog.md +5 -2
- data/.rubocop_todo.yml +34 -12
- data/CHANGELOG.md +23 -0
- data/Gemfile.lock +23 -23
- data/README.md +146 -5
- data/bin/commands/add.rb +19 -1
- data/bin/commands/find.rb +61 -10
- data/bin/commands/next.rb +51 -0
- data/bin/commands/saved.rb +43 -8
- data/lib/na/action.rb +3 -1
- data/lib/na/next_action.rb +804 -3
- data/lib/na/todo.rb +42 -3
- data/lib/na/version.rb +1 -1
- data/src/_README.md +145 -4
- metadata +1 -1
data/lib/na/next_action.rb
CHANGED
|
@@ -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
|