Clock history, idle handling and open clock resolution !172
20 files changed, +1447 −50
Layout: unified · split
Sources/OrgApp/ClockModel.swift +399 −15
| @@ -2,8 +2,8 @@ import Foundation | |||
| 2 | import Observation | 2 | import Observation |
| 3 | import OrgCore | 3 | import 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 |
| 9 | public final class ClockModel { | 9 | public 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 @@ | |||
| 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 +184
| @@ -0,0 +1,184 @@ | |||
| 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 | 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 @@ | |||
| 1 | import AppKit | 1 | import AppKit |
| 2 | import CoreGraphics | ||
| 2 | import OrgApp | 3 | import OrgApp |
| 3 | import OrgCore | 4 | import OrgCore |
| 5 | import OrgEditorAppKit | ||
| 4 | import SwiftUI | 6 | import 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. | ||
| 15 | struct 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. | ||
| 21 | enum 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`. | ||
| 29 | struct 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. | ||
| 46 | struct 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 |
| 14 | final class ClockStatusItem: NSObject { | 67 | final 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 | |||
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import SwiftUI | 4 | import SwiftUI |
| 5 | 5 | ||
| 6 | /// Clock commands that ask questions, and their answers in `ClockPromptSheet`. | ||
| 7 | @MainActor | ||
| 8 | @Observable | ||
| 9 | final 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 | ||
| 35 | enum 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. | ||
| 82 | struct 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. | ||
| 100 | struct 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. |
| 7 | struct ClockAccessory: View { | 170 | struct 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 { | |||
| 38 | extension View { | 203 | extension 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | import OrgDocument | ||
| 4 | import Testing | ||
| 5 | @testable import OrgApp | ||
| 6 | |||
| 7 | /// Answers prompts in order and remembers what they asked. | ||
| 8 | @MainActor | ||
| 9 | final 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 | ||
| 22 | struct 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 @@ | |||
| 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 | } | ||