krz/orgstar

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

Sources/OrgApp/ClockModel.swift

main
orgstar/Sources/OrgApp/ClockModel.swift history · blame · raw

509 lines · 25471 bytes

35 symbols in this file
  1import Foundation
  2import Observation
  3import OrgCore
  4
  5/// The running clock and the clock history, kept across launches as Org keeps them with
  6/// `org-clock-persist`, and the commands that start, stop and resolve clocks.
  7@MainActor
  8@Observable
  9public final class ClockModel {
 10    public struct Running: Codable, Equatable, Sendable {
 11        public let path: String
 12        /// The stamp in `CLOCK: [start]`.
 13        public let start: String
 14        public let heading: String
 15        public let startedAt: Date
 16    }
 17
 18    /// An open `CLOCK:` line: the running clock, or one left dangling.
 19    public struct OpenClock: Equatable, Sendable {
 20        public let path: String
 21        public let start: String
 22        public let startedAt: Date
 23        public let heading: String
 24    }
 25
 26    /// What a question about a clock led to: messages, and where to look (`j`).
 27    public struct Outcome {
 28        public var messages: [String] = []
 29        public var jump: (path: String, offset: Int)?
 30    }
 31
 32    /// Asks a question in the echo area or a sheet; nil when it is cancelled.
 33    public typealias Ask = @MainActor (Prompt) async -> String?
 34
 35    private struct Saved: Codable {
 36        var running: Running?
 37        var history: [ClockTask]
 38    }
 39
 40    public private(set) var running: Running?
 41    /// `org-clock-history`, most recent first.
 42    public private(set) var history: [ClockTask] = []
 43    /// `org-clock-default-task`, for the session.
 44    public private(set) var defaultTask: ClockTask?
 45    /// `org-clock-interrupted-task`.
 46    public private(set) var interruptedTask: ClockTask?
 47    /// `org-clock-leftover-time`: where a clock resolved with `K`, `S` or `T` stopped.
 48    public private(set) var leftover: Date?
 49    /// A clock question is open (`org-clock-resolving-clocks`).
 50    public private(set) var isResolving = false
 51    private let file: URL
 52    private let defaults: UserDefaults
 53
 54    public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json"), defaults: UserDefaults = .standard) {
 55        self.file = file
 56        self.defaults = defaults
 57        guard let data = try? Data(contentsOf: file) else { return }
 58        if let running = try? JSONDecoder().decode(Running.self, from: data) {
 59            self.running = running
 60        } else if let saved = try? JSONDecoder().decode(Saved.self, from: data) {
 61            running = saved.running
 62            history = saved.history
 63        }
 64    }
 65
 66    private func save() {
 67        try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
 68        if running != nil || !history.isEmpty, let data = try? JSONEncoder().encode(Saved(running: running, history: history)) {
 69            try? data.write(to: file, options: .atomic)
 70        } else {
 71            try? FileManager.default.removeItem(at: file)
 72        }
 73    }
 74
 75    // MARK: - Clocking in and out
 76
 77    /// `org-clock-in` at the heading under the caret, clocking out a running clock first.
 78    public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String {
 79        guard let path = session.path, session.document != nil else { return "No file is open." }
 80        return clockIn(session, path: path, at: session.caret, now: now)
 81    }
 82
 83    /// `org-clock-in` at the heading containing `offset` in `path`.
 84    public func clockIn(_ session: DocumentSession, path: String, at offset: Int, now: Date = Date()) -> String {
 85        guard let text = Self.text(path, session) else { return "Can't read \(path)" }
 86        let headings = DocumentModel(tree: OrgParser.parse(text)).headings
 87        guard let index = headings.lastIndex(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" }
 88        let entry = headings[index]
 89        var offset = offset
 90        var heading = entry.headingRange.lowerBound
 91        if let running {
 92            if isRunning(path: path, heading: heading, session) { return "Clock continues in \"\(running.heading)\"" }
 93            let interrupted = runningTask(session)
 94            let message = clockOut(session, now: now)
 95            if self.running != nil { return message }
 96            interruptedTask = interrupted
 97            // Clocking out may have moved the entry.
 98            let target = ClockTask(path: path, title: entry.title, ordinal: index, id: entry.id)
 99            guard let moved = Self.text(path, session).flatMap(target.locate) else { return "Can't find \(entry.title) after clocking out." }
100            offset += moved - heading
101            heading = moved
102        } else {
103            interruptedTask = nil
104        }
105        switch session.perform(ClockIn(), path: path, at: offset, expect: nil, now: now) {
106        case .commit:
107            guard let updated = Self.text(path, session), let start = ClockIn.runningStart(in: updated, near: heading) else {
108                return "The clock line wasn't found after clocking in."
109            }
110            running = Running(path: path, start: start, heading: entry.title, startedAt: now)
111            if let task = ClockTask.at(heading, in: updated, path: path) { push(task, session) }
112            save()
113            return "Clock starts at [\(start)]"
114        case .failed(let message):
115            return message
116        default:
117            return "Can't clock in here."
118        }
119    }
120
121    /// `org-clock-out`, or `org-clock-cancel` with `cancel`.
122    public func clockOut(_ session: DocumentSession, cancel: Bool = false, now: Date = Date()) -> String {
123        guard let clock = running else { return "No active clock" }
124        switch session.perform(ClockOut(start: clock.start, cancel: cancel), path: clock.path, at: 0, expect: nil, now: now) {
125        case .commit:
126            running = nil
127            save()
128            if cancel { return "Clock canceled" }
129            let minutes = Int(now.timeIntervalSince(clock.startedAt) / 60)
130            return String(format: "Clock stopped after %d:%02d", minutes / 60, minutes % 60)
131        case .failed(let message):
132            if message == "Clock start time is gone" || message == "No active clock" {
133                running = nil
134                save()
135            }
136            return message
137        default:
138            return "Can't clock out."
139        }
140    }
141
142    /// A template's `clock-in` once its entry is filed: clocked from when capture began and,
143    /// without `clock-keep`, clocked out now, the clock it interrupted resuming with
144    /// `clock-resume`. The messages, in order.
145    public func captured(
146        _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, opened: Date, now: Date = Date()
147    ) -> [String] {
148        guard options.clockIn else { return [] }
149        let previous = self.entry(session)
150        var messages = [clockIn(session, path: entry.path, at: entry.offset, now: opened)]
151        if !options.clockKeep {
152            messages.append(clockOut(session, now: now))
153            if options.clockResume, let previous {
154                messages.append(clockIn(session, path: previous.path, at: previous.offset, now: now))
155            }
156        }
157        return messages
158    }
159
160    static func text(_ path: String, _ session: DocumentSession) -> String? {
161        session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8))
162    }
163
164    /// The clocked entry's heading, for the `clock` capture target.
165    public func entry(_ session: DocumentSession) -> (path: String, offset: Int)? {
166        guard let line = location(session), let text = Self.text(line.path, session) else { return nil }
167        let heading = DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= line.offset }
168        return heading.map { (line.path, $0.headingRange.lowerBound) }
169    }
170
171    /// Where the running clock's line is, for `org-clock-goto`.
172    public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
173        guard let clock = running else { return nil }
174        return lineLocation(path: clock.path, start: clock.start, session)
175    }
176
177    private func lineLocation(path: String, start: String, _ session: DocumentSession) -> (path: String, offset: Int)? {
178        guard let text = Self.text(path, session) else { return nil }
179        let range = (text as NSString).range(of: "CLOCK: [\(start)]")
180        return range.location == NSNotFound ? (path, 0) : (path, range.location)
181    }
182
183    /// The running clock's entry.
184    private func runningTask(_ session: DocumentSession) -> ClockTask? {
185        guard let line = location(session), let text = Self.text(line.path, session) else { return nil }
186        return ClockTask.at(line.offset, in: text, path: line.path)
187    }
188
189    private func isRunning(path: String, heading: Int, _ session: DocumentSession) -> Bool {
190        guard running?.path == path, let current = entry(session) else { return false }
191        return current.offset == heading
192    }
193
194    // MARK: - History
195
196    private func push(_ task: ClockTask, _ session: DocumentSession) {
197        history = ClockHistory.push(task, onto: history, length: OrgPreferences.clockHistoryLength(defaults)) { Self.text($0, session) }
198    }
199
200    /// Where a remembered entry's heading is now.
201    public func locate(_ task: ClockTask, _ session: DocumentSession) -> (path: String, offset: Int)? {
202        Self.text(task.path, session).flatMap { task.locate(in: $0) }.map { (task.path, $0) }
203    }
204
205    /// `org-clock-mark-default-task` for the heading under the caret.
206    public func markDefault(_ session: DocumentSession) -> String {
207        guard let path = session.path, session.document != nil else { return "No file is open." }
208        return markDefault(session, path: path, at: session.caret)
209    }
210
211    /// `org-clock-mark-default-task` for the heading containing `offset` in `path`.
212    public func markDefault(_ session: DocumentSession, path: String, at offset: Int) -> String {
213        guard let text = Self.text(path, session) else { return "Can't read \(path)" }
214        guard let task = ClockTask.at(offset, in: text, path: path) else { return "Before first headline" }
215        defaultTask = task
216        return "Default clock task: \(task.title)"
217    }
218
219    /// `org-clock-select-task`'s entries, or nil with no recent clock.
220    public func selection(_ session: DocumentSession) -> [ClockHistory.Choice]? {
221        ClockHistory.selection(
222            defaultTask: defaultTask, interrupted: interruptedTask, current: running == nil ? nil : runningTask(session), history: history
223        ) { Self.text($0, session) }
224    }
225
226    /// `org-clock-select-task`: the chosen entry, nil when the question is left with `q`,
227    /// `x` or Escape, or an error message.
228    public func chooseTask(_ session: DocumentSession, prompt: String, ask: Ask) async -> Result<ClockTask?, ClockResolution.Failure> {
229        guard let choices = selection(session) else { return .failure(.init(message: "No recent clock")) }
230        guard let answer = await ask(Prompt(key: "clock-task", message: prompt, fast: ClockHistory.fastSelection(choices))),
231              let key = answer.first, key != "q", key != "x" else { return .success(nil) }
232        guard let choice = choices.first(where: { $0.key == key }) else { return .failure(.init(message: "Invalid task choice \(key)")) }
233        return .success(choice.task)
234    }
235
236    /// `org-clock-goto`: the running clock, else the most recent entry clocked.
237    public func goto(_ session: DocumentSession) -> (location: (path: String, offset: Int)?, message: String?) {
238        if let location = location(session) { return (location, nil) }
239        if let recent = history.first, let location = locate(recent, session) {
240            return (location, "No running clock, this is the most recently clocked task")
241        }
242        return (nil, "No active or recent clock task")
243    }
244
245    /// `org-clock-goto` with a prefix argument: an entry from the selection.
246    public func gotoRecent(_ session: DocumentSession, ask: Ask) async -> (location: (path: String, offset: Int)?, message: String?) {
247        switch await chooseTask(session, prompt: "Select task to go to: ", ask: ask) {
248        case .failure(let failure): return (nil, failure.message)
249        case .success(nil): return (nil, "No task selected")
250        case .success(let task?):
251            guard let location = locate(task, session) else { return (nil, "\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more") }
252            return (location, nil)
253        }
254    }
255
256    // MARK: - Clocking in with questions
257
258    /// Where an interactive clock-in goes.
259    public enum Target {
260        /// The heading under the caret (`org-clock-in`).
261        case caret
262        /// An entry chosen from the selection (`org-clock-in` with a prefix argument).
263        case select
264        /// The most recent entry (`org-clock-in-last`).
265        case last
266        /// A remembered entry.
267        case task(ClockTask)
268    }
269
270    /// `org-clock-in` as a command: dangling clocks in `paths` are resolved first when no
271    /// clock runs (`org-clock-auto-clock-resolution` `when-no-clock-is-running`), and a clock
272    /// stopped with `K`, `S` or `T` offers its time as the start.
273    public func clockIn(_ session: DocumentSession, target: Target, paths: [String], ask: Ask, now: () -> Date = Date.init) async -> Outcome {
274        var outcome = Outcome()
275        if case .last = target, history.isEmpty {
276            outcome.messages.append("No last clock")
277            return outcome
278        }
279        let wasRunning = running != nil
280        let stopped = leftover
281        if !wasRunning {
282            leftover = nil
283            for clock in openClocks(session, paths: paths) {
284                let minutes = Int(now().timeIntervalSince(clock.startedAt) / 60)
285                let result = await resolve(
286                    clock, description: "Dangling clock started \(minutes) mins ago", lastValid: clock.startedAt, clockingIn: true, session, ask: ask, now: now
287                )
288                outcome.messages += result.messages
289                if let jump = result.jump { outcome.jump = jump }
290            }
291        }
292        let location: (path: String, offset: Int)
293        switch target {
294        case .caret:
295            guard let path = session.path, session.document != nil else {
296                outcome.messages.append("No file is open.")
297                return outcome
298            }
299            location = (path, session.caret)
300        case .select:
301            switch await chooseTask(session, prompt: "Clock-in on task: ", ask: ask) {
302            case .failure(let failure):
303                outcome.messages.append(failure.message)
304                return outcome
305            case .success(nil):
306                outcome.messages.append("Abort")
307                return outcome
308            case .success(let task?):
309                guard let found = locate(task, session) else {
310                    outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
311                    return outcome
312                }
313                location = found
314            }
315        case .last, .task:
316            let task: ClockTask
317            if case .task(let chosen) = target { task = chosen } else { task = history[0] }
318            guard let found = locate(task, session) else {
319                outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
320                return outcome
321            }
322            location = found
323        }
324        var start = now()
325        if let stopped, !isRunning(path: location.path, heading: headingStart(location, session) ?? -1, session) {
326            let minutes = Int(start.timeIntervalSince(stopped) / 60)
327            let answer = await ask(Prompt(
328                key: "clock-leftover", message: "You stopped another clock \(minutes) mins ago; start this one from then? (y or n) ",
329                fast: FastSelection(items: [.option(key: "y", name: "yes"), .option(key: "n", name: "no")], multiple: false)
330            ))
331            if answer == "y" { start = stopped }
332        }
333        let message = clockIn(session, path: location.path, at: location.offset, now: start)
334        if case .last = target, !wasRunning, let running {
335            outcome.messages.append("Clocking back: \(running.heading) (in \((running.path as NSString).lastPathComponent))")
336        } else {
337            outcome.messages.append(message)
338        }
339        return outcome
340    }
341
342    private func headingStart(_ location: (path: String, offset: Int), _ session: DocumentSession) -> Int? {
343        guard let text = Self.text(location.path, session) else { return nil }
344        return DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= location.offset }?.headingRange.lowerBound
345    }
346
347    // MARK: - Resolving clocks
348
349    /// `org-find-open-clocks` over `paths` and the open org buffers.
350    public func openClocks(_ session: DocumentSession, paths: [String]) -> [OpenClock] {
351        // A file open under another spelling of its path is read from its buffer.
352        func resolved(_ path: String) -> String { URL(fileURLWithPath: path).resolvingSymlinksInPath().path }
353        var open: [String: String] = [:]
354        for document in session.openDocuments { open[resolved(document.path)] = document.path }
355        var seen = Set<String>()
356        let all = (session.openDocuments.map(\.path).filter { $0.hasSuffix(".org") } + paths.map { open[resolved($0)] ?? $0 })
357            .filter { seen.insert(resolved($0)).inserted }
358        var clocks: [OpenClock] = []
359        for path in all {
360            guard let text = Self.text(path, session), text.contains("CLOCK: [") else { continue }
361            let open = ClockResolution.openClocks(in: text)
362            guard !open.isEmpty else { continue }
363            let headings = DocumentModel(tree: OrgParser.parse(text)).headings
364            for clock in open {
365                guard let startedAt = ClockResolution.date(of: clock.start) else { continue }
366                let heading = headings.last { $0.headingRange.lowerBound <= clock.offset }?.title ?? ""
367                clocks.append(OpenClock(path: path, start: clock.start, startedAt: startedAt, heading: heading))
368            }
369        }
370        return clocks
371    }
372
373    private func isActive(_ clock: OpenClock) -> Bool {
374        running?.path == clock.path && running?.start == clock.start
375    }
376
377    /// `org-resolve-clocks`: every open clock in `paths`, the running one included.
378    public func resolveOpenClocks(_ session: DocumentSession, paths: [String], ask: Ask, now: () -> Date = Date.init) async -> Outcome {
379        var outcome = Outcome()
380        let clocks = openClocks(session, paths: paths)
381        guard !clocks.isEmpty else {
382            outcome.messages.append("No open clocks")
383            return outcome
384        }
385        for clock in clocks {
386            let minutes = Int(now().timeIntervalSince(clock.startedAt) / 60)
387            let result = await resolve(
388                clock, description: "Dangling clock started \(minutes) mins ago", lastValid: clock.startedAt, clockingIn: false, session, ask: ask, now: now
389            )
390            outcome.messages += result.messages
391            if let jump = result.jump { outcome.jump = jump }
392        }
393        return outcome
394    }
395
396    /// `org-resolve-clocks-if-idle`: the running clock after the user was idle since `idleSince`.
397    public func resolveIdle(_ session: DocumentSession, idleSince: Date, ask: Ask, now: () -> Date = Date.init) async -> Outcome {
398        guard let running, !isResolving else { return Outcome() }
399        let clock = OpenClock(path: running.path, start: running.start, startedAt: running.startedAt, heading: running.heading)
400        let minutes = now().timeIntervalSince(idleSince) / 60
401        return await resolve(
402            clock, description: String(format: "Clocked in & idle for %.1f mins", minutes), lastValid: idleSince, clockingIn: false, session, ask: ask, now: now
403        )
404    }
405
406    /// `org-clock-resolve`: asks what to do with `clock`'s time since `lastValid` and does it.
407    func resolve(
408        _ clock: OpenClock, description: String, lastValid: Date, clockingIn: Bool, _ session: DocumentSession, ask: Ask, now: () -> Date
409    ) async -> Outcome {
410        var outcome = Outcome()
411        // An earlier answer may have closed it.
412        guard let text = Self.text(clock.path, session), ClockResolution.openClocks(in: text).contains(where: { $0.start == clock.start }) else {
413            return outcome
414        }
415        isResolving = true
416        defer { isResolving = false }
417        var key: Character = "q"
418        while true {
419            guard let answer = await ask(Prompt(key: "clock-resolve", message: description + ClockResolution.keyHint, fast: ClockResolution.menu)) else {
420                return outcome
421            }
422            if let c = answer.first, ClockResolution.keys.contains(c) {
423                key = c
424                break
425            }
426        }
427        var minutes: Int?
428        var defaultMinutes: Int?
429        switch ClockResolution.input(for: key) {
430        case .minutes(let prompt):
431            let fallback = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
432            defaultMinutes = fallback
433            while minutes == nil {
434                guard let answer = await ask(Prompt(key: "clock-minutes", message: "\(prompt) (default \(fallback)): ")) else { return outcome }
435                let trimmed = answer.trimmingCharacters(in: .whitespaces)
436                minutes = trimmed.isEmpty ? fallback : Int(trimmed)
437            }
438        case .date:
439            guard let answer = await ask(Prompt(key: "clock-date", message: "Date+time: ", date: .init(defaultDate: nil))) else { return outcome }
440            let calendar = Calendar.current
441            let read = ReadDate.read(answer, default: nil, now: now(), calendar: calendar)
442            guard let date = calendar.date(from: read.components(calendar)) else { return outcome }
443            minutes = ClockResolution.minutes(from: lastValid, to: date)
444            defaultMinutes = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
445        case nil:
446            break
447        }
448        let plan: ClockResolution.Plan
449        do {
450            plan = try ClockResolution.plan(
451                key: key, minutes: minutes, start: clock.startedAt, lastValid: lastValid, now: now(), active: isActive(clock),
452                clockingIn: clockingIn, defaultMinutes: defaultMinutes
453            )
454        } catch {
455            outcome.messages.append((error as? ClockResolution.Failure)?.message ?? "\(error)")
456            return outcome
457        }
458        outcome.messages = apply(plan, to: clock, session)
459        if plan.jump { outcome.jump = lineLocation(path: clock.path, start: clock.start, session) }
460        return outcome
461    }
462
463    /// Carries out `plan` on `clock`'s line.
464    func apply(_ plan: ClockResolution.Plan, to clock: OpenClock, _ session: DocumentSession) -> [String] {
465        var messages: [String] = []
466        let active = isActive(clock)
467        let task = lineLocation(path: clock.path, start: clock.start, session).flatMap { line in
468            Self.text(clock.path, session).flatMap { ClockTask.at(line.offset, in: $0, path: clock.path) }
469        }
470        for step in plan.steps {
471            switch step {
472            case .cancel:
473                if active {
474                    messages.append(clockOut(session, cancel: true))
475                } else {
476                    messages.append(perform(ClockOut(start: clock.start, cancel: true), clock, session, now: Date()) ?? "Clock canceled")
477                }
478            case .clockOut(let time):
479                if active {
480                    messages.append(clockOut(session, now: time))
481                } else {
482                    let minutes = Int(time.timeIntervalSince(clock.startedAt) / 60)
483                    messages.append(perform(ClockOut(start: clock.start), clock, session, now: time)
484                        ?? String(format: "Clock stopped after %d:%02d", minutes / 60, minutes % 60))
485                }
486            case .clockIn(let time):
487                guard let task, let location = locate(task, session) else { continue }
488                messages.append(clockIn(session, path: location.path, at: location.offset, now: time))
489            case .resume:
490                if running != nil { messages.append(clockOut(session, now: Date())) }
491                running = Running(path: clock.path, start: clock.start, heading: clock.heading, startedAt: clock.startedAt)
492                if let task { push(task, session) }
493                save()
494                messages.append("Clock starts at [\(clock.start)]")
495            }
496        }
497        if plan.setsLeftover { leftover = plan.leftover }
498        return messages
499    }
500
501    /// Runs a clock command on a clock that isn't the running one; nil when it worked.
502    private func perform(_ command: ClockOut, _ clock: OpenClock, _ session: DocumentSession, now: Date) -> String? {
503        switch session.perform(command, path: clock.path, at: 0, expect: nil, now: now) {
504        case .commit: return nil
505        case .failed(let message): return message
506        default: return "Can't change the clock."
507        }
508    }
509}