| @@ -0,0 +1,447 @@ |
| |
1 | import 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 | |
| |
11 | enum 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) throws { |
| |
26 | values = Self.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 | |
| |
289 | extension 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 | guard name == "clocktable" else { throw UserError(message: "Dynamic blocks named \(name) aren't supported") } |
| |
311 | let paramText = start[3].map { substring($0.lowerBound, $0.upperBound) } ?? "" |
| |
312 | let params = try ClockTable.Params(paramText) |
| |
313 | let indentation = currentIndentation |
| |
314 | let deleteFrom = start[0]!.upperBound + 1 |
| |
315 | point = start[0]!.upperBound |
| |
316 | guard let end = searchForward("^[ \\t]*#\\+(?:END|end):?", caseInsensitive: false)?[0] else { throw UserError(message: "Dynamic block not terminated") } |
| |
317 | let content = substring(min(deleteFrom, end.lowerBound), end.lowerBound) |
| |
318 | // The scope's text. |
| |
319 | point = begin |
| |
320 | let region: Range<Int> |
| |
321 | switch params.symbol(":scope") ?? "file" { |
| |
322 | case "nil", "file": |
| |
323 | region = 0..<length |
| |
324 | case "subtree": |
| |
325 | guard !beforeFirstHeading, let r = try? subtreeRange() else { throw UserError(message: "Before first headline") } |
| |
326 | region = r |
| |
327 | case let scope where scope.hasPrefix("tree"): |
| |
328 | guard !beforeFirstHeading else { throw UserError(message: "Before first headline") } |
| |
329 | try backToHeading() |
| |
330 | let limit = Int(scope.dropFirst(4)) |
| |
331 | while let level = currentLevel, level > (limit ?? 1), upHeadingSafe() != nil { |
| |
332 | if let limit, outlineLevel <= limit { break } |
| |
333 | } |
| |
334 | region = try subtreeRange() |
| |
335 | case let scope: |
| |
336 | throw UserError(message: "Clock tables over \(scope) aren't supported yet") |
| |
337 | } |
| |
338 | let table = try ClockTable.write(self, params: params, region: region, content: content) |
| |
339 | // Replace the old contents. |
| |
340 | let contentEnd = end.lowerBound |
| |
341 | deleteRegion(min(deleteFrom, contentEnd), contentEnd) |
| |
342 | point = min(deleteFrom, length) |
| |
343 | insert(table + "\n") |
| |
344 | // Indent like the #+BEGIN line, as `org-update-dblock` does. |
| |
345 | if indentation > 0 { |
| |
346 | let pad = String(repeating: " ", count: indentation) |
| |
347 | point = deleteFrom |
| |
348 | while lookingAt("^[ \\t]*#\\+(?:END|end):?") == nil { |
| |
349 | insert(pad) |
| |
350 | if forwardLine(1) != 0 { break } |
| |
351 | } |
| |
352 | if let blank = lookingAt("[ \\t]+") { deleteRegion(blank[0]!.lowerBound, blank[0]!.upperBound) } |
| |
353 | insert(pad) |
| |
354 | } |
| |
355 | point = deleteFrom |
| |
356 | if let bar = searchForward("^[ \\t]*\\|", caseInsensitive: false) { |
| |
357 | point = bar[0]!.upperBound |
| |
358 | tableAlign() |
| |
359 | } |
| |
360 | point = begin |
| |
361 | } |
| |
362 | } |
| |
363 | |
| |
364 | /// C-c C-x C-u, and C-c C-c on a dynamic block's #+BEGIN line: `org-update-dblock` (clock |
| |
365 | /// tables). |
| |
366 | public struct UpdateDynamicBlock: OrgCommand { |
| |
367 | public init() {} |
| |
368 | public var id: String { "org.dblock.update" } |
| |
369 | public var title: String { "Update Dynamic Block" } |
| |
370 | public func applies(in context: EditContext) -> Bool { |
| |
371 | EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).dynamicBlockStart() != nil |
| |
372 | } |
| |
373 | public func run(in context: EditContext) -> CommandStep { |
| |
374 | guard let begin = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).dynamicBlockStart() else { |
| |
375 | return .failed("Not at a dynamic block") |
| |
376 | } |
| |
377 | return commitBuffer(context) { buffer in |
| |
378 | try buffer.updateDynamicBlock(at: begin) |
| |
379 | buffer.point = begin |
| |
380 | } |
| |
381 | } |
| |
382 | } |
| |
383 | |
| |
384 | /// C-c C-x C-r: `org-clock-report`. Updates the clock table at point, or inserts one for the |
| |
385 | /// subtree (the file before the first heading). |
| |
386 | public struct ClockReportTable: OrgCommand { |
| |
387 | public init() {} |
| |
388 | public var id: String { "org.clock.report" } |
| |
389 | public var title: String { "Insert or Update Clock Table" } |
| |
390 | public func applies(in context: EditContext) -> Bool { true } |
| |
391 | public func run(in context: EditContext) -> CommandStep { |
| |
392 | commitBuffer(context) { buffer in |
| |
393 | if let begin = buffer.dynamicBlockStart(), buffer.saveExcursion({ buffer.point = begin; return buffer.lookingAt("^[ \\t]*#\\+(?:BEGIN|begin):[ \\t]+clocktable") != nil }) { |
| |
394 | try buffer.updateDynamicBlock(at: begin) |
| |
395 | buffer.point = begin |
| |
396 | return |
| |
397 | } |
| |
398 | let scope = buffer.beforeFirstHeading ? "file" : "subtree" |
| |
399 | // `org-create-dblock`. |
| |
400 | if buffer.substring(buffer.lineStart(buffer.point), buffer.lineEnd(buffer.point)).contains(where: { !$0.isWhitespace }) { |
| |
401 | buffer.point = buffer.lineEnd(buffer.point) |
| |
402 | buffer.insert("\n") |
| |
403 | } |
| |
404 | let column = buffer.currentColumn |
| |
405 | let begin = buffer.lineStart(buffer.point) |
| |
406 | buffer.insert("#+BEGIN: clocktable :scope \(scope) :maxlevel 2\n\n" + String(repeating: " ", count: column) + "#+END:\n") |
| |
407 | try buffer.updateDynamicBlock(at: begin) |
| |
408 | buffer.point = begin |
| |
409 | } |
| |
410 | } |
| |
411 | } |
| |
412 | |
| |
413 | /// C-c C-x e: `org-set-effort`. Offers the entry's `Effort_ALL` values; sets `Effort`. |
| |
414 | public struct SetEffort: OrgCommand { |
| |
415 | public init() {} |
| |
416 | public var id: String { "org.effort.set" } |
| |
417 | public var title: String { "Set Effort" } |
| |
418 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
| |
419 | public func run(in context: EditContext) -> CommandStep { |
| |
420 | guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") } |
| |
421 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) |
| |
422 | guard let answer = context.answers["effort"] else { |
| |
423 | let current = buffer.saveExcursion { () -> String in |
| |
424 | _ = try? buffer.backToHeading() |
| |
425 | let end = buffer.saveExcursion { () -> Int in Capture.nextHeading(buffer); return buffer.point } |
| |
426 | guard let line = buffer.searchForward("^[ \\t]*:Effort:[ \\t]+(.*?)[ \\t]*$", bound: end, caseInsensitive: true), let value = line[1] else { return "" } |
| |
427 | return buffer.substring(value.lowerBound, value.upperBound) |
| |
428 | } |
| |
429 | let allowed = (buffer.inheritedProperty("Effort_ALL")?.value ?? Self.fileProperty(context.text, "Effort_ALL") ?? "") |
| |
430 | .split(whereSeparator: \.isWhitespace).map(String.init) |
| |
431 | return .prompt(Prompt(key: "effort", message: "Effort:", initial: current, choices: allowed, requireMatch: false)) |
| |
432 | } |
| |
433 | let value = answer.trimmingCharacters(in: .whitespaces) |
| |
434 | return commitBuffer(context) { buffer in |
| |
435 | buffer.saveExcursion { |
| |
436 | _ = try? buffer.backToHeading() |
| |
437 | buffer.entryPut("Effort", value) |
| |
438 | } |
| |
439 | } |
| |
440 | } |
| |
441 | |
| |
442 | /// `#+PROPERTY: NAME value`. |
| |
443 | static func fileProperty(_ text: String, _ name: String) -> String? { |
| |
444 | text.firstMatch(of: try! Regex("(?im)^[ \\t]*#\\+PROPERTY:[ \\t]+" + NSRegularExpression.escapedPattern(for: name) + "[ \\t]+(.*?)[ \\t]*$")) |
| |
445 | .map { String($0.output[1].substring!) } |
| |
446 | } |
| |
447 | } |