Sources/OrgCore/Commands/ColumnView.swift
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}