Sources/OrgCore/Commands/ClockResolution.swift
184 lines · 8506 bytes
14 symbols in this file
1import Foundation
2
3// Resolving an open clock after idle time or a dangling one (`org-clock-resolve`,
4// `org-clock-resolve-clock`, Org 9.8.7) with `org-clock-resolve-expert` nil. The answer
5// becomes steps on the CLOCK line, which the app carries out with `ClockOut` and `ClockIn`.
6
7public enum ClockResolution {
8 /// The keys `org-clock-resolve` reads.
9 public static let keys: [Character] = ["j", "J", "k", "K", "t", "T", "g", "G", "S", "s", "C", "i", "q"]
10
11 /// The question's suffix, after the clock's description.
12 public static let keyHint = " [jkKtTgGSscCiq]? "
13
14 /// The keys with what they do, from Org's *Org Clock* help.
15 public static let menu = FastSelection(items: [
16 .option(key: "i", name: "Ignore this question; the same as keeping all the idle time"), .newline,
17 .option(key: "q", name: "Ignore this question; the same as keeping all the idle time"), .newline,
18 .option(key: "k", name: "Keep X minutes of the idle time (default is all)"), .newline,
19 .option(key: "K", name: "Keep X minutes of the idle time, then clock out"), .newline,
20 .option(key: "t", name: "Like k, but ask for the time you got distracted"), .newline,
21 .option(key: "T", name: "Like t, then clock out"), .newline,
22 .option(key: "g", name: "Got back X minutes ago: clock out at the start of idling, in again X minutes ago"), .newline,
23 .option(key: "G", name: "Like g, but stay clocked out"), .newline,
24 .option(key: "s", name: "Subtract the idle time from the current clock"), .newline,
25 .option(key: "S", name: "Subtract the idle time and clock out"), .newline,
26 .option(key: "C", name: "Cancel the open clock altogether, as though you never clocked in"), .newline,
27 .option(key: "j", name: "Jump to the clock to make manual adjustments"), .newline,
28 .option(key: "J", name: "Clock out now, then jump to the clock"), .newline,
29 ], multiple: false)
30
31 /// What a key asks for next, if anything.
32 public enum Input: Equatable, Sendable {
33 /// `read-number` with the idle minutes as the default.
34 case minutes(prompt: String)
35 /// `org-read-date` with a time: when the user got distracted.
36 case date
37 }
38
39 public static func input(for key: Character) -> Input? {
40 switch key {
41 case "k", "K": .minutes(prompt: "Keep how many minutes")
42 case "g", "G": .minutes(prompt: "Got back how many minutes ago")
43 case "t", "T": .date
44 default: nil
45 }
46 }
47
48 /// The default for `k` and `g`: whole minutes since `lastValid`.
49 public static func defaultMinutes(lastValid: Date, now: Date) -> Int {
50 Int((now.timeIntervalSince(lastValid).rounded(.down) / 60).rounded(.down))
51 }
52
53 /// `t`'s answer as minutes kept after `lastValid`.
54 public static func minutes(from lastValid: Date, to date: Date) -> Int {
55 Int((date.timeIntervalSince(lastValid) / 60).rounded(.down))
56 }
57
58 /// Org's error, for the echo area.
59 public struct Failure: Error, Equatable {
60 public let message: String
61 public init(message: String) { self.message = message }
62 }
63
64 public enum Step: Equatable, Sendable {
65 /// `org-clock-cancel` on the open clock.
66 case cancel
67 /// The open clock closes at this time.
68 case clockOut(Date)
69 /// A new clock in the same entry, starting at this time.
70 case clockIn(Date)
71 /// The open clock becomes the running clock (`org-clock-in` with resume).
72 case resume
73 }
74
75 public struct Plan: Equatable, Sendable {
76 public var steps: [Step] = []
77 /// `org-clock-leftover-time` is set to this when `setsLeftover`.
78 public var leftover: Date?
79 public var setsLeftover = false
80 /// Show the clock afterwards (`j`, `J`).
81 public var jump = false
82 }
83
84 /// What `key` does to a clock that started at `start`, idle since `lastValid`.
85 /// `minutes` is the answer to `input(for:)` (for `t`, `minutes(from:to:)`). `active`: the
86 /// clock is the running one. `clockingIn`: resolving before a clock-in
87 /// (`org-clock-clocking-in`), which never restarts or resumes. `defaultMinutes` is the
88 /// default the minutes prompt offered, when there was one.
89 public static func plan(
90 key: Character, minutes: Int?, start: Date, lastValid: Date, now: Date, active: Bool, clockingIn: Bool, defaultMinutes: Int? = nil
91 ) throws -> Plan {
92 var plan = Plan()
93 if key == "j" || key == "J" {
94 if key == "J" { plan.steps = try resolve(.now, clockOutTime: nil, close: true, restart: false, active: active, clockingIn: clockingIn, now: now).steps }
95 plan.jump = true
96 return plan
97 }
98 guard "kKgGsSCtT".contains(key) else { return plan }
99 let defaultMinutes = defaultMinutes ?? Self.defaultMinutes(lastValid: lastValid, now: now)
100 let keep = "kKtT".contains(key) ? minutes : nil
101 let gotback = "gG".contains(key) ? minutes : nil
102 let subtract = key == "s" || key == "S"
103 // `barely-started-p`: less than 45 seconds on the clock before idling.
104 let startOver = subtract && lastValid.timeIntervalSince(start) < 45
105 let target: Target
106 if key == "C" || startOver {
107 target = .cancel
108 } else if subtract || gotback == 0 {
109 target = .time(lastValid)
110 } else if keep == defaultMinutes || gotback == defaultMinutes {
111 target = .now
112 } else if let keep {
113 target = .time(lastValid.addingTimeInterval(Double(keep * 60)))
114 } else if let gotback {
115 target = .time(now.addingTimeInterval(Double(-gotback * 60)))
116 } else {
117 throw Failure(message: "Unexpected, please report this as a bug")
118 }
119 return try resolve(
120 target, clockOutTime: gotback == nil ? nil : lastValid, close: "KGST".contains(key),
121 restart: startOver && !"KGSC".contains(key), active: active, clockingIn: clockingIn, now: now
122 )
123 }
124
125 enum Target {
126 case cancel, now
127 case time(Date)
128 }
129
130 /// `org-clock-resolve-clock`.
131 static func resolve(_ target: Target, clockOutTime: Date?, close: Bool, restart: Bool, active: Bool, clockingIn: Bool, now: Date) throws -> Plan {
132 var plan = Plan()
133 switch target {
134 case .cancel:
135 plan.steps = [.cancel]
136 if restart, !clockingIn { plan.steps.append(.clockIn(now)) }
137 case .now:
138 if restart { throw Failure(message: "RESTART is not valid here") }
139 if close || clockingIn {
140 plan.steps = [.clockOut(now)]
141 } else if !active {
142 plan.steps = [.resume]
143 }
144 case .time(let time):
145 if now < time { throw Failure(message: "RESOLVE-TO must refer to a time in the past") }
146 if restart { throw Failure(message: "RESTART is not valid here") }
147 plan.steps = [.clockOut(clockOutTime ?? time)]
148 if clockingIn {
149 } else if close {
150 plan.setsLeftover = true
151 plan.leftover = clockOutTime == nil ? time : nil
152 } else {
153 plan.steps.append(.clockIn(clockOutTime == nil ? now : time))
154 }
155 }
156 return plan
157 }
158
159 /// The time of a clock stamp (`2026-10-08 Thu 09:00`).
160 public static func date(of stamp: String, calendar: Calendar = .current) -> Date? {
161 EmacsBuffer.parseTimeString(stamp).flatMap { calendar.date(from: $0) }
162 }
163
164 /// `org-find-open-clocks`: each open `CLOCK:` line in `text` (in a clock element), with
165 /// its start stamp and where the line starts.
166 public static func openClocks(in text: String) -> [(start: String, offset: Int)] {
167 guard text.contains("CLOCK:") else { return [] }
168 var result: [(String, Int)] = []
169 func walk(_ node: SyntaxNode) {
170 for child in node.children {
171 if child.kind == .clock {
172 let line = child.text.trimmingCharacters(in: .newlines)
173 if let match = line.firstMatch(of: /^[ \t]*CLOCK: \[([^\]]+)\][ \t]*$/) {
174 result.append((String(match.1), child.range.lowerBound))
175 }
176 } else {
177 walk(child)
178 }
179 }
180 }
181 walk(OrgParser.parse(text).root)
182 return result
183 }
184}