Clock history, idle handling and open clock resolution !172

merged merged by cmc on 2026-10-08 07:47 UTC · krz/orgstar:clock-history-idle into main

20 files changed, +1447 −50

Layout: unified · split

Sources/OrgApp/ClockModel.swift +399 −15
@@ -2,8 +2,8 @@ import Foundation
22import Observation
33import OrgCore
44
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.
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.
77@MainActor
88@Observable
99public final class ClockModel {
@@ -15,23 +15,65 @@ public final class ClockModel {
1515 public let startedAt: Date
1616 }
1717
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
1840 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
1951 private let file: URL
52 private let defaults: UserDefaults
2053
21 public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json")) {
54 public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json"), defaults: UserDefaults = .standard) {
2255 self.file = file
23 running = (try? JSONDecoder().decode(Running.self, from: Data(contentsOf: 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 }
2464 }
2565
2666 private func save() {
2767 try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
28 if let running, let data = try? JSONEncoder().encode(running) {
68 if running != nil || !history.isEmpty, let data = try? JSONEncoder().encode(Saved(running: running, history: history)) {
2969 try? data.write(to: file, options: .atomic)
3070 } else {
3171 try? FileManager.default.removeItem(at: file)
3272 }
3373 }
3474
75 // MARK: - Clocking in and out
76
3577 /// `org-clock-in` at the heading under the caret, clocking out a running clock first.
3678 public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String {
3779 guard let path = session.path, session.document != nil else { return "No file is open." }
@@ -41,18 +83,32 @@ public final class ClockModel {
4183 /// `org-clock-in` at the heading containing `offset` in `path`.
4284 public func clockIn(_ session: DocumentSession, path: String, at offset: Int, now: Date = Date()) -> String {
4385 guard let text = Self.text(path, session) else { return "Can't read \(path)" }
44 let tree = OrgParser.parse(text)
45 guard let entry = DocumentModel(tree: tree).headings.last(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" }
46 if running != nil {
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)
4794 let message = clockOut(session, now: now)
48 if running != nil { return message }
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
49104 }
50105 switch session.perform(ClockIn(), path: path, at: offset, expect: nil, now: now) {
51106 case .commit:
52 guard let updated = Self.text(path, session), let start = ClockIn.runningStart(in: updated, near: entry.headingRange.lowerBound) else {
107 guard let updated = Self.text(path, session), let start = ClockIn.runningStart(in: updated, near: heading) else {
53108 return "The clock line wasn't found after clocking in."
54109 }
55110 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) }
56112 save()
57113 return "Clock starts at [\(start)]"
58114 case .failed(let message):
@@ -115,10 +171,338 @@ public final class ClockModel {
115171 /// Where the running clock's line is, for `org-clock-goto`.
116172 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
117173 guard let clock = running else { return nil }
118 let open = session.openDocuments.first { $0.path == clock.path }?.document.text
119 guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil }
120 let buffer = text as NSString
121 let range = buffer.range(of: "CLOCK: [\(clock.start)]")
122 return range.location == NSNotFound ? (clock.path, 0) : (clock.path, range.location)
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 }
290 }
291 let location: (path: String, offset: Int)
292 switch target {
293 case .caret:
294 guard let path = session.path, session.document != nil else {
295 outcome.messages.append("No file is open.")
296 return outcome
297 }
298 location = (path, session.caret)
299 case .select:
300 switch await chooseTask(session, prompt: "Clock-in on task: ", ask: ask) {
301 case .failure(let failure):
302 outcome.messages.append(failure.message)
303 return outcome
304 case .success(nil):
305 outcome.messages.append("Abort")
306 return outcome
307 case .success(let task?):
308 guard let found = locate(task, session) else {
309 outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
310 return outcome
311 }
312 location = found
313 }
314 case .last, .task:
315 let task: ClockTask
316 if case .task(let chosen) = target { task = chosen } else { task = history[0] }
317 guard let found = locate(task, session) else {
318 outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
319 return outcome
320 }
321 location = found
322 }
323 var start = now()
324 if let stopped, !isRunning(path: location.path, heading: headingStart(location, session) ?? -1, session) {
325 let minutes = Int(start.timeIntervalSince(stopped) / 60)
326 let answer = await ask(Prompt(
327 key: "clock-leftover", message: "You stopped another clock \(minutes) mins ago; start this one from then? (y or n) ",
328 fast: FastSelection(items: [.option(key: "y", name: "yes"), .option(key: "n", name: "no")], multiple: false)
329 ))
330 if answer == "y" { start = stopped }
331 }
332 let message = clockIn(session, path: location.path, at: location.offset, now: start)
333 if case .last = target, !wasRunning, let running {
334 outcome.messages.append("Clocking back: \(running.heading) (in \((running.path as NSString).lastPathComponent))")
335 } else {
336 outcome.messages.append(message)
337 }
338 return outcome
339 }
340
341 private func headingStart(_ location: (path: String, offset: Int), _ session: DocumentSession) -> Int? {
342 guard let text = Self.text(location.path, session) else { return nil }
343 return DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= location.offset }?.headingRange.lowerBound
344 }
345
346 // MARK: - Resolving clocks
347
348 /// `org-find-open-clocks` over `paths` and the open org buffers.
349 public func openClocks(_ session: DocumentSession, paths: [String]) -> [OpenClock] {
350 // A file open under another spelling of its path is read from its buffer.
351 func resolved(_ path: String) -> String { URL(fileURLWithPath: path).resolvingSymlinksInPath().path }
352 var open: [String: String] = [:]
353 for document in session.openDocuments { open[resolved(document.path)] = document.path }
354 var seen = Set<String>()
355 let all = (session.openDocuments.map(\.path).filter { $0.hasSuffix(".org") } + paths.map { open[resolved($0)] ?? $0 })
356 .filter { seen.insert(resolved($0)).inserted }
357 var clocks: [OpenClock] = []
358 for path in all {
359 guard let text = Self.text(path, session), text.contains("CLOCK: [") else { continue }
360 let open = ClockResolution.openClocks(in: text)
361 guard !open.isEmpty else { continue }
362 let headings = DocumentModel(tree: OrgParser.parse(text)).headings
363 for clock in open {
364 guard let startedAt = ClockResolution.date(of: clock.start) else { continue }
365 let heading = headings.last { $0.headingRange.lowerBound <= clock.offset }?.title ?? ""
366 clocks.append(OpenClock(path: path, start: clock.start, startedAt: startedAt, heading: heading))
367 }
368 }
369 return clocks
370 }
371
372 private func isActive(_ clock: OpenClock) -> Bool {
373 running?.path == clock.path && running?.start == clock.start
374 }
375
376 /// `org-resolve-clocks`: every open clock in `paths`, the running one included.
377 public func resolveOpenClocks(_ session: DocumentSession, paths: [String], ask: Ask, now: () -> Date = Date.init) async -> Outcome {
378 var outcome = Outcome()
379 let clocks = openClocks(session, paths: paths)
380 guard !clocks.isEmpty else {
381 outcome.messages.append("No open clocks")
382 return outcome
383 }
384 for clock in clocks {
385 let minutes = Int(now().timeIntervalSince(clock.startedAt) / 60)
386 let result = await resolve(
387 clock, description: "Dangling clock started \(minutes) mins ago", lastValid: clock.startedAt, clockingIn: false, session, ask: ask, now: now
388 )
389 outcome.messages += result.messages
390 if let jump = result.jump { outcome.jump = jump }
391 }
392 return outcome
393 }
394
395 /// `org-resolve-clocks-if-idle`: the running clock after the user was idle since `idleSince`.
396 public func resolveIdle(_ session: DocumentSession, idleSince: Date, ask: Ask, now: () -> Date = Date.init) async -> Outcome {
397 guard let running, !isResolving else { return Outcome() }
398 let clock = OpenClock(path: running.path, start: running.start, startedAt: running.startedAt, heading: running.heading)
399 let minutes = now().timeIntervalSince(idleSince) / 60
400 return await resolve(
401 clock, description: String(format: "Clocked in & idle for %.1f mins", minutes), lastValid: idleSince, clockingIn: false, session, ask: ask, now: now
402 )
403 }
404
405 /// `org-clock-resolve`: asks what to do with `clock`'s time since `lastValid` and does it.
406 func resolve(
407 _ clock: OpenClock, description: String, lastValid: Date, clockingIn: Bool, _ session: DocumentSession, ask: Ask, now: () -> Date
408 ) async -> Outcome {
409 var outcome = Outcome()
410 // An earlier answer may have closed it.
411 guard let text = Self.text(clock.path, session), ClockResolution.openClocks(in: text).contains(where: { $0.start == clock.start }) else {
412 return outcome
413 }
414 isResolving = true
415 defer { isResolving = false }
416 var key: Character = "q"
417 while true {
418 guard let answer = await ask(Prompt(key: "clock-resolve", message: description + ClockResolution.keyHint, fast: ClockResolution.menu)) else {
419 return outcome
420 }
421 if let c = answer.first, ClockResolution.keys.contains(c) {
422 key = c
423 break
424 }
425 }
426 var minutes: Int?
427 var defaultMinutes: Int?
428 switch ClockResolution.input(for: key) {
429 case .minutes(let prompt):
430 let fallback = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
431 defaultMinutes = fallback
432 while minutes == nil {
433 guard let answer = await ask(Prompt(key: "clock-minutes", message: "\(prompt) (default \(fallback)): ")) else { return outcome }
434 let trimmed = answer.trimmingCharacters(in: .whitespaces)
435 minutes = trimmed.isEmpty ? fallback : Int(trimmed)
436 }
437 case .date:
438 guard let answer = await ask(Prompt(key: "clock-date", message: "Date+time: ", date: .init(defaultDate: nil))) else { return outcome }
439 let calendar = Calendar.current
440 let read = ReadDate.read(answer, default: nil, now: now(), calendar: calendar)
441 guard let date = calendar.date(from: read.components(calendar)) else { return outcome }
442 minutes = ClockResolution.minutes(from: lastValid, to: date)
443 defaultMinutes = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
444 case nil:
445 break
446 }
447 let plan: ClockResolution.Plan
448 do {
449 plan = try ClockResolution.plan(
450 key: key, minutes: minutes, start: clock.startedAt, lastValid: lastValid, now: now(), active: isActive(clock),
451 clockingIn: clockingIn, defaultMinutes: defaultMinutes
452 )
453 } catch {
454 outcome.messages.append((error as? ClockResolution.Failure)?.message ?? "\(error)")
455 return outcome
456 }
457 outcome.messages = apply(plan, to: clock, session)
458 if plan.jump { outcome.jump = lineLocation(path: clock.path, start: clock.start, session) }
459 return outcome
460 }
461
462 /// Carries out `plan` on `clock`'s line.
463 func apply(_ plan: ClockResolution.Plan, to clock: OpenClock, _ session: DocumentSession) -> [String] {
464 var messages: [String] = []
465 let active = isActive(clock)
466 let task = lineLocation(path: clock.path, start: clock.start, session).flatMap { line in
467 Self.text(clock.path, session).flatMap { ClockTask.at(line.offset, in: $0, path: clock.path) }
468 }
469 for step in plan.steps {
470 switch step {
471 case .cancel:
472 if active {
473 messages.append(clockOut(session, cancel: true))
474 } else {
475 messages.append(perform(ClockOut(start: clock.start, cancel: true), clock, session, now: Date()) ?? "Clock canceled")
476 }
477 case .clockOut(let time):
478 if active {
479 messages.append(clockOut(session, now: time))
480 } else {
481 let minutes = Int(time.timeIntervalSince(clock.startedAt) / 60)
482 messages.append(perform(ClockOut(start: clock.start), clock, session, now: time)
483 ?? String(format: "Clock stopped after %d:%02d", minutes / 60, minutes % 60))
484 }
485 case .clockIn(let time):
486 guard let task, let location = locate(task, session) else { continue }
487 messages.append(clockIn(session, path: location.path, at: location.offset, now: time))
488 case .resume:
489 if running != nil { messages.append(clockOut(session, now: Date())) }
490 running = Running(path: clock.path, start: clock.start, heading: clock.heading, startedAt: clock.startedAt)
491 if let task { push(task, session) }
492 save()
493 messages.append("Clock starts at [\(clock.start)]")
494 }
495 }
496 if plan.setsLeftover { leftover = plan.leftover }
497 return messages
498 }
499
500 /// Runs a clock command on a clock that isn't the running one; nil when it worked.
501 private func perform(_ command: ClockOut, _ clock: OpenClock, _ session: DocumentSession, now: Date) -> String? {
502 switch session.perform(command, path: clock.path, at: 0, expect: nil, now: now) {
503 case .commit: return nil
504 case .failed(let message): return message
505 default: return "Can't change the clock."
506 }
123507 }
124508}
Sources/OrgApp/EmacsImport.swift +2 −1
@@ -638,7 +638,8 @@ public struct EmacsImport: Sendable {
638638 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column",
639639 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline",
640640 "org-babel-execute-src-block": "org.babel.execute", "org-clock-in": "app.clock.in", "org-clock-out": "app.clock.out",
641 "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto",
641 "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto", "org-clock-in-last": "app.clock.in-last",
642 "org-resolve-clocks": "app.clock.resolve", "org-clock-mark-default-task": "app.clock.default",
642643 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive",
643644 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda",
644645 "save-buffer": "app.save", "find-file": "app.quick-open", "projectile-find-file": "app.quick-open", "+default/find-file-under-here": "app.quick-open",
Sources/OrgApp/OrgPreferences.swift +4
@@ -53,6 +53,10 @@ public enum OrgPreferences {
5353 public static func reminders(_ defaults: UserDefaults = .standard) -> Bool { bool("reminders", defaults) }
5454 /// `appt-message-warning-time`, in minutes.
5555 public static func reminderLead(_ defaults: UserDefaults = .standard) -> Int { integer("reminderLead", defaults) }
56 /// `org-clock-idle-time` in minutes; 0 is nil (never).
57 public static func clockIdleTime(_ defaults: UserDefaults = .standard) -> Int { integer("clockIdleTime", defaults) }
58 /// `org-clock-history-length`.
59 public static func clockHistoryLength(_ defaults: UserDefaults = .standard) -> Int { integer("clockHistoryLength", defaults) }
5660
5761 private static func fallback(_ key: String) -> TOML.Value? {
5862 UserConfig.settings.first { $0.defaultsKey == key }?.fallback
Sources/OrgApp/UserConfig.swift +4
@@ -143,6 +143,10 @@ public enum UserConfig {
143143 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
144144 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
145145 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
146 Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0),
147 comment: "minutes idle with a clock running before asking what to do with the time; 0 never (nil)", legacy: ""),
148 Setting(section: "", key: "org-clock-history-length", defaultsKey: "clockHistoryLength", kind: .integer, fallback: .integer(5),
149 comment: "recently clocked entries to remember", legacy: ""),
146150 Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
147151 comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
148152 Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!),
Sources/OrgCore/Commands/ClockHistory.swift added +154
@@ -0,0 +1,154 @@
1import Foundation
2
3// Clock history and task selection (`org-clock-history`, `org-clock-history-push`,
4// `org-clock-select-task`, Org 9.8.7). Org keeps markers, which follow edits; a task here is
5// found again by its ID, else by its heading's position among the file's headings and its
6// title, else by its title.
7
8/// A clocked entry, as `org-clock-history` and `org-clock-default-task` remember it.
9public struct ClockTask: Codable, Equatable, Hashable, Sendable {
10 public let path: String
11 public let title: String
12 /// The heading's index among the file's headings.
13 public let ordinal: Int
14 public let id: String?
15
16 public init(path: String, title: String, ordinal: Int, id: String?) {
17 self.path = path
18 self.title = title
19 self.ordinal = ordinal
20 self.id = id
21 }
22
23 /// The entry containing `offset`; nil before the first heading.
24 public static func at(_ offset: Int, in text: String, path: String) -> ClockTask? {
25 let model = DocumentModel(tree: OrgParser.parse(text))
26 guard let index = model.headings.lastIndex(where: { $0.headingRange.lowerBound <= offset }) else { return nil }
27 let heading = model.headings[index]
28 return ClockTask(path: path, title: heading.title, ordinal: index, id: heading.id)
29 }
30
31 /// The heading's index in `model`, or nil when the entry is gone.
32 public func index(in model: DocumentModel) -> Int? {
33 if let id, let index = model.headings.firstIndex(where: { $0.id == id }) { return index }
34 if ordinal < model.headings.count, model.headings[ordinal].title == title { return ordinal }
35 return model.headings.firstIndex { $0.title == title }
36 }
37
38 /// Where the heading starts in `text`, or nil when the entry is gone.
39 public func locate(in text: String) -> Int? {
40 let model = DocumentModel(tree: OrgParser.parse(text))
41 return index(in: model).map { model.headings[$0].headingRange.lowerBound }
42 }
43
44 /// The task as found in `text`, with its current position; nil when the entry is gone.
45 public func refreshed(in text: String) -> ClockTask? {
46 let model = DocumentModel(tree: OrgParser.parse(text))
47 guard let index = index(in: model) else { return nil }
48 return ClockTask(path: path, title: model.headings[index].title, ordinal: index, id: model.headings[index].id)
49 }
50}
51
52public enum ClockHistory {
53 /// `org-clock-history-length`.
54 public static let defaultLength = 5
55
56 /// `org-clock-history-push`: `task` goes first, other entries for the same heading and
57 /// entries that are gone leave, and the oldest go past `length`. `text` reads a file.
58 public static func push(_ task: ClockTask, onto history: [ClockTask], length: Int, text: (String) -> String?) -> [ClockTask] {
59 let length = max(1, length)
60 var texts: [String: String?] = [:]
61 func read(_ path: String) -> String? {
62 if let cached = texts[path] { return cached }
63 let value = text(path)
64 texts[path] = value
65 return value
66 }
67 let target = read(task.path).flatMap { task.locate(in: $0) }
68 var kept: [ClockTask] = []
69 for entry in history {
70 guard let contents = read(entry.path), let current = entry.refreshed(in: contents) else { continue }
71 if entry.path == task.path, let target, current.locate(in: contents) == target { continue }
72 kept.append(current)
73 }
74 if kept.count >= length { kept = Array(kept.prefix(length - 1)) }
75 return [task] + kept
76 }
77
78 /// One line of the selection: its key, the section it is under and `[k] category task`.
79 public struct Choice: Equatable, Sendable {
80 public let key: Character
81 public let section: String
82 public let task: ClockTask
83 /// `%-12s %s` of the category and the heading without tags.
84 public let line: String
85 }
86
87 /// `org-clock-select-task`'s entries: the default task (`d`), the interrupted one (`i`),
88 /// the clocking one (`c`), then recent tasks `1`–`9`, `A`… Nil when there is no recent
89 /// clock. Entries that are gone are left out.
90 public static func selection(
91 defaultTask: ClockTask?, interrupted: ClockTask?, current: ClockTask?, history: [ClockTask], text: (String) -> String?
92 ) -> [Choice]? {
93 // Successive duplicates go.
94 var recent: [ClockTask] = []
95 for task in history where task != recent.last { recent.append(task) }
96 guard !recent.isEmpty else { return nil }
97 var choices: [Choice] = []
98 func add(_ key: Character, _ section: String, _ task: ClockTask?) -> Bool {
99 guard let task, let contents = text(task.path), let line = line(task, in: contents) else { return false }
100 choices.append(Choice(key: key, section: section, task: task, line: line))
101 return true
102 }
103 _ = add("d", "Default Task", defaultTask)
104 _ = add("i", "The task interrupted by starting the last one", interrupted)
105 _ = add("c", "Current Clocking Task", current)
106 var i = 0
107 for task in recent {
108 guard let contents = text(task.path), line(task, in: contents) != nil else { continue }
109 i += 1
110 let scalar = i < 10 ? 48 + i : 65 + i - 10
111 _ = add(Character(UnicodeScalar(UInt8(clamping: scalar))), "Recent Tasks", task)
112 }
113 return choices
114 }
115
116 /// The prompt's keys and lines, under their section titles.
117 public static func fastSelection(_ choices: [Choice]) -> FastSelection {
118 var items: [FastSelection.Item] = []
119 var section: String?
120 for choice in choices {
121 if choice.section != section {
122 section = choice.section
123 items.append(.label(choice.section))
124 }
125 items.append(.option(key: choice.key, name: choice.line))
126 items.append(.newline)
127 }
128 return FastSelection(items: items, multiple: false)
129 }
130
131 /// `org-clock-insert-selection-line`'s text for a task, nil when it is gone.
132 public static func line(_ task: ClockTask, in text: String) -> String? {
133 let model = DocumentModel(tree: OrgParser.parse(text))
134 guard let index = task.index(in: model) else { return nil }
135 let heading = model.headings[index]
136 let category = category(of: index, in: model, text: text, path: task.path)
137 let words = [heading.todo, heading.priority.map { "[#\($0)]" }, heading.title].compactMap { $0 }.filter { !$0.isEmpty }
138 let padded = category.count >= 12 ? category : category + String(repeating: " ", count: 12 - category.count)
139 return padded + " " + words.joined(separator: " ")
140 }
141
142 /// `org-get-category`: the entry's or an ancestor's CATEGORY, `#+CATEGORY`, or the file
143 /// name without its extension.
144 static func category(of index: Int, in model: DocumentModel, text: String, path: String) -> String {
145 for layer in [index] + model.ancestors(of: index).reversed() {
146 if let value = model.headings[layer].properties.last(where: { $0.key.uppercased() == "CATEGORY" && !$0.additive })?.value {
147 return value
148 }
149 }
150 let keyword = text.components(separatedBy: "\n").last { $0.uppercased().hasPrefix("#+CATEGORY:") }
151 .map { String($0.dropFirst("#+CATEGORY:".count)).trimmingCharacters(in: .whitespaces) }
152 return keyword ?? model.property("CATEGORY", of: nil)?.value ?? ((path as NSString).lastPathComponent as NSString).deletingPathExtension
153 }
154}
Sources/OrgCore/Commands/ClockResolution.swift added +184
@@ -0,0 +1,184 @@
1import Foundation
2
3// Resolving an open clock after idle time or a dangling one (`org-clock-resolve`,
4// `org-clock-resolve-clock`, Org 9.8.7) with `org-clock-resolve-expert` nil. The answer
5// becomes steps on the CLOCK line, which the app carries out with `ClockOut` and `ClockIn`.
6
7public enum ClockResolution {
8 /// The keys `org-clock-resolve` reads.
9 public static let keys: [Character] = ["j", "k", "K", "t", "T", "g", "G", "S", "s", "c", "C", "i", "q"]
10
11 /// The question's suffix, after the clock's description.
12 public static let keyHint = " [jkKtTgGSscCiq]? "
13
14 /// The keys with what they do, from Org's *Org Clock* help.
15 public static let menu = FastSelection(items: [
16 .option(key: "i", name: "Ignore this question; the same as keeping all the idle time"), .newline,
17 .option(key: "q", name: "Ignore this question; the same as keeping all the idle time"), .newline,
18 .option(key: "k", name: "Keep X minutes of the idle time (default is all)"), .newline,
19 .option(key: "K", name: "Keep X minutes of the idle time, then clock out"), .newline,
20 .option(key: "t", name: "Like k, but ask for the time you got distracted"), .newline,
21 .option(key: "T", name: "Like t, then clock out"), .newline,
22 .option(key: "g", name: "Got back X minutes ago: clock out at the start of idling, in again X minutes ago"), .newline,
23 .option(key: "G", name: "Like g, but stay clocked out"), .newline,
24 .option(key: "s", name: "Subtract the idle time from the current clock"), .newline,
25 .option(key: "S", name: "Subtract the idle time and clock out"), .newline,
26 .option(key: "C", name: "Cancel the open clock altogether, as though you never clocked in"), .newline,
27 .option(key: "j", name: "Jump to the clock to make manual adjustments"), .newline,
28 .option(key: "J", name: "Clock out now, then jump to the clock"), .newline,
29 ], multiple: false)
30
31 /// What a key asks for next, if anything.
32 public enum Input: Equatable, Sendable {
33 /// `read-number` with the idle minutes as the default.
34 case minutes(prompt: String)
35 /// `org-read-date` with a time: when the user got distracted.
36 case date
37 }
38
39 public static func input(for key: Character) -> Input? {
40 switch key {
41 case "k", "K": .minutes(prompt: "Keep how many minutes")
42 case "g", "G": .minutes(prompt: "Got back how many minutes ago")
43 case "t", "T": .date
44 default: nil
45 }
46 }
47
48 /// The default for `k` and `g`: whole minutes since `lastValid`.
49 public static func defaultMinutes(lastValid: Date, now: Date) -> Int {
50 Int((now.timeIntervalSince(lastValid).rounded(.down) / 60).rounded(.down))
51 }
52
53 /// `t`'s answer as minutes kept after `lastValid`.
54 public static func minutes(from lastValid: Date, to date: Date) -> Int {
55 Int((date.timeIntervalSince(lastValid) / 60).rounded(.down))
56 }
57
58 /// Org's error, for the echo area.
59 public struct Failure: Error, Equatable {
60 public let message: String
61 public init(message: String) { self.message = message }
62 }
63
64 public enum Step: Equatable, Sendable {
65 /// `org-clock-cancel` on the open clock.
66 case cancel
67 /// The open clock closes at this time.
68 case clockOut(Date)
69 /// A new clock in the same entry, starting at this time.
70 case clockIn(Date)
71 /// The open clock becomes the running clock (`org-clock-in` with resume).
72 case resume
73 }
74
75 public struct Plan: Equatable, Sendable {
76 public var steps: [Step] = []
77 /// `org-clock-leftover-time` is set to this when `setsLeftover`.
78 public var leftover: Date?
79 public var setsLeftover = false
80 /// Show the clock afterwards (`j`, `J`).
81 public var jump = false
82 }
83
84 /// What `key` does to a clock that started at `start`, idle since `lastValid`.
85 /// `minutes` is the answer to `input(for:)` (for `t`, `minutes(from:to:)`). `active`: the
86 /// clock is the running one. `clockingIn`: resolving before a clock-in
87 /// (`org-clock-clocking-in`), which never restarts or resumes. `defaultMinutes` is the
88 /// default the minutes prompt offered, when there was one.
89 public static func plan(
90 key: Character, minutes: Int?, start: Date, lastValid: Date, now: Date, active: Bool, clockingIn: Bool, defaultMinutes: Int? = nil
91 ) throws -> Plan {
92 var plan = Plan()
93 if key == "j" || key == "J" {
94 if key == "J" { plan.steps = try resolve(.now, clockOutTime: nil, close: true, restart: false, active: active, clockingIn: clockingIn, now: now).steps }
95 plan.jump = true
96 return plan
97 }
98 guard "kKgGsSCtT".contains(key) else { return plan }
99 let defaultMinutes = defaultMinutes ?? Self.defaultMinutes(lastValid: lastValid, now: now)
100 let keep = "kKtT".contains(key) ? minutes : nil
101 let gotback = "gG".contains(key) ? minutes : nil
102 let subtract = key == "s" || key == "S"
103 // `barely-started-p`: less than 45 seconds on the clock before idling.
104 let startOver = subtract && lastValid.timeIntervalSince(start) < 45
105 let target: Target
106 if key == "C" || startOver {
107 target = .cancel
108 } else if subtract || gotback == 0 {
109 target = .time(lastValid)
110 } else if keep == defaultMinutes || gotback == defaultMinutes {
111 target = .now
112 } else if let keep {
113 target = .time(lastValid.addingTimeInterval(Double(keep * 60)))
114 } else if let gotback {
115 target = .time(now.addingTimeInterval(Double(-gotback * 60)))
116 } else {
117 throw Failure(message: "Unexpected, please report this as a bug")
118 }
119 return try resolve(
120 target, clockOutTime: gotback == nil ? nil : lastValid, close: "KGST".contains(key),
121 restart: startOver && !"KGSC".contains(key), active: active, clockingIn: clockingIn, now: now
122 )
123 }
124
125 enum Target {
126 case cancel, now
127 case time(Date)
128 }
129
130 /// `org-clock-resolve-clock`.
131 static func resolve(_ target: Target, clockOutTime: Date?, close: Bool, restart: Bool, active: Bool, clockingIn: Bool, now: Date) throws -> Plan {
132 var plan = Plan()
133 switch target {
134 case .cancel:
135 plan.steps = [.cancel]
136 if restart, !clockingIn { plan.steps.append(.clockIn(now)) }
137 case .now:
138 if restart { throw Failure(message: "RESTART is not valid here") }
139 if close || clockingIn {
140 plan.steps = [.clockOut(now)]
141 } else if !active {
142 plan.steps = [.resume]
143 }
144 case .time(let time):
145 if now < time { throw Failure(message: "RESOLVE-TO must refer to a time in the past") }
146 if restart { throw Failure(message: "RESTART is not valid here") }
147 plan.steps = [.clockOut(clockOutTime ?? time)]
148 if clockingIn {
149 } else if close {
150 plan.setsLeftover = true
151 plan.leftover = clockOutTime == nil ? time : nil
152 } else {
153 plan.steps.append(.clockIn(clockOutTime == nil ? now : time))
154 }
155 }
156 return plan
157 }
158
159 /// The time of a clock stamp (`2026-10-08 Thu 09:00`).
160 public static func date(of stamp: String, calendar: Calendar = .current) -> Date? {
161 EmacsBuffer.parseTimeString(stamp).flatMap { calendar.date(from: $0) }
162 }
163
164 /// `org-find-open-clocks`: each open `CLOCK:` line in `text` (in a clock element), with
165 /// its start stamp and where the line starts.
166 public static func openClocks(in text: String) -> [(start: String, offset: Int)] {
167 guard text.contains("CLOCK:") else { return [] }
168 var result: [(String, Int)] = []
169 func walk(_ node: SyntaxNode) {
170 for child in node.children {
171 if child.kind == .clock {
172 let line = child.text.trimmingCharacters(in: .newlines)
173 if let match = line.firstMatch(of: /^[ \t]*CLOCK: \[([^\]]+)\][ \t]*$/) {
174 result.append((String(match.1), child.range.lowerBound))
175 }
176 } else {
177 walk(child)
178 }
179 }
180 }
181 walk(OrgParser.parse(text).root)
182 return result
183 }
184}
Sources/OrgCore/Commands/FastSelection.swift +2
@@ -15,6 +15,8 @@ public struct FastSelection: Sendable, Equatable {
1515 /// `[`, `]` and `:` of a group tag; shown, without effect on selection.
1616 case startTagGroup, endTagGroup, groupTags
1717 case newline
18 /// A line of text above the options that follow (`org-clock-select-task`'s sections).
19 case label(String)
1820 }
1921
2022 public let items: [Item]
Sources/OrgCore/Keymap/Presets.swift +28
@@ -370,6 +370,22 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
370370 command = "app.clock.goto"
371371 mode = "normal"
372372 [[bind]]
373 keys = "SPC m c G"
374 command = "app.clock.goto-recent"
375 mode = "normal"
376 [[bind]]
377 keys = "SPC m c I"
378 command = "app.clock.in-last"
379 mode = "normal"
380 [[bind]]
381 keys = "SPC m c d"
382 command = "app.clock.default"
383 mode = "normal"
384 [[bind]]
385 keys = "SPC m c r"
386 command = "app.clock.resolve"
387 mode = "normal"
388 [[bind]]
373389 keys = "SPC m c R"
374390 command = "app.clock.report"
375391 mode = "normal"
@@ -830,6 +846,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
830846 keys = "C-c C-x C-j"
831847 command = "app.clock.goto"
832848 [[bind]]
849 keys = "C-c C-x C-x"
850 command = "app.clock.in-last"
851 [[bind]]
852 keys = "C-c C-x C-z"
853 command = "app.clock.resolve"
854 [[bind]]
833855 keys = "C-c C-e h h"
834856 command = "app.export.html"
835857 [[bind]]
@@ -1260,6 +1282,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
12601282 keys = "C-s-j"
12611283 command = "app.clock.goto"
12621284 [[bind]]
1285 keys = "C-M-s-i"
1286 command = "app.clock.in-recent"
1287 [[bind]]
1288 keys = "C-M-s-j"
1289 command = "app.clock.goto-recent"
1290 [[bind]]
12631291 keys = "s-S-e"
12641292 command = "app.export-dialog"
12651293 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +5
@@ -87,9 +87,14 @@ public enum EditorCommands {
8787 EditorCommand(id: "app.capture", title: "Capture…", action: .app),
8888 EditorCommand(id: "app.board", title: "Board", action: .app),
8989 EditorCommand(id: "app.clock.in", title: "Clock In", action: .app),
90 EditorCommand(id: "app.clock.in-recent", title: "Clock In to Recent Entry…", action: .app),
91 EditorCommand(id: "app.clock.in-last", title: "Clock In to Last Entry", action: .app),
9092 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app),
9193 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app),
9294 EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app),
95 EditorCommand(id: "app.clock.goto-recent", title: "Go to Recent Clocked Entry…", action: .app),
96 EditorCommand(id: "app.clock.default", title: "Mark as Default Clock Task", action: .app),
97 EditorCommand(id: "app.clock.resolve", title: "Resolve Open Clocks…", action: .app),
9398 EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app),
9499 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app),
95100 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app),
Sources/Orgstar/ClockViews.swift +71 −6
@@ -1,6 +1,8 @@
11import AppKit
2import CoreGraphics
23import OrgApp
34import OrgCore
5import OrgEditorAppKit
46import SwiftUI
57
68/// Elapsed time of the running clock, `H:MM`.
@@ -9,6 +11,57 @@ func clockElapsed(_ running: ClockModel.Running, now: Date) -> String {
911 return String(format: "%d:%02d", minutes / 60, minutes % 60)
1012}
1113
14/// What the menu bar item shows.
15struct ClockMenuState: Equatable {
16 let running: ClockModel.Running?
17 let history: [ClockTask]
18}
19
20/// `org-user-idle-seconds` on macOS: time since the last keyboard or mouse input anywhere.
21enum ClockIdle {
22 static func seconds() -> Double {
23 // `kCGAnyInputEventType`.
24 CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: CGEventType(rawValue: ~0)!)
25 }
26}
27
28/// The clock history in a menu: clocking in to an entry runs `app.clock.history.N`.
29struct RecentClocks: View {
30 let clock: ClockModel
31 let run: (String) -> Void
32
33 var body: some View {
34 if !clock.history.isEmpty {
35 Section("Recent Entries") {
36 ForEach(Array(clock.history.enumerated()), id: \.offset) { index, task in
37 Button(task.title) { run("app.clock.history.\(index)") }
38 .help("Clock in to \(task.title) in \((task.path as NSString).lastPathComponent)")
39 }
40 }
41 }
42 }
43}
44
45/// Org ▸ Clock: the clock commands, with their keys, and the recent entries.
46struct ClockMenu: View {
47 let commands: AppCommands
48 let clock: ClockModel
49
50 var body: some View {
51 Menu("Clock") {
52 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("app.clock.") }, id: \.id) { command in
53 Button {
54 commands.run(command.id)
55 } label: {
56 Text(command.title)
57 if let keys = commands.keyLabel(for: command.id) { Text(keys) }
58 }
59 }
60 RecentClocks(clock: clock) { commands.run($0) }
61 }
62 }
63}
64
1265/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to.
1366@MainActor
1467final class ClockStatusItem: NSObject {
@@ -21,7 +74,7 @@ final class ClockStatusItem: NSObject {
2174 self.run = run
2275 }
2376
24 func update(_ running: ClockModel.Running?) {
77 func update(_ running: ClockModel.Running?, recent: [String] = []) {
2578 self.running = running
2679 guard let running else {
2780 timer?.invalidate()
@@ -39,16 +92,28 @@ final class ClockStatusItem: NSObject {
3992 let menu = NSMenu()
4093 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "")
4194 menu.addItem(.separator())
42 for (title, id) in [("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"), ("Clock Report", "app.clock.report")] {
43 let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "")
44 entry.target = self
45 entry.representedObject = id
46 menu.addItem(entry)
95 for (title, id) in [
96 ("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"),
97 ("Clock In to Recent Entry…", "app.clock.in-recent"), ("Clock Report", "app.clock.report"),
98 ] {
99 add(title, id, to: menu)
100 }
101 if !recent.isEmpty {
102 menu.addItem(.separator())
103 menu.addItem(NSMenuItem.sectionHeader(title: "Recent Entries"))
104 for (index, title) in recent.enumerated() { add(title, "app.clock.history.\(index)", to: menu) }
47105 }
48106 item?.menu = menu
49107 refresh()
50108 }
51109
110 private func add(_ title: String, _ id: String, to menu: NSMenu) {
111 let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "")
112 entry.target = self
113 entry.representedObject = id
114 menu.addItem(entry)
115 }
116
52117 private func refresh() {
53118 guard let running else { return }
54119 item?.button?.title = "⏱ " + clockElapsed(running, now: Date())
Sources/Orgstar/ContentView.swift +62 −5
@@ -16,6 +16,7 @@ struct ContentView: View {
1616 let clock: ClockModel
1717 @AppStorage("reminders") private var remindersEnabled = true
1818 @AppStorage("reminderLead") private var reminderLead = 12
19 @AppStorage("clockIdleTime") private var clockIdleTime = 0
1920 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
2021 @State private var reminderAgenda: AgendaModel?
2122 @State private var trust = TrustStore()
@@ -150,6 +151,10 @@ struct ContentView: View {
150151 Button("Clock Out") { perform("app.clock.out") }
151152 Button("Cancel Clock") { perform("app.clock.cancel") }
152153 Button("Go to Clocked Entry") { perform("app.clock.goto") }
154 Divider()
155 Button("Clock In to Recent Entry…") { perform("app.clock.in-recent") }
156 Button("Go to Recent Clocked Entry…") { perform("app.clock.goto-recent") }
157 RecentClocks(clock: clock) { perform($0) }
153158 } label: {
154159 TimelineView(.periodic(from: .now, by: 30)) { context in
155160 Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1)
@@ -280,9 +285,9 @@ struct ContentView: View {
280285
281286 var body: some View {
282287 main
283 .onChange(of: clock.running, initial: true) { _, running in
288 .onChange(of: ClockMenuState(running: clock.running, history: clock.history), initial: true) { _, state in
284289 if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } }
285 statusItem?.update(running)
290 statusItem?.update(state.running, recent: state.history.map(\.title))
286291 }
287292 .onChange(of: globalCapture, initial: true) { _, enabled in
288293 hotKey?.unregister()
@@ -345,6 +350,18 @@ struct ContentView: View {
345350 hour += 1
346351 }
347352 }
353 // `org-resolve-clocks-if-idle`, checked every minute while a clock runs.
354 .task(id: "\(clock.running?.start ?? "") \(clockIdleTime)") {
355 guard clock.running != nil, clockIdleTime > 0 else { return }
356 while !Task.isCancelled {
357 try? await Task.sleep(for: .seconds(60))
358 guard !Task.isCancelled, clock.running != nil, !clock.isResolving else { continue }
359 let idle = ClockIdle.seconds()
360 guard idle > Double(clockIdleTime * 60) else { continue }
361 NSApp.requestUserAttention(.informationalRequest)
362 report(await clock.resolveIdle(session, idleSince: Date().addingTimeInterval(-idle), ask: askClock))
363 }
364 }
348365 .task {
349366 DevelopmentHooks.scheduleSnapshot()
350367 pendingCarets = BufferRestore.restore(session)
@@ -523,7 +540,7 @@ struct ContentView: View {
523540
524541 /// Runs a command from a key, the palette or the Org menu.
525542 private func perform(_ id: String) {
526 let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.")
543 let orgOnly = ["app.refile", "app.archive", "app.clock.in", "app.clock.default"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.")
527544 if orgOnly, !session.isOrg {
528545 commands.show("Not an org file")
529546 return
@@ -620,13 +637,31 @@ struct ContentView: View {
620637 case "app.agenda":
621638 openWindow(id: "agenda")
622639 case "app.clock.in":
623 commands.show(clock.clockIn(session))
640 clockIn(.caret)
641 case "app.clock.in-recent":
642 clockIn(.select)
643 case "app.clock.in-last":
644 clockIn(.last)
645 case let id where id.hasPrefix("app.clock.history."):
646 if let index = Int(id.dropFirst("app.clock.history.".count)), index < clock.history.count { clockIn(.task(clock.history[index])) }
624647 case "app.clock.out":
625648 commands.show(clock.clockOut(session))
626649 case "app.clock.cancel":
627650 commands.show(clock.clockOut(session, cancel: true))
628651 case "app.clock.goto":
629 if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") }
652 let found = clock.goto(session)
653 if let location = found.location { commands.jump(path: location.path, offset: location.offset) }
654 if let message = found.message { commands.show(message) }
655 case "app.clock.goto-recent":
656 Task {
657 let found = await clock.gotoRecent(session, ask: askClock)
658 if let location = found.location { commands.jump(path: location.path, offset: location.offset) }
659 if let message = found.message { commands.show(message) }
660 }
661 case "app.clock.default":
662 commands.show(clock.markDefault(session))
663 case "app.clock.resolve":
664 Task { report(await clock.resolveOpenClocks(session, paths: orgFiles, ask: askClock)) }
630665 case "app.clock.report":
631666 openWindow(id: "clock-report")
632667 case "app.board":
@@ -655,6 +690,28 @@ struct ContentView: View {
655690 }
656691 }
657692
693 /// Clock questions in the echo area.
694 private var askClock: ClockModel.Ask {
695 { [commands] prompt in
696 await withCheckedContinuation { continuation in
697 commands.ask(prompt) { continuation.resume(returning: $0) }
698 }
699 }
700 }
701
702 /// The files `org-resolve-clocks` looks through: the workspace's org files.
703 private var orgFiles: [String] { workspace.files.filter { $0.kind == .org }.map(\.path) }
704
705 private func clockIn(_ target: ClockModel.Target) {
706 Task { report(await clock.clockIn(session, target: target, paths: orgFiles, ask: askClock)) }
707 }
708
709 /// The last message of a clock command, and the clock to show for `j`.
710 private func report(_ outcome: ClockModel.Outcome) {
711 if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) }
712 if let message = outcome.messages.last(where: { !$0.isEmpty }) { commands.show(message) }
713 }
714
658715 private var errorShown: Binding<Bool> {
659716 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
660717 }
Sources/Orgstar/FastSelectionView.swift +4
@@ -28,6 +28,8 @@ struct FastSelectionView: View {
2828 switch cell {
2929 case .text(let text):
3030 Text(text).foregroundStyle(.secondary)
31 case .label(let text):
32 Text(text).bold()
3133 case .option(let key, let name):
3234 HStack(spacing: 4) {
3335 Text("[\(String(key))]").foregroundStyle(.tint)
@@ -106,6 +108,7 @@ struct FastSelectionView: View {
106108
107109 private enum Cell {
108110 case text(String)
111 case label(String)
109112 case option(Character, String)
110113 }
111114
@@ -121,6 +124,7 @@ struct FastSelectionView: View {
121124 case .endTagGroup: rows[rows.count - 1].append(.text("]")); newRow()
122125 case .groupTags: rows[rows.count - 1].append(.text(":"))
123126 case .newline: newRow()
127 case .label(let text): newRow(); rows[rows.count - 1].append(.label(text)); newRow()
124128 case .option(let key, let name): rows[rows.count - 1].append(.option(key, name))
125129 }
126130 }
Sources/Orgstar/OrgstarApp.swift +18 −1
@@ -401,6 +401,8 @@ struct OrgstarApp: App {
401401 }
402402 .commands {
403403 CommandMenu("Org") {
404 ClockMenu(commands: commands, clock: clock)
405 Divider()
404406 OrgMenuItems(commands: commands)
405407 }
406408 }
@@ -503,6 +505,8 @@ struct SettingsView: View {
503505 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
504506 @AppStorage("reminders") private var reminders = true
505507 @AppStorage("reminderLead") private var reminderLead = 12
508 @AppStorage("clockIdleTime") private var clockIdleTime = 0
509 @AppStorage("clockHistoryLength") private var clockHistoryLength = 5
506510 @AppStorage("globalCapture") private var globalCapture = true
507511 @AppStorage("settingsTab") private var tab = "general"
508512 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -664,9 +668,22 @@ struct SettingsView: View {
664668 Text("An entry's APPT_WARNTIME property overrides the lead time.")
665669 .settingsFooter()
666670 }
671 Section {
672 Toggle("Ask what to do with idle time while clocked in", isOn: Binding {
673 clockIdleTime > 0
674 } set: { on in
675 clockIdleTime = on ? 15 : 0
676 })
677 Stepper("After \(max(clockIdleTime, 1)) minutes without keyboard or mouse input", value: $clockIdleTime, in: 1...240)
678 .disabled(clockIdleTime == 0)
679 Stepper("Remember \(clockHistoryLength) recently clocked entries", value: $clockHistoryLength, in: 1...35)
680 } footer: {
681 Text("org-clock-idle-time and org-clock-history-length. Org ▸ Clock lists the recent entries.")
682 .settingsFooter()
683 }
667684 }
668685 .formStyle(.grouped)
669 .frame(height: 290)
686 .frame(height: 430)
670687 }
671688
672689 private var capture: some View {
Sources/OrgstarMobile/ClockScreens.swift +169 −4
@@ -3,9 +3,173 @@ import OrgApp
33import OrgCore
44import SwiftUI
55
6/// Clock commands that ask questions, and their answers in `ClockPromptSheet`.
7@MainActor
8@Observable
9final class ClockPrompter {
10 struct Pending: Identifiable {
11 let id = UUID()
12 let prompt: Prompt
13 let reply: (String?) -> Void
14 }
15
16 var pending: Pending?
17
18 var ask: ClockModel.Ask {
19 { [self] prompt in
20 await withCheckedContinuation { continuation in
21 pending = Pending(prompt: prompt) { continuation.resume(returning: $0) }
22 }
23 }
24 }
25
26 func answer(_ text: String?) {
27 guard let request = pending else { return }
28 pending = nil
29 request.reply(text)
30 }
31}
32
33/// The clock commands both the editor and the clock accessory run.
34@MainActor
35enum MobileClock {
36 /// Runs a clock command; false for other ids. Messages go to `show`; entries to show open.
37 static func run(_ id: String, model: MobileModel, workspace: WorkspaceModel, show: @escaping (String) -> Void) -> Bool {
38 let clock = model.clock
39 let session = model.session
40 let ask = model.clockPrompts.ask
41 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
42 func open(_ location: (path: String, offset: Int)?) {
43 if let location { model.openRequest = .offset(location.path, location.offset) }
44 }
45 func report(_ outcome: ClockModel.Outcome) {
46 open(outcome.jump)
47 if let message = outcome.messages.last(where: { !$0.isEmpty }) { show(message) }
48 }
49 func clockIn(_ target: ClockModel.Target) {
50 Task { report(await clock.clockIn(session, target: target, paths: paths, ask: ask)) }
51 }
52 switch id {
53 case "app.clock.in": clockIn(.caret)
54 case "app.clock.in-recent": clockIn(.select)
55 case "app.clock.in-last": clockIn(.last)
56 case let id where id.hasPrefix("app.clock.history."):
57 guard let index = Int(id.dropFirst("app.clock.history.".count)), index < clock.history.count else { return true }
58 clockIn(.task(clock.history[index]))
59 case "app.clock.out": show(clock.clockOut(session))
60 case "app.clock.cancel": show(clock.clockOut(session, cancel: true))
61 case "app.clock.goto":
62 let found = clock.goto(session)
63 open(found.location)
64 if let message = found.message { show(message) }
65 case "app.clock.goto-recent":
66 Task {
67 let found = await clock.gotoRecent(session, ask: ask)
68 open(found.location)
69 if let message = found.message { show(message) }
70 }
71 case "app.clock.default": show(clock.markDefault(session))
72 case "app.clock.resolve":
73 Task { report(await clock.resolveOpenClocks(session, paths: paths, ask: ask)) }
74 default:
75 return false
76 }
77 return true
78 }
79}
80
81/// Recent entries to clock in to, and the commands that choose among them.
82struct RecentClockMenu: View {
83 let clock: ClockModel
84 let perform: (String) -> Void
85
86 var body: some View {
87 Menu("Recent Entries", systemImage: "clock.arrow.circlepath") {
88 ForEach(Array(clock.history.enumerated()), id: \.offset) { index, task in
89 Button(task.title) { perform("app.clock.history.\(index)") }
90 }
91 Divider()
92 Button("Clock In to Recent Entry…") { perform("app.clock.in-recent") }
93 Button("Go to Recent Clocked Entry…") { perform("app.clock.goto-recent") }
94 }
95 .disabled(clock.history.isEmpty)
96 }
97}
98
99/// A clock question: keys to choose from under their sections, or a number or date to type.
100struct ClockPromptSheet: View {
101 let prompt: Prompt
102 let answer: (String?) -> Void
103 @State private var text = ""
104
105 var body: some View {
106 NavigationStack {
107 Form {
108 if let fast = prompt.fast {
109 ForEach(Array(sections(fast).enumerated()), id: \.offset) { _, section in
110 Section(section.title) {
111 ForEach(Array(section.options.enumerated()), id: \.offset) { _, option in
112 Button {
113 answer(String(option.key))
114 } label: {
115 HStack(alignment: .firstTextBaseline) {
116 Text(String(option.key)).font(.body.monospaced()).foregroundStyle(.tint)
117 Text(option.name).foregroundStyle(.primary)
118 }
119 }
120 }
121 }
122 }
123 } else {
124 Section {
125 TextField(prompt.message, text: $text)
126 .autocorrectionDisabled()
127 .textInputAutocapitalization(.never)
128 .keyboardType(prompt.date == nil ? .numbersAndPunctuation : .default)
129 .onSubmit { answer(text) }
130 } footer: {
131 Text(prompt.date == nil ? "Empty takes the default." : "Org's date syntax: 14:00, -1h, today 9:30")
132 }
133 }
134 }
135 .navigationTitle(prompt.message.trimmingCharacters(in: CharacterSet(charactersIn: ":?[] ")))
136 .navigationBarTitleDisplayMode(.inline)
137 .toolbar {
138 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { answer(nil) } }
139 if prompt.fast == nil {
140 ToolbarItem(placement: .confirmationAction) { Button("Done") { answer(text) } }
141 }
142 }
143 }
144 .onAppear { text = prompt.initial }
145 .interactiveDismissDisabled()
146 }
147
148 private struct Group {
149 var title: String
150 var options: [(key: Character, name: String)]
151 }
152
153 /// Options under the labels before them.
154 private func sections(_ fast: FastSelection) -> [Group] {
155 var groups: [Group] = []
156 for item in fast.items {
157 switch item {
158 case .label(let title): groups.append(Group(title: title, options: []))
159 case .option(let key, let name):
160 if groups.isEmpty { groups.append(Group(title: "", options: [])) }
161 groups[groups.count - 1].options.append((key, name))
162 default: break
163 }
164 }
165 return groups
166 }
167}
168
6169/// The running clock over the tab bar: the entry and time so far, and clocking out.
7170struct ClockAccessory: View {
8171 let running: ClockModel.Running
172 let clock: ClockModel
9173 let perform: (String) -> Void
10174
11175 var body: some View {
@@ -13,6 +177,7 @@ struct ClockAccessory: View {
13177 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") }
14178 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") }
15179 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") }
180 RecentClockMenu(clock: clock, perform: perform)
16181 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") }
17182 } label: {
18183 HStack {
@@ -38,14 +203,14 @@ struct ClockAccessory: View {
38203extension View {
39204 /// The running clock over the tab bar, while there is one.
40205 @ViewBuilder
41 func clockAccessory(_ running: ClockModel.Running?, perform: @escaping (String) -> Void) -> some View {
206 func clockAccessory(_ clock: ClockModel, perform: @escaping (String) -> Void) -> some View {
42207 if #available(iOS 26.1, *) {
43 tabViewBottomAccessory(isEnabled: running != nil) {
44 if let running { ClockAccessory(running: running, perform: perform) }
208 tabViewBottomAccessory(isEnabled: clock.running != nil) {
209 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform) }
45210 }
46211 } else {
47212 safeAreaInset(edge: .bottom) {
48 if let running { ClockAccessory(running: running, perform: perform).padding(.vertical, 8).background(.bar) }
213 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform).padding(.vertical, 8).background(.bar) }
49214 }
50215 }
51216 }
Sources/OrgstarMobile/EditorScreen.swift +9 −9
@@ -70,6 +70,7 @@ struct EditorScreen: View {
7070 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) {
7171 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right")
7272 }
73 RecentClockMenu(clock: clock) { editor?.run($0) }
7374 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
7475 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
7576 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -175,14 +176,10 @@ struct EditorScreen: View {
175176 editor.onRefreshSetup = { session.refreshSetup() }
176177 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
177178 editor.onAppCommand = { id in
178 switch id {
179 case "app.clock.in": message = clock.clockIn(session)
180 case "app.clock.out": message = clock.clockOut(session)
181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true)
182 case "app.clock.goto":
183 if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" }
184 case "app.toggle-markup": model.showMarkup.toggle()
185 default: message = "Not available on iOS yet"
179 if id == "app.toggle-markup" {
180 model.showMarkup.toggle()
181 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) {
182 message = "Not available on iOS yet"
186183 }
187184 }
188185 self.editor = editor
@@ -412,7 +409,10 @@ struct CommandPalette: View {
412409 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
413410 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
414411 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
415 ("app.clock.goto", "Go to Clocked Entry"), ("app.toggle-markup", "Show or Hide Markup"),
412 ("app.clock.goto", "Go to Clocked Entry"), ("app.clock.in-recent", "Clock In to Recent Entry…"),
413 ("app.clock.in-last", "Clock In to Last Entry"), ("app.clock.goto-recent", "Go to Recent Clocked Entry…"),
414 ("app.clock.default", "Mark as Default Clock Task"), ("app.clock.resolve", "Resolve Open Clocks…"),
415 ("app.toggle-markup", "Show or Hide Markup"),
416416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
417417 ]
418418
Sources/OrgstarMobile/MobileEditor.swift +2 −1
@@ -333,7 +333,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
333333 case "org.cycle":
334334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
335335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.toggle-markup": return onAppCommand != nil
336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last",
337 "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil
337338 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
338339 default: return false
339340 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -22,6 +22,7 @@ final class MobileModel {
2222 let agenda: AgendaModel?
2323 let scheduler = ReminderScheduler(center: SystemReminderCenter())
2424 let clock = ClockModel()
25 let clockPrompts = ClockPrompter()
2526 let spotlight = SpotlightIndexer()
2627 /// Captures from the share extension; nil without the app group.
2728 let inbox = CaptureInbox()
Sources/OrgstarMobile/OrgstarMobileApp.swift +8 −8
@@ -131,7 +131,10 @@ struct RootView: View {
131131 }
132132 }
133133 }
134 .clockAccessory(model.clock.running, perform: clock)
134 .clockAccessory(model.clock, perform: clock)
135 .sheet(item: Binding(get: { model.clockPrompts.pending }, set: { if $0 == nil { model.clockPrompts.answer(nil) } })) { pending in
136 ClockPromptSheet(prompt: pending.prompt) { model.clockPrompts.answer($0) }
137 }
135138 // Above the tab bar, clear of every screen's toolbar.
136139 .overlay(alignment: .bottom) {
137140 if let message {
@@ -222,13 +225,10 @@ struct RootView: View {
222225 }
223226
224227 private func clock(_ id: String) {
225 switch id {
226 case "app.clock.out": message = model.clock.clockOut(session)
227 case "app.clock.cancel": message = model.clock.clockOut(session, cancel: true)
228 case "app.clock.goto":
229 if let location = model.clock.location(session) { model.openRequest = .offset(location.path, location.offset) }
230 case "app.clock.report": showingClockReport = true
231 default: break
228 if id == "app.clock.report" {
229 showingClockReport = true
230 } else {
231 _ = MobileClock.run(id, model: model, workspace: workspace) { message = $0 }
232232 }
233233 }
234234
Tests/OrgAppTests/ClockHistoryModelTests.swift added +130
@@ -0,0 +1,130 @@
1import Foundation
2import OrgCore
3import OrgDocument
4import Testing
5@testable import OrgApp
6
7/// Answers prompts in order and remembers what they asked.
8@MainActor
9final class ScriptedAnswers {
10 var answers: [String?]
11 var asked: [String] = []
12 init(_ answers: [String?]) { self.answers = answers }
13 var ask: ClockModel.Ask {
14 { prompt in
15 self.asked.append(prompt.message)
16 return self.answers.isEmpty ? nil : self.answers.removeFirst()
17 }
18 }
19}
20
21@MainActor
22struct ClockHistoryModelTests {
23 let notes: Folder
24 let work: URL
25 let session: DocumentSession
26 let stateFile: URL
27 let defaults: UserDefaults
28 let start = Date(timeIntervalSince1970: 1_789_000_000)
29
30 init() throws {
31 notes = try Folder()
32 work = try notes.write("work.org", "* A\n* B\n:PROPERTIES:\n:ID: b1\n:END:\n* C\n")
33 session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
34 session.open(work.path)
35 stateFile = notes.url.appendingPathComponent("clock.json")
36 defaults = UserDefaults(suiteName: "orgstar-clock-\(UUID().uuidString)")!
37 }
38
39 func offset(_ heading: String) -> Int {
40 (session.document!.text as NSString).range(of: heading).location
41 }
42
43 @Test func historyIsKeptAndPersisted() throws {
44 let clock = ClockModel(file: stateFile, defaults: defaults)
45 for (i, heading) in ["* A", "* B", "* A", "* C"].enumerated() {
46 _ = clock.clockIn(session, path: work.path, at: offset(heading), now: start.addingTimeInterval(Double(i * 600)))
47 }
48 #expect(clock.history.map(\.title) == ["C", "A", "B"])
49 #expect(clock.interruptedTask?.title == "A")
50 let reloaded = ClockModel(file: stateFile, defaults: defaults)
51 #expect(reloaded.history == clock.history && reloaded.running == clock.running)
52
53 // Headings move and are renamed: entries are found by ID, else position and title.
54 session.reveal(0)
55 _ = session.perform(PrecomputedEdit(EditResult(baseRevision: session.document!.revision, edits: [TextEdit(range: 0..<0, replacement: "* New\n")])), path: work.path, at: 0, expect: nil)
56 #expect(clock.locate(clock.history[2], session)?.offset == offset("* B"))
57
58 defaults.set(2, forKey: "clockHistoryLength")
59 _ = clock.clockIn(session, path: work.path, at: offset("* B"), now: start.addingTimeInterval(3000))
60 #expect(clock.history.map(\.title) == ["B", "C"])
61 }
62
63 @Test func oldClockFilesStillLoad() throws {
64 let running = ClockModel.Running(path: work.path, start: "2026-09-10 Thu 00:26", heading: "A", startedAt: start)
65 try JSONEncoder().encode(running).write(to: stateFile)
66 let clock = ClockModel(file: stateFile, defaults: defaults)
67 #expect(clock.running == running && clock.history.isEmpty)
68 }
69
70 @Test func selectingRecentEntries() async throws {
71 let clock = ClockModel(file: stateFile, defaults: defaults)
72 let none = await clock.clockIn(session, target: .select, paths: [], ask: ScriptedAnswers([]).ask)
73 #expect(none.messages == ["No recent clock"])
74 _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
75 _ = clock.clockIn(session, path: work.path, at: offset("* B"), now: start.addingTimeInterval(600))
76 _ = clock.markDefault(session, path: work.path, at: offset("* C"))
77 let choices = try #require(clock.selection(session))
78 #expect(choices.map(\.key) == ["d", "i", "c", "1", "2"])
79 #expect(choices.map(\.task.title) == ["C", "A", "B", "B", "A"])
80
81 let answers = ScriptedAnswers(["2"])
82 let outcome = await clock.clockIn(session, target: .select, paths: [work.path], ask: answers.ask, now: { start.addingTimeInterval(1200) })
83 #expect(answers.asked == ["Clock-in on task: "])
84 #expect(clock.running?.heading == "A" && outcome.messages.last?.hasPrefix("Clock starts at") == true)
85 let same = await clock.clockIn(session, target: .last, paths: [], ask: ScriptedAnswers([]).ask)
86 #expect(same.messages == ["Clock continues in \"A\""])
87 let aborted = await clock.clockIn(session, target: .select, paths: [], ask: ScriptedAnswers(["q"]).ask)
88 #expect(aborted.messages == ["Abort"])
89
90 _ = clock.clockOut(session, now: start.addingTimeInterval(1800))
91 let goto = clock.goto(session)
92 #expect(goto.location?.offset == offset("* A") && goto.message == "No running clock, this is the most recently clocked task")
93 let recent = await clock.gotoRecent(session, ask: ScriptedAnswers(["2"]).ask)
94 #expect(recent.location?.offset == offset("* B"))
95 }
96
97 @Test func danglingClocksAreResolvedBeforeClockingIn() async throws {
98 let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
99 let clock = ClockModel(file: stateFile, defaults: defaults)
100 let opened = clock.openClocks(session, paths: [other.path])
101 #expect(opened.map(\.heading) == ["Old"])
102 let answers = ScriptedAnswers(["x", "K", "30"])
103 let outcome = await clock.clockIn(session, target: .caret, paths: [other.path], ask: answers.ask, now: { opened[0].startedAt.addingTimeInterval(7200) })
104 #expect(answers.asked.first == "Dangling clock started 120 mins ago" + ClockResolution.keyHint)
105 #expect(answers.asked.count == 3 && answers.asked[2] == "Keep how many minutes (default 120): ")
106 #expect(outcome.messages.count == 2)
107 #expect(clock.running?.heading == "A")
108 let text = try #require(ClockModel.text(other.path, session))
109 #expect(text.contains("CLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 09:30] => 0:30"), "\(text)")
110 // Resolving before a clock-in never leaves time over.
111 #expect(clock.leftover == nil)
112 }
113
114 @Test func idleTimeIsSubtractedAndOfferedToTheNextClock() async throws {
115 let clock = ClockModel(file: stateFile, defaults: defaults)
116 _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
117 let idle = start.addingTimeInterval(20 * 60)
118 let answers = ScriptedAnswers(["S"])
119 _ = await clock.resolveIdle(session, idleSince: idle, ask: answers.ask, now: { idle.addingTimeInterval(45 * 60) })
120 #expect(answers.asked == ["Clocked in & idle for 45.0 mins" + ClockResolution.keyHint])
121 #expect(clock.running == nil && clock.leftover == idle)
122 #expect(session.document!.text.contains("=> 0:20"))
123
124 let next = ScriptedAnswers(["y"])
125 let c = try #require(ClockTask.at(offset("* C"), in: session.document!.text, path: work.path))
126 _ = await clock.clockIn(session, target: .task(c), paths: [], ask: next.ask, now: { idle.addingTimeInterval(50 * 60) })
127 #expect(next.asked == ["You stopped another clock 50 mins ago; start this one from then? (y or n) "])
128 #expect(clock.running?.heading == "C" && clock.running?.startedAt == idle && clock.leftover == nil)
129 }
130}
Tests/OrgCoreTests/ClockHistoryTests.swift added +191
@@ -0,0 +1,191 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ClockResolutionTests {
6 struct Case {
7 var text: String
8 var key: Character
9 var minutes: Int?
10 /// For `t`: when the user got distracted.
11 var date: Int?
12 /// Seconds after the clock-in (active) or the clock's start (dangling) that idling began.
13 var idle: Int
14 var active = true
15 var clockingIn = false
16 }
17
18 static let t1 = 1_789_000_000 // Thu 2026-09-10 00:26:40
19 static let now = t1 + 95 * 60 + 7
20 static let task = "* TODO Task\nBody\n"
21 static let logged = "* Task\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 10:00] => 1:00\n:END:\nNotes\n"
22 static let dangling = "* Task\n:LOGBOOK:\nCLOCK: [2026-09-10 Thu 00:26]\nCLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 10:00] => 1:00\n:END:\n* Next\n"
23
24 static let cases: [Case] = [
25 Case(text: task, key: "k", minutes: 77, idle: 17 * 60 + 20),
26 Case(text: task, key: "k", minutes: 5, idle: 17 * 60 + 20),
27 Case(text: logged, key: "K", minutes: 5, idle: 17 * 60 + 20),
28 Case(text: task, key: "K", minutes: 77, idle: 17 * 60 + 20),
29 Case(text: task, key: "s", idle: 17 * 60 + 20),
30 Case(text: logged, key: "S", idle: 17 * 60 + 20),
31 Case(text: logged, key: "C", idle: 17 * 60 + 20),
32 Case(text: task, key: "g", minutes: 10, idle: 17 * 60 + 20),
33 Case(text: task, key: "G", minutes: 10, idle: 17 * 60 + 20),
34 Case(text: task, key: "g", minutes: 0, idle: 17 * 60 + 20),
35 Case(text: task, key: "s", idle: 30),
36 Case(text: logged, key: "S", idle: 30),
37 Case(text: task, key: "t", date: t1 + 40 * 60, idle: 17 * 60 + 20),
38 Case(text: task, key: "T", date: t1 + 40 * 60, idle: 17 * 60 + 20),
39 Case(text: task, key: "J", idle: 17 * 60 + 20),
40 Case(text: task, key: "q", idle: 17 * 60 + 20),
41 Case(text: dangling, key: "k", minutes: 95, idle: 0, active: false),
42 Case(text: dangling, key: "k", minutes: 5, idle: 0, active: false),
43 Case(text: dangling, key: "S", idle: 0, active: false),
44 Case(text: dangling, key: "C", idle: 0, active: false),
45 Case(text: dangling, key: "k", minutes: 95, idle: 0, active: false, clockingIn: true),
46 Case(text: dangling, key: "g", minutes: 3, idle: 0, active: false, clockingIn: true),
47 ]
48
49 static func date(_ seconds: Int) -> Date { Date(timeIntervalSince1970: Double(seconds)) }
50
51 /// Clocks in (active cases), then carries out the plan with the clock commands.
52 func ours(_ c: Case) throws -> String {
53 let clock = ClockTests()
54 var text = c.text
55 var start: String
56 if c.active {
57 text = clock.run(ClockIn(), text, caret: 2, at: Self.t1).text
58 start = try #require(ClockIn.runningStart(in: text, near: 0))
59 } else {
60 start = try #require(ClockResolution.openClocks(in: text).first?.start)
61 }
62 let startDate = c.active ? Self.date(Self.t1) : try #require(ClockResolution.date(of: start, calendar: ClockTests.utc))
63 let lastValid = startDate.addingTimeInterval(Double(c.idle))
64 let minutes = c.date.map { ClockResolution.minutes(from: lastValid, to: Self.date($0)) } ?? c.minutes
65 let plan = try ClockResolution.plan(
66 key: c.key, minutes: minutes, start: startDate, lastValid: lastValid, now: Self.date(Self.now), active: c.active, clockingIn: c.clockingIn
67 )
68 let heading = try #require(ClockTask.at(0, in: text, path: "x"))
69 for step in plan.steps {
70 switch step {
71 case .cancel:
72 text = clock.run(ClockOut(start: start, cancel: true), text, caret: 0, at: Self.now).text
73 case .clockOut(let at):
74 text = clock.run(ClockOut(start: start), text, caret: 0, at: Int(at.timeIntervalSince1970)).text
75 case .clockIn(let at):
76 let offset = try #require(heading.locate(in: text))
77 text = clock.run(ClockIn(), text, caret: offset, at: Int(at.timeIntervalSince1970)).text
78 start = try #require(ClockIn.runningStart(in: text, near: offset))
79 case .resume:
80 break
81 }
82 }
83 return text
84 }
85
86 func form(_ c: Case) -> String {
87 let reads = """
88 ((symbol-function 'read-char-exclusive) (lambda (&rest _) ?\(c.key)))
89 ((symbol-function 'read-number) (lambda (&rest _) \(c.minutes ?? 0)))
90 ((symbol-function 'org-read-date) (lambda (&rest _) (seconds-to-time \(c.date ?? 0))))
91 """
92 let find = c.active ? "(cons org-clock-marker org-clock-start-time)" : """
93 (save-excursion
94 (goto-char (point-min))
95 (re-search-forward "CLOCK: \\\\(\\\\[.*?\\\\]\\\\)$")
96 (cons (copy-marker (match-end 1) t) (org-time-string-to-time (match-string 1))))
97 """
98 let resolve = """
99 (cl-letf (\(reads))
100 (let* ((clock \(find))
101 (org-clock-resolving-clocks-due-to-idleness t)
102 (org-clock-clocking-in \(c.clockingIn ? "t" : "nil")))
103 (org-clock-resolve clock (lambda (_) "Idle") (time-add (cdr clock) \(c.idle)))))
104 """
105 return """
106 (progn
107 (require 'org-clock)
108 (setq org-clock-persist nil org-clock-idle-time nil org-clock-auto-clock-resolution nil org-clock-into-drawer t
109 org-clock-leftover-time nil)
110 \(c.active ? frozenClock(Self.t1, "(org-clock-in)") : "")
111 \(frozenClock(Self.now, resolve)))
112 """
113 }
114
115 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
116 func matchesEmacs() throws {
117 let mine = try Self.cases.map(ours)
118 let emacs = try EmacsOracle.run(Self.cases.map { EmacsOracle.Case(text: $0.text, point: 3, form: form($0)) })
119 for (i, (ours, theirs)) in zip(mine, emacs).enumerated() {
120 let c = Self.cases[i]
121 #expect(ours == theirs.text, "case \(i) \(c.key) \(c.minutes.map(String.init) ?? "")\nours: \(ours.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
122 }
123 }
124
125 @Test func plans() throws {
126 let start = Self.date(Self.t1)
127 let idle = start.addingTimeInterval(600)
128 let now = start.addingTimeInterval(3600)
129 func plan(_ key: Character, _ minutes: Int? = nil, active: Bool = true) throws -> ClockResolution.Plan {
130 try ClockResolution.plan(key: key, minutes: minutes, start: start, lastValid: idle, now: now, active: active, clockingIn: false)
131 }
132 #expect(try plan("k", 50).steps.isEmpty)
133 #expect(try plan("k", 50, active: false).steps == [.resume])
134 #expect(try plan("K", 5) == .init(steps: [.clockOut(idle.addingTimeInterval(300))], leftover: idle.addingTimeInterval(300), setsLeftover: true))
135 #expect(try plan("S") == .init(steps: [.clockOut(idle)], leftover: idle, setsLeftover: true))
136 #expect(try plan("G", 5) == .init(steps: [.clockOut(idle)], leftover: nil, setsLeftover: true))
137 #expect(try plan("j") == .init(jump: true))
138 #expect(throws: ClockResolution.Failure.self) { try plan("k", 70) }
139 #expect(ClockResolution.defaultMinutes(lastValid: idle, now: now.addingTimeInterval(59)) == 50)
140 }
141}
142
143struct ClockHistoryTests {
144 static let text = "* A\n:PROPERTIES:\n:CATEGORY: work\n:END:\n** TODO [#B] Child :tag:\n* B\n:PROPERTIES:\n:ID: b1\n:END:\n* A\n"
145
146 static func offset(_ needle: String, _ text: String = text, last: Bool = false) -> Int {
147 (text as NSString).range(of: needle, options: last ? .backwards : []).location
148 }
149
150 @Test func pushKeepsTheNewestFirstWithoutDuplicates() {
151 let files = ["f.org": Self.text, "g.org": "* G\n"]
152 let read = { (path: String) in files[path] }
153 let a = ClockTask.at(0, in: Self.text, path: "f.org")!
154 let child = ClockTask.at(Self.offset("** TODO"), in: Self.text, path: "f.org")!
155 let b = ClockTask.at(Self.offset("* B"), in: Self.text, path: "f.org")!
156 let g = ClockTask.at(0, in: "* G\n", path: "g.org")!
157 let lastA = ClockTask.at(Self.offset("* A", last: true), in: Self.text, path: "f.org")!
158 #expect(child.title == "Child" && b.id == "b1" && lastA.ordinal == 3)
159 var history: [ClockTask] = []
160 for task in [a, child, b, a, g] { history = ClockHistory.push(task, onto: history, length: 3, text: read) }
161 #expect(history == [g, a, b])
162 // The second heading titled A is another entry.
163 history = ClockHistory.push(lastA, onto: history, length: 5, text: read)
164 #expect(history == [lastA, g, a, b])
165 // A file that is gone takes its entries along.
166 #expect(ClockHistory.push(a, onto: history, length: 5, text: { $0 == "g.org" ? nil : files[$0] }) == [a, lastA, b])
167 }
168
169 @Test func tasksAreFoundAfterEdits() {
170 let b = ClockTask.at(Self.offset("* B"), in: Self.text, path: "f.org")!
171 let a = ClockTask.at(0, in: Self.text, path: "f.org")!
172 let moved = "* New\n" + Self.text.replacingOccurrences(of: "* B\n", with: "* B renamed\n")
173 #expect(b.locate(in: moved) == Self.offset("* B renamed", moved))
174 #expect(a.locate(in: moved) == 6)
175 #expect(a.locate(in: "* Other\n") == nil)
176 }
177
178 @Test func selectionKeysAndLines() throws {
179 let read = { (_: String) in Self.text }
180 let tasks = [0, Self.offset("** TODO"), Self.offset("* B"), Self.offset("* A", last: true)].map { ClockTask.at($0, in: Self.text, path: "/n/f.org")! }
181 let choices = try #require(ClockHistory.selection(defaultTask: tasks[2], interrupted: nil, current: tasks[1], history: [tasks[1], tasks[1], tasks[0], tasks[3]], text: read))
182 #expect(choices.map(\.key) == ["d", "c", "1", "2", "3"])
183 #expect(choices[1].line == "work" + String(repeating: " ", count: 10) + "TODO [#B] Child")
184 #expect(choices[0].line == "f" + String(repeating: " ", count: 13) + "B")
185 #expect(choices.map(\.section) == ["Default Task", "Current Clocking Task", "Recent Tasks", "Recent Tasks", "Recent Tasks"])
186 #expect(ClockHistory.selection(defaultTask: tasks[0], interrupted: nil, current: nil, history: [], text: read) == nil)
187 let many = (0..<12).map { ClockTask(path: "/n/f.org", title: "A", ordinal: 0, id: "\($0)") }
188 let keys = ClockHistory.selection(defaultTask: nil, interrupted: nil, current: nil, history: many, text: read)!.map(\.key)
189 #expect(String(keys) == "123456789ABC")
190 }
191}