Clock tables and effort !80

merged merged by cmc on 2026-10-06 03:27 UTC · krz/orgstar:clocktable into main

6 files changed, +568 −1

Layout: unified · split

Sources/OrgCore/Commands/ClockTable.swift added +447
@@ -0,0 +1,447 @@
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) 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
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 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).
366public 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).
386public 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`.
414public 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}
Sources/OrgCore/Commands/Command.swift +1
@@ -216,6 +216,7 @@ public enum Commands {
216 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 216 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
217 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), 217 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
218 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), 218 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
219 UpdateDynamicBlock(), ClockReportTable(), SetEffort(),
219 ] 220 ]
220 221
221 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } 222 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Keymap/Keymap.swift +5 −1
@@ -130,7 +130,7 @@ public struct Keymap: Sendable, Equatable {
130 130
131/// Context names a binding's `when` can use. 131/// Context names a binding's `when` can use.
132public enum KeyContext { 132public enum KeyContext {
133 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line"] 133 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line", "dblock"]
134 134
135 public static func holds(_ name: String, in context: EditContext) -> Bool { 135 public static func holds(_ name: String, in context: EditContext) -> Bool {
136 switch name { 136 switch name {
@@ -143,6 +143,10 @@ public enum KeyContext {
143 case "src": return enclosing(.block, context).map(DocumentModel.isSrcBlock) ?? false 143 case "src": return enclosing(.block, context).map(DocumentModel.isSrcBlock) ?? false
144 // The first or last line of a drawer or block, where TAB folds it. 144 // The first or last line of a drawer or block, where TAB folds it.
145 case "fold-line": return Wrappers.toggleable(at: context.caret, in: context.tree) != nil 145 case "fold-line": return Wrappers.toggleable(at: context.caret, in: context.tree) != nil
146 // The #+BEGIN line of a dynamic block, where C-c C-c updates it.
147 case "dblock":
148 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
149 return buffer.lookingAtLine(EmacsBuffer.dblockStart)
146 default: return false 150 default: return false
147 } 151 }
148 } 152 }
Sources/OrgCore/Keymap/Presets.swift +17
@@ -326,6 +326,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
326 command = "app.clock.report" 326 command = "app.clock.report"
327 mode = "normal" 327 mode = "normal"
328 [[bind]] 328 [[bind]]
329 keys = "SPC m c E"
330 command = "org.effort.set"
331 mode = "normal"
332 [[bind]]
329 keys = "SPC m p u" 333 keys = "SPC m p u"
330 command = "org.priority.up" 334 command = "org.priority.up"
331 mode = "normal" 335 mode = "normal"
@@ -631,6 +635,19 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
631 keys = "C-c ^" 635 keys = "C-c ^"
632 command = "org.sort" 636 command = "org.sort"
633 [[bind]] 637 [[bind]]
638 keys = "C-c C-x C-r"
639 command = "org.clock.report"
640 [[bind]]
641 keys = "C-c C-x C-u"
642 command = "org.dblock.update"
643 [[bind]]
644 keys = "C-c C-x e"
645 command = "org.effort.set"
646 [[bind]]
647 keys = "C-c C-c"
648 command = "org.dblock.update"
649 when = "dblock"
650 [[bind]]
634 keys = "C-c C-," 651 keys = "C-c C-,"
635 command = "org.structure-template" 652 command = "org.structure-template"
636 [[bind]] 653 [[bind]]
Tests/OrgCoreTests/ClockTableTests.swift added +88
@@ -0,0 +1,88 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ClockTableTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let body = """
9 * Project Alpha :work:
10 :PROPERTIES:
11 :Effort: 3:00
12 :END:
13 ** TODO Design the [[https://x.org][interface]] [1/2]
14 :LOGBOOK:
15 CLOCK: [2026-09-07 Mon 09:00]--[2026-09-07 Mon 11:30] => 2:30
16 CLOCK: [2026-09-09 Wed 23:00]--[2026-09-10 Thu 01:00] => 2:00
17 :END:
18 *** A very long heading title that goes on and on beyond the narrowing width
19 CLOCK: [2026-09-10 Thu 08:00]--[2026-09-10 Thu 08:45] => 0:45
20 ** Write code
21 CLOCK: [2026-08-30 Sun 10:00]--[2026-08-30 Sun 12:00] => 2:00
22 CLOCK: => 1:15
23 * COMMENT Beta
24 CLOCK: [2026-09-08 Tue 14:00]--[2026-09-09 Wed 16:00] => 26:00
25 * Untouched
26
27 """
28
29 static let params = [
30 ":scope file :maxlevel 2", ":maxlevel 3 :emphasize t", ":maxlevel 1", ":block thisweek", ":block lastweek",
31 ":block today :maxlevel 3", ":block 2026-09", ":tstart \"<2026-09-08>\" :tend \"<2026-09-10>\"",
32 ":link t :maxlevel 3", ":narrow 20! :maxlevel 3", ":tags t :level t :maxlevel 3", ":properties (\"Effort\") :formula %",
33 ":indent nil :maxlevel 3", ":block yesterday", ":block 2026-W36 :maxlevel 3", ":fileskip0 t :block 2025",
34 ]
35
36 static func ours(_ text: String, caret: Int, command: any OrgCommand, answers: [String: String] = [:]) -> String {
37 var utc = Calendar(identifier: .gregorian)
38 utc.timeZone = TimeZone(identifier: "UTC")!
39 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
40 now: Date(timeIntervalSince1970: 1_789_000_000), calendar: utc, answers: answers)
41 switch command.run(in: context) {
42 case .commit(let result):
43 var new = text
44 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
45 return new
46 case .failed(let message): return "failed: \(message)"
47 default: return "other"
48 }
49 }
50
51 @Test(.enabled(if: oracle))
52 func updatesLikeOrgUpdateDblock() throws {
53 var cases: [EmacsOracle.Case] = []
54 var ours: [String] = []
55 for param in Self.params {
56 let text = "#+BEGIN: clocktable \(param)\nold contents\n#+END:\n" + Self.body
57 cases.append(EmacsOracle.Case(text: text, point: 1, form: frozenClock(1_789_000_000, "(org-update-dblock)")))
58 ours.append(Self.ours(text, caret: 0, command: UpdateDynamicBlock()))
59 }
60 for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() {
61 #expect(mine == theirs.text, "\(Self.params[i])\nours:\n\(mine)\nemacs:\n\(theirs.text) \(theirs.error)")
62 }
63 }
64
65 @Test(.enabled(if: oracle))
66 func reportsLikeOrgClockReport() throws {
67 let text = "Intro\n" + Self.body
68 let ns = text as NSString
69 let points = [ns.range(of: "Intro").location + 2, ns.range(of: "** Write").location + 3, ns.range(of: "CLOCK: [2026-09-07").location]
70 let cases = points.map { EmacsOracle.Case(text: text, point: EmacsOracle.point($0, in: text), form: frozenClock(1_789_000_000, "(org-clock-report)")) }
71 let ours = points.map { Self.ours(text, caret: $0, command: ClockReportTable()) }
72 for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() {
73 #expect(mine == theirs.text, "at \(points[i])\nours:\n\(mine)\nemacs:\n\(theirs.text) \(theirs.error)")
74 }
75 }
76
77 @Test(.enabled(if: oracle))
78 func setsEffortLikeOrgSetEffort() throws {
79 let text = "* Task\n* Other\n:PROPERTIES:\n:Effort: 1:00\n:END:\n"
80 let cases = [(0, "0:30"), (8, "2:00")].map { EmacsOracle.Case(text: text, point: $0.0 + 1, form: "(org-set-effort nil \"\($0.1)\")") }
81 let ours = [(0, "0:30"), (8, "2:00")].map { Self.ours(text, caret: $0.0, command: SetEffort(), answers: ["effort": $0.1]) }
82 #expect(ours == (try EmacsOracle.run(cases)).map(\.text))
83 }
84
85 @Test func durations() {
86 #expect(ClockTable.duration(0) == "0:00" && ClockTable.duration(95) == "1:35" && ClockTable.duration(1560) == "1d 2:00")
87 }
88}
Tests/OrgCoreTests/KeymapTests.swift +10
@@ -180,3 +180,13 @@ struct ToggleKeyTests {
180 #expect(first(minus, plain) == "org.item.toggle") 180 #expect(first(minus, plain) == "org.item.toggle")
181 } 181 }
182} 182}
183
184struct DynamicBlockKeyTests {
185 @Test func ctrlCCtrlCUpdatesOnTheBeginLine() {
186 let keymap = KeymapPreset.emacs.keymap
187 let text = "#+BEGIN: clocktable :maxlevel 2\n#+END:\n* a\n"
188 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [3..<3])
189 let found = keymap.candidates(for: KeySequence.parse("C-c C-c")!).first { $0.when.map { KeyContext.holds($0, in: context) } ?? true }
190 #expect(found?.command == "org.dblock.update")
191 }
192}