krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Agenda/AgendaSearch.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgCore/Agenda/AgendaSearch.swift history · blame · raw

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}