krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Commands/ClockResolution.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgCore/Commands/ClockResolution.swift history · blame · raw

184 lines · 8506 bytes

  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}