Sources/OrgCore/Commands/ClockTable.swift
518 lines · 27953 bytes
34 symbols in this file
ClockTableParamssymbolflagintRowdurationrangesumownersecondsshortenwritefielddynamicBlockStartupdateDynamicBlockreplaceDynamicBlockContentUpdateDynamicBlockappliesrunClockReportTableappliesruncreateDynamicBlockInsertColumnViewappliesrunShowColumnViewappliesrunSetEffortappliesrunfileProperty
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}