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