Sources/OrgCore/Commands/Clock.swift
208 lines · 9562 bytes
1import Foundation
2
3// Clocking (org-clock.el, Org 9.8.7) with `org-clock-into-drawer` t (a LOGBOOK drawer),
4// `org-log-states-order-reversed` t (newest first), no rounding, no resume, and zero-time
5// clocks kept.
6
7extension EmacsBuffer {
8 static let clockDrawer = "LOGBOOK"
9 static let openClockPattern = "^[ \\t]*CLOCK: \\[([0-9]{4}-[0-9]{2}-[0-9]{2} *[^\\]\\s]+ +[012][0-9]:[0-5][0-9])\\][ \\t]*$"
10
11 /// `org-clock-find-position` without `find-unclosed`.
12 func clockFindPosition() throws {
13 let beg = try backToHeading()
14 let end = saveExcursion { () -> Int in
15 _ = outlineNextHeading()
16 return point
17 }
18 let drawerPattern = "^[ \\t]*:" + Self.clockDrawer + ":[ \\t]*$"
19 point = beg
20 while let match = searchForward(drawerPattern, bound: end, caseInsensitive: false) {
21 if element(at: lineStart(match[0]!.lowerBound))?.kind == .drawer {
22 point = match[0]!.upperBound
23 forwardLine(1)
24 return
25 }
26 }
27 point = beg
28 var positions: [Int] = []
29 saveExcursion {
30 while let match = searchForward("^[ \\t]*CLOCK:", bound: end, caseInsensitive: false) {
31 if element(at: lineStart(match[0]!.lowerBound))?.kind == .clock { positions.insert(lineStart(match[0]!.lowerBound), at: 0) }
32 }
33 }
34 if positions.isEmpty {
35 endOfMetaData()
36 if !bolp { insertBeforeMarkers("\n") }
37 insertBeforeMarkers(":" + Self.clockDrawer + ":\n:END:\n")
38 forwardLine(-1)
39 return
40 }
41 // Clocks outside a drawer move into a new one.
42 endOfMetaData()
43 let start = point
44 var moved: [String] = []
45 for position in positions {
46 let lineStartOffset = position
47 let next = min(length, lineEnd(lineStartOffset) + 1)
48 var from = lineStartOffset
49 while from > 0, let c = character(at: from - 1), c == 32 || c == 9 || c == 10 || c == 13 { from -= 1 }
50 from = min(length, lineEnd(from) + 1)
51 if from > lineStartOffset { from = lineStartOffset }
52 let text = substring(from, next).trimmingCharacters(in: .whitespacesAndNewlines)
53 deleteRegion(from, next)
54 moved.append(text)
55 }
56 point = min(start, length)
57 insert(moved.joined(separator: "\n") + "\n:END:\n")
58 let endMarker = marker(point)
59 defer { release(endMarker) }
60 point = start
61 saveExcursion { insertBeforeMarkers(":" + Self.clockDrawer + ":\n") }
62 forwardLine(1)
63 }
64
65 /// `org-clock-in` on the heading at point. Returns the clock's start stamp.
66 @discardableResult
67 func clockIn() throws -> String {
68 let start = point
69 let pointMarker = marker(start)
70 defer {
71 point = pointMarker.position
72 release(pointMarker)
73 }
74 if eobp, !atHeadingLine { point = lineStart(max(0, lineStart(point) - 1)) }
75 try clockFindPosition()
76 insertBeforeMarkers("\n")
77 point -= 1
78 if point > 0, saveExcursion({ () -> Int? in
79 point = lineStart(point) - 1
80 return point >= 0 ? inItem() : nil
81 }) != nil {
82 beginningOfLine()
83 let current = substring(point, lineEnd(point)).prefix { $0 == " " || $0 == "\t" }.count
84 indentLineTo(max(0, current - 2))
85 }
86 insert("CLOCK: ")
87 let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now)
88 let stamp = insertTimestamp(components, withTime: true, inactive: true)
89 // `org-indent-line` puts lines in a drawer at column 0 without adapted indentation.
90 let lineText = substring(lineStart(point), lineEnd(point))
91 let indent = lineText.prefix { $0 == " " || $0 == "\t" }.count
92 if indent > 0 { deleteRegion(lineStart(point), lineStart(point) + indent) }
93 return String(stamp.dropFirst().dropLast())
94 }
95
96 var atHeadingLine: Bool { lookingAtLine("^\\*+ ") }
97
98 /// The running clock's line: `CLOCK: [start]` with nothing after it.
99 func runningClockLine(_ start: String) -> Int? {
100 let pattern = "^[ \\t]*CLOCK: \\[" + NSRegularExpression.escapedPattern(for: start) + "\\][ \\t]*$"
101 return saveExcursion {
102 point = 0
103 return searchForward(pattern, caseInsensitive: false).map { lineStart($0[0]!.lowerBound) }
104 }
105 }
106
107 /// `org-clock-out` for the clock that started at `start`.
108 func clockOut(_ start: String) throws {
109 guard let line = runningClockLine(start) else { throw UserError(message: "Clock start time is gone") }
110 point = line
111 guard let match = looking("[ \\t]*CLOCK: \\[([^\\]]+)\\]", caseInsensitive: false) else { throw UserError(message: "Clock start time is gone") }
112 point = match[0]!.upperBound
113 deleteRegion(point, lineEnd(point))
114 insert("--")
115 let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now)
116 let end = insertTimestamp(components, withTime: true, inactive: true)
117 guard let startTime = Self.parseTimeString(start), let endTime = Self.parseTimeString(String(end.dropFirst().dropLast())),
118 let a = calendar.date(from: startTime), let b = calendar.date(from: endTime) else { throw UserError(message: "Clock start time is gone") }
119 let seconds = Int(b.timeIntervalSince(a))
120 let hours = Int((Double(seconds) / 3600).rounded(.down))
121 let minutes = Int((Double(seconds - hours * 3600) / 60).rounded(.down))
122 insert(" => " + String(format: "%2d:%02d", hours, minutes))
123 removeEmptyClockDrawer()
124 }
125
126 /// `org-clock-cancel`: the running clock's line goes, and the drawer if it empties.
127 func clockCancel(_ start: String) throws {
128 guard let line = runningClockLine(start) else { throw UserError(message: "No active clock") }
129 point = lineEnd(line)
130 let from = max(0, line - 1)
131 deleteRegion(from, lineEnd(line))
132 point = from
133 removeEmptyDrawer(at: point)
134 }
135
136 /// `org-clock-remove-empty-clock-drawer` for the subtree at point.
137 func removeEmptyClockDrawer() {
138 saveExcursion {
139 guard let heading = try? backToHeading() else { return }
140 let end = saveExcursion { () -> Int in
141 point = heading
142 return endOfSubtree()
143 }
144 point = heading
145 let pattern = "(?i)^[ \\t]*:" + Self.clockDrawer + ":[ \\t]*$"
146 while let match = searchForward(pattern, bound: end, caseInsensitive: true) {
147 removeEmptyDrawer(at: match[0]!.upperBound)
148 }
149 }
150 }
151
152 /// `org-remove-empty-drawer-at`.
153 func removeEmptyDrawer(at position: Int) {
154 let lineStartOffset = lineStart(min(position, length))
155 let line = substring(lineStartOffset, lineEnd(lineStartOffset))
156 guard line.trimmingCharacters(in: .whitespaces).range(of: "^:[\\w-]+:$", options: .regularExpression) != nil,
157 line.trimmingCharacters(in: .whitespaces).uppercased() != ":END:" else { return }
158 let next = min(length, lineEnd(lineStartOffset) + 1)
159 let following = substring(next, lineEnd(next))
160 guard following.trimmingCharacters(in: .whitespaces).uppercased() == ":END:" else { return }
161 deleteRegion(lineStartOffset, min(length, lineEnd(next) + 1))
162 }
163}
164
165/// C-c C-x C-i: `org-clock-in` at the heading at the caret.
166public struct ClockIn: OrgCommand {
167 public init() {}
168 public var id: String { "org.clock.in" }
169 public var title: String { "Clock In" }
170 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
171 public func run(in context: EditContext) -> CommandStep {
172 guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
173 return commitBuffer(context) { try $0.clockIn() }
174 }
175
176 /// The start of the clock `ClockIn` left running in the entry at `offset`.
177 public static func runningStart(in text: String, near offset: Int) -> String? {
178 let buffer = EmacsBuffer(text, point: offset)
179 guard let heading = try? buffer.backToHeading() else { return nil }
180 let end = buffer.saveExcursion { () -> Int in
181 _ = buffer.outlineNextHeading()
182 return buffer.point
183 }
184 buffer.point = heading
185 guard let match = buffer.searchForward(EmacsBuffer.openClockPattern, bound: end, caseInsensitive: false) else { return nil }
186 return buffer.substring(match[1]!.lowerBound, match[1]!.upperBound)
187 }
188}
189
190/// C-c C-x C-o and C-c C-x C-q for the clock that started at `start`.
191public struct ClockOut: OrgCommand {
192 public let start: String
193 public let cancel: Bool
194 public init(start: String, cancel: Bool = false) {
195 self.start = start
196 self.cancel = cancel
197 }
198 public var id: String { cancel ? "org.clock.cancel" : "org.clock.out" }
199 public var title: String { cancel ? "Cancel Clock" : "Clock Out" }
200 public func applies(in context: EditContext) -> Bool { true }
201 public func run(in context: EditContext) -> CommandStep {
202 commitBuffer(context) { buffer in
203 try buffer.saveExcursion {
204 if cancel { try buffer.clockCancel(start) } else { try buffer.clockOut(start) }
205 }
206 }
207 }
208}