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