krz/orgstar

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

Sources/OrgApp/ClockModel.swift

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

88 lines · 3861 bytes

 1import Foundation
 2import Observation
 3import OrgCore
 4
 5/// The running clock, kept across launches as Emacs keeps `org-clock-marker` for the session,
 6/// and the commands that start and stop it.
 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    public private(set) var running: Running?
19    private let file: URL
20
21    public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json")) {
22        self.file = file
23        running = (try? JSONDecoder().decode(Running.self, from: Data(contentsOf: file)))
24    }
25
26    private func save() {
27        try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
28        if let running, let data = try? JSONEncoder().encode(running) {
29            try? data.write(to: file, options: .atomic)
30        } else {
31            try? FileManager.default.removeItem(at: file)
32        }
33    }
34
35    /// `org-clock-in` at the heading under the caret, clocking out a running clock first.
36    public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String {
37        guard let path = session.path, session.document != nil else { return "No file is open." }
38        guard let entry = session.outline.last(where: { $0.start <= session.caret }) else { return "Before first headline" }
39        if running != nil {
40            let message = clockOut(session, now: now)
41            if running != nil { return message }
42        }
43        switch session.perform(ClockIn(), path: path, at: session.caret, expect: nil, now: now) {
44        case .commit:
45            guard let updated = session.document?.text, let start = ClockIn.runningStart(in: updated, near: entry.start) else {
46                return "The clock line wasn't found after clocking in."
47            }
48            running = Running(path: path, start: start, heading: entry.title, startedAt: now)
49            save()
50            return "Clock starts at [\(start)]"
51        case .failed(let message):
52            return message
53        default:
54            return "Can't clock in here."
55        }
56    }
57
58    /// `org-clock-out`, or `org-clock-cancel` with `cancel`.
59    public func clockOut(_ session: DocumentSession, cancel: Bool = false, now: Date = Date()) -> String {
60        guard let clock = running else { return "No active clock" }
61        switch session.perform(ClockOut(start: clock.start, cancel: cancel), path: clock.path, at: 0, expect: nil, now: now) {
62        case .commit:
63            running = nil
64            save()
65            if cancel { return "Clock canceled" }
66            let minutes = Int(now.timeIntervalSince(clock.startedAt) / 60)
67            return String(format: "Clock stopped after %d:%02d", minutes / 60, minutes % 60)
68        case .failed(let message):
69            if message == "Clock start time is gone" || message == "No active clock" {
70                running = nil
71                save()
72            }
73            return message
74        default:
75            return "Can't clock out."
76        }
77    }
78
79    /// Where the running clock's line is, for `org-clock-goto`.
80    public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
81        guard let clock = running else { return nil }
82        let open = session.path == clock.path ? session.document?.text : nil
83        guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil }
84        let buffer = text as NSString
85        let range = buffer.range(of: "CLOCK: [\(clock.start)]")
86        return range.location == NSNotFound ? (clock.path, 0) : (clock.path, range.location)
87    }
88}