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