Sources/OrgApp/ClockModel.swift
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}