krz/orgstar

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

Sources/OrgCore/Commands/ColumnView.swift

c1295824f55c7bba681fca8338bf08167e110105
orgstar/Sources/OrgCore/Commands/ColumnView.swift history · blame · raw

392 lines · 19903 bytes

  1import Foundation
  2
  3// Column view (`org-colview.el`, Org 9.8.7): the column format, values and summaries of each
  4// entry, and the `columnview` dynamic block that captures them.
  5
  6public enum ColumnView {
  7    /// One column of `org-columns-compile-format`.
  8    public struct Spec: Sendable, Equatable {
  9        public let property: String
 10        public let title: String
 11        public let width: Int?
 12        public let summary: String?
 13        public let format: String?
 14    }
 15
 16    public struct Row: Sendable, Equatable {
 17        public let level: Int
 18        /// Start of the heading line.
 19        public let start: Int
 20        public let values: [String]
 21    }
 22
 23    public struct View: Sendable, Equatable {
 24        public let specs: [Spec]
 25        public let rows: [Row]
 26    }
 27
 28    public static let defaultFormat = "%25ITEM %TODO %3PRIORITY %TAGS"
 29
 30    /// `org-columns-compile-format`.
 31    public static func compile(_ format: String) -> [Spec] {
 32        format.matches(of: /%([0-9]+)?([A-Za-z0-9_-]+)(?:\(([^)]*)\))?(?:\{([^}]*)\})?\s*/).map { m in
 33            let property = String(m.2)
 34            let title = m.3.map(String.init).flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil } ?? property
 35            var summary = m.4.map(String.init).flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil }
 36            var fmt: String?
 37            if let s = summary, let semicolon = s.firstIndex(of: ";") {
 38                fmt = String(s[s.index(after: semicolon)...])
 39                summary = String(s[..<semicolon])
 40            }
 41            return Spec(property: property.uppercased(), title: title, width: m.1.flatMap { Int($0) }, summary: summary, format: fmt)
 42        }
 43    }
 44
 45    // MARK: - Numbers and durations as Emacs Lisp has them
 46
 47    /// A number from `string-to-number`: an integer unless the text has a fraction or exponent.
 48    enum Number {
 49        case int(Int)
 50        case float(Double)
 51
 52        init(_ s: String) {
 53            let t = s.trimmingCharacters(in: .whitespaces)
 54            if let m = t.prefixMatch(of: /[-+]?[0-9]+(?![.eE0-9])/), let i = Int(m.0) { self = .int(i); return }
 55            if let m = t.prefixMatch(of: /[-+]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?/), let d = Double(m.0) {
 56                self = m.0.contains(where: { $0 == "." || $0 == "e" || $0 == "E" }) ? .float(d) : .int(Int(d)); return
 57            }
 58            self = .int(0)
 59        }
 60
 61        var double: Double {
 62            switch self {
 63            case .int(let i): Double(i)
 64            case .float(let d): d
 65            }
 66        }
 67
 68        /// `format "%s"`.
 69        var lisp: String {
 70            switch self {
 71            case .int(let i): String(i)
 72            case .float(let d): d.isFinite && d == d.rounded() && abs(d) < 1e16 ? String(format: "%.1f", d) : "\(d)"
 73            }
 74        }
 75
 76        static func + (a: Number, b: Number) -> Number {
 77            if case .int(let x) = a, case .int(let y) = b { return .int(x + y) }
 78            return .float(a.double + b.double)
 79        }
 80    }
 81
 82    /// `format FMT` with a number, for the formats summaries use.
 83    static func format(_ fmt: String?, _ n: Number) -> String {
 84        guard let fmt else { return n.lisp }
 85        if fmt.contains("%d") { return String(format: fmt.replacingOccurrences(of: "%d", with: "%ld"), Int(n.double)) }
 86        if fmt.contains("%s") { return fmt.replacingOccurrences(of: "%s", with: n.lisp) }
 87        return String(format: fmt, n.double)
 88    }
 89
 90    static let unitMinutes: [(String, Double)] = [("min", 1), ("h", 60), ("d", 1440), ("w", 10080), ("m", 43200), ("y", 525960)]
 91
 92    /// `org-duration-to-minutes`.
 93    static func minutes(_ duration: String) -> Double? {
 94        let s = duration.trimmingCharacters(in: .whitespaces)
 95        if s.isEmpty { return 0 }
 96        if s.wholeMatch(of: /[0-9]+(?::[0-9]{2}){1,2}/) != nil {
 97            let parts = s.split(separator: ":").map { Double($0) ?? 0 }
 98            return parts[0] * 60 + parts[1] + (parts.count > 2 ? parts[2] / 60 : 0)
 99        }
100        let unit = /([0-9]+(?:\.[0-9]*)?)[ \t]*(min|h|d|w|m|y)/
101        if s.wholeMatch(of: /(?:[0-9]+(?:\.[0-9]*)?[ \t]*(?:min|h|d|w|m|y)[ \t]*)+/) != nil {
102            var total = 0.0
103            for m in s.matches(of: unit) {
104                let name = String(m.2)
105                total += (Double(m.1) ?? 0) * (unitMinutes.first { $0.0 == name }?.1 ?? 0)
106            }
107            return total
108        }
109        if let m = s.wholeMatch(of: /((?:[0-9]+(?:\.[0-9]*)?[ \t]*(?:min|h|d|w|m|y)[ \t]*)+)[ \t]*([0-9]+(?::[0-9]{2}){1,2})/) {
110            return (minutes(String(m.1)) ?? 0) + (minutes(String(m.2)) ?? 0)
111        }
112        if s.wholeMatch(of: /[0-9]+(?:\.[0-9]*)?/) != nil { return Double(s) }
113        return nil
114    }
115
116    /// `org-duration-from-minutes` with `h:mm`, or with nil the default format (days, then
117    /// `H:MM`).
118    static func duration(_ minutes: Double, hmmOnly: Bool) -> String {
119        if minutes < 0 { return "-" + duration(-minutes, hmmOnly: hmmOnly) }
120        func hmm(_ m: Double) -> String { String(format: "%d:%02d", Int(m / 60), Int(m.truncatingRemainder(dividingBy: 60))) }
121        if hmmOnly || minutes < 1440 { return hmm(minutes) }
122        let days = (minutes / 1440).rounded(.down)
123        return "\(Int(days))d " + hmm(minutes - days * 1440)
124    }
125
126    /// `org-columns--summarize`: nil for an unknown operator.
127    static func summarize(_ op: String, _ values: [String], fmt: String?, now: Date) -> String? {
128        let numbers = values.map(Number.init)
129        switch op {
130        case "+": return format(fmt, numbers.reduce(.int(0), +))
131        case "$": return String(format: "%.2f", numbers.reduce(0) { $0 + $1.double })
132        case "X":
133            let done = values.filter { $0 == "[X]" }.count
134            return done == values.count ? "[X]" : done > 0 ? "[-]" : "[ ]"
135        case "X/":
136            let done = values.filter { $0 == "[X]" || $0.firstMatch(of: /\[([1-9])\/\1\]/) != nil }.count
137            return "[\(done)/\(values.count)]"
138        case "X%":
139            let done = values.filter { $0 == "[X]" || $0 == "[100%]" }.count
140            return "[\(Int((100.0 * Double(done) / Double(values.count)).rounded()))%]"
141        case "min", "max":
142            var best = numbers[0]
143            for n in numbers.dropFirst() where op == "min" ? n.double < best.double : n.double > best.double { best = n }
144            return format(fmt, best)
145        case "mean": return format(fmt, .float(numbers.reduce(0) { $0 + $1.double } / Double(numbers.count)))
146        case ":", ":min", ":max", ":mean":
147            let times = values.compactMap(minutes)
148            guard !times.isEmpty else { return nil }
149            let hmmOnly = values.allSatisfy { $0.trimmingCharacters(in: .whitespaces).wholeMatch(of: /[0-9]+(?::[0-9]{2}){1,2}/) != nil }
150            let result: Double = switch op {
151            case ":": times.reduce(0, +)
152            case ":min": times.min()!
153            case ":max": times.max()!
154            default: times.reduce(0, +) / Double(times.count)
155            }
156            return duration(result, hmmOnly: hmmOnly)
157        case "est+":
158            var mean = 0.0, variance = 0.0
159            for e in values {
160                let parts = e.split(separator: "-", omittingEmptySubsequences: false).map { Number(String($0)).double }
161                if parts.count == 2 {
162                    let m = (parts[0] + parts[1]) / 2
163                    mean += m
164                    variance += (parts[0] * parts[0] + parts[1] * parts[1]) / 2 - m * m
165                } else if let v = parts.first {
166                    mean += v
167                }
168            }
169            let sd = variance.squareRoot()
170            return String(format: "%.0f-%.0f", mean - sd, mean + sd)
171        default: return nil
172        }
173    }
174
175    // MARK: - Values
176
177    static let specials: Set<String> = PropertyValues.special.union(["CATEGORY"])
178
179    /// `org-entry-get` with `selective` inheritance, and the special properties column view
180    /// shows.
181    static func value(_ property: String, heading index: Int, model: DocumentModel, text: NSString, clocksum: [Int: Int]) -> String {
182        let h = model.headings[index]
183        switch property {
184        case "ITEM": return h.title.replacingOccurrences(of: "\t", with: "        ")
185        case "TODO": return h.todo ?? ""
186        case "PRIORITY": return h.priority ?? model.orgSettings.priorities.default
187        case "TAGS": return h.localTags.isEmpty ? "" : ":" + h.localTags.joined(separator: ":") + ":"
188        case "ALLTAGS":
189            let tags = model.ancestors(of: index).flatMap { model.headings[$0].localTags } + h.localTags
190            var seen: Set<String> = []
191            let all = (model.fileTags + tags).filter { seen.insert($0).inserted }
192            return all.isEmpty ? "" : ":" + all.joined(separator: ":") + ":"
193        case "DEADLINE", "SCHEDULED", "CLOSED":
194            // From the planning line under the heading.
195            let next = NSMaxRange(text.lineRange(for: NSRange(location: h.headingRange.lowerBound, length: 0)))
196            guard next < text.length else { return "" }
197            let line = text.substring(with: text.lineRange(for: NSRange(location: next, length: 0)))
198            guard line.range(of: "^[ \\t]*(?:SCHEDULED|DEADLINE|CLOSED):", options: .regularExpression) != nil,
199                  let m = line.range(of: property + ": *(<[^>\\n]+>|\\[[^\\]\\n]+\\])", options: .regularExpression) else { return "" }
200            return String(line[m]).replacingOccurrences(of: "^" + property + ": *", with: "", options: .regularExpression)
201        case "CLOCKSUM": return clocksum[index].map { ClockTable.duration($0) } ?? ""
202        default:
203            return model.property(property, of: index)?.value ?? ""
204        }
205    }
206
207    /// `org-columns--displayed-value` without the stars on ITEM.
208    static func displayed(_ spec: Spec, _ value: String) -> String {
209        switch spec.property {
210        case "ITEM": return value
211        case "DEADLINE", "SCHEDULED", "TIMESTAMP": return value.replacingOccurrences(of: "<([^>]*)>", with: "[$1]", options: .regularExpression)
212        default: return spec.format.map { format($0, Number(value)) } ?? value
213        }
214    }
215
216    // MARK: - The view
217
218    /// `org-columns` and `org-columns--capture-view` at `offset`: the format, the entries from the
219    /// top level (the first ancestor with COLUMNS, else the entry; the file before the first
220    /// heading or with `global`), their values with summaries. `updates` are the parents whose
221    /// summarized property org rewrites.
222    public static func capture(text: String, tree: OrgTree, at offset: Int, global: Bool, format override: String? = nil,
223                               maxLevel: Int? = nil, skipEmpty: Bool = false, excludeTags: [String] = [], now: Date = Date())
224        -> (view: View, updates: [(start: Int, property: String, value: String)]) {
225        let model = DocumentModel(tree: tree)
226        let ns = text as NSString
227        let headings = model.headings
228        let here = global ? nil : headings.lastIndex { $0.headingRange.lowerBound <= offset }
229        // The top level and the format.
230        var top: Int?
231        var format = override
232        if let here {
233            for h in [here] + model.ancestors(of: here).reversed() {
234                if let columns = headings[h].properties.last(where: { $0.key.uppercased() == "COLUMNS" }) {
235                    top = h
236                    if format == nil { format = columns.value }
237                    break
238                }
239            }
240            if top == nil { top = here }
241        }
242        if format == nil, let keyword = (tree.settings.setupKeywords + text).firstMatch(of: #/(?mi)^[ \t]*#\+COLUMNS:[ \t]+(.+?)[ \t]*$/#) { format = String(keyword.1) }
243        let specs = compile(format ?? defaultFormat)
244        // The entries in scope, without archived or commented subtrees.
245        var scope: [Int] = []
246        let end: Int = top.map { t in headings[(t + 1)...].firstIndex { $0.level <= headings[t].level } ?? headings.count } ?? headings.count
247        var skipBelow: Int?
248        for i in (top ?? 0)..<end {
249            if let level = skipBelow, headings[i].level > level { continue }
250            skipBelow = nil
251            let title = headings[i].title
252            if headings[i].localTags.contains("ARCHIVE") || title == "COMMENT" || title.hasPrefix("COMMENT ") {
253                skipBelow = headings[i].level
254                continue
255            }
256            scope.append(i)
257        }
258        // Clocked minutes per subtree, as `org-clock-sum`.
259        var clocksum: [Int: Int] = [:]
260        if specs.contains(where: { $0.property == "CLOCKSUM" }) {
261            for i in headings.indices {
262                let own = headings[i].clocks.compactMap(\.minutes).reduce(0, +)
263                guard own > 0 else { continue }
264                for h in [i] + model.ancestors(of: i) { clocksum[h, default: 0] += own }
265            }
266        }
267        // Summaries, bottom up through the whole top-level subtree.
268        var summaries: [Int: [Int: String]] = [:]
269        var updates: [(Int, String, String)] = []
270        var seen: Set<String> = []
271        for (column, spec) in specs.enumerated() {
272            let first = seen.insert(spec.property).inserted
273            guard let op = spec.summary, !PropertyValues.special.contains(spec.property) else { continue }
274            var accumulators = [[String]](repeating: [], count: 31)
275            var level = 0
276            var lastLevel = 30
277            for i in ((top ?? 0)..<end).reversed() {
278                if level != 0 { lastLevel = level }
279                level = min(30, headings[i].level)
280                let own = headings[i].properties.last { $0.key.uppercased() == spec.property }?.value
281                let set = own.map { $0.contains { !$0.isWhitespace } } ?? false
282                if level < lastLevel {
283                    let below = accumulators[(level + 1)...].flatMap { $0 }
284                    let summary = below.isEmpty ? nil : summarize(op, below, fmt: spec.format, now: now)
285                    if let summary {
286                        summaries[i, default: [:]][column] = summary
287                        let trimmed = summary.trimmingCharacters(in: .whitespaces)
288                        if first, let own, own != trimmed { updates.append((headings[i].headingRange.lowerBound, spec.property, trimmed)) }
289                    }
290                    if let value = summary ?? (set ? own : nil) { accumulators[level].append(value) }
291                    for l in (level + 1)...30 { accumulators[l] = [] }
292                } else if set, let own {
293                    accumulators[level].append(own)
294                }
295            }
296        }
297        // Rows.
298        var rows: [Row] = []
299        for i in scope {
300            let level = headings[i].level
301            if let maxLevel, level > maxLevel { continue }
302            let values = specs.enumerated().map { column, spec -> String in
303                let raw = summaries[i]?[column] ?? value(spec.property, heading: i, model: model, text: ns, clocksum: clocksum)
304                return displayed(spec, raw)
305            }
306            if skipEmpty {
307                let others = Set(zip(specs, values).filter { $0.0.property != "ITEM" }.map(\.1)).subtracting([""])
308                if others.isEmpty { continue }
309            }
310            if !excludeTags.isEmpty {
311                let tags = model.ancestors(of: i).flatMap { headings[$0].localTags } + headings[i].localTags + model.fileTags
312                if tags.contains(where: excludeTags.contains) { continue }
313            }
314            rows.append(Row(level: level, start: headings[i].headingRange.lowerBound, values: values))
315        }
316        return (View(specs: specs, rows: rows), updates)
317    }
318
319    /// `org-columns--clean-item`: no footnote references, targets, statistics cookies or inline
320    /// source blocks, and `|` as `\vert`.
321    static func cleanItem(_ item: String) -> String {
322        var s = item
323        s = s.replacingOccurrences(of: "\\[fn:[^\\]]*\\]", with: "", options: .regularExpression)
324        s = s.replacingOccurrences(of: "<<<?([^<>]*)>>>?", with: "", options: .regularExpression)
325        s = s.replacingOccurrences(of: "\\[[0-9]*(?:%|/[0-9]*)\\]", with: "", options: .regularExpression)
326        s = s.replacingOccurrences(of: "src_[^\\s{\\[]+(?:\\[[^\\]]*\\])?\\{[^}]*\\}", with: "", options: .regularExpression)
327        return s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "|", with: "\\vert")
328    }
329
330    /// `org-columns-dblock-write-default`'s rows, before alignment.
331    static func dblockTable(_ view: View, hlines: Int?, hlinesAll: Bool, indent: Bool) -> [[String]?] {
332        var table: [[String]?] = [view.specs.map(\.title), nil]
333        let item = view.specs.firstIndex { $0.property == "ITEM" }
334        for row in view.rows {
335            if table.last! != nil, hlinesAll || hlines.map({ row.level <= $0 }) == true { table.append(nil) }
336            var values = row.values
337            if let item {
338                let cleaned = cleanItem(values[item])
339                values[item] = indent && row.level > 1 ? "\\_" + String(repeating: " ", count: 2 * (row.level - 1)) + cleaned : cleaned
340            }
341            table.append(values)
342        }
343        if view.specs.contains(where: { $0.width != nil }) {
344            table.insert(view.specs.map { $0.width.map { "<\($0)>" } ?? "" }, at: 0)
345        }
346        return table
347    }
348}
349
350extension EmacsBuffer {
351    /// `org-dblock-write:columnview` and the default writer, at point (the line after #+BEGIN).
352    func writeColumnView(_ params: ClockTable.Params, at begin: Int, content: String, tree: OrgTree) throws -> String {
353        let id = params.values[":id"]
354        var offset = begin
355        var global = false
356        if let id {
357            switch id.symbol ?? id.string ?? "" {
358            case "global": global = true
359            case "local", "nil", "": break
360            case let value:
361                if value.hasPrefix("file:") { throw UserError(message: "Column views of other files aren't supported yet") }
362                guard let match = searchForwardFromStart("^[ \\t]*:ID:[ \\t]+" + NSRegularExpression.escapedPattern(for: value) + "[ \\t]*$") else {
363                    throw UserError(message: "Cannot find entry with :ID: \(value)")
364                }
365                offset = match
366            }
367        }
368        let hlines = params.values[":hlines"]
369        let excluded = params.values[":exclude-tags"]?.elements?.compactMap { $0.string ?? $0.symbol } ?? []
370        let captured = ColumnView.capture(text: string, tree: tree, at: offset, global: global, format: params.values[":format"]?.string,
371                                          maxLevel: params.int(":maxlevel"), skipEmpty: params.flag(":skip-empty-rows"),
372                                          excludeTags: excluded, now: now)
373        for update in captured.updates.sorted(by: { $0.start > $1.start }) {
374            point = update.start
375            entryPut(update.property, update.value)
376        }
377        let table = ColumnView.dblockTable(captured.view, hlines: hlines?.integer, hlinesAll: hlines?.symbol == "t",
378                                           indent: params.flag(":indent"))
379        var lines = table.map { row in row.map { "|" + $0.joined(separator: "|") + "|" } ?? "|-|" }
380        for line in content.split(separator: "\n") where line.range(of: "^[ \\t]*#\\+TBLFM:", options: [.regularExpression, .caseInsensitive]) != nil {
381            lines.append(line.trimmingCharacters(in: .whitespaces))
382        }
383        return lines.joined(separator: "\n")
384    }
385
386    private func searchForwardFromStart(_ pattern: String) -> Int? {
387        saveExcursion {
388            point = 0
389            return searchForward(pattern, caseInsensitive: true)?[0]?.lowerBound
390        }
391    }
392}