krz/orgstar

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

Sources/OrgCore/Commands/ClockTable.swift

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

518 lines · 27953 bytes

  1import Foundation
  2
  3// Clock tables (org-clock.el, Org 9.8.7): the `clocktable` dynamic block written by
  4// `org-clocktable-write-default` from `org-clock-sum`, updated by `org-update-dblock` (C-c C-c
  5// on its #+BEGIN line, C-c C-x C-u) and inserted by `org-clock-report` (C-c C-x C-r), with
  6// `org-clocktable-defaults` and `org-duration-format` ((\"d\" . nil) (special . h:mm)).
  7// Scopes within the file (file, subtree, tree, treeN); `:link` links within the file, as
  8// Emacs does in a buffer without a file. `:match`, `:step`, `:formatter` and scopes over
  9// other files aren't supported.
 10
 11enum ClockTable {
 12    struct Params {
 13        var values: [String: Sexp] = [:]
 14
 15        func symbol(_ key: String) -> String? { values[key]?.symbol ?? values[key]?.string }
 16        func flag(_ key: String) -> Bool { values[key].map { $0.symbol != "nil" } ?? false }
 17        func int(_ key: String) -> Int? { values[key]?.integer }
 18
 19        /// `org-clocktable-defaults` under the block's own.
 20        static let defaults: [String: Sexp] = [
 21            ":maxlevel": .integer(2), ":scope": .symbol("file"), ":wstart": .integer(1), ":mstart": .integer(1),
 22            ":narrow": .symbol("40!"), ":indent": .symbol("t"), ":lang": .string("en"),
 23        ]
 24
 25        init(_ line: String, defaults: [String: Sexp] = Self.defaults) throws {
 26            values = defaults
 27            let forms = try LispReader.read("(" + line + ")")
 28            let items = forms.first?.sexp.elements ?? []
 29            var i = 0
 30            while i + 1 < items.count {
 31                if let key = items[i].symbol, key.hasPrefix(":") { values[key] = items[i + 1] }
 32                i += 2
 33            }
 34        }
 35    }
 36
 37    struct Row {
 38        let level: Int
 39        let headline: String
 40        let tags: [String]
 41        let minutes: Int
 42        let properties: [String: String]
 43    }
 44
 45    /// `org-duration-from-minutes` with the default format: `H:MM`, days as `Nd ` before it.
 46    static func duration(_ minutes: Int) -> String {
 47        let days = minutes / 1440
 48        let rest = minutes % 1440
 49        let hm = String(format: "%d:%02d", rest / 60, rest % 60)
 50        return days > 0 ? "\(days)d " + hm : hm
 51    }
 52
 53    /// `org-clock-special-range` for `:block`: the start and end in seconds, and the caption's
 54    /// text.
 55    static func range(_ block: String, now: Date, calendar: Calendar, wstart: Int, mstart: Int) throws -> (Double, Double, String) {
 56        let c = calendar.dateComponents([.year, .month, .day, .weekday], from: now)
 57        var key = block
 58        var shift = 0
 59        var y = c.year!, month = c.month!, d = c.day!
 60        // `decode-time`'s day of week: Sunday 0.
 61        var dow = c.weekday! - 1
 62        if let m = block.wholeMatch(of: /([0-9]+)/) {
 63            y = Int(m.1)!; month = 1; d = 1; key = "year"
 64        } else if let m = block.wholeMatch(of: /([0-9]+)-([0-9]{1,2})/) {
 65            y = Int(m.1)!; month = Int(m.2)!; d = 1; key = "month"
 66        } else if let m = block.wholeMatch(of: /([0-9]+)-[wW]([0-9]{1,2})/) {
 67            var iso = Calendar(identifier: .iso8601)
 68            iso.timeZone = calendar.timeZone
 69            let date = iso.date(from: DateComponents(weekday: 2, weekOfYear: Int(m.2)!, yearForWeekOfYear: Int(m.1)!))!
 70            let g = calendar.dateComponents([.year, .month, .day], from: date)
 71            y = g.year!; month = g.month!; d = g.day!; dow = 1; key = "week"
 72        } else if let m = block.wholeMatch(of: /([0-9]+)-([0-9]{1,2})-([0-9]{1,2})/) {
 73            y = Int(m.1)!; month = Int(m.2)!; d = Int(m.3)!; key = "day"
 74        } else if let m = block.firstMatch(of: /([-+][0-9]+)$/) {
 75            shift = Int(m.1.replacingOccurrences(of: "+", with: ""))!
 76            key = String(block[..<m.range.lowerBound])
 77        }
 78        if shift == 0 {
 79            switch key {
 80            case "yesterday": key = "today"; shift = -1
 81            case "lastweek": key = "week"; shift = -1
 82            case "lastmonth": key = "month"; shift = -1
 83            case "lastyear": key = "year"; shift = -1
 84            default: break
 85            }
 86        }
 87        var start = DateComponents(year: y, month: month, day: d, hour: 0, minute: 0)
 88        var end = start
 89        let text: String
 90        let formatter = DateFormatter()
 91        formatter.locale = Locale(identifier: "en_US_POSIX")
 92        formatter.timeZone = calendar.timeZone
 93        switch key {
 94        case "day", "today":
 95            start.day = d + shift
 96            end = start
 97            end.hour = 24
 98            formatter.dateFormat = "EEEE, MMMM dd, yyyy"
 99            text = formatter.string(from: calendar.date(from: start)!)
100        case "week", "thisweek":
101            let diff = -7 * shift + ((dow + 7 - wstart) % 7 + 7) % 7
102            start.day = d - diff
103            end = start
104            end.day = start.day! + 7
105            var iso = Calendar(identifier: .iso8601)
106            iso.timeZone = calendar.timeZone
107            let s = calendar.date(from: start)!
108            text = String(format: "week %04d-W%02d", iso.component(.yearForWeekOfYear, from: s), iso.component(.weekOfYear, from: s))
109        case "month", "thismonth":
110            start.day = mstart
111            start.month = month + shift
112            end = start
113            end.month = start.month! + 1
114            formatter.dateFormat = "MMMM yyyy"
115            text = formatter.string(from: calendar.date(from: start)!)
116        case "year", "thisyear":
117            start.day = 1
118            start.month = 1
119            start.year = y + shift
120            end = start
121            end.year = start.year! + 1
122            text = "the year \(start.year!)"
123        default:
124            throw EmacsBuffer.UserError(message: "No such time block \(block)")
125        }
126        return (calendar.date(from: start)!.timeIntervalSince1970, calendar.date(from: end)!.timeIntervalSince1970, text)
127    }
128
129    static let clockRange = try! NSRegularExpression(pattern:
130        "^[ \\t]*CLOCK:[ \\t]*\\[([0-9]{4})-([0-9]{2})-([0-9]{2})[^\\]\\n]*? ([0-9]{1,2}):([0-9]{2})\\]-+\\[([0-9]{4})-([0-9]{2})-([0-9]{2})[^\\]\\n]*? ([0-9]{1,2}):([0-9]{2})\\]",
131        options: .anchorsMatchLines)
132    static let nakedClock = try! NSRegularExpression(pattern: "^[ \\t]*CLOCK:[ \\t]*=>[ \\t]+([0-9]+):([0-9]+)", options: .anchorsMatchLines)
133
134    /// `org-clock-sum` over `region`: each heading's minutes with its subtree's, and the total.
135    static func sum(_ text: String, region: Range<Int>, tstart: Double?, tend: Double?, calendar: Calendar) -> (rows: [(start: Int, level: Int, minutes: Int)], total: Int) {
136        let ns = text as NSString
137        let range = NSRange(region)
138        var own: [Int: Int] = [:] // heading start → minutes
139        let headings = (try! NSRegularExpression(pattern: "^(\\*+)[ \\t]", options: .anchorsMatchLines)).matches(in: text, range: range)
140            .map { (start: $0.range.location, level: $0.range(at: 1).length) }
141        func owner(_ offset: Int) -> Int? { headings.last { $0.start <= offset }?.start }
142        func seconds(_ m: NSTextCheckingResult, _ first: Int) -> Double {
143            let parts = (first..<(first + 5)).map { Int(ns.substring(with: m.range(at: $0)))! }
144            return calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2], hour: parts[3], minute: parts[4]))!.timeIntervalSince1970
145        }
146        for m in clockRange.matches(in: text, range: range) {
147            guard let head = owner(m.range.location) else { continue }
148            let ts = seconds(m, 1)
149            let te = seconds(m, 6)
150            let dt = (tend.map { min(te, $0) } ?? te) - (tstart.map { max(ts, $0) } ?? ts)
151            if dt > 0 { own[head, default: 0] += Int(floor(dt / 60)) }
152        }
153        for m in nakedClock.matches(in: text, range: range) {
154            guard let head = owner(m.range.location) else { continue }
155            own[head, default: 0] += Int(ns.substring(with: m.range(at: 1)))! * 60 + Int(ns.substring(with: m.range(at: 2)))!
156        }
157        var rows: [(Int, Int, Int)] = []
158        for (i, heading) in headings.enumerated() {
159            var minutes = own[heading.start] ?? 0
160            for later in headings[(i + 1)...] {
161                if later.level <= heading.level { break }
162                minutes += own[later.start] ?? 0
163            }
164            if minutes > 0 { rows.append((heading.start, heading.level, minutes)) }
165        }
166        let total = own.values.reduce(0, +)
167        return (rows.map { (start: $0.0, level: $0.1, minutes: $0.2) }, total)
168    }
169
170    /// `org-shorten-string`.
171    static func shorten(_ s: String, _ max: Int) -> String {
172        guard s.count > max else { return s }
173        let n = Swift.max(max - 4, 1)
174        if let m = s.firstMatch(of: try! Regex("^(.{1,\(n)}[^ ])( |$)")) {
175            return String(m.output[1].substring!) + "..."
176        }
177        return String(s.prefix(Swift.max(max - 3, 0))) + "..."
178    }
179
180    /// The table `org-clocktable-write-default` writes, without its final newline.
181    static func write(_ buffer: EmacsBuffer, params: Params, region: Range<Int>, content: String) throws -> String {
182        let maxlevel = params.int(":maxlevel") ?? 3
183        let emph = params.flag(":emphasize")
184        let link = params.flag(":link")
185        let compact = params.flag(":compact")
186        let showLevel = !compact && params.flag(":level")
187        let showTags = params.flag(":tags")
188        let indent = compact || params.flag(":indent")
189        let percent = params.values[":formula"]?.symbol == "%"
190        let properties = params.values[":properties"]?.elements?.compactMap(\.string) ?? []
191        if params.values[":match"].map({ $0.symbol != "nil" }) ?? false { throw EmacsBuffer.UserError(message: "Clock tables with :match aren't supported yet") }
192        if params.values[":step"].map({ $0.symbol != "nil" }) ?? false { throw EmacsBuffer.UserError(message: "Clock tables with :step aren't supported yet") }
193        var narrow: Int?
194        var narrowCut = false
195        if let value = params.values[":narrow"] {
196            if let n = value.integer { narrow = n }
197            else if let s = value.symbol, let m = s.wholeMatch(of: /([0-9]+)!/) { narrow = Int(m.1); narrowCut = true }
198            else if value.symbol != "nil" { throw EmacsBuffer.UserError(message: "Invalid value \(value.description) of :narrow property in clock table") }
199        }
200        if compact, narrow == nil { narrow = 40; narrowCut = true }
201        if let n = narrow, !narrowCut, link { narrow = n; narrowCut = true }
202
203        var tstart: Double?
204        var tend: Double?
205        var rangeText: String?
206        if let block = params.symbol(":block") ?? params.values[":block"]?.integer.map(String.init), block != "nil" {
207            let r = try range(block, now: buffer.now, calendar: buffer.calendar, wstart: params.int(":wstart") ?? 1, mstart: params.int(":mstart") ?? 1)
208            (tstart, tend, rangeText) = (r.0, r.1, r.2)
209        } else {
210            if let s = params.values[":tstart"]?.string { tstart = TagsMatcher.matcherTime(s, now: buffer.now, calendar: buffer.calendar) }
211            if let s = params.values[":tend"]?.string { tend = TagsMatcher.matcherTime(s, now: buffer.now, calendar: buffer.calendar) }
212        }
213        let text = buffer.string
214        let summed = sum(text, region: region, tstart: tstart, tend: tend, calendar: buffer.calendar)
215        var rows: [Row] = []
216        for entry in summed.rows where entry.level <= maxlevel {
217            buffer.point = entry.start
218            guard let match = buffer.lookingAt(buffer.complexHeadingPattern) else { continue }
219            var title = match[4].map { buffer.substring($0.lowerBound, $0.upperBound) } ?? ""
220            if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) }
221            var headline = title
222            if link {
223                let search = "*" + Links.normalize(title, context: false)
224                let description = Links.displayFormat(title.replacingOccurrences(of: "\\[[0-9]*(?:%|/[0-9]*)\\]", with: "", options: .regularExpression)).trimmingCharacters(in: .whitespaces)
225                headline = Links.makeString(search, description)
226            }
227            // `org-get-tags`: inherited ones first.
228            let own = buffer.tags()
229            let tags = showTags ? own.inherited + own.local : []
230            var props: [String: String] = [:]
231            for name in properties {
232                if let line = buffer.saveExcursion({ () -> [Range<Int>?]? in
233                    let end = buffer.saveExcursion { () -> Int in Capture.nextHeading(buffer); return buffer.point }
234                    return buffer.searchForward("^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]+(.*?)[ \\t]*$", bound: end, caseInsensitive: true)
235                }), let value = line[1] {
236                    props[name] = buffer.substring(value.lowerBound, value.upperBound)
237                }
238            }
239            rows.append(Row(level: entry.level, headline: headline, tags: tags, minutes: entry.minutes, properties: props))
240        }
241        let total = summed.total
242        let columns = compact || maxlevel < 2 ? 1 : min(maxlevel, params.int(":tcolumns") ?? 100, rows.map(\.level).max() ?? 1)
243
244        let stamp = buffer.formatTimestamp(buffer.calendar.dateComponents([.year, .month, .day, .hour, .minute], from: buffer.now), withTime: true, inactive: true)
245        var out = "#+CAPTION: Clock summary at \(stamp)\(rangeText.map { ", for \($0)." } ?? "")\n"
246        if let narrow, !narrowCut {
247            out += "|" + (showLevel ? "|" : "") + (showTags ? "|" : "") + String(repeating: "|", count: properties.count) + "<\(narrow)>| |\n"
248        }
249        out += "|" + (showLevel ? "L|" : "") + (showTags ? "Tags |" : "") + properties.map { $0 + "|" }.joined()
250            + "Headline|Time|" + String(repeating: "|", count: max(0, columns - 1)) + (percent ? "%|\n" : "\n")
251        out += "|-\n|" + (showLevel ? "|" : "") + (showTags ? "|" : "") + String(repeating: "|", count: properties.count)
252            + "*Total time*| *\(duration(total))*|" + String(repeating: "|", count: max(0, columns - 1))
253            + (percent ? (total == 0 ? "0.0|" : "100.0|") : "") + "\n"
254        if total > 0 {
255            if total > 0 || !params.flag(":fileskip0") { out += "|-\n" }
256            if maxlevel > 0 {
257                for row in rows {
258                    var headline = row.headline
259                    if narrowCut, let narrow {
260                        if let m = headline.wholeMatch(of: /\[\[(.+?)\]\[(.+)\]\]/) {
261                            headline = "[[\(m.1)][\(shorten(String(m.2), narrow))]]"
262                        } else {
263                            headline = shorten(headline, narrow)
264                        }
265                    }
266                    func field(_ f: String) -> String {
267                        guard emph else { return f + " |" }
268                        return row.level == 1 ? "*\(f)* |" : row.level == 2 ? "/\(f)/ |" : f + " |"
269                    }
270                    out += "|" + (showLevel ? "\(row.level)|" : "") + (showTags ? row.tags.joined(separator: ", ") + "|" : "")
271                        + properties.map { (row.properties[$0] ?? "") + "|" }.joined()
272                        + (indent && row.level > 1 ? "\\_" + String(repeating: " ", count: 2 * (row.level - 1)) : "")
273                        + field(headline) + String(repeating: "|", count: max(0, min(columns, row.level) - 1))
274                        + field(duration(row.minutes)) + String(repeating: "|", count: max(0, columns - row.level))
275                        + (percent ? String(format: "%.1f |", 100 * Double(row.minutes) / Double(total)) : "") + "\n"
276                }
277            }
278        }
279        out.removeLast()
280        if !percent, params.values[":formula"].map({ $0.symbol != "nil" }) ?? false {
281            if let formula = params.values[":formula"]?.string { out += "\n#+TBLFM: " + formula }
282        } else if let tblfm = content.range(of: "(?m)^[ \\t]*#\\+tblfm:.*", options: [.regularExpression, .caseInsensitive]) {
283            out += "\n" + content[tblfm]
284        }
285        return out
286    }
287}
288
289extension EmacsBuffer {
290    /// `org-dblock-start-re` on point's line.
291    static let dblockStart = "^[ \\t]*#\\+(?:BEGIN|begin):[ \\t]+(\\S+)([ \\t]+(.*))?"
292
293    /// The dynamic block whose lines hold point: its #+BEGIN line's start.
294    func dynamicBlockStart() -> Int? {
295        saveExcursion {
296            let origin = point
297            point = lineEnd(point)
298            guard let begin = searchBackward(Self.dblockStart, caseInsensitive: false)?[0]?.lowerBound else { return nil }
299            point = lineEnd(begin)
300            guard let end = searchForward("^[ \\t]*#\\+(?:END|end):?", caseInsensitive: false)?[0] else { return nil }
301            return origin <= lineEnd(end.lowerBound) ? begin : nil
302        }
303    }
304
305    /// `org-update-dblock` for a clock table at `begin`.
306    func updateDynamicBlock(at begin: Int) throws {
307        point = begin
308        guard let start = lookingAt(Self.dblockStart) else { throw UserError(message: "Not at a dynamic block") }
309        let name = start[1].map { substring($0.lowerBound, $0.upperBound) } ?? ""
310        let paramText = start[3].map { substring($0.lowerBound, $0.upperBound) } ?? ""
311        let deleteFrom = start[0]!.upperBound + 1
312        point = start[0]!.upperBound
313        guard let end = searchForward("^[ \\t]*#\\+(?:END|end):?", caseInsensitive: false)?[0] else { throw UserError(message: "Dynamic block not terminated") }
314        let content = substring(min(deleteFrom, end.lowerBound), end.lowerBound)
315        if name == "columnview" {
316            let params = try ClockTable.Params(paramText, defaults: [:])
317            let mark = marker(begin)
318            defer { release(mark) }
319            let table = try writeColumnView(params, at: begin, content: content, tree: OrgParser.parse(string))
320            try replaceDynamicBlockContent(at: mark.position, with: table, recalculate: table.range(of: "(?mi)^[ \\t]*#\\+TBLFM:", options: .regularExpression) != nil)
321            return
322        }
323        guard name == "clocktable" else { throw UserError(message: "Dynamic blocks named \(name) aren't supported") }
324        let params = try ClockTable.Params(paramText)
325        // The scope's text.
326        point = begin
327        let region: Range<Int>
328        switch params.symbol(":scope") ?? "file" {
329        case "nil", "file":
330            region = 0..<length
331        case "subtree":
332            guard !beforeFirstHeading, let r = try? subtreeRange() else { throw UserError(message: "Before first headline") }
333            region = r
334        case let scope where scope.hasPrefix("tree"):
335            guard !beforeFirstHeading else { throw UserError(message: "Before first headline") }
336            try backToHeading()
337            let limit = Int(scope.dropFirst(4))
338            while let level = currentLevel, level > (limit ?? 1), upHeadingSafe() != nil {
339                if let limit, outlineLevel <= limit { break }
340            }
341            region = try subtreeRange()
342        case let scope:
343            throw UserError(message: "Clock tables over \(scope) aren't supported yet")
344        }
345        let table = try ClockTable.write(self, params: params, region: region, content: content)
346        try replaceDynamicBlockContent(at: begin, with: table)
347    }
348
349    /// Puts `table` between the #+BEGIN and #+END lines of the block at `begin`, indented like
350    /// the #+BEGIN line as `org-update-dblock` does, and aligns it.
351    func replaceDynamicBlockContent(at begin: Int, with table: String, recalculate: Bool = false) throws {
352        point = begin
353        guard let start = lookingAt(Self.dblockStart) else { throw UserError(message: "Not at a dynamic block") }
354        let indentation = currentIndentation
355        let deleteFrom = start[0]!.upperBound + 1
356        point = start[0]!.upperBound
357        guard let end = searchForward("^[ \\t]*#\\+(?:END|end):?", caseInsensitive: false)?[0] else { throw UserError(message: "Dynamic block not terminated") }
358        // Replace the old contents.
359        let contentEnd = end.lowerBound
360        deleteRegion(min(deleteFrom, contentEnd), contentEnd)
361        point = min(deleteFrom, length)
362        insert(table + "\n")
363        // Indent like the #+BEGIN line, as `org-update-dblock` does.
364        if indentation > 0 {
365            let pad = String(repeating: " ", count: indentation)
366            point = deleteFrom
367            while lookingAt("^[ \\t]*#\\+(?:END|end):?") == nil {
368                insert(pad)
369                if forwardLine(1) != 0 { break }
370            }
371            if let blank = lookingAt("[ \\t]+") { deleteRegion(blank[0]!.lowerBound, blank[0]!.upperBound) }
372            insert(pad)
373        }
374        point = deleteFrom
375        if let bar = searchForward("^[ \\t]*\\|", caseInsensitive: false) {
376            point = bar[0]!.upperBound
377            // A table kept with formulas is recalculated, as the column view writer does.
378            if recalculate {
379                do {
380                    try tableRecalculate(all: true)
381                } catch let error as Calc.Unsupported {
382                    throw UserError(message: TableRecalculate.emacsMessage + error.reason)
383                }
384            }
385            tableAlign()
386        }
387        point = begin
388    }
389}
390
391/// C-c C-x C-u, and C-c C-c on a dynamic block's #+BEGIN line: `org-update-dblock` (clock
392/// tables).
393public struct UpdateDynamicBlock: OrgCommand {
394    public init() {}
395    public var id: String { "org.dblock.update" }
396    public var title: String { "Update Dynamic Block" }
397    public func applies(in context: EditContext) -> Bool {
398        EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).dynamicBlockStart() != nil
399    }
400    public func run(in context: EditContext) -> CommandStep {
401        guard let begin = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).dynamicBlockStart() else {
402            return .failed("Not at a dynamic block")
403        }
404        return commitBuffer(context) { buffer in
405            try buffer.updateDynamicBlock(at: begin)
406            buffer.point = begin
407        }
408    }
409}
410
411/// C-c C-x C-r: `org-clock-report`. Updates the clock table at point, or inserts one for the
412/// subtree (the file before the first heading).
413public struct ClockReportTable: OrgCommand {
414    public init() {}
415    public var id: String { "org.clock.report" }
416    public var title: String { "Insert or Update Clock Table" }
417    public func applies(in context: EditContext) -> Bool { true }
418    public func run(in context: EditContext) -> CommandStep {
419        commitBuffer(context) { buffer in
420            if let begin = buffer.dynamicBlockStart(), buffer.saveExcursion({ buffer.point = begin; return buffer.lookingAt("^[ \\t]*#\\+(?:BEGIN|begin):[ \\t]+clocktable") != nil }) {
421                try buffer.updateDynamicBlock(at: begin)
422                buffer.point = begin
423                return
424            }
425            let scope = buffer.beforeFirstHeading ? "file" : "subtree"
426            let begin = buffer.createDynamicBlock("clocktable :scope \(scope) :maxlevel 2")
427            try buffer.updateDynamicBlock(at: begin)
428            buffer.point = begin
429        }
430    }
431}
432
433extension EmacsBuffer {
434    /// `org-create-dblock`: an empty block named by `header` on a line of its own at point.
435    func createDynamicBlock(_ header: String) -> Int {
436        if substring(lineStart(point), lineEnd(point)).contains(where: { !$0.isWhitespace }) {
437            point = lineEnd(point)
438            insert("\n")
439        }
440        let column = currentColumn
441        let begin = lineStart(point)
442        insert("#+BEGIN: \(header)\n\n" + String(repeating: " ", count: column) + "#+END:\n")
443        return begin
444    }
445}
446
447/// C-c C-x i: `org-columns-insert-dblock`, asking what to capture (this subtree, the file, or an
448/// entry by ID).
449public struct InsertColumnView: OrgCommand {
450    public init() {}
451    public var id: String { "org.columns.insert-dblock" }
452    public var title: String { "Insert Column View Table" }
453    public func applies(in context: EditContext) -> Bool { true }
454    public func run(in context: EditContext) -> CommandStep {
455        guard let answer = context.answers["capture"] else {
456            let ids = SparseTreeCommand.propertyValues("ID", in: context.text)
457            return .prompt(Prompt(key: "capture", message: "Capture columns (local, global, entry with :ID: property) [local]:",
458                                  choices: ["global", "local"] + ids, requireMatch: false))
459        }
460        let id = answer.isEmpty || answer == "local" ? "local" : answer == "global" ? "global" : answer
461        return commitBuffer(context) { buffer in
462            let begin = buffer.createDynamicBlock("columnview :hlines 1 :id \(id)")
463            try buffer.updateDynamicBlock(at: begin)
464            buffer.point = begin
465        }
466    }
467}
468
469/// C-c C-x C-c: `org-columns`, shown read-only: the entries from the column view's top level
470/// with their values and summaries.
471public struct ShowColumnView: OrgCommand {
472    let global: Bool
473    public init(global: Bool = false) { self.global = global }
474    public var id: String { global ? "org.columns.global" : "org.columns" }
475    public var title: String { global ? "Column View of File" : "Column View" }
476    public func applies(in context: EditContext) -> Bool { true }
477    public func run(in context: EditContext) -> CommandStep {
478        let view = ColumnView.capture(text: context.text, tree: context.tree, at: context.caret, global: global, now: context.now).view
479        guard !view.rows.isEmpty else { return .failed("No entries for column view") }
480        return .external(.columnView(view))
481    }
482}
483
484/// C-c C-x e: `org-set-effort`. Offers the entry's `Effort_ALL` values; sets `Effort`.
485public struct SetEffort: OrgCommand {
486    public init() {}
487    public var id: String { "org.effort.set" }
488    public var title: String { "Set Effort" }
489    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
490    public func run(in context: EditContext) -> CommandStep {
491        guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
492        let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
493        guard let answer = context.answers["effort"] else {
494            let current = buffer.saveExcursion { () -> String in
495                _ = try? buffer.backToHeading()
496                let end = buffer.saveExcursion { () -> Int in Capture.nextHeading(buffer); return buffer.point }
497                guard let line = buffer.searchForward("^[ \\t]*:Effort:[ \\t]+(.*?)[ \\t]*$", bound: end, caseInsensitive: true), let value = line[1] else { return "" }
498                return buffer.substring(value.lowerBound, value.upperBound)
499            }
500            let allowed = (buffer.inheritedProperty("Effort_ALL")?.value ?? Self.fileProperty(context.text, "Effort_ALL") ?? "")
501                .split(whereSeparator: \.isWhitespace).map(String.init)
502            return .prompt(Prompt(key: "effort", message: "Effort:", initial: current, choices: allowed, requireMatch: false))
503        }
504        let value = answer.trimmingCharacters(in: .whitespaces)
505        return commitBuffer(context) { buffer in
506            buffer.saveExcursion {
507                _ = try? buffer.backToHeading()
508                buffer.entryPut("Effort", value)
509            }
510        }
511    }
512
513    /// `#+PROPERTY: NAME value`.
514    static func fileProperty(_ text: String, _ name: String) -> String? {
515        text.firstMatch(of: try! Regex("(?im)^[ \\t]*#\\+PROPERTY:[ \\t]+" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]+(.*?)[ \\t]*$"))
516            .map { String($0.output[1].substring!) }
517    }
518}