krz/orgstar

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

Sources/OrgApp/ClockModel.swift

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

528 lines · 26526 bytes

  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: `org-clock-in` at the entry as when
143    /// capture began, dangling clocks resolved first, and, without `clock-keep`, clocked out
144    /// now, the clock it interrupted resuming with `clock-resume`.
145    public func captured(
146        _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, paths: [String], ask: Ask,
147        opened: Date, now: Date = Date()
148    ) async -> Outcome {
149        guard options.clockIn else { return Outcome() }
150        let previous = running == nil ? nil : runningTask(session)
151        var outcome = await clockIn(session, target: .entry(path: entry.path, offset: entry.offset), paths: paths, ask: ask, now: { opened })
152        if !options.clockKeep {
153            outcome.messages.append(clockOut(session, now: now))
154            if options.clockResume, let previous {
155                let resumed = await clockIn(session, target: .task(previous), paths: paths, ask: ask, now: { now })
156                outcome.messages += resumed.messages
157                if let jump = resumed.jump { outcome.jump = jump }
158            }
159        }
160        return outcome
161    }
162
163    static func text(_ path: String, _ session: DocumentSession) -> String? {
164        session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8))
165    }
166
167    /// The clocked entry's heading, for the `clock` capture target.
168    public func entry(_ session: DocumentSession) -> (path: String, offset: Int)? {
169        guard let line = location(session), let text = Self.text(line.path, session) else { return nil }
170        let heading = DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= line.offset }
171        return heading.map { (line.path, $0.headingRange.lowerBound) }
172    }
173
174    /// Where the running clock's line is, for `org-clock-goto`.
175    public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
176        guard let clock = running else { return nil }
177        return lineLocation(path: clock.path, start: clock.start, session)
178    }
179
180    private func lineLocation(path: String, start: String, _ session: DocumentSession) -> (path: String, offset: Int)? {
181        guard let text = Self.text(path, session) else { return nil }
182        let range = (text as NSString).range(of: "CLOCK: [\(start)]")
183        return range.location == NSNotFound ? (path, 0) : (path, range.location)
184    }
185
186    /// The running clock's entry.
187    private func runningTask(_ session: DocumentSession) -> ClockTask? {
188        guard let line = location(session), let text = Self.text(line.path, session) else { return nil }
189        return ClockTask.at(line.offset, in: text, path: line.path)
190    }
191
192    private func isRunning(path: String, heading: Int, _ session: DocumentSession) -> Bool {
193        guard running?.path == path, let current = entry(session) else { return false }
194        return current.offset == heading
195    }
196
197    // MARK: - History
198
199    private func push(_ task: ClockTask, _ session: DocumentSession) {
200        history = ClockHistory.push(task, onto: history, length: OrgPreferences.clockHistoryLength(defaults)) { Self.text($0, session) }
201    }
202
203    /// Where a remembered entry's heading is now.
204    public func locate(_ task: ClockTask, _ session: DocumentSession) -> (path: String, offset: Int)? {
205        Self.text(task.path, session).flatMap { task.locate(in: $0) }.map { (task.path, $0) }
206    }
207
208    /// `org-clock-mark-default-task` for the heading under the caret.
209    public func markDefault(_ session: DocumentSession) -> String {
210        guard let path = session.path, session.document != nil else { return "No file is open." }
211        return markDefault(session, path: path, at: session.caret)
212    }
213
214    /// `org-clock-mark-default-task` for the heading containing `offset` in `path`.
215    public func markDefault(_ session: DocumentSession, path: String, at offset: Int) -> String {
216        guard let text = Self.text(path, session) else { return "Can't read \(path)" }
217        guard let task = ClockTask.at(offset, in: text, path: path) else { return "Before first headline" }
218        defaultTask = task
219        return "Default clock task: \(task.title)"
220    }
221
222    /// `org-clock-select-task`'s entries, or nil with no recent clock.
223    public func selection(_ session: DocumentSession) -> [ClockHistory.Choice]? {
224        ClockHistory.selection(
225            defaultTask: defaultTask, interrupted: interruptedTask, current: running == nil ? nil : runningTask(session), history: history
226        ) { Self.text($0, session) }
227    }
228
229    /// `org-clock-select-task`: the chosen entry, nil when the question is left with `q`,
230    /// `x` or Escape, or an error message.
231    public func chooseTask(_ session: DocumentSession, prompt: String, ask: Ask) async -> Result<ClockTask?, ClockResolution.Failure> {
232        guard let choices = selection(session) else { return .failure(.init(message: "No recent clock")) }
233        guard let answer = await ask(Prompt(key: "clock-task", message: prompt, fast: ClockHistory.fastSelection(choices))),
234              let key = answer.first, key != "q", key != "x" else { return .success(nil) }
235        guard let choice = choices.first(where: { $0.key == key }) else { return .failure(.init(message: "Invalid task choice \(key)")) }
236        return .success(choice.task)
237    }
238
239    /// `org-clock-goto`: the running clock, else the most recent entry clocked.
240    public func goto(_ session: DocumentSession) -> (location: (path: String, offset: Int)?, message: String?) {
241        if let location = location(session) { return (location, nil) }
242        if let recent = history.first, let location = locate(recent, session) {
243            return (location, "No running clock, this is the most recently clocked task")
244        }
245        return (nil, "No active or recent clock task")
246    }
247
248    /// `org-clock-goto` with a prefix argument: an entry from the selection.
249    public func gotoRecent(_ session: DocumentSession, ask: Ask) async -> (location: (path: String, offset: Int)?, message: String?) {
250        switch await chooseTask(session, prompt: "Select task to go to: ", ask: ask) {
251        case .failure(let failure): return (nil, failure.message)
252        case .success(nil): return (nil, "No task selected")
253        case .success(let task?):
254            guard let location = locate(task, session) else { return (nil, "\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more") }
255            return (location, nil)
256        }
257    }
258
259    // MARK: - Clocking in with questions
260
261    /// Where an interactive clock-in goes.
262    public enum Target {
263        /// The heading under the caret (`org-clock-in`).
264        case caret
265        /// An entry chosen from the selection (`org-clock-in` with a prefix argument).
266        case select
267        /// The most recent entry (`org-clock-in-last`).
268        case last
269        /// A remembered entry.
270        case task(ClockTask)
271        /// The heading containing `offset` in `path` (the agenda's `org-agenda-clock-in`, a
272        /// capture template's `clock-in`).
273        case entry(path: String, offset: Int)
274    }
275
276    /// `org-clock-in` as a command: dangling clocks in `paths` are resolved first when no
277    /// clock runs (`org-clock-auto-clock-resolution` `when-no-clock-is-running`), and a clock
278    /// stopped with `K`, `S` or `T` offers its time as the start.
279    public func clockIn(_ session: DocumentSession, target: Target, paths: [String], ask: Ask, now: () -> Date = Date.init) async -> Outcome {
280        var outcome = Outcome()
281        if case .last = target, history.isEmpty {
282            outcome.messages.append("No last clock")
283            return outcome
284        }
285        var target = target
286        // Resolving may edit the entry's file, so its heading is found again afterwards.
287        if case .entry(let path, let offset) = target {
288            guard let text = Self.text(path, session) else {
289                outcome.messages.append("Can't read \(path)")
290                return outcome
291            }
292            guard let task = ClockTask.at(offset, in: text, path: path) else {
293                outcome.messages.append("Before first headline")
294                return outcome
295            }
296            target = .task(task)
297        }
298        let wasRunning = running != nil
299        let stopped = leftover
300        if !wasRunning {
301            leftover = nil
302            for clock in openClocks(session, paths: paths) {
303                let minutes = Int(now().timeIntervalSince(clock.startedAt) / 60)
304                let result = await resolve(
305                    clock, description: "Dangling clock started \(minutes) mins ago", lastValid: clock.startedAt, clockingIn: true, session, ask: ask, now: now
306                )
307                outcome.messages += result.messages
308                if let jump = result.jump { outcome.jump = jump }
309            }
310        }
311        let location: (path: String, offset: Int)
312        switch target {
313        case .caret:
314            guard let path = session.path, session.document != nil else {
315                outcome.messages.append("No file is open.")
316                return outcome
317            }
318            location = (path, session.caret)
319        case .select:
320            switch await chooseTask(session, prompt: "Clock-in on task: ", ask: ask) {
321            case .failure(let failure):
322                outcome.messages.append(failure.message)
323                return outcome
324            case .success(nil):
325                outcome.messages.append("Abort")
326                return outcome
327            case .success(let task?):
328                guard let found = locate(task, session) else {
329                    outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
330                    return outcome
331                }
332                location = found
333            }
334        case .last, .task, .entry:
335            let task: ClockTask
336            if case .task(let chosen) = target { task = chosen } else { task = history[0] }
337            guard let found = locate(task, session) else {
338                outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
339                return outcome
340            }
341            location = found
342        }
343        var start = now()
344        if let stopped, !isRunning(path: location.path, heading: headingStart(location, session) ?? -1, session) {
345            let minutes = Int(start.timeIntervalSince(stopped) / 60)
346            let answer = await ask(Prompt(
347                key: "clock-leftover", message: "You stopped another clock \(minutes) mins ago; start this one from then? (y or n) ",
348                fast: FastSelection(items: [.option(key: "y", name: "yes"), .option(key: "n", name: "no")], multiple: false)
349            ))
350            if answer == "y" { start = stopped }
351        }
352        let message = clockIn(session, path: location.path, at: location.offset, now: start)
353        if case .last = target, !wasRunning, let running {
354            outcome.messages.append("Clocking back: \(running.heading) (in \((running.path as NSString).lastPathComponent))")
355        } else {
356            outcome.messages.append(message)
357        }
358        return outcome
359    }
360
361    private func headingStart(_ location: (path: String, offset: Int), _ session: DocumentSession) -> Int? {
362        guard let text = Self.text(location.path, session) else { return nil }
363        return DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= location.offset }?.headingRange.lowerBound
364    }
365
366    // MARK: - Resolving clocks
367
368    /// `org-find-open-clocks` over `paths` and the open org buffers.
369    public func openClocks(_ session: DocumentSession, paths: [String]) -> [OpenClock] {
370        // A file open under another spelling of its path is read from its buffer.
371        func resolved(_ path: String) -> String { URL(fileURLWithPath: path).resolvingSymlinksInPath().path }
372        var open: [String: String] = [:]
373        for document in session.openDocuments { open[resolved(document.path)] = document.path }
374        var seen = Set<String>()
375        let all = (session.openDocuments.map(\.path).filter { $0.hasSuffix(".org") } + paths.map { open[resolved($0)] ?? $0 })
376            .filter { seen.insert(resolved($0)).inserted }
377        var clocks: [OpenClock] = []
378        for path in all {
379            guard let text = Self.text(path, session), text.contains("CLOCK: [") else { continue }
380            let open = ClockResolution.openClocks(in: text)
381            guard !open.isEmpty else { continue }
382            let headings = DocumentModel(tree: OrgParser.parse(text)).headings
383            for clock in open {
384                guard let startedAt = ClockResolution.date(of: clock.start) else { continue }
385                let heading = headings.last { $0.headingRange.lowerBound <= clock.offset }?.title ?? ""
386                clocks.append(OpenClock(path: path, start: clock.start, startedAt: startedAt, heading: heading))
387            }
388        }
389        return clocks
390    }
391
392    private func isActive(_ clock: OpenClock) -> Bool {
393        running?.path == clock.path && running?.start == clock.start
394    }
395
396    /// `org-resolve-clocks`: every open clock in `paths`, the running one included.
397    public func resolveOpenClocks(_ session: DocumentSession, paths: [String], ask: Ask, now: () -> Date = Date.init) async -> Outcome {
398        var outcome = Outcome()
399        let clocks = openClocks(session, paths: paths)
400        guard !clocks.isEmpty else {
401            outcome.messages.append("No open clocks")
402            return outcome
403        }
404        for clock in clocks {
405            let minutes = Int(now().timeIntervalSince(clock.startedAt) / 60)
406            let result = await resolve(
407                clock, description: "Dangling clock started \(minutes) mins ago", lastValid: clock.startedAt, clockingIn: false, session, ask: ask, now: now
408            )
409            outcome.messages += result.messages
410            if let jump = result.jump { outcome.jump = jump }
411        }
412        return outcome
413    }
414
415    /// `org-resolve-clocks-if-idle`: the running clock after the user was idle since `idleSince`.
416    public func resolveIdle(_ session: DocumentSession, idleSince: Date, ask: Ask, now: () -> Date = Date.init) async -> Outcome {
417        guard let running, !isResolving else { return Outcome() }
418        let clock = OpenClock(path: running.path, start: running.start, startedAt: running.startedAt, heading: running.heading)
419        let minutes = now().timeIntervalSince(idleSince) / 60
420        return await resolve(
421            clock, description: String(format: "Clocked in & idle for %.1f mins", minutes), lastValid: idleSince, clockingIn: false, session, ask: ask, now: now
422        )
423    }
424
425    /// `org-clock-resolve`: asks what to do with `clock`'s time since `lastValid` and does it.
426    func resolve(
427        _ clock: OpenClock, description: String, lastValid: Date, clockingIn: Bool, _ session: DocumentSession, ask: Ask, now: () -> Date
428    ) async -> Outcome {
429        var outcome = Outcome()
430        // An earlier answer may have closed it.
431        guard let text = Self.text(clock.path, session), ClockResolution.openClocks(in: text).contains(where: { $0.start == clock.start }) else {
432            return outcome
433        }
434        isResolving = true
435        defer { isResolving = false }
436        var key: Character = "q"
437        while true {
438            guard let answer = await ask(Prompt(key: "clock-resolve", message: description + ClockResolution.keyHint, fast: ClockResolution.menu)) else {
439                return outcome
440            }
441            if let c = answer.first, ClockResolution.keys.contains(c) {
442                key = c
443                break
444            }
445        }
446        var minutes: Int?
447        var defaultMinutes: Int?
448        switch ClockResolution.input(for: key) {
449        case .minutes(let prompt):
450            let fallback = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
451            defaultMinutes = fallback
452            while minutes == nil {
453                guard let answer = await ask(Prompt(key: "clock-minutes", message: "\(prompt) (default \(fallback)): ")) else { return outcome }
454                let trimmed = answer.trimmingCharacters(in: .whitespaces)
455                minutes = trimmed.isEmpty ? fallback : Int(trimmed)
456            }
457        case .date:
458            guard let answer = await ask(Prompt(key: "clock-date", message: "Date+time: ", date: .init(defaultDate: nil))) else { return outcome }
459            let calendar = Calendar.current
460            let read = ReadDate.read(answer, default: nil, now: now(), calendar: calendar)
461            guard let date = calendar.date(from: read.components(calendar)) else { return outcome }
462            minutes = ClockResolution.minutes(from: lastValid, to: date)
463            defaultMinutes = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
464        case nil:
465            break
466        }
467        let plan: ClockResolution.Plan
468        do {
469            plan = try ClockResolution.plan(
470                key: key, minutes: minutes, start: clock.startedAt, lastValid: lastValid, now: now(), active: isActive(clock),
471                clockingIn: clockingIn, defaultMinutes: defaultMinutes
472            )
473        } catch {
474            outcome.messages.append((error as? ClockResolution.Failure)?.message ?? "\(error)")
475            return outcome
476        }
477        outcome.messages = apply(plan, to: clock, session)
478        if plan.jump { outcome.jump = lineLocation(path: clock.path, start: clock.start, session) }
479        return outcome
480    }
481
482    /// Carries out `plan` on `clock`'s line.
483    func apply(_ plan: ClockResolution.Plan, to clock: OpenClock, _ session: DocumentSession) -> [String] {
484        var messages: [String] = []
485        let active = isActive(clock)
486        let task = lineLocation(path: clock.path, start: clock.start, session).flatMap { line in
487            Self.text(clock.path, session).flatMap { ClockTask.at(line.offset, in: $0, path: clock.path) }
488        }
489        for step in plan.steps {
490            switch step {
491            case .cancel:
492                if active {
493                    messages.append(clockOut(session, cancel: true))
494                } else {
495                    messages.append(perform(ClockOut(start: clock.start, cancel: true), clock, session, now: Date()) ?? "Clock canceled")
496                }
497            case .clockOut(let time):
498                if active {
499                    messages.append(clockOut(session, now: time))
500                } else {
501                    let minutes = Int(time.timeIntervalSince(clock.startedAt) / 60)
502                    messages.append(perform(ClockOut(start: clock.start), clock, session, now: time)
503                        ?? String(format: "Clock stopped after %d:%02d", minutes / 60, minutes % 60))
504                }
505            case .clockIn(let time):
506                guard let task, let location = locate(task, session) else { continue }
507                messages.append(clockIn(session, path: location.path, at: location.offset, now: time))
508            case .resume:
509                if running != nil { messages.append(clockOut(session, now: Date())) }
510                running = Running(path: clock.path, start: clock.start, heading: clock.heading, startedAt: clock.startedAt)
511                if let task { push(task, session) }
512                save()
513                messages.append("Clock starts at [\(clock.start)]")
514            }
515        }
516        if plan.setsLeftover { leftover = plan.leftover }
517        return messages
518    }
519
520    /// Runs a clock command on a clock that isn't the running one; nil when it worked.
521    private func perform(_ command: ClockOut, _ clock: OpenClock, _ session: DocumentSession, now: Date) -> String? {
522        switch session.perform(command, path: clock.path, at: 0, expect: nil, now: now) {
523        case .commit: return nil
524        case .failed(let message): return message
525        default: return "Can't change the clock."
526        }
527    }
528}