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 | /// `[`, `]` 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 @@ | |||
| 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 | } | ||