krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 30accd16a9

30accd16a9b3a993852072e2d02aa39ae9313b97

parent: 6ee5ec510b

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 07:27 UTC

Clock history, task selection and clock resolution in OrgCore

ClockHistory follows org-clock-history-push and org-clock-select-task;
ClockResolution turns org-clock-resolve's answers into CLOCK line steps,
checked against Emacs. Fast selection gains section labels.

Layout: unified · split

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 +182
@@ -0,0 +1,182 @@
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 }
62
63 public enum Step: Equatable, Sendable {
64 /// `org-clock-cancel` on the open clock.
65 case cancel
66 /// The open clock closes at this time.
67 case clockOut(Date)
68 /// A new clock in the same entry, starting at this time.
69 case clockIn(Date)
70 /// The open clock becomes the running clock (`org-clock-in` with resume).
71 case resume
72 }
73
74 public struct Plan: Equatable, Sendable {
75 public var steps: [Step] = []
76 /// `org-clock-leftover-time` is set to this when `setsLeftover`.
77 public var leftover: Date?
78 public var setsLeftover = false
79 /// Show the clock afterwards (`j`, `J`).
80 public var jump = false
81 }
82
83 /// What `key` does to a clock that started at `start`, idle since `lastValid`.
84 /// `minutes` is the answer to `input(for:)` (for `t`, `minutes(from:to:)`). `active`: the
85 /// clock is the running one. `clockingIn`: resolving before a clock-in
86 /// (`org-clock-clocking-in`), which never restarts or resumes.
87 public static func plan(
88 key: Character, minutes: Int?, start: Date, lastValid: Date, now: Date, active: Bool, clockingIn: Bool
89 ) throws -> Plan {
90 var plan = Plan()
91 if key == "j" || key == "J" {
92 if key == "J" { plan.steps = try resolve(.now, clockOutTime: nil, close: true, restart: false, active: active, clockingIn: clockingIn, now: now).steps }
93 plan.jump = true
94 return plan
95 }
96 guard "kKgGsSCtT".contains(key) else { return plan }
97 let defaultMinutes = defaultMinutes(lastValid: lastValid, now: now)
98 let keep = "kKtT".contains(key) ? minutes : nil
99 let gotback = "gG".contains(key) ? minutes : nil
100 let subtract = key == "s" || key == "S"
101 // `barely-started-p`: less than 45 seconds on the clock before idling.
102 let startOver = subtract && lastValid.timeIntervalSince(start) < 45
103 let target: Target
104 if key == "C" || startOver {
105 target = .cancel
106 } else if subtract || gotback == 0 {
107 target = .time(lastValid)
108 } else if keep == defaultMinutes || gotback == defaultMinutes {
109 target = .now
110 } else if let keep {
111 target = .time(lastValid.addingTimeInterval(Double(keep * 60)))
112 } else if let gotback {
113 target = .time(now.addingTimeInterval(Double(-gotback * 60)))
114 } else {
115 throw Failure(message: "Unexpected, please report this as a bug")
116 }
117 return try resolve(
118 target, clockOutTime: gotback == nil ? nil : lastValid, close: "KGST".contains(key),
119 restart: startOver && !"KGSC".contains(key), active: active, clockingIn: clockingIn, now: now
120 )
121 }
122
123 enum Target {
124 case cancel, now
125 case time(Date)
126 }
127
128 /// `org-clock-resolve-clock`.
129 static func resolve(_ target: Target, clockOutTime: Date?, close: Bool, restart: Bool, active: Bool, clockingIn: Bool, now: Date) throws -> Plan {
130 var plan = Plan()
131 switch target {
132 case .cancel:
133 plan.steps = [.cancel]
134 if restart, !clockingIn { plan.steps.append(.clockIn(now)) }
135 case .now:
136 if restart { throw Failure(message: "RESTART is not valid here") }
137 if close || clockingIn {
138 plan.steps = [.clockOut(now)]
139 } else if !active {
140 plan.steps = [.resume]
141 }
142 case .time(let time):
143 if now < time { throw Failure(message: "RESOLVE-TO must refer to a time in the past") }
144 if restart { throw Failure(message: "RESTART is not valid here") }
145 plan.steps = [.clockOut(clockOutTime ?? time)]
146 if clockingIn {
147 } else if close {
148 plan.setsLeftover = true
149 plan.leftover = clockOutTime == nil ? time : nil
150 } else {
151 plan.steps.append(.clockIn(clockOutTime == nil ? now : time))
152 }
153 }
154 return plan
155 }
156
157 /// The time of a clock stamp (`2026-10-08 Thu 09:00`).
158 public static func date(of stamp: String, calendar: Calendar = .current) -> Date? {
159 EmacsBuffer.parseTimeString(stamp).flatMap { calendar.date(from: $0) }
160 }
161
162 /// `org-find-open-clocks`: each open `CLOCK:` line in `text` (in a clock element), with
163 /// its start stamp and where the line starts.
164 public static func openClocks(in text: String) -> [(start: String, offset: Int)] {
165 guard text.contains("CLOCK:") else { return [] }
166 var result: [(String, Int)] = []
167 func walk(_ node: SyntaxNode) {
168 for child in node.children {
169 if child.kind == .clock {
170 let line = child.text.trimmingCharacters(in: .newlines)
171 if let match = line.firstMatch(of: /^[ \t]*CLOCK: \[([^\]]+)\][ \t]*$/) {
172 result.append((String(match.1), child.range.lowerBound))
173 }
174 } else {
175 walk(child)
176 }
177 }
178 }
179 walk(OrgParser.parse(text).root)
180 return result
181 }
182}
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/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 }
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}