Sources/OrgApp/ClockModel.swift
509 lines · 25471 bytes
35 symbols in this file
ClockModelRunningOpenClockOutcomeAskSavedsaveclockInclockInclockOutcapturedtextentrylocationlineLocationrunningTaskisRunningpushlocatemarkDefaultmarkDefaultselectionchooseTaskgotoRecentTargetclockInheadingStartopenClocksresolvedisActiveresolveOpenClocksresolveIdleresolveapplyperform
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}