krz/orgstar

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

Sources/OrgCore/Agenda/AgendaSearch.swift

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

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}