Commit 30accd16a9
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/ClockHistory.swift added +154
| @@ -0,0 +1,154 @@ | ||
| 1 | import 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. | |
| 9 | public 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 | ||
| 52 | public 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 @@ | ||
| 1 | import 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 | ||
| 7 | public 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 | 15 | /// `[`, `]` and `:` of a group tag; shown, without effect on selection. |
| 16 | 16 | case startTagGroup, endTagGroup, groupTags |
| 17 | 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 | 22 | public let items: [Item] |
Sources/Orgstar/FastSelectionView.swift +4
| @@ -28,6 +28,8 @@ struct FastSelectionView: View { | ||
| 28 | 28 | switch cell { |
| 29 | 29 | case .text(let text): |
| 30 | 30 | Text(text).foregroundStyle(.secondary) |
| 31 | case .label(let text): | |
| 32 | Text(text).bold() | |
| 31 | 33 | case .option(let key, let name): |
| 32 | 34 | HStack(spacing: 4) { |
| 33 | 35 | Text("[\(String(key))]").foregroundStyle(.tint) |
| @@ -106,6 +108,7 @@ struct FastSelectionView: View { | ||
| 106 | 108 | |
| 107 | 109 | private enum Cell { |
| 108 | 110 | case text(String) |
| 111 | case label(String) | |
| 109 | 112 | case option(Character, String) |
| 110 | 113 | } |
| 111 | 114 | |
| @@ -121,6 +124,7 @@ struct FastSelectionView: View { | ||
| 121 | 124 | case .endTagGroup: rows[rows.count - 1].append(.text("]")); newRow() |
| 122 | 125 | case .groupTags: rows[rows.count - 1].append(.text(":")) |
| 123 | 126 | case .newline: newRow() |
| 127 | case .label(let text): newRow(); rows[rows.count - 1].append(.label(text)); newRow() | |
| 124 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | ||
| 143 | struct 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 | } | |