Sources/OrgCore/Agenda/AgendaSearch.swift
369 lines · 16701 bytes
1import Foundation
2
3extension Agenda {
4 /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of
5 /// the keywords.
6 public static func todoList(_ sources: [AgendaSource], keywords: String? = nil, options: AgendaOptions = AgendaOptions()) -> [AgendaItem] {
7 let selected = keywords.map { $0.split(separator: "|").map(String.init) }
8 var items: [AgendaItem] = []
9 for source in sources {
10 for heading in source.headings where !heading.skipped {
11 guard let todo = heading.todo else { continue }
12 if let selected {
13 guard selected.contains(todo) else { continue }
14 } else if heading.isDone {
15 continue
16 }
17 items.append(format(
18 source, heading, kind: .todo, marker: heading.start, extra: "", dotime: .headline, removing: nil,
19 trailing: "", prefix: options.prefix("todo"), urgency: { 1 + $0 }
20 ))
21 }
22 }
23 return sortByUrgency(items)
24 }
25
26 /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps
27 /// unfinished TODO entries, as `M` does.
28 public static func tagsMatch(
29 _ sources: [AgendaSource], match: String, todoOnly: Bool = false, now: Date = Date(), calendar: Calendar = .current,
30 options: AgendaOptions = AgendaOptions()
31 ) -> [AgendaItem] {
32 let matcher = TagsMatcher(match, todoOnly: todoOnly)
33 var items: [AgendaItem] = []
34 for source in sources {
35 for heading in source.headings {
36 if matcher.todoOnly, heading.todo == nil { continue }
37 guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue }
38 items.append(format(
39 source, heading, kind: .tagsMatch, marker: heading.start, extra: "", dotime: nil, removing: nil,
40 trailing: "", prefix: options.prefix("tags"), urgency: { $0 }
41 ))
42 }
43 }
44 return sortByUrgency(items)
45 }
46
47 /// `(urgency-down category-keep)`, stably.
48 static func sortByUrgency(_ items: [AgendaItem]) -> [AgendaItem] {
49 items.enumerated().sorted { a, b in
50 a.element.urgency != b.element.urgency ? a.element.urgency > b.element.urgency : a.offset < b.offset
51 }.map(\.element)
52 }
53}
54
55/// `org-make-tags-matcher`: `+work-boss|LEVEL>2+TODO="WAIT"/!NEXT`.
56public struct TagsMatcher: Sendable {
57 indirect enum Term: Sendable {
58 case tag(String)
59 case tagRegex(String)
60 case property(name: String, op: String, value: Value, needsValue: Bool)
61 case not(Term)
62 }
63
64 enum Value: Sendable {
65 case regex(String)
66 case string(String)
67 case time(String)
68 case number(Double)
69 }
70
71 /// Alternatives of conjunctions; nil matches everything.
72 let tags: [[Term]]?
73 let todo: [[(regex: Bool, keyword: String, negated: Bool)]]?
74 let todoOnly: Bool
75
76 static let term = try! NSRegularExpression(pattern:
77 "^&?([-+:])?(\\{[^}]+\\}|(?:((?:[\\p{L}\\p{N}_]|\\\\[^\\s])+)([<=>]=?|[!/]=|<>)(\\*)?(\\{[^}]+\\}|\"[^\"]*\"|-?[.0-9]+(?:[eE][-+]?[0-9]+)?))|[\\p{L}\\p{N}_@#%]+)"
78 )
79 static let timeValue = try! NSRegularExpression(pattern: "^\"[\\[<](?:[0-9]+|now|today|tomorrow|[+-][0-9]+[dmwy]).*[\\]>]\"$")
80
81 public init(_ match: String, todoOnly: Bool = false) {
82 var only = todoOnly
83 var tagsPart = match
84 var todoPart: String?
85 let ns = match as NSString
86 let slashes = try! NSRegularExpression(pattern: "/+")
87 if let last = slashes.matches(in: match, range: NSRange(location: 0, length: ns.length)).last,
88 !ns.substring(from: last.range.location).contains("\"") {
89 tagsPart = ns.substring(to: last.range.location)
90 var rest = ns.substring(from: NSMaxRange(last.range))
91 if rest.hasPrefix("!") {
92 only = true
93 rest.removeFirst()
94 }
95 todoPart = rest.trimmingCharacters(in: .whitespaces).isEmpty ? nil : rest
96 }
97 self.todoOnly = only
98
99 func terms(_ s: String) -> [(sign: String?, match: NSTextCheckingResult, text: NSString)] {
100 var result: [(String?, NSTextCheckingResult, NSString)] = []
101 var rest = s
102 while let m = Self.term.firstMatch(in: rest, range: NSRange(location: 0, length: (rest as NSString).length)) {
103 let text = rest as NSString
104 let sign = m.range(at: 1).location != NSNotFound ? text.substring(with: m.range(at: 1)) : nil
105 result.append((sign, m, text))
106 rest = text.substring(from: NSMaxRange(m.range))
107 if m.range.length == 0 { break }
108 }
109 return result
110 }
111
112 if tagsPart.contains(where: { !$0.isWhitespace }) {
113 var alternatives: [[Term]] = []
114 let parts = Self.split(tagsPart)
115 var i = 0
116 while i < parts.count {
117 var part = parts[i]
118 // A `\|` inside a term is not a separator.
119 while part.hasSuffix("\\"), i + 1 < parts.count {
120 i += 1
121 part += "|" + parts[i]
122 }
123 var conjunction: [Term] = []
124 for (sign, m, text) in terms(part) {
125 let whole = text.substring(with: m.range(at: 2))
126 var term: Term
127 if whole.hasPrefix("{") {
128 term = .tagRegex(String(whole.dropFirst().dropLast()))
129 } else if m.range(at: 3).location != NSNotFound {
130 let name = text.substring(with: m.range(at: 3))
131 .replacingOccurrences(of: "\\\\(.)", with: "$1", options: .regularExpression).uppercased()
132 let raw = text.substring(with: m.range(at: 6))
133 let value: Value
134 if raw.hasPrefix("{") {
135 value = .regex(String(raw.dropFirst().dropLast()))
136 } else if raw.hasPrefix("\"") {
137 let inner = String(raw.dropFirst().dropLast())
138 let isTime = Self.timeValue.firstMatch(in: raw, range: NSRange(location: 0, length: (raw as NSString).length)) != nil
139 value = isTime ? .time(inner) : .string(inner)
140 } else {
141 value = .number(Self.number(raw))
142 }
143 term = .property(name: name, op: text.substring(with: m.range(at: 4)), value: value, needsValue: m.range(at: 5).location != NSNotFound)
144 } else {
145 term = .tag(whole)
146 }
147 if sign == "-" { term = .not(term) }
148 conjunction.append(term)
149 }
150 alternatives.append(conjunction)
151 i += 1
152 }
153 tags = alternatives
154 } else {
155 tags = nil
156 }
157
158 if let todoPart {
159 todo = Self.split(todoPart).map { part in
160 terms(part).map { sign, m, text in
161 let keyword = text.substring(with: m.range(at: 2))
162 return keyword.hasPrefix("{")
163 ? (true, String(keyword.dropFirst().dropLast()), sign == "-")
164 : (false, keyword, sign == "-")
165 }
166 }
167 } else {
168 todo = nil
169 }
170 }
171
172 /// `org-split-string` on `|`: empty strings between separators stay.
173 static func split(_ s: String) -> [String] {
174 var parts = s.components(separatedBy: "|")
175 if parts.first == "" { parts.removeFirst() }
176 if parts.last == "", !parts.isEmpty { parts.removeLast() }
177 return parts.isEmpty ? [""] : parts
178 }
179
180 /// `string-to-number`: the leading number, or 0.
181 static func number(_ s: String) -> Double {
182 let trimmed = s.drop { $0 == " " || $0 == "\t" }
183 guard let r = trimmed.range(of: "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?", options: .regularExpression) else { return 0 }
184 return Double(trimmed[r]) ?? 0
185 }
186
187 func matches(_ heading: AgendaSource.Heading, in source: AgendaSource, now: Date, calendar: Calendar) -> Bool {
188 let todoKeyword = heading.todo
189 if todoOnly {
190 guard let todoKeyword, !heading.isDone else { return false }
191 _ = todoKeyword
192 }
193 let tagList = heading.tags.map(\.name)
194 func value(_ name: String) -> String? {
195 switch name {
196 case "LEVEL": return String(heading.level)
197 case "CATEGORY": return heading.category
198 case "TODO": return heading.todo
199 case "ITEM": return heading.title
200 case "PRIORITY": return heading.priority ?? source.priorities.default
201 case "FILE": return source.path
202 case "TAGS": return heading.localTags.isEmpty ? nil : ":" + heading.localTags.joined(separator: ":") + ":"
203 case "ALLTAGS": return tagList.isEmpty ? nil : ":" + tagList.joined(separator: ":") + ":"
204 case "SCHEDULED", "DEADLINE", "CLOSED": return heading.planning[name]
205 case "TIMESTAMP": return heading.firstActive
206 case "TIMESTAMP_IA": return heading.firstInactive
207 default: return heading.properties[name]
208 }
209 }
210 func evaluate(_ term: Term) -> Bool {
211 switch term {
212 case .not(let inner):
213 return !evaluate(inner)
214 case .tag(let tag):
215 return tagList.contains(tag)
216 case .tagRegex(let pattern):
217 return tagList.contains { EmacsRegex.matches(pattern, $0) }
218 case .property(let name, let op, let expected, let needsValue):
219 let found = value(name)
220 if needsValue, found == nil { return false }
221 let actual = found ?? ""
222 switch expected {
223 case .regex(let pattern):
224 let hit = EmacsRegex.matches(pattern, actual)
225 return op == "/=" ? !hit : hit
226 case .string(let string):
227 return Self.compare(op, actual, string)
228 case .number(let number):
229 return Self.compare(op, Self.number(actual), number)
230 case .time(let time):
231 let a = Self.seconds(actual, now: now, calendar: calendar)
232 let b = Self.matcherTime(time, now: now, calendar: calendar)
233 guard a > 0, b > 0 else { return false }
234 // `org-time<>` in Org 9.8.7 (org-macs.el) is `(\= a b)`, which reads as `=`:
235 // `DEADLINE<>"<2026-10-07>"` keeps the entries due that day.
236 // AgendaTests.listsMatchEmacs checks this against Emacs.
237 return op == "<>" || op == "!=" || op == "/=" ? a == b : Self.compare(op, a, b)
238 }
239 }
240 }
241 if let tags, !tags.contains(where: { $0.allSatisfy(evaluate) }) { return false }
242 if let todo {
243 let ok = todo.contains { conjunction in
244 conjunction.allSatisfy { term in
245 let hit = term.regex
246 ? todoKeyword.map { EmacsRegex.matches(term.keyword, $0) } ?? false
247 : todoKeyword == term.keyword
248 return term.negated ? !hit : hit
249 }
250 }
251 if !ok { return false }
252 }
253 return true
254 }
255
256 static func compare<T: Comparable>(_ op: String, _ a: T, _ b: T) -> Bool {
257 switch op {
258 case "<": a < b
259 case ">": a > b
260 case "<=", "=<": a <= b
261 case ">=", "=>": a >= b
262 case "=", "==": a == b
263 default: a != b
264 }
265 }
266
267 /// `org-2ft`: a timestamp string as seconds, or 0.
268 static func seconds(_ s: String, now: Date, calendar: Calendar) -> Double {
269 let ns = s as NSString
270 guard let m = Days.dateRegex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return 0 }
271 var c = DateComponents(year: Int(ns.substring(with: m.range(at: 1))), month: Int(ns.substring(with: m.range(at: 2))), day: Int(ns.substring(with: m.range(at: 3))))
272 let hour = Days.hour(of: s)
273 c.hour = hour
274 if let time = try? NSRegularExpression(pattern: " ([0-9]{1,2}):([0-9]{2})").firstMatch(in: s, range: NSRange(location: NSMaxRange(m.range), length: ns.length - NSMaxRange(m.range))), Int(ns.substring(with: time.range(at: 1))) == hour {
275 c.minute = Int(ns.substring(with: time.range(at: 2)))
276 }
277 return calendar.date(from: c)?.timeIntervalSince1970 ?? 0
278 }
279
280 /// `org-matcher-time`.
281 static func matcherTime(_ s: String, now: Date, calendar: Calendar) -> Double {
282 let today = calendar.startOfDay(for: now).timeIntervalSince1970
283 switch s {
284 case "<now>": return now.timeIntervalSince1970
285 case "<today>": return today
286 case "<tomorrow>": return today + 86400
287 case "<yesterday>": return today - 86400
288 default: break
289 }
290 let ns = s as NSString
291 if let m = try! NSRegularExpression(pattern: "^<([-+][0-9]+)([hdwmy])>$").firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) {
292 let n = Double(ns.substring(with: m.range(at: 1)))!
293 let unit = ns.substring(with: m.range(at: 2))
294 let size: Double = ["h": 3600, "d": 86400, "w": 604800, "m": 2678400, "y": 31557600][unit]!
295 return (unit == "h" ? now.timeIntervalSince1970 : today) + n * size
296 }
297 return seconds(s, now: now, calendar: calendar)
298 }
299}
300
301/// Emacs regular expressions run through ICU, case-insensitively as the matcher binds
302/// `case-fold-search`.
303enum EmacsRegex {
304 static func translate(_ pattern: String) -> String {
305 var out = ""
306 var chars = Array(pattern)
307 var i = 0
308 var inClass = false
309 while i < chars.count {
310 let c = chars[i]
311 if inClass {
312 if c == "]" { inClass = false }
313 if c == "[" , i + 1 < chars.count, chars[i + 1] == ":" {
314 // `[:alpha:]` passes through.
315 if let end = String(chars[i...]).range(of: ":]") {
316 let length = String(chars[i...]).distance(from: String(chars[i...]).startIndex, to: end.upperBound)
317 out += String(chars[i..<(i + length)])
318 i += length
319 continue
320 }
321 }
322 out += c == "\\" ? "\\\\" : String(c)
323 i += 1
324 continue
325 }
326 if c == "\\", i + 1 < chars.count {
327 let n = chars[i + 1]
328 switch n {
329 case "(":
330 if i + 3 < chars.count, chars[i + 2] == "?", chars[i + 3] == ":" {
331 out += "(?:"
332 i += 4
333 continue
334 }
335 out += "("
336 case ")": out += ")"
337 case "|": out += "|"
338 case "{": out += "{"
339 case "}": out += "}"
340 case "<", ">": out += "\\b"
341 case "`": out += "^"
342 case "'": out += "$"
343 case "w": out += "[\\p{L}\\p{N}]"
344 case "W": out += "[^\\p{L}\\p{N}]"
345 default: out += "\\" + String(n)
346 }
347 i += 2
348 continue
349 }
350 switch c {
351 case "(", ")", "|", "{", "}": out += "\\" + String(c)
352 case "[":
353 inClass = true
354 out += "["
355 if i + 1 < chars.count, chars[i + 1] == "^" { out += "^"; i += 1 }
356 if i + 1 < chars.count, chars[i + 1] == "]" { out += "\\]"; i += 1 }
357 default: out += String(c)
358 }
359 i += 1
360 }
361 chars = []
362 return out
363 }
364
365 static func matches(_ pattern: String, _ string: String) -> Bool {
366 guard let regex = try? NSRegularExpression(pattern: translate(pattern), options: .caseInsensitive) else { return false }
367 return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length)) != nil
368 }
369}