krz/orgstar

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

Sources/OrgCore/Commands/SubtreeCommands.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgCore/Commands/SubtreeCommands.swift history · blame · raw

514 lines · 28382 bytes

  1import Foundation
  2
  3// Moving subtrees as text and sorting them (Org 9.8.7): `org-copy-subtree`, `org-cut-subtree`,
  4// `org-paste-subtree`, `org-clone-subtree-with-time-shift` and `org-sort-entries`. The kill
  5// ring is the clipboard: cutting and copying hand the text to the app (`Effect.copy`), and
  6// pasting takes the clipboard's text as `text`.
  7
  8/// C-c C-x M-w and C-c C-x C-w: `org-copy-subtree` and `org-cut-subtree`.
  9public struct CopySubtree: OrgCommand {
 10    public let cut: Bool
 11    public init(cut: Bool) { self.cut = cut }
 12    public var id: String { cut ? "org.subtree.cut" : "org.subtree.copy" }
 13    public var title: String { cut ? "Cut Subtree" : "Copy Subtree" }
 14    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
 15    public func run(in context: EditContext) -> CommandStep {
 16        let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
 17        guard !buffer.beforeFirstHeading, let range = try? buffer.subtreeRange(), !range.isEmpty else { return .failed("Before first headline") }
 18        let text = buffer.substring(range.lowerBound, range.upperBound)
 19        let effects: [Effect] = [.copy(text), .message("\(cut ? "Cut" : "Copied"): Subtree(s) with \(text.count) characters")]
 20        let edits = cut ? [TextEdit(range: range, replacement: "")] : []
 21        let caret = cut ? range.lowerBound : context.caret
 22        return .commit(EditResult(baseRevision: context.revision, edits: edits, selection: [caret..<caret], effects: effects))
 23    }
 24}
 25
 26/// `org-kill-is-subtree-p`: starts with a heading, and no later heading is above it.
 27func isSubtree(_ text: String) -> Bool {
 28    guard let first = text.firstMatch(of: #/^(?:[ \t\n\r]*?\n)?(\*+) /#) else { return false }
 29    let level = first.1.count
 30    for match in text.matches(of: #/(?m)^(\*+) /#) where match.1.count < level { return false }
 31    return true
 32}
 33
 34/// C-c C-x C-y: `org-paste-subtree` with the clipboard's text, its levels shifted to fit
 35/// between the headings around point.
 36public struct PasteSubtree: OrgCommand {
 37    public let text: String
 38    public init(text: String) { self.text = text }
 39    public var id: String { "org.subtree.paste" }
 40    public var title: String { "Paste Subtree" }
 41    public func applies(in context: EditContext) -> Bool { true }
 42    public func run(in context: EditContext) -> CommandStep {
 43        guard isSubtree(text) else { return .failed("The kill is not a (set of) tree(s).  Use `C-y' to yank anyway") }
 44        var newLevel = 0
 45        let step = commitBuffer(context) { buffer in
 46            let oldLevel = text.firstMatch(of: #/(?m)^(\*+) /#).map { $0.1.count } ?? -1
 47            var indicator: Int?
 48            var forced: Int?
 49            if buffer.lookingAtLine("^\\*+[ \\t]*$"), buffer.point >= buffer.length || buffer.character(at: buffer.point) != 42 {
 50                indicator = buffer.substring(buffer.lineStart(buffer.point), buffer.lineEnd(buffer.point)).prefix { $0 == "*" }.count
 51                forced = indicator
 52            } else if buffer.bolp, buffer.lookingAt("\\*+ ") != nil {
 53                forced = buffer.outlineLevel
 54            }
 55            let previous = buffer.saveExcursion { () -> Int in
 56                if !buffer.atHeading { buffer.point = Self.previousHeading(buffer) ?? buffer.point }
 57                return buffer.atHeading ? buffer.outlineLevel : 1
 58            }
 59            let next = buffer.saveExcursion { () -> Int in
 60                buffer.point = Self.nextHeading(buffer)
 61                return buffer.atHeading ? buffer.outlineLevel : 1
 62            }
 63            newLevel = forced ?? max(0, previous, next)
 64            let shift = oldLevel == -1 || oldLevel == newLevel ? 0 : newLevel - oldLevel
 65            if indicator != nil {
 66                let start = buffer.lineStart(buffer.point)
 67                buffer.deleteRegion(start, min(buffer.length, buffer.lineEnd(start) + 1))
 68                buffer.point = start
 69            }
 70            if !(buffer.bolp && buffer.atHeading) {
 71                buffer.point = Self.nextHeading(buffer)
 72                if !buffer.bolp { buffer.insert("\n") }
 73            }
 74            let begin = buffer.point
 75            var pasted = text.hasSuffix("\n") ? text : text + "\n"
 76            if shift != 0 {
 77                pasted = pasted.components(separatedBy: "\n").map { line in
 78                    guard let stars = line.firstMatch(of: #/^(\*+) /#) else { return line }
 79                    return String(repeating: "*", count: max(1, stars.1.count + shift)) + line.dropFirst(stars.1.count)
 80                }.joined(separator: "\n")
 81            }
 82            buffer.insert(pasted)
 83            buffer.point = begin
 84            buffer.skipForward([32, 9, 10, 13])
 85        }
 86        guard case .commit(let result) = step else { return step }
 87        return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection,
 88                                  effects: [.message("Clipboard pasted as level \(newLevel) subtree")]))
 89    }
 90
 91    /// `org-previous-visible-heading` 1: the heading line before point's line.
 92    static func previousHeading(_ buffer: EmacsBuffer) -> Int? {
 93        buffer.saveExcursion {
 94            buffer.point = buffer.lineStart(buffer.point)
 95            return buffer.searchBackward("^\\*+ ", caseInsensitive: false)?[0]?.lowerBound
 96        }
 97    }
 98
 99    /// `org-next-visible-heading` 1: the next heading's start, or the end of the text.
100    static func nextHeading(_ buffer: EmacsBuffer) -> Int {
101        buffer.saveExcursion {
102            buffer.point = buffer.lineEnd(buffer.point)
103            return buffer.searchForward("^\\*+ ", caseInsensitive: false)?[0]?.lowerBound ?? buffer.length
104        }
105    }
106}
107
108/// C-c C-x c: `org-clone-subtree-with-time-shift`. Asks how many clones and, when the subtree
109/// has timestamps, the shift per clone (`+1w`). Clones get new IDs (`org-clone-delete-id`
110/// nil) and no clock lines; a repeating entry ends up after its clones, its dates moved past
111/// them.
112public struct CloneSubtree: OrgCommand {
113    let newID: @Sendable () -> String
114    public init(newID: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }) { self.newID = newID }
115    public var id: String { "org.subtree.clone" }
116    public var title: String { "Clone Subtree with Time Shift" }
117    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
118    public func run(in context: EditContext) -> CommandStep {
119        let probe = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
120        guard !probe.beforeFirstHeading, let range = try? probe.subtreeRange() else { return .failed("No subtree to clone") }
121        guard let countText = context.answers["count"] else { return .prompt(Prompt(key: "count", message: "Number of clones to produce:")) }
122        guard let count = Int(countText.trimmingCharacters(in: .whitespaces)), count >= 0 else {
123            return .failed("Invalid number of replications \(countText)")
124        }
125        let hasStamp = probe.saveExcursion { () -> Bool in
126            probe.point = range.lowerBound
127            return probe.searchForward("[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]", bound: range.upperBound, caseInsensitive: false) != nil
128        }
129        var shift = ""
130        if hasStamp {
131            guard let answer = context.answers["shift"] else {
132                return .prompt(Prompt(key: "shift", message: "Date shift per clone (e.g. +1w, empty to copy unchanged):"))
133            }
134            shift = answer
135        }
136        var shiftN = 0
137        var unit: EmacsBuffer.TimestampUnit?
138        if shift.contains(where: { !$0.isWhitespace }) {
139            guard let m = shift.wholeMatch(of: /[ \t]*([+-]?[0-9]+)([hdwmy])[ \t]*/) else { return .failed("Invalid shift specification \(shift)") }
140            shiftN = Int(m.1.replacingOccurrences(of: "+", with: ""))!
141            switch m.2 {
142            case "h": unit = .hour
143            case "d": unit = .day
144            case "w": unit = .day; shiftN *= 7
145            case "m": unit = .month
146            default: unit = .year
147            }
148        }
149        return commitBuffer(context) { buffer in
150            buffer.point = range.upperBound
151            if !buffer.bolp { buffer.insert("\n") }
152            var end = buffer.point
153            let template = buffer.substring(range.lowerBound, end)
154            let hasID = buffer.saveExcursion { () -> Bool in
155                buffer.point = range.lowerBound
156                return buffer.entryID() != nil
157            }
158            var first = 1
159            var last = count
160            var keepRepeater = -1
161            if unit != nil, template.range(of: "<[^<>\\n]+ [.+]?\\+[0-9]+[hdwmy][^<>\\n]*>", options: .regularExpression) != nil {
162                buffer.deleteRegion(range.lowerBound, end)
163                end = range.lowerBound
164                first = 0
165                last = count + 1
166                keepRepeater = last
167            }
168            buffer.point = end
169            guard first <= last else { return }
170            for n in first...last {
171                let clone = EmacsBuffer(template, point: 0, settings: buffer.settings, options: buffer.options)
172                clone.now = buffer.now
173                clone.calendar = buffer.calendar
174                if hasID {
175                    // `org-id-get-create` with FORCE: the old ID goes, a new one is added.
176                    clone.saveExcursion {
177                        if let line = clone.searchForward("^[ \\t]*:ID:.*\\n", caseInsensitive: true) {
178                            clone.deleteRegion(line[0]!.lowerBound, line[0]!.upperBound)
179                        }
180                    }
181                    clone.point = 0
182                    clone.entryPut("ID", newID())
183                }
184                if n != 0 {
185                    clone.point = 0
186                    while let line = clone.searchForward("^[ \\t]*CLOCK:.*$", caseInsensitive: false) {
187                        let start = clone.lineStart(line[0]!.lowerBound)
188                        clone.deleteRegion(start, min(clone.length, clone.lineEnd(start) + 1))
189                        clone.point = start
190                    }
191                    clone.point = 0
192                    while let drawer = clone.searchForward("^[ \\t]*:[\\w-]+:[ \\t]*$", caseInsensitive: false) {
193                        let at = drawer[0]!.upperBound
194                        clone.removeEmptyDrawer(at: at)
195                        clone.point = min(at, clone.length)
196                        if clone.point < clone.length { clone.point = min(clone.length, clone.lineEnd(clone.point) + 1) } else { break }
197                    }
198                }
199                if let unit {
200                    clone.point = 0
201                    while let stamp = clone.searchForward("[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]", caseInsensitive: false) {
202                        clone.point = stamp[0]!.lowerBound + 1
203                        try clone.changeTimestamp(n * shiftN, what: unit)
204                        clone.point = clone.lineStart(clone.point)
205                        clone.point = min(clone.length, (clone.searchForward("[\\]>]", bound: clone.lineEnd(clone.point), caseInsensitive: false)?[0]?.upperBound) ?? clone.lineEnd(clone.point))
206                        if let after = clone.searchForward("[\\[<][0-9]{4}", bound: clone.lineEnd(clone.point), caseInsensitive: false), after[0]!.lowerBound > 0 {
207                            clone.point = after[0]!.lowerBound
208                        } else {
209                            clone.point = min(clone.length, clone.lineEnd(clone.point) + 1)
210                        }
211                    }
212                    if n != keepRepeater {
213                        clone.point = 0
214                        while let repeater = clone.searchForward("<[^<>\\n]+?( +[.+]?\\+[0-9]+[hdwmy])", caseInsensitive: false) {
215                            clone.deleteRegion(repeater[1]!.lowerBound, repeater[1]!.upperBound)
216                        }
217                    }
218                }
219                buffer.insert(clone.string)
220            }
221            buffer.point = range.lowerBound
222        }
223    }
224}
225
226/// C-c ^ on headings: `org-sort-entries`. The children of the heading at point (all top-level
227/// headings before the first one, or the region's headings) sort by a key: [a]lpha,
228/// [n]umeric, [p]riority, p[r]operty, todo[o]rder, [t]ime, [s]cheduled, [d]eadline,
229/// [c]reated, cloc[k]ing; the capital letter reverses.
230public struct SortEntries: OrgCommand {
231    public init() {}
232    public var id: String { "org.sort" }
233    public var title: String { "Sort Entries" }
234    public func applies(in context: EditContext) -> Bool { true }
235
236    static let keys: [(Character, String)] = [
237        ("a", "alpha"), ("n", "numeric"), ("p", "priority"), ("r", "property"), ("o", "todo order"),
238        ("t", "time"), ("s", "scheduled"), ("d", "deadline"), ("c", "created"), ("k", "clocking"),
239    ]
240
241    public func run(in context: EditContext) -> CommandStep {
242        // `org-sort`: table lines in a table, the list on an item line.
243        if KeyContext.holds("table", in: context) { return SortTableLines().run(in: context) }
244        if EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).atItem { return sortList(context) }
245        let probe = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
246        guard let bounds = Self.bounds(probe, context.selection.first ?? context.caret..<context.caret) else { return .failed("Nothing to sort") }
247        guard let answer = context.answers["sort"], let key = answer.first else {
248            var items: [FastSelection.Item] = Self.keys.map { .option(key: $0.0, name: $0.1) }
249            items.append(.newline)
250            items += Self.keys.map { .option(key: Character($0.0.uppercased()), name: $0.1 + " (reversed)") }
251            return .prompt(Prompt(key: "sort", message: "Sort \(bounds.what): [a]lpha [n]umeric [p]riority p[r]operty todo[o]rder [t]ime [s]cheduled [d]eadline [c]reated cloc[k]ing; capitals reverse",
252                                  fast: FastSelection(items: items, multiple: false)))
253        }
254        let kind = Character(key.lowercased())
255        guard Self.keys.contains(where: { $0.0 == kind }) else { return .failed("Invalid sorting type `\(key)'") }
256        var property = ""
257        if kind == "r" {
258            guard let name = context.answers["property"] else {
259                return .prompt(Prompt(key: "property", message: "Property:", choices: Self.propertyKeys(context.text)))
260            }
261            property = name
262        }
263        return commitBuffer(context) { buffer in
264            try Self.sort(buffer, key: kind, reverse: key.isUppercase, property: property, selection: context.selection.first ?? context.caret..<context.caret)
265        }
266    }
267
268    /// `org-sort-list`: [a]lpha, [n]umeric, [t]ime, [x] checked; the capital letter reverses.
269    func sortList(_ context: EditContext) -> CommandStep {
270        guard let answer = context.answers["sort"], let key = answer.first else {
271            let keys: [(Character, String)] = [("a", "alpha"), ("n", "numeric"), ("t", "time"), ("x", "checked")]
272            var items: [FastSelection.Item] = keys.map { .option(key: $0.0, name: $0.1) }
273            items.append(.newline)
274            items += keys.map { .option(key: Character($0.0.uppercased()), name: $0.1 + " (reversed)") }
275            return .prompt(Prompt(key: "sort", message: "Sort plain list: [a]lpha  [n]umeric  [t]ime  [x]checked  A/N/T/X means reversed:",
276                                  fast: FastSelection(items: items, multiple: false)))
277        }
278        let kind = Character(key.lowercased())
279        guard "antx".contains(kind) else { return .failed("Invalid sorting type `\(key)'") }
280        return commitBuffer(context) { buffer in Self.sortList(buffer, key: kind, reverse: key.isUppercase) }
281    }
282
283    static func sortList(_ buffer: EmacsBuffer, key: Character, reverse: Bool) {
284        let line = buffer.lineStart(buffer.point)
285        var structure = buffer.saveExcursion { buffer.point = line; return buffer.listStruct() }
286        let prevs = structure.prevs
287        let siblings = structure.allItems(line, prevs)
288        let records = siblings.map { $0..<buffer.itemEndBeforeBlank($0, structure) }
289        let now = buffer.now.timeIntervalSince1970
290        let keys = records.map { record -> SortKey in
291            let first = buffer.substring(record.lowerBound, buffer.lineEnd(record.lowerBound))
292            guard let m = first.firstMatch(of: /^[ \t]*[-+*0-9.)]+([ \t]+\[[- X]\])?[ \t]+/) else { return .text("") }
293            let rest = String(first[m.range.upperBound...])
294            switch key {
295            case "n": return .number((try? Elisp.number(Elisp.stringToNumber(visibleTitle(rest))))?.double ?? 0)
296            case "a": return .text(visibleTitle(rest).lowercased())
297            case "x": return .text(m.1.map(String.init) ?? "")
298            default:
299                if let timer = rest.firstMatch(of: /^([0-9]+):([0-9]+):([0-9]+)[ \t]+::/) {
300                    return .number(Double(Int(timer.1)! * 3600 + Int(timer.2)! * 60 + Int(timer.3)!))
301                }
302                for pattern in ["<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>", "[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]"] {
303                    if let r = first.range(of: pattern, options: .regularExpression) {
304                        return .number(TagsMatcher.seconds(String(first[r]), now: buffer.now, calendar: buffer.calendar))
305                    }
306                }
307                return .number(now)
308            }
309        }
310        // `sort-subr`: stable, reversed before and after for the capital letters.
311        var order = Array(records.indices)
312        if reverse { order.reverse() }
313        order = order.enumerated().sorted { a, b in
314            let less = keys[a.element] < keys[b.element]
315            let greater = keys[b.element] < keys[a.element]
316            return less != greater ? less : a.offset < b.offset
317        }.map(\.element)
318        if reverse { order.reverse() }
319        let texts = records.map { buffer.substring($0.lowerBound, $0.upperBound) }
320        for (i, record) in records.enumerated().reversed() {
321            buffer.deleteRegion(record.lowerBound, record.upperBound)
322            buffer.point = record.lowerBound
323            buffer.insert(texts[order[i]])
324        }
325        // `org-list-repair`.
326        buffer.point = records[0].lowerBound
327        structure = buffer.listStruct()
328        buffer.writeStruct(&structure, structure.parents)
329        buffer.point = records[0].lowerBound
330    }
331
332    /// `org-buffer-property-keys`, roughly: the property names in the text.
333    static func propertyKeys(_ text: String) -> [String] {
334        var names: [String] = []
335        for match in text.matches(of: #/(?m)^[ \t]*:([^\s:]+):[ \t]/#) where !names.contains(String(match.1)) && String(match.1).uppercased() != "END" {
336            names.append(String(match.1))
337        }
338        return names
339    }
340
341    /// The text to sort and what it is: a region's headings, a heading's children, or the
342    /// top-level headings. Nil when there's nothing.
343    static func bounds(_ buffer: EmacsBuffer, _ selection: Range<Int>) -> (start: Int, end: Int, first: Int, what: String)? {
344        buffer.saveExcursion {
345            var start: Int
346            var end: Int
347            let what: String
348            if !selection.isEmpty {
349                buffer.point = selection.lowerBound
350                if buffer.atHeading { buffer.point = buffer.lineStart(buffer.point) } else { Capture.nextHeading(buffer) }
351                start = buffer.point
352                buffer.point = selection.upperBound
353                if !buffer.beforeFirstHeading, (try? buffer.backToHeading()) != nil { Capture.endOfSubtree(buffer, heading: buffer.point) }
354                end = buffer.point
355                what = "region"
356            } else if !buffer.beforeFirstHeading {
357                _ = try? buffer.backToHeading()
358                start = buffer.point
359                Capture.endOfSubtree(buffer, heading: start)
360                end = buffer.point
361                // Without the blank lines after the subtree, but with one.
362                var back = end
363                while back > start, let c = buffer.character(at: back - 1), c == 10 || c == 32 || c == 9 {
364                    if c == 10, back - 1 > start, buffer.character(at: back - 2) == 10 { back -= 1 } else if c != 10 { back -= 1 } else { break }
365                }
366                end = back
367                what = "children"
368                buffer.point = start
369                buffer.point = min(buffer.length, buffer.lineEnd(start) + 1)
370                if !buffer.atHeading { Capture.nextHeading(buffer) }
371                return buffer.point < end ? (start, end, buffer.point, what) : nil
372            } else {
373                buffer.point = 0
374                if !buffer.atHeading { Capture.nextHeading(buffer) }
375                start = buffer.point
376                end = buffer.length
377                what = "top-level"
378            }
379            return start < end ? (start, end, start, what) : nil
380        }
381    }
382
383    static func sort(_ buffer: EmacsBuffer, key: Character, reverse: Bool, property: String, selection: Range<Int>) throws {
384        guard let bounds = bounds(buffer, selection) else { throw EmacsBuffer.UserError(message: "Nothing to sort") }
385        var end = bounds.end
386        // The sorted text ends with a newline.
387        if end > 0, buffer.character(at: end - 1) != 10 {
388            buffer.point = end
389            buffer.insert("\n")
390            end += 1
391        }
392        let begin = bounds.first
393        guard begin < end else { throw EmacsBuffer.UserError(message: "Nothing to sort") }
394        let stars = buffer.substring(begin, buffer.lineEnd(begin)).prefix { $0 == "*" }
395        let level = stars.count
396        let text = buffer.substring(begin, end)
397        if level > 1, text.range(of: "(?m)^\\*{1,\(level - 1)}[ \\t\\n]", options: .regularExpression) != nil {
398            throw EmacsBuffer.UserError(message: "Region to sort contains a level above the first entry")
399        }
400        // Records: from each heading at the first one's level to the next.
401        var starts: [Int] = []
402        buffer.point = begin
403        while let match = buffer.searchForward("^\\*{\(level)} +", bound: end, caseInsensitive: false) {
404            starts.append(match[0]!.lowerBound)
405            buffer.point = match[0]!.upperBound
406        }
407        guard !starts.isEmpty else { return }
408        let records = starts.enumerated().map { i, s in s..<(i + 1 < starts.count ? starts[i + 1] : end) }
409        let now = buffer.now.timeIntervalSince1970
410        let keys = records.map { record -> SortKey in
411            buffer.point = record.lowerBound
412            return sortKey(buffer, kind: key, record: record, property: property, now: now)
413        }
414        var order = Array(records.indices)
415        if reverse { order.reverse() }
416        order = order.enumerated().sorted { a, b in
417            let less = keys[a.element] < keys[b.element]
418            let greater = keys[b.element] < keys[a.element]
419            return less != greater ? less : a.offset < b.offset
420        }.map(\.element)
421        if reverse { order.reverse() }
422        let sorted = order.map { buffer.substring(records[$0].lowerBound, records[$0].upperBound) }.joined()
423        buffer.deleteRegion(records.first!.lowerBound, end)
424        buffer.point = records.first!.lowerBound
425        buffer.insert(sorted)
426        buffer.point = bounds.start
427    }
428
429    enum SortKey: Comparable {
430        case number(Double)
431        case text(String)
432
433        static func < (a: SortKey, b: SortKey) -> Bool {
434            switch (a, b) {
435            case (.number(let x), .number(let y)): x < y
436            case (.text(let x), .text(let y)): x < y
437            case (.number, .text): true
438            case (.text, .number): false
439            }
440        }
441    }
442
443    static func sortKey(_ buffer: EmacsBuffer, kind: Character, record: Range<Int>, property: String, now: Double) -> SortKey {
444        let entryEnd = buffer.saveExcursion { () -> Int in
445            Capture.nextHeading(buffer)
446            return min(buffer.point, record.upperBound)
447        }
448        let match = buffer.lookingAt(buffer.complexHeadingPattern)
449        func group(_ i: Int) -> String? { match?[i].map { buffer.substring($0.lowerBound, $0.upperBound) } }
450        func seconds(_ pattern: String, group: Int = 0) -> Double {
451            buffer.saveExcursion {
452                guard let found = buffer.searchForward(pattern, bound: entryEnd, caseInsensitive: false), let range = found[group] else { return now }
453                return TagsMatcher.seconds(buffer.substring(range.lowerBound, range.upperBound), now: buffer.now, calendar: buffer.calendar)
454            }
455        }
456        switch kind {
457        case "a":
458            return .text(visibleTitle(group(4) ?? "").lowercased())
459        case "n":
460            return .number(Double(visibleTitle(group(4) ?? "").prefix { $0.isNumber || $0 == "." || $0 == "-" || $0 == "+" }) ?? 0)
461        case "p":
462            // `org-priority-regexp` anywhere on the heading line.
463            let line = buffer.substring(record.lowerBound, buffer.lineEnd(record.lowerBound))
464            let cookie = line.firstMatch(of: /\[#([A-Z]|[0-9]+)\]/).map { String($0.1) }
465            let value: String = cookie ?? buffer.settings.priorities.default
466            return .number(value.allSatisfy(\.isNumber) ? Double(value)! : Double(value.unicodeScalars.first!.value))
467        case "r":
468            let value = buffer.saveExcursion { () -> String in
469                buffer.point = record.lowerBound
470                guard let drawer = buffer.searchForward("^[ \\t]*:" + NSRegularExpression.escapedPattern(for: property) + ":[ \\t]+(.*?)[ \\t]*$", bound: entryEnd, caseInsensitive: true) else { return "" }
471                return drawer[1].map { buffer.substring($0.lowerBound, $0.upperBound) } ?? ""
472            }
473            return .text(value)
474        case "o":
475            let keywords = buffer.keywordsInOrder
476            guard let todo = group(2), let index = keywords.firstIndex(of: todo) else { return .number(99) }
477            let remaining = Double(keywords.count - index)
478            return .number(buffer.settings.isDone(todo) ? 99 + remaining : 99 - remaining)
479        case "t":
480            let active = seconds("<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>")
481            if active != now { return .number(active) }
482            return .number(seconds("[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]"))
483        case "c":
484            return .number(seconds("^[ \\t]*\\[[0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]\\n]*\\]"))
485        case "s":
486            return .number(seconds("SCHEDULED: *(<[^>]+>)", group: 1))
487        case "d":
488            return .number(seconds("DEADLINE: *(<[^>]+>)", group: 1))
489        default:
490            // Clocked minutes in the whole subtree.
491            var minutes = 0.0
492            buffer.saveExcursion {
493                buffer.point = record.lowerBound
494                while let line = buffer.searchForward("^[ \\t]*CLOCK:.*=>[ \\t]*([0-9]+):([0-9]{2})", bound: record.upperBound, caseInsensitive: false) {
495                    minutes += Double(buffer.substring(line[1]!.lowerBound, line[1]!.upperBound))! * 60 + Double(buffer.substring(line[2]!.lowerBound, line[2]!.upperBound))!
496                }
497            }
498            return .number(minutes)
499        }
500    }
501
502    /// `org-get-heading t t t t` through `org-sort-remove-invisible`: no COMMENT, links as their
503    /// descriptions, emphasis markers gone.
504    static func visibleTitle(_ title: String) -> String {
505        var t = title
506        if t == "COMMENT" { t = "" } else if t.hasPrefix("COMMENT ") { t = String(t.dropFirst(8)) }
507        return removeInvisible(t)
508    }
509
510    /// `org-sort-remove-invisible`: links as their descriptions, emphasis markers gone.
511    static func removeInvisible(_ s: String) -> String {
512        Links.displayFormat(s).replacingOccurrences(of: "(^|[\\s(])[*/_+=~]([^\\s*/_+=~](?:.*?[^\\s])?)[*/_+=~](?=$|[\\s.,;:!?)])", with: "$1$2", options: .regularExpression)
513    }
514}