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
2import Observation 2import Observation
3import OrgCore 3import OrgCore
4 4
5/// The running clock, kept across launches as Emacs keeps `org-clock-marker` for the session, 5/// The running clock and the clock history, kept across launches as Org keeps them with
6/// and the commands that start and stop it. 6/// `org-clock-persist`, and the commands that start, stop and resolve clocks.
7@MainActor 7@MainActor
8@Observable 8@Observable
9public final class ClockModel { 9public final class ClockModel {
@@ -15,23 +15,65 @@ public final class ClockModel {
15 public let startedAt: Date 15 public let startedAt: Date
16 } 16 }
17 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
18 public private(set) var running: Running? 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
19 private let file: URL 51 private let file: URL
52 private let defaults: UserDefaults
20 53
21 public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json")) { 54 public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json"), defaults: UserDefaults = .standard) {
22 self.file = file 55 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 }
24 } 64 }
25 65
26 private func save() { 66 private func save() {
27 try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) 67 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)) {
29 try? data.write(to: file, options: .atomic) 69 try? data.write(to: file, options: .atomic)
30 } else { 70 } else {
31 try? FileManager.default.removeItem(at: file) 71 try? FileManager.default.removeItem(at: file)
32 } 72 }
33 } 73 }
34 74
75 // MARK: - Clocking in and out
76
35 /// `org-clock-in` at the heading under the caret, clocking out a running clock first. 77 /// `org-clock-in` at the heading under the caret, clocking out a running clock first.
36 public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String { 78 public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String {
37 guard let path = session.path, session.document != nil else { return "No file is open." } 79 guard let path = session.path, session.document != nil else { return "No file is open." }
@@ -41,18 +83,32 @@ public final class ClockModel {
41 /// `org-clock-in` at the heading containing `offset` in `path`. 83 /// `org-clock-in` at the heading containing `offset` in `path`.
42 public func clockIn(_ session: DocumentSession, path: String, at offset: Int, now: Date = Date()) -> String { 84 public func clockIn(_ session: DocumentSession, path: String, at offset: Int, now: Date = Date()) -> String {
43 guard let text = Self.text(path, session) else { return "Can't read \(path)" } 85 guard let text = Self.text(path, session) else { return "Can't read \(path)" }
44 let tree = OrgParser.parse(text) 86 let headings = DocumentModel(tree: OrgParser.parse(text)).headings
45 guard let entry = DocumentModel(tree: tree).headings.last(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" } 87 guard let index = headings.lastIndex(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" }
46 if running != nil { 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)
47 let message = clockOut(session, now: now) 94 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
49 } 104 }
50 switch session.perform(ClockIn(), path: path, at: offset, expect: nil, now: now) { 105 switch session.perform(ClockIn(), path: path, at: offset, expect: nil, now: now) {
51 case .commit: 106 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 {
53 return "The clock line wasn't found after clocking in." 108 return "The clock line wasn't found after clocking in."
54 } 109 }
55 running = Running(path: path, start: start, heading: entry.title, startedAt: now) 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) }
56 save() 112 save()
57 return "Clock starts at [\(start)]" 113 return "Clock starts at [\(start)]"
58 case .failed(let message): 114 case .failed(let message):
@@ -115,10 +171,338 @@ public final class ClockModel {
115 /// Where the running clock's line is, for `org-clock-goto`. 171 /// Where the running clock's line is, for `org-clock-goto`.
116 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? { 172 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
117 guard let clock = running else { return nil } 173 guard let clock = running else { return nil }
118 let open = session.openDocuments.first { $0.path == clock.path }?.document.text 174 return lineLocation(path: clock.path, start: clock.start, session)
119 guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil } 175 }
120 let buffer = text as NSString 176
121 let range = buffer.range(of: "CLOCK: [\(clock.start)]") 177 private func lineLocation(path: String, start: String, _ session: DocumentSession) -> (path: String, offset: Int)? {
122 return range.location == NSNotFound ? (clock.path, 0) : (clock.path, range.location) 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 }
123 } 507 }
124} 508}
Sources/OrgApp/EmacsImport.swift +2 −1
@@ -638,7 +638,8 @@ public struct EmacsImport: Sendable {
638 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column", 638 "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column",
639 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline", 639 "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline",
640 "org-babel-execute-src-block": "org.babel.execute", "org-clock-in": "app.clock.in", "org-clock-out": "app.clock.out", 640 "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",
642 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive", 643 "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive",
643 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda", 644 "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda",
644 "save-buffer": "app.save", "find-file": "app.quick-open", "projectile-find-file": "app.quick-open", "+default/find-file-under-here": "app.quick-open", 645 "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 {
53 public static func reminders(_ defaults: UserDefaults = .standard) -> Bool { bool("reminders", defaults) } 53 public static func reminders(_ defaults: UserDefaults = .standard) -> Bool { bool("reminders", defaults) }
54 /// `appt-message-warning-time`, in minutes. 54 /// `appt-message-warning-time`, in minutes.
55 public static func reminderLead(_ defaults: UserDefaults = .standard) -> Int { integer("reminderLead", defaults) } 55 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) }
56 60
57 private static func fallback(_ key: String) -> TOML.Value? { 61 private static func fallback(_ key: String) -> TOML.Value? {
58 UserConfig.settings.first { $0.defaultsKey == key }?.fallback 62 UserConfig.settings.first { $0.defaultsKey == key }?.fallback
Sources/OrgApp/UserConfig.swift +4
@@ -143,6 +143,10 @@ public enum UserConfig {
143 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"), 143 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
144 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), 144 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
145 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"), 145 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: ""),
146 Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!), 150 Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
147 comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""), 151 comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
148 Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!), 152 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 {
15 /// `[`, `]` and `:` of a group tag; shown, without effect on selection. 15 /// `[`, `]` and `:` of a group tag; shown, without effect on selection.
16 case startTagGroup, endTagGroup, groupTags 16 case startTagGroup, endTagGroup, groupTags
17 case newline 17 case newline
18 /// A line of text above the options that follow (`org-clock-select-task`'s sections).
19 case label(String)
18 } 20 }
19 21
20 public let items: [Item] 22 public let items: [Item]
Sources/OrgCore/Keymap/Presets.swift +28
@@ -370,6 +370,22 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
370 command = "app.clock.goto" 370 command = "app.clock.goto"
371 mode = "normal" 371 mode = "normal"
372 [[bind]] 372 [[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]]
373 keys = "SPC m c R" 389 keys = "SPC m c R"
374 command = "app.clock.report" 390 command = "app.clock.report"
375 mode = "normal" 391 mode = "normal"
@@ -830,6 +846,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
830 keys = "C-c C-x C-j" 846 keys = "C-c C-x C-j"
831 command = "app.clock.goto" 847 command = "app.clock.goto"
832 [[bind]] 848 [[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]]
833 keys = "C-c C-e h h" 855 keys = "C-c C-e h h"
834 command = "app.export.html" 856 command = "app.export.html"
835 [[bind]] 857 [[bind]]
@@ -1260,6 +1282,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
1260 keys = "C-s-j" 1282 keys = "C-s-j"
1261 command = "app.clock.goto" 1283 command = "app.clock.goto"
1262 [[bind]] 1284 [[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]]
1263 keys = "s-S-e" 1291 keys = "s-S-e"
1264 command = "app.export-dialog" 1292 command = "app.export-dialog"
1265 [[bind]] 1293 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +5
@@ -87,9 +87,14 @@ public enum EditorCommands {
87 EditorCommand(id: "app.capture", title: "Capture…", action: .app), 87 EditorCommand(id: "app.capture", title: "Capture…", action: .app),
88 EditorCommand(id: "app.board", title: "Board", action: .app), 88 EditorCommand(id: "app.board", title: "Board", action: .app),
89 EditorCommand(id: "app.clock.in", title: "Clock In", action: .app), 89 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),
90 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app), 92 EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app),
91 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), 93 EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app),
92 EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app), 94 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),
93 EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), 98 EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app),
94 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app), 99 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app),
95 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), 100 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app),
Sources/Orgstar/ClockViews.swift +71 −6
@@ -1,6 +1,8 @@
1import AppKit 1import AppKit
2import CoreGraphics
2import OrgApp 3import OrgApp
3import OrgCore 4import OrgCore
5import OrgEditorAppKit
4import SwiftUI 6import SwiftUI
5 7
6/// Elapsed time of the running clock, `H:MM`. 8/// Elapsed time of the running clock, `H:MM`.
@@ -9,6 +11,57 @@ func clockElapsed(_ running: ClockModel.Running, now: Date) -> String {
9 return String(format: "%d:%02d", minutes / 60, minutes % 60) 11 return String(format: "%d:%02d", minutes / 60, minutes % 60)
10} 12}
11 13
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
12/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to. 65/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to.
13@MainActor 66@MainActor
14final class ClockStatusItem: NSObject { 67final class ClockStatusItem: NSObject {
@@ -21,7 +74,7 @@ final class ClockStatusItem: NSObject {
21 self.run = run 74 self.run = run
22 } 75 }
23 76
24 func update(_ running: ClockModel.Running?) { 77 func update(_ running: ClockModel.Running?, recent: [String] = []) {
25 self.running = running 78 self.running = running
26 guard let running else { 79 guard let running else {
27 timer?.invalidate() 80 timer?.invalidate()
@@ -39,16 +92,28 @@ final class ClockStatusItem: NSObject {
39 let menu = NSMenu() 92 let menu = NSMenu()
40 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "") 93 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "")
41 menu.addItem(.separator()) 94 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")] { 95 for (title, id) in [
43 let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "") 96 ("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"),
44 entry.target = self 97 ("Clock In to Recent Entry…", "app.clock.in-recent"), ("Clock Report", "app.clock.report"),
45 entry.representedObject = id 98 ] {
46 menu.addItem(entry) 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) }
47 } 105 }
48 item?.menu = menu 106 item?.menu = menu
49 refresh() 107 refresh()
50 } 108 }
51 109
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
52 private func refresh() { 117 private func refresh() {
53 guard let running else { return } 118 guard let running else { return }
54 item?.button?.title = "⏱ " + clockElapsed(running, now: Date()) 119 item?.button?.title = "⏱ " + clockElapsed(running, now: Date())
Sources/Orgstar/ContentView.swift +62 −5
@@ -16,6 +16,7 @@ struct ContentView: View {
16 let clock: ClockModel 16 let clock: ClockModel
17 @AppStorage("reminders") private var remindersEnabled = true 17 @AppStorage("reminders") private var remindersEnabled = true
18 @AppStorage("reminderLead") private var reminderLead = 12 18 @AppStorage("reminderLead") private var reminderLead = 12
19 @AppStorage("clockIdleTime") private var clockIdleTime = 0
19 @AppStorage("agendaSubfolders") private var agendaSubfolders = false 20 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
20 @State private var reminderAgenda: AgendaModel? 21 @State private var reminderAgenda: AgendaModel?
21 @State private var trust = TrustStore() 22 @State private var trust = TrustStore()
@@ -150,6 +151,10 @@ struct ContentView: View {
150 Button("Clock Out") { perform("app.clock.out") } 151 Button("Clock Out") { perform("app.clock.out") }
151 Button("Cancel Clock") { perform("app.clock.cancel") } 152 Button("Cancel Clock") { perform("app.clock.cancel") }
152 Button("Go to Clocked Entry") { perform("app.clock.goto") } 153 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) }
153 } label: { 158 } label: {
154 TimelineView(.periodic(from: .now, by: 30)) { context in 159 TimelineView(.periodic(from: .now, by: 30)) { context in
155 Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1) 160 Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1)
@@ -280,9 +285,9 @@ struct ContentView: View {
280 285
281 var body: some View { 286 var body: some View {
282 main 287 main
283 .onChange(of: clock.running, initial: true) { _, running in 288 .onChange(of: ClockMenuState(running: clock.running, history: clock.history), initial: true) { _, state in
284 if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } } 289 if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } }
285 statusItem?.update(running) 290 statusItem?.update(state.running, recent: state.history.map(\.title))
286 } 291 }
287 .onChange(of: globalCapture, initial: true) { _, enabled in 292 .onChange(of: globalCapture, initial: true) { _, enabled in
288 hotKey?.unregister() 293 hotKey?.unregister()
@@ -345,6 +350,18 @@ struct ContentView: View {
345 hour += 1 350 hour += 1
346 } 351 }
347 } 352 }
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 }
348 .task { 365 .task {
349 DevelopmentHooks.scheduleSnapshot() 366 DevelopmentHooks.scheduleSnapshot()
350 pendingCarets = BufferRestore.restore(session) 367 pendingCarets = BufferRestore.restore(session)
@@ -523,7 +540,7 @@ struct ContentView: View {
523 540
524 /// Runs a command from a key, the palette or the Org menu. 541 /// Runs a command from a key, the palette or the Org menu.
525 private func perform(_ id: String) { 542 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.")
527 if orgOnly, !session.isOrg { 544 if orgOnly, !session.isOrg {
528 commands.show("Not an org file") 545 commands.show("Not an org file")
529 return 546 return
@@ -620,13 +637,31 @@ struct ContentView: View {
620 case "app.agenda": 637 case "app.agenda":
621 openWindow(id: "agenda") 638 openWindow(id: "agenda")
622 case "app.clock.in": 639 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])) }
624 case "app.clock.out": 647 case "app.clock.out":
625 commands.show(clock.clockOut(session)) 648 commands.show(clock.clockOut(session))
626 case "app.clock.cancel": 649 case "app.clock.cancel":
627 commands.show(clock.clockOut(session, cancel: true)) 650 commands.show(clock.clockOut(session, cancel: true))
628 case "app.clock.goto": 651 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)) }
630 case "app.clock.report": 665 case "app.clock.report":
631 openWindow(id: "clock-report") 666 openWindow(id: "clock-report")
632 case "app.board": 667 case "app.board":
@@ -655,6 +690,28 @@ struct ContentView: View {
655 } 690 }
656 } 691 }
657 692
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
658 private var errorShown: Binding<Bool> { 715 private var errorShown: Binding<Bool> {
659 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) 716 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
660 } 717 }
Sources/Orgstar/FastSelectionView.swift +4
@@ -28,6 +28,8 @@ struct FastSelectionView: View {
28 switch cell { 28 switch cell {
29 case .text(let text): 29 case .text(let text):
30 Text(text).foregroundStyle(.secondary) 30 Text(text).foregroundStyle(.secondary)
31 case .label(let text):
32 Text(text).bold()
31 case .option(let key, let name): 33 case .option(let key, let name):
32 HStack(spacing: 4) { 34 HStack(spacing: 4) {
33 Text("[\(String(key))]").foregroundStyle(.tint) 35 Text("[\(String(key))]").foregroundStyle(.tint)
@@ -106,6 +108,7 @@ struct FastSelectionView: View {
106 108
107 private enum Cell { 109 private enum Cell {
108 case text(String) 110 case text(String)
111 case label(String)
109 case option(Character, String) 112 case option(Character, String)
110 } 113 }
111 114
@@ -121,6 +124,7 @@ struct FastSelectionView: View {
121 case .endTagGroup: rows[rows.count - 1].append(.text("]")); newRow() 124 case .endTagGroup: rows[rows.count - 1].append(.text("]")); newRow()
122 case .groupTags: rows[rows.count - 1].append(.text(":")) 125 case .groupTags: rows[rows.count - 1].append(.text(":"))
123 case .newline: newRow() 126 case .newline: newRow()
127 case .label(let text): newRow(); rows[rows.count - 1].append(.label(text)); newRow()
124 case .option(let key, let name): rows[rows.count - 1].append(.option(key, name)) 128 case .option(let key, let name): rows[rows.count - 1].append(.option(key, name))
125 } 129 }
126 } 130 }
Sources/Orgstar/OrgstarApp.swift +18 −1
@@ -401,6 +401,8 @@ struct OrgstarApp: App {
401 } 401 }
402 .commands { 402 .commands {
403 CommandMenu("Org") { 403 CommandMenu("Org") {
404 ClockMenu(commands: commands, clock: clock)
405 Divider()
404 OrgMenuItems(commands: commands) 406 OrgMenuItems(commands: commands)
405 } 407 }
406 } 408 }
@@ -503,6 +505,8 @@ struct SettingsView: View {
503 @AppStorage("agendaSubfolders") private var agendaSubfolders = false 505 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
504 @AppStorage("reminders") private var reminders = true 506 @AppStorage("reminders") private var reminders = true
505 @AppStorage("reminderLead") private var reminderLead = 12 507 @AppStorage("reminderLead") private var reminderLead = 12
508 @AppStorage("clockIdleTime") private var clockIdleTime = 0
509 @AppStorage("clockHistoryLength") private var clockHistoryLength = 5
506 @AppStorage("globalCapture") private var globalCapture = true 510 @AppStorage("globalCapture") private var globalCapture = true
507 @AppStorage("settingsTab") private var tab = "general" 511 @AppStorage("settingsTab") private var tab = "general"
508 @AppStorage("showHiddenFiles") private var showHiddenFiles = true 512 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
@@ -664,9 +668,22 @@ struct SettingsView: View {
664 Text("An entry's APPT_WARNTIME property overrides the lead time.") 668 Text("An entry's APPT_WARNTIME property overrides the lead time.")
665 .settingsFooter() 669 .settingsFooter()
666 } 670 }
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 }
667 } 684 }
668 .formStyle(.grouped) 685 .formStyle(.grouped)
669 .frame(height: 290) 686 .frame(height: 430)
670 } 687 }
671 688
672 private var capture: some View { 689 private var capture: some View {
Sources/OrgstarMobile/ClockScreens.swift +169 −4
@@ -3,9 +3,173 @@ import OrgApp
3import OrgCore 3import OrgCore
4import SwiftUI 4import SwiftUI
5 5
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
6/// The running clock over the tab bar: the entry and time so far, and clocking out. 169/// The running clock over the tab bar: the entry and time so far, and clocking out.
7struct ClockAccessory: View { 170struct ClockAccessory: View {
8 let running: ClockModel.Running 171 let running: ClockModel.Running
172 let clock: ClockModel
9 let perform: (String) -> Void 173 let perform: (String) -> Void
10 174
11 var body: some View { 175 var body: some View {
@@ -13,6 +177,7 @@ struct ClockAccessory: View {
13 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") } 177 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") }
14 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") } 178 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") }
15 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") } 179 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") }
180 RecentClockMenu(clock: clock, perform: perform)
16 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") } 181 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") }
17 } label: { 182 } label: {
18 HStack { 183 HStack {
@@ -38,14 +203,14 @@ struct ClockAccessory: View {
38extension View { 203extension View {
39 /// The running clock over the tab bar, while there is one. 204 /// The running clock over the tab bar, while there is one.
40 @ViewBuilder 205 @ViewBuilder
41 func clockAccessory(_ running: ClockModel.Running?, perform: @escaping (String) -> Void) -> some View { 206 func clockAccessory(_ clock: ClockModel, perform: @escaping (String) -> Void) -> some View {
42 if #available(iOS 26.1, *) { 207 if #available(iOS 26.1, *) {
43 tabViewBottomAccessory(isEnabled: running != nil) { 208 tabViewBottomAccessory(isEnabled: clock.running != nil) {
44 if let running { ClockAccessory(running: running, perform: perform) } 209 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform) }
45 } 210 }
46 } else { 211 } else {
47 safeAreaInset(edge: .bottom) { 212 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) }
49 } 214 }
50 } 215 }
51 } 216 }
Sources/OrgstarMobile/EditorScreen.swift +9 −9
@@ -70,6 +70,7 @@ struct EditorScreen: View {
70 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) { 70 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) {
71 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right") 71 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right")
72 } 72 }
73 RecentClockMenu(clock: clock) { editor?.run($0) }
73 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) } 74 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
74 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } 75 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
75 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } 76 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -175,14 +176,10 @@ struct EditorScreen: View {
175 editor.onRefreshSetup = { session.refreshSetup() } 176 editor.onRefreshSetup = { session.refreshSetup() }
176 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } 177 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
177 editor.onAppCommand = { id in 178 editor.onAppCommand = { id in
178 switch id { 179 if id == "app.toggle-markup" {
179 case "app.clock.in": message = clock.clockIn(session) 180 model.showMarkup.toggle()
180 case "app.clock.out": message = clock.clockOut(session) 181 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) {
181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true) 182 message = "Not available on iOS yet"
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"
186 } 183 }
187 } 184 }
188 self.editor = editor 185 self.editor = editor
@@ -412,7 +409,10 @@ struct CommandPalette: View {
412 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), 409 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
413 ("edit.undo", "Undo"), ("edit.redo", "Redo"), 410 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
414 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"), 411 ("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"),
416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"), 416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
417 ] 417 ]
418 418
Sources/OrgstarMobile/MobileEditor.swift +2 −1
@@ -333,7 +333,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
333 case "org.cycle": 333 case "org.cycle":
334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil 334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true 335 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
337 case "org.link.open": return OpenLinkAtPoint().applies(in: context) 338 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
338 default: return false 339 default: return false
339 } 340 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -22,6 +22,7 @@ final class MobileModel {
22 let agenda: AgendaModel? 22 let agenda: AgendaModel?
23 let scheduler = ReminderScheduler(center: SystemReminderCenter()) 23 let scheduler = ReminderScheduler(center: SystemReminderCenter())
24 let clock = ClockModel() 24 let clock = ClockModel()
25 let clockPrompts = ClockPrompter()
25 let spotlight = SpotlightIndexer() 26 let spotlight = SpotlightIndexer()
26 /// Captures from the share extension; nil without the app group. 27 /// Captures from the share extension; nil without the app group.
27 let inbox = CaptureInbox() 28 let inbox = CaptureInbox()
Sources/OrgstarMobile/OrgstarMobileApp.swift +8 −8
@@ -131,7 +131,10 @@ struct RootView: View {
131 } 131 }
132 } 132 }
133 } 133 }
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 }
135 // Above the tab bar, clear of every screen's toolbar. 138 // Above the tab bar, clear of every screen's toolbar.
136 .overlay(alignment: .bottom) { 139 .overlay(alignment: .bottom) {
137 if let message { 140 if let message {
@@ -222,13 +225,10 @@ struct RootView: View {
222 } 225 }
223 226
224 private func clock(_ id: String) { 227 private func clock(_ id: String) {
225 switch id { 228 if id == "app.clock.report" {
226 case "app.clock.out": message = model.clock.clockOut(session) 229 showingClockReport = true
227 case "app.clock.cancel": message = model.clock.clockOut(session, cancel: true) 230 } else {
228 case "app.clock.goto": 231 _ = MobileClock.run(id, model: model, workspace: workspace) { message = $0 }
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
232 } 232 }
233 } 233 }
234 234
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}