Clocking !28
11 files changed, +779 −1
Layout: unified · split
Sources/OrgApp/ClockModel.swift added +88
| @@ -0,0 +1,88 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | import OrgCore | ||
| 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. | ||
| 7 | @MainActor | ||
| 8 | @Observable | ||
| 9 | public final class ClockModel { | ||
| 10 | public struct Running: Codable, Equatable, Sendable { | ||
| 11 | public let path: String | ||
| 12 | /// The stamp in `CLOCK: [start]`. | ||
| 13 | public let start: String | ||
| 14 | public let heading: String | ||
| 15 | public let startedAt: Date | ||
| 16 | } | ||
| 17 | |||
| 18 | public private(set) var running: Running? | ||
| 19 | private let file: URL | ||
| 20 | |||
| 21 | public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json")) { | ||
| 22 | self.file = file | ||
| 23 | running = (try? JSONDecoder().decode(Running.self, from: Data(contentsOf: file))) | ||
| 24 | } | ||
| 25 | |||
| 26 | private func save() { | ||
| 27 | try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| 28 | if let running, let data = try? JSONEncoder().encode(running) { | ||
| 29 | try? data.write(to: file, options: .atomic) | ||
| 30 | } else { | ||
| 31 | try? FileManager.default.removeItem(at: file) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | /// `org-clock-in` at the heading under the caret, clocking out a running clock first. | ||
| 36 | public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String { | ||
| 37 | guard let path = session.path, session.document != nil else { return "No file is open." } | ||
| 38 | guard let entry = session.outline.last(where: { $0.start <= session.caret }) else { return "Before first headline" } | ||
| 39 | if running != nil { | ||
| 40 | let message = clockOut(session, now: now) | ||
| 41 | if running != nil { return message } | ||
| 42 | } | ||
| 43 | switch session.perform(ClockIn(), path: path, at: session.caret, expect: nil, now: now) { | ||
| 44 | case .commit: | ||
| 45 | guard let updated = session.document?.text, let start = ClockIn.runningStart(in: updated, near: entry.start) else { | ||
| 46 | return "The clock line wasn't found after clocking in." | ||
| 47 | } | ||
| 48 | running = Running(path: path, start: start, heading: entry.title, startedAt: now) | ||
| 49 | save() | ||
| 50 | return "Clock starts at [\(start)]" | ||
| 51 | case .failed(let message): | ||
| 52 | return message | ||
| 53 | default: | ||
| 54 | return "Can't clock in here." | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | /// `org-clock-out`, or `org-clock-cancel` with `cancel`. | ||
| 59 | public func clockOut(_ session: DocumentSession, cancel: Bool = false, now: Date = Date()) -> String { | ||
| 60 | guard let clock = running else { return "No active clock" } | ||
| 61 | switch session.perform(ClockOut(start: clock.start, cancel: cancel), path: clock.path, at: 0, expect: nil, now: now) { | ||
| 62 | case .commit: | ||
| 63 | running = nil | ||
| 64 | save() | ||
| 65 | if cancel { return "Clock canceled" } | ||
| 66 | let minutes = Int(now.timeIntervalSince(clock.startedAt) / 60) | ||
| 67 | return String(format: "Clock stopped after %d:%02d", minutes / 60, minutes % 60) | ||
| 68 | case .failed(let message): | ||
| 69 | if message == "Clock start time is gone" || message == "No active clock" { | ||
| 70 | running = nil | ||
| 71 | save() | ||
| 72 | } | ||
| 73 | return message | ||
| 74 | default: | ||
| 75 | return "Can't clock out." | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | /// Where the running clock's line is, for `org-clock-goto`. | ||
| 80 | public func location(_ session: DocumentSession) -> (path: String, offset: Int)? { | ||
| 81 | guard let clock = running else { return nil } | ||
| 82 | let open = session.path == clock.path ? session.document?.text : nil | ||
| 83 | guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil } | ||
| 84 | let buffer = text as NSString | ||
| 85 | let range = buffer.range(of: "CLOCK: [\(clock.start)]") | ||
| 86 | return range.location == NSNotFound ? (clock.path, 0) : (clock.path, range.location) | ||
| 87 | } | ||
| 88 | } | ||
Sources/OrgCore/Commands/Clock.swift added +208
| @@ -0,0 +1,208 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Clocking (org-clock.el, Org 9.8.7) with `org-clock-into-drawer` t (a LOGBOOK drawer), | ||
| 4 | // `org-log-states-order-reversed` t (newest first), no rounding, no resume, and zero-time | ||
| 5 | // clocks kept. | ||
| 6 | |||
| 7 | extension EmacsBuffer { | ||
| 8 | static let clockDrawer = "LOGBOOK" | ||
| 9 | static let openClockPattern = "^[ \\t]*CLOCK: \\[([0-9]{4}-[0-9]{2}-[0-9]{2} *[^\\]\\s]+ +[012][0-9]:[0-5][0-9])\\][ \\t]*$" | ||
| 10 | |||
| 11 | /// `org-clock-find-position` without `find-unclosed`. | ||
| 12 | func clockFindPosition() throws { | ||
| 13 | let beg = try backToHeading() | ||
| 14 | let end = saveExcursion { () -> Int in | ||
| 15 | _ = outlineNextHeading() | ||
| 16 | return point | ||
| 17 | } | ||
| 18 | let drawerPattern = "^[ \\t]*:" + Self.clockDrawer + ":[ \\t]*$" | ||
| 19 | point = beg | ||
| 20 | while let match = searchForward(drawerPattern, bound: end, caseInsensitive: false) { | ||
| 21 | if element(at: lineStart(match[0]!.lowerBound))?.kind == .drawer { | ||
| 22 | point = match[0]!.upperBound | ||
| 23 | forwardLine(1) | ||
| 24 | return | ||
| 25 | } | ||
| 26 | } | ||
| 27 | point = beg | ||
| 28 | var positions: [Int] = [] | ||
| 29 | saveExcursion { | ||
| 30 | while let match = searchForward("^[ \\t]*CLOCK:", bound: end, caseInsensitive: false) { | ||
| 31 | if element(at: lineStart(match[0]!.lowerBound))?.kind == .clock { positions.insert(lineStart(match[0]!.lowerBound), at: 0) } | ||
| 32 | } | ||
| 33 | } | ||
| 34 | if positions.isEmpty { | ||
| 35 | endOfMetaData() | ||
| 36 | if !bolp { insertBeforeMarkers("\n") } | ||
| 37 | insertBeforeMarkers(":" + Self.clockDrawer + ":\n:END:\n") | ||
| 38 | forwardLine(-1) | ||
| 39 | return | ||
| 40 | } | ||
| 41 | // Clocks outside a drawer move into a new one. | ||
| 42 | endOfMetaData() | ||
| 43 | let start = point | ||
| 44 | var moved: [String] = [] | ||
| 45 | for position in positions { | ||
| 46 | let lineStartOffset = position | ||
| 47 | let next = min(length, lineEnd(lineStartOffset) + 1) | ||
| 48 | var from = lineStartOffset | ||
| 49 | while from > 0, let c = character(at: from - 1), c == 32 || c == 9 || c == 10 || c == 13 { from -= 1 } | ||
| 50 | from = min(length, lineEnd(from) + 1) | ||
| 51 | if from > lineStartOffset { from = lineStartOffset } | ||
| 52 | let text = substring(from, next).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 53 | deleteRegion(from, next) | ||
| 54 | moved.append(text) | ||
| 55 | } | ||
| 56 | point = min(start, length) | ||
| 57 | insert(moved.joined(separator: "\n") + "\n:END:\n") | ||
| 58 | let endMarker = marker(point) | ||
| 59 | defer { release(endMarker) } | ||
| 60 | point = start | ||
| 61 | saveExcursion { insertBeforeMarkers(":" + Self.clockDrawer + ":\n") } | ||
| 62 | forwardLine(1) | ||
| 63 | } | ||
| 64 | |||
| 65 | /// `org-clock-in` on the heading at point. Returns the clock's start stamp. | ||
| 66 | @discardableResult | ||
| 67 | func clockIn() throws -> String { | ||
| 68 | let start = point | ||
| 69 | let pointMarker = marker(start) | ||
| 70 | defer { | ||
| 71 | point = pointMarker.position | ||
| 72 | release(pointMarker) | ||
| 73 | } | ||
| 74 | if eobp, !atHeadingLine { point = lineStart(max(0, lineStart(point) - 1)) } | ||
| 75 | try clockFindPosition() | ||
| 76 | insertBeforeMarkers("\n") | ||
| 77 | point -= 1 | ||
| 78 | if point > 0, saveExcursion({ () -> Int? in | ||
| 79 | point = lineStart(point) - 1 | ||
| 80 | return point >= 0 ? inItem() : nil | ||
| 81 | }) != nil { | ||
| 82 | beginningOfLine() | ||
| 83 | let current = substring(point, lineEnd(point)).prefix { $0 == " " || $0 == "\t" }.count | ||
| 84 | indentLineTo(max(0, current - 2)) | ||
| 85 | } | ||
| 86 | insert("CLOCK: ") | ||
| 87 | let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now) | ||
| 88 | let stamp = insertTimestamp(components, withTime: true, inactive: true) | ||
| 89 | // `org-indent-line` puts lines in a drawer at column 0 without adapted indentation. | ||
| 90 | let lineText = substring(lineStart(point), lineEnd(point)) | ||
| 91 | let indent = lineText.prefix { $0 == " " || $0 == "\t" }.count | ||
| 92 | if indent > 0 { deleteRegion(lineStart(point), lineStart(point) + indent) } | ||
| 93 | return String(stamp.dropFirst().dropLast()) | ||
| 94 | } | ||
| 95 | |||
| 96 | var atHeadingLine: Bool { lookingAtLine("^\\*+ ") } | ||
| 97 | |||
| 98 | /// The running clock's line: `CLOCK: [start]` with nothing after it. | ||
| 99 | func runningClockLine(_ start: String) -> Int? { | ||
| 100 | let pattern = "^[ \\t]*CLOCK: \\[" + NSRegularExpression.escapedPattern(for: start) + "\\][ \\t]*$" | ||
| 101 | return saveExcursion { | ||
| 102 | point = 0 | ||
| 103 | return searchForward(pattern, caseInsensitive: false).map { lineStart($0[0]!.lowerBound) } | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /// `org-clock-out` for the clock that started at `start`. | ||
| 108 | func clockOut(_ start: String) throws { | ||
| 109 | guard let line = runningClockLine(start) else { throw UserError(message: "Clock start time is gone") } | ||
| 110 | point = line | ||
| 111 | guard let match = looking("[ \\t]*CLOCK: \\[([^\\]]+)\\]", caseInsensitive: false) else { throw UserError(message: "Clock start time is gone") } | ||
| 112 | point = match[0]!.upperBound | ||
| 113 | deleteRegion(point, lineEnd(point)) | ||
| 114 | insert("--") | ||
| 115 | let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now) | ||
| 116 | let end = insertTimestamp(components, withTime: true, inactive: true) | ||
| 117 | guard let startTime = Self.parseTimeString(start), let endTime = Self.parseTimeString(String(end.dropFirst().dropLast())), | ||
| 118 | let a = calendar.date(from: startTime), let b = calendar.date(from: endTime) else { throw UserError(message: "Clock start time is gone") } | ||
| 119 | let seconds = Int(b.timeIntervalSince(a)) | ||
| 120 | let hours = Int((Double(seconds) / 3600).rounded(.down)) | ||
| 121 | let minutes = Int((Double(seconds - hours * 3600) / 60).rounded(.down)) | ||
| 122 | insert(" => " + String(format: "%2d:%02d", hours, minutes)) | ||
| 123 | removeEmptyClockDrawer() | ||
| 124 | } | ||
| 125 | |||
| 126 | /// `org-clock-cancel`: the running clock's line goes, and the drawer if it empties. | ||
| 127 | func clockCancel(_ start: String) throws { | ||
| 128 | guard let line = runningClockLine(start) else { throw UserError(message: "No active clock") } | ||
| 129 | point = lineEnd(line) | ||
| 130 | let from = max(0, line - 1) | ||
| 131 | deleteRegion(from, lineEnd(line)) | ||
| 132 | point = from | ||
| 133 | removeEmptyDrawer(at: point) | ||
| 134 | } | ||
| 135 | |||
| 136 | /// `org-clock-remove-empty-clock-drawer` for the subtree at point. | ||
| 137 | func removeEmptyClockDrawer() { | ||
| 138 | saveExcursion { | ||
| 139 | guard let heading = try? backToHeading() else { return } | ||
| 140 | let end = saveExcursion { () -> Int in | ||
| 141 | point = heading | ||
| 142 | return endOfSubtree() | ||
| 143 | } | ||
| 144 | point = heading | ||
| 145 | let pattern = "(?i)^[ \\t]*:" + Self.clockDrawer + ":[ \\t]*$" | ||
| 146 | while let match = searchForward(pattern, bound: end, caseInsensitive: true) { | ||
| 147 | removeEmptyDrawer(at: match[0]!.upperBound) | ||
| 148 | } | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | /// `org-remove-empty-drawer-at`. | ||
| 153 | func removeEmptyDrawer(at position: Int) { | ||
| 154 | let lineStartOffset = lineStart(min(position, length)) | ||
| 155 | let line = substring(lineStartOffset, lineEnd(lineStartOffset)) | ||
| 156 | guard line.trimmingCharacters(in: .whitespaces).range(of: "^:[\\w-]+:$", options: .regularExpression) != nil, | ||
| 157 | line.trimmingCharacters(in: .whitespaces).uppercased() != ":END:" else { return } | ||
| 158 | let next = min(length, lineEnd(lineStartOffset) + 1) | ||
| 159 | let following = substring(next, lineEnd(next)) | ||
| 160 | guard following.trimmingCharacters(in: .whitespaces).uppercased() == ":END:" else { return } | ||
| 161 | deleteRegion(lineStartOffset, min(length, lineEnd(next) + 1)) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | /// C-c C-x C-i: `org-clock-in` at the heading at the caret. | ||
| 166 | public struct ClockIn: OrgCommand { | ||
| 167 | public init() {} | ||
| 168 | public var id: String { "org.clock.in" } | ||
| 169 | public var title: String { "Clock In" } | ||
| 170 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } | ||
| 171 | public func run(in context: EditContext) -> CommandStep { | ||
| 172 | guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") } | ||
| 173 | return commitBuffer(context) { try $0.clockIn() } | ||
| 174 | } | ||
| 175 | |||
| 176 | /// The start of the clock `ClockIn` left running in the entry at `offset`. | ||
| 177 | public static func runningStart(in text: String, near offset: Int) -> String? { | ||
| 178 | let buffer = EmacsBuffer(text, point: offset) | ||
| 179 | guard let heading = try? buffer.backToHeading() else { return nil } | ||
| 180 | let end = buffer.saveExcursion { () -> Int in | ||
| 181 | _ = buffer.outlineNextHeading() | ||
| 182 | return buffer.point | ||
| 183 | } | ||
| 184 | buffer.point = heading | ||
| 185 | guard let match = buffer.searchForward(EmacsBuffer.openClockPattern, bound: end, caseInsensitive: false) else { return nil } | ||
| 186 | return buffer.substring(match[1]!.lowerBound, match[1]!.upperBound) | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | /// C-c C-x C-o and C-c C-x C-q for the clock that started at `start`. | ||
| 191 | public struct ClockOut: OrgCommand { | ||
| 192 | public let start: String | ||
| 193 | public let cancel: Bool | ||
| 194 | public init(start: String, cancel: Bool = false) { | ||
| 195 | self.start = start | ||
| 196 | self.cancel = cancel | ||
| 197 | } | ||
| 198 | public var id: String { cancel ? "org.clock.cancel" : "org.clock.out" } | ||
| 199 | public var title: String { cancel ? "Cancel Clock" : "Clock Out" } | ||
| 200 | public func applies(in context: EditContext) -> Bool { true } | ||
| 201 | public func run(in context: EditContext) -> CommandStep { | ||
| 202 | commitBuffer(context) { buffer in | ||
| 203 | try buffer.saveExcursion { | ||
| 204 | if cancel { try buffer.clockCancel(start) } else { try buffer.clockOut(start) } | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | } | ||
Sources/OrgCore/Commands/ClockReport.swift added +102
| @@ -0,0 +1,102 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Time spent per day and heading, from finished `CLOCK:` lines. | ||
| 4 | public enum ClockReport { | ||
| 5 | public struct Entry: Sendable, Equatable { | ||
| 6 | /// `YYYY-MM-DD` of the clock's start. | ||
| 7 | public let date: String | ||
| 8 | /// The heading's title, without keyword, priority, tags or COMMENT. | ||
| 9 | public let heading: String | ||
| 10 | public let minutes: Int | ||
| 11 | } | ||
| 12 | |||
| 13 | static let clockLine = try! NSRegularExpression(pattern: | ||
| 14 | "CLOCK: \\[([0-9]{4}-[0-9]{2}-[0-9]{2}) [A-Za-z]{3} [0-9:]{5}\\]--\\[([0-9]{4}-[0-9]{2}-[0-9]{2}) [A-Za-z]{3} ([0-9:]{5})\\] => +([0-9]+):([0-9]+)" | ||
| 15 | ) | ||
| 16 | |||
| 17 | /// The finished clocks in `text`, in order. | ||
| 18 | public static func entries(_ text: String, settings: OrgSettings = .default) -> [Entry] { | ||
| 19 | let tree = OrgParser.parse(text, defaults: settings) | ||
| 20 | let model = DocumentModel(tree: tree) | ||
| 21 | let starts = model.headings.map(\.headingRange.lowerBound) | ||
| 22 | let ns = text as NSString | ||
| 23 | var result: [Entry] = [] | ||
| 24 | for m in clockLine.matches(in: text, range: NSRange(location: 0, length: ns.length)) { | ||
| 25 | var low = 0 | ||
| 26 | var high = starts.count | ||
| 27 | while low < high { | ||
| 28 | let mid = (low + high) / 2 | ||
| 29 | if starts[mid] <= m.range.location { low = mid + 1 } else { high = mid } | ||
| 30 | } | ||
| 31 | guard low > 0 else { continue } | ||
| 32 | var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces) | ||
| 33 | if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) } | ||
| 34 | let minutes = Int(ns.substring(with: m.range(at: 4)))! * 60 + Int(ns.substring(with: m.range(at: 5)))! | ||
| 35 | result.append(Entry(date: ns.substring(with: m.range(at: 1)), heading: title, minutes: minutes)) | ||
| 36 | } | ||
| 37 | return result | ||
| 38 | } | ||
| 39 | |||
| 40 | static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) } | ||
| 41 | |||
| 42 | /// Emacs `format "%-Ns"` pads by characters. | ||
| 43 | static func pad(_ s: String, _ width: Int) -> String { | ||
| 44 | s.count >= width ? s : s + String(repeating: " ", count: width - s.count) | ||
| 45 | } | ||
| 46 | |||
| 47 | static func padLeft(_ s: String, _ width: Int) -> String { | ||
| 48 | s.count >= width ? s : String(repeating: " ", count: width - s.count) + s | ||
| 49 | } | ||
| 50 | |||
| 51 | /// `%Y-W%V`. | ||
| 52 | static func isoWeek(_ date: String) -> String { | ||
| 53 | let parts = date.split(separator: "-").compactMap { Int($0) } | ||
| 54 | guard parts.count == 3 else { return date } | ||
| 55 | let day = Days.absolute(year: parts[0], month: parts[1], day: parts[2]) | ||
| 56 | let thursday = day - (Days.weekday(day) + 6) % 7 + 3 | ||
| 57 | return String(format: "%04d-W%02d", Days.date(thursday).year, Days.isoWeek(day)) | ||
| 58 | } | ||
| 59 | |||
| 60 | /// The time report your Doom configuration writes (`tr/generate-report`): minutes per | ||
| 61 | /// date and heading between `start` and `end` (inclusive, `YYYY-MM-DD`), with a total | ||
| 62 | /// after each ISO week. | ||
| 63 | public static func timeReport(_ entries: [Entry], start: String? = nil, end: String? = nil) -> String { | ||
| 64 | var totals: [String: [String: Int]] = [:] | ||
| 65 | for entry in entries { | ||
| 66 | if let start, entry.date < start { continue } | ||
| 67 | if let end, entry.date > end { continue } | ||
| 68 | totals[entry.date, default: [:]][entry.heading, default: 0] += entry.minutes | ||
| 69 | } | ||
| 70 | var keys: [(date: String, heading: String)] = [] | ||
| 71 | for (date, headings) in totals { | ||
| 72 | for heading in headings.keys { keys.append((date, heading)) } | ||
| 73 | } | ||
| 74 | keys.sort { a, b in a.date != b.date ? a.date < b.date : a.heading < b.heading } | ||
| 75 | let dashes14 = String(repeating: "-", count: 14) | ||
| 76 | let dashes47 = String(repeating: "-", count: 47) | ||
| 77 | let dashes7 = String(repeating: "-", count: 7) | ||
| 78 | let rule: String = "|" + dashes14 + "+" + dashes47 + "+" + dashes7 + "|\n" | ||
| 79 | func row(_ a: String, _ b: String, _ c: String) -> String { "| \(pad(a, 12)) | \(pad(b, 45)) | \(padLeft(c, 5)) |\n" } | ||
| 80 | var out = "#+title: Time Report\n\n" + row("Date", "Code", "Hours") + rule | ||
| 81 | var currentWeek: String? | ||
| 82 | var weekMinutes = 0 | ||
| 83 | for (date, heading) in keys { | ||
| 84 | let minutes = totals[date]![heading]! | ||
| 85 | let week = isoWeek(date) | ||
| 86 | if let current = currentWeek, week != current { | ||
| 87 | out += rule + row(current, "WEEK TOTAL", hours(weekMinutes)) + rule | ||
| 88 | weekMinutes = 0 | ||
| 89 | } | ||
| 90 | if week != currentWeek { | ||
| 91 | currentWeek = week | ||
| 92 | weekMinutes = 0 | ||
| 93 | } | ||
| 94 | weekMinutes += minutes | ||
| 95 | out += row(date, heading, hours(minutes)) | ||
| 96 | } | ||
| 97 | if let current = currentWeek { | ||
| 98 | out += rule + row(current, "WEEK TOTAL", hours(weekMinutes)) | ||
| 99 | } | ||
| 100 | return out + "\n" | ||
| 101 | } | ||
| 102 | } | ||
Sources/OrgCore/Keymap/Presets.swift +12
| @@ -157,6 +157,18 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 157 | keys = "C-c c" | 157 | keys = "C-c c" |
| 158 | command = "app.capture" | 158 | command = "app.capture" |
| 159 | [[bind]] | 159 | [[bind]] |
| 160 | keys = "C-c C-x C-i" | ||
| 161 | command = "app.clock.in" | ||
| 162 | [[bind]] | ||
| 163 | keys = "C-c C-x C-o" | ||
| 164 | command = "app.clock.out" | ||
| 165 | [[bind]] | ||
| 166 | keys = "C-c C-x C-q" | ||
| 167 | command = "app.clock.cancel" | ||
| 168 | [[bind]] | ||
| 169 | keys = "C-c C-x C-j" | ||
| 170 | command = "app.clock.goto" | ||
| 171 | [[bind]] | ||
| 160 | keys = "C-c C-e h h" | 172 | keys = "C-c C-e h h" |
| 161 | command = "app.export.html" | 173 | command = "app.export.html" |
| 162 | [[bind]] | 174 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +5
| @@ -44,6 +44,11 @@ public enum EditorCommands { | |||
| 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), | 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), |
| 46 | EditorCommand(id: "app.capture", title: "Capture…", action: .app), | 46 | EditorCommand(id: "app.capture", title: "Capture…", action: .app), |
| 47 | EditorCommand(id: "app.clock.in", title: "Clock In", action: .app), | ||
| 48 | EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app), | ||
| 49 | EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), | ||
| 50 | EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app), | ||
| 51 | EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), | ||
| 47 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), | 52 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), |
| 48 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), | 53 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), |
| 49 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), | 54 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), |
Sources/Orgstar/ClockViews.swift added +141
| @@ -0,0 +1,141 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgApp | ||
| 3 | import OrgCore | ||
| 4 | import SwiftUI | ||
| 5 | |||
| 6 | /// Elapsed time of the running clock, `H:MM`. | ||
| 7 | func clockElapsed(_ running: ClockModel.Running, now: Date) -> String { | ||
| 8 | let minutes = max(0, Int(now.timeIntervalSince(running.startedAt) / 60)) | ||
| 9 | return String(format: "%d:%02d", minutes / 60, minutes % 60) | ||
| 10 | } | ||
| 11 | |||
| 12 | /// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to. | ||
| 13 | @MainActor | ||
| 14 | final class ClockStatusItem: NSObject { | ||
| 15 | private var item: NSStatusItem? | ||
| 16 | private var timer: Timer? | ||
| 17 | private var running: ClockModel.Running? | ||
| 18 | private let run: (String) -> Void | ||
| 19 | |||
| 20 | init(run: @escaping (String) -> Void) { | ||
| 21 | self.run = run | ||
| 22 | } | ||
| 23 | |||
| 24 | func update(_ running: ClockModel.Running?) { | ||
| 25 | self.running = running | ||
| 26 | guard let running else { | ||
| 27 | timer?.invalidate() | ||
| 28 | timer = nil | ||
| 29 | if let item { NSStatusBar.system.removeStatusItem(item) } | ||
| 30 | item = nil | ||
| 31 | return | ||
| 32 | } | ||
| 33 | if item == nil { | ||
| 34 | item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) | ||
| 35 | timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in | ||
| 36 | MainActor.assumeIsolated { self?.refresh() } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | let menu = NSMenu() | ||
| 40 | menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "") | ||
| 41 | 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) | ||
| 47 | } | ||
| 48 | item?.menu = menu | ||
| 49 | refresh() | ||
| 50 | } | ||
| 51 | |||
| 52 | private func refresh() { | ||
| 53 | guard let running else { return } | ||
| 54 | item?.button?.title = "⏱ " + clockElapsed(running, now: Date()) | ||
| 55 | } | ||
| 56 | |||
| 57 | @objc private func choose(_ sender: NSMenuItem) { | ||
| 58 | if let id = sender.representedObject as? String { | ||
| 59 | NSApp.activate() | ||
| 60 | run(id) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Time per day and heading, as your `tr/generate-report` writes it, for chosen files and | ||
| 66 | /// dates. | ||
| 67 | struct ClockReportView: View { | ||
| 68 | let workspace: WorkspaceModel | ||
| 69 | @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date() | ||
| 70 | @State private var end = Date() | ||
| 71 | @State private var limit = false | ||
| 72 | @State private var selected: Set<String> = [] | ||
| 73 | @State private var report = "" | ||
| 74 | |||
| 75 | private var candidates: [FileEntry] { workspace.files.filter { $0.kind == .org } } | ||
| 76 | |||
| 77 | var body: some View { | ||
| 78 | HSplitView { | ||
| 79 | List(candidates, selection: $selected) { file in | ||
| 80 | Text(file.relativePath).tag(file.path) | ||
| 81 | } | ||
| 82 | .frame(minWidth: 180, idealWidth: 220, maxWidth: 320) | ||
| 83 | VStack(alignment: .leading, spacing: 8) { | ||
| 84 | HStack { | ||
| 85 | Toggle("From", isOn: $limit) | ||
| 86 | DatePicker("", selection: $start, displayedComponents: .date).labelsHidden().disabled(!limit) | ||
| 87 | Text("to") | ||
| 88 | DatePicker("", selection: $end, displayedComponents: .date).labelsHidden().disabled(!limit) | ||
| 89 | Spacer() | ||
| 90 | Button("Copy") { | ||
| 91 | NSPasteboard.general.clearContents() | ||
| 92 | NSPasteboard.general.setString(report, forType: .string) | ||
| 93 | } | ||
| 94 | Button("Save…", action: saveReport) | ||
| 95 | } | ||
| 96 | ScrollView([.horizontal, .vertical]) { | ||
| 97 | Text(report.isEmpty ? "Choose files with clocked time." : report) | ||
| 98 | .font(.body.monospaced()) | ||
| 99 | .textSelection(.enabled) | ||
| 100 | .fixedSize() | ||
| 101 | .padding(4) | ||
| 102 | } | ||
| 103 | .defaultScrollAnchor(.topLeading) | ||
| 104 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) | ||
| 105 | } | ||
| 106 | .padding(12) | ||
| 107 | } | ||
| 108 | .navigationTitle("Clock Report") | ||
| 109 | .onAppear { | ||
| 110 | if selected.isEmpty { | ||
| 111 | selected = Set(candidates.filter { (try? String(contentsOfFile: $0.path, encoding: .utf8))?.contains("CLOCK: [") ?? false }.map(\.path)) | ||
| 112 | } | ||
| 113 | compute() | ||
| 114 | } | ||
| 115 | .onChange(of: selected) { compute() } | ||
| 116 | .onChange(of: start) { compute() } | ||
| 117 | .onChange(of: end) { compute() } | ||
| 118 | .onChange(of: limit) { compute() } | ||
| 119 | } | ||
| 120 | |||
| 121 | private func day(_ date: Date) -> String { | ||
| 122 | let c = Calendar.current.dateComponents([.year, .month, .day], from: date) | ||
| 123 | return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | ||
| 124 | } | ||
| 125 | |||
| 126 | private func compute() { | ||
| 127 | var entries: [ClockReport.Entry] = [] | ||
| 128 | for path in selected.sorted() { | ||
| 129 | guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue } | ||
| 130 | entries += ClockReport.entries(text, settings: workspace.settings.org) | ||
| 131 | } | ||
| 132 | report = selected.isEmpty ? "" : ClockReport.timeReport(entries, start: limit ? day(start) : nil, end: limit ? day(end) : nil) | ||
| 133 | } | ||
| 134 | |||
| 135 | private func saveReport() { | ||
| 136 | let panel = NSSavePanel() | ||
| 137 | panel.nameFieldStringValue = "time-report.org" | ||
| 138 | guard panel.runModal() == .OK, let url = panel.url else { return } | ||
| 139 | try? report.write(to: url, atomically: true, encoding: .utf8) | ||
| 140 | } | ||
| 141 | } | ||
Sources/Orgstar/ContentView.swift +28
| @@ -10,12 +10,14 @@ struct ContentView: View { | |||
| 10 | let session: DocumentSession | 10 | let session: DocumentSession |
| 11 | @Bindable var commands: AppCommands | 11 | @Bindable var commands: AppCommands |
| 12 | let scheduler: ReminderScheduler? | 12 | let scheduler: ReminderScheduler? |
| 13 | let clock: ClockModel | ||
| 13 | @AppStorage("reminders") private var remindersEnabled = true | 14 | @AppStorage("reminders") private var remindersEnabled = true |
| 14 | @AppStorage("reminderLead") private var reminderLead = 12 | 15 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 15 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | 16 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 16 | @State private var reminderAgenda: AgendaModel? | 17 | @State private var reminderAgenda: AgendaModel? |
| 17 | @State private var trust = TrustStore() | 18 | @State private var trust = TrustStore() |
| 18 | @State private var hotKey: GlobalHotKey? | 19 | @State private var hotKey: GlobalHotKey? |
| 20 | @State private var statusItem: ClockStatusItem? | ||
| 19 | @AppStorage("globalCapture") private var globalCapture = true | 21 | @AppStorage("globalCapture") private var globalCapture = true |
| 20 | @State private var hour = 0 | 22 | @State private var hour = 0 |
| 21 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 23 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| @@ -76,6 +78,18 @@ struct ContentView: View { | |||
| 76 | .searchFocused($searchFocused) | 78 | .searchFocused($searchFocused) |
| 77 | .toolbar { | 79 | .toolbar { |
| 78 | if workspace.isIndexing { ProgressView().controlSize(.small) } | 80 | if workspace.isIndexing { ProgressView().controlSize(.small) } |
| 81 | if let running = clock.running { | ||
| 82 | Menu { | ||
| 83 | Button("Clock Out") { perform("app.clock.out") } | ||
| 84 | Button("Cancel Clock") { perform("app.clock.cancel") } | ||
| 85 | Button("Go to Clocked Entry") { perform("app.clock.goto") } | ||
| 86 | } label: { | ||
| 87 | TimelineView(.periodic(from: .now, by: 30)) { context in | ||
| 88 | Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))") | ||
| 92 | } | ||
| 79 | if session.conflict != nil { | 93 | if session.conflict != nil { |
| 80 | Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true } | 94 | Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true } |
| 81 | .help("The file changed on disk, and the changes conflict with yours") | 95 | .help("The file changed on disk, and the changes conflict with yours") |
| @@ -116,6 +130,10 @@ struct ContentView: View { | |||
| 116 | 130 | ||
| 117 | var body: some View { | 131 | var body: some View { |
| 118 | main | 132 | main |
| 133 | .onChange(of: clock.running, initial: true) { _, running in | ||
| 134 | if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } } | ||
| 135 | statusItem?.update(running) | ||
| 136 | } | ||
| 119 | .onChange(of: globalCapture, initial: true) { _, enabled in | 137 | .onChange(of: globalCapture, initial: true) { _, enabled in |
| 120 | hotKey?.unregister() | 138 | hotKey?.unregister() |
| 121 | hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil | 139 | hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil |
| @@ -315,6 +333,16 @@ struct ContentView: View { | |||
| 315 | if let message = session.archive() { commands.show(message) } | 333 | if let message = session.archive() { commands.show(message) } |
| 316 | case "app.agenda": | 334 | case "app.agenda": |
| 317 | openWindow(id: "agenda") | 335 | openWindow(id: "agenda") |
| 336 | case "app.clock.in": | ||
| 337 | commands.show(clock.clockIn(session)) | ||
| 338 | case "app.clock.out": | ||
| 339 | commands.show(clock.clockOut(session)) | ||
| 340 | case "app.clock.cancel": | ||
| 341 | commands.show(clock.clockOut(session, cancel: true)) | ||
| 342 | case "app.clock.goto": | ||
| 343 | if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") } | ||
| 344 | case "app.clock.report": | ||
| 345 | openWindow(id: "clock-report") | ||
| 318 | case "app.capture": | 346 | case "app.capture": |
| 319 | NSApp.activate() | 347 | NSApp.activate() |
| 320 | openWindow(id: "capture") | 348 | openWindow(id: "capture") |
Sources/Orgstar/OrgstarApp.swift +8 −1
| @@ -134,6 +134,7 @@ struct OrgstarApp: App { | |||
| 134 | try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings)) | 134 | try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings)) |
| 135 | } | 135 | } |
| 136 | @State private var commands = AppCommands() | 136 | @State private var commands = AppCommands() |
| 137 | @State private var clock = ClockModel() | ||
| 137 | @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil | 138 | @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil |
| 138 | @State private var session: DocumentSession = { | 139 | @State private var session: DocumentSession = { |
| 139 | let session = DocumentSession() | 140 | let session = DocumentSession() |
| @@ -145,7 +146,7 @@ struct OrgstarApp: App { | |||
| 145 | Window("Orgstar", id: "main") { | 146 | Window("Orgstar", id: "main") { |
| 146 | switch workspace { | 147 | switch workspace { |
| 147 | case .success(let model): | 148 | case .success(let model): |
| 148 | ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler) | 149 | ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock) |
| 149 | .onAppear { | 150 | .onAppear { |
| 150 | delegate.session = session | 151 | delegate.session = session |
| 151 | delegate.commands = commands | 152 | delegate.commands = commands |
| @@ -161,6 +162,12 @@ struct OrgstarApp: App { | |||
| 161 | } | 162 | } |
| 162 | } | 163 | } |
| 163 | .defaultSize(width: 760, height: 720) | 164 | .defaultSize(width: 760, height: 720) |
| 165 | Window("Clock Report", id: "clock-report") { | ||
| 166 | if case .success(let model) = workspace { | ||
| 167 | ClockReportView(workspace: model) | ||
| 168 | } | ||
| 169 | } | ||
| 170 | .defaultSize(width: 860, height: 560) | ||
| 164 | Window("Capture", id: "capture") { | 171 | Window("Capture", id: "capture") { |
| 165 | if case .success(let model) = workspace { | 172 | if case .success(let model) = workspace { |
| 166 | CaptureView(workspace: model, session: session, commands: commands) | 173 | CaptureView(workspace: model, session: session, commands: commands) |
Tests/OrgAppTests/AppTests.swift +30
| @@ -517,3 +517,33 @@ struct CaptureAppTests { | |||
| 517 | #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n") | 517 | #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n") |
| 518 | } | 518 | } |
| 519 | } | 519 | } |
| 520 | |||
| 521 | @MainActor | ||
| 522 | struct ClockModelTests { | ||
| 523 | @Test func clocksInAndOutAcrossFiles() throws { | ||
| 524 | let notes = try Folder() | ||
| 525 | let work = try notes.write("work.org", "* Task\nBody\n") | ||
| 526 | let other = try notes.write("other.org", "* Other\n") | ||
| 527 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | ||
| 528 | session.open(work.path) | ||
| 529 | session.reveal(2) | ||
| 530 | let stateFile = notes.url.appendingPathComponent("clock.json") | ||
| 531 | let clock = ClockModel(file: stateFile) | ||
| 532 | var utc = Calendar(identifier: .gregorian) | ||
| 533 | utc.timeZone = TimeZone(identifier: "UTC")! | ||
| 534 | let start = Date(timeIntervalSince1970: 1_789_000_000) | ||
| 535 | _ = clock.clockIn(session, now: start) | ||
| 536 | #expect(clock.running?.heading == "Task") | ||
| 537 | #expect(ClockModel(file: stateFile).running == clock.running) | ||
| 538 | |||
| 539 | // Out from another file: the clocked file is closed now (saved first, as leaving a | ||
| 540 | // file does in the app). | ||
| 541 | #expect(session.save()) | ||
| 542 | session.open(other.path) | ||
| 543 | let message = clock.clockOut(session, now: start.addingTimeInterval(95 * 60)) | ||
| 544 | #expect(message == "Clock stopped after 1:35") | ||
| 545 | #expect(clock.running == nil) | ||
| 546 | let text = try String(contentsOf: work, encoding: .utf8) | ||
| 547 | #expect(text.hasPrefix("* Task\n:LOGBOOK:\nCLOCK: [") && text.contains("=> 1:35\n:END:\nBody\n"), "\(text)") | ||
| 548 | } | ||
| 549 | } | ||
Tests/OrgCoreTests/ClockTests.swift added +133
| @@ -0,0 +1,133 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct ClockTests { | ||
| 6 | static let texts = [ | ||
| 7 | "* TODO Task\n", | ||
| 8 | "* TODO Task\nBody text.\n", | ||
| 9 | "* Task\nSCHEDULED: <2026-10-05 Mon>\n:PROPERTIES:\n:ID: x\n:END:\nBody\n", | ||
| 10 | "* Task\n:LOGBOOK:\nCLOCK: [2026-10-04 Sun 09:00]--[2026-10-04 Sun 10:00] => 1:00\n:END:\nNotes\n", | ||
| 11 | "* Task\nCLOCK: [2026-10-04 Sun 09:00]--[2026-10-04 Sun 10:00] => 1:00\nNotes\n", | ||
| 12 | "* Task\n** Child\nChild body\n* Next\n", | ||
| 13 | "* Task", | ||
| 14 | "Preamble\n* Task\n- item\n", | ||
| 15 | "* Task\n:LOGBOOK:\n- State \"DONE\" from \"TODO\" [2026-10-01 Thu 10:00]\n:END:\n", | ||
| 16 | ] | ||
| 17 | |||
| 18 | static var utc: Calendar { | ||
| 19 | var c = Calendar(identifier: .gregorian) | ||
| 20 | c.timeZone = TimeZone(identifier: "UTC")! | ||
| 21 | return c | ||
| 22 | } | ||
| 23 | |||
| 24 | func run(_ command: any OrgCommand, _ text: String, caret: Int, at seconds: Int) -> (text: String, caret: Int, failure: String?) { | ||
| 25 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Date(timeIntervalSince1970: Double(seconds)), calendar: Self.utc) | ||
| 26 | switch command.run(in: context) { | ||
| 27 | case .commit(let result): | ||
| 28 | var new = text | ||
| 29 | for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) } | ||
| 30 | return (new, result.selection?.first?.lowerBound ?? caret, nil) | ||
| 31 | case .failed(let message): return (text, caret, message) | ||
| 32 | default: return (text, caret, "other") | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 37 | func matchesEmacs() throws { | ||
| 38 | let t1 = 1_789_000_000 // Thu 2026-09-10 00:26 | ||
| 39 | let t2 = t1 + 3 * 3600 + 17 * 60 + 42 | ||
| 40 | var cases: [EmacsOracle.Case] = [] | ||
| 41 | var ours: [(String, String)] = [] | ||
| 42 | for text in Self.texts { | ||
| 43 | let caret = (text as NSString).range(of: "Task").location | ||
| 44 | for mode in ["out", "cancel"] { | ||
| 45 | // Clock in at t1, then out (or cancel) at t2. | ||
| 46 | let first = run(ClockIn(), text, caret: caret, at: t1) | ||
| 47 | guard let start = ClockIn.runningStart(in: first.text, near: caret) else { | ||
| 48 | ours.append(("no running clock: \(first.failure ?? "")", text)) | ||
| 49 | cases.append(EmacsOracle.Case(text: text, point: 1, form: "nil")) | ||
| 50 | continue | ||
| 51 | } | ||
| 52 | let second = run(ClockOut(start: start, cancel: mode == "cancel"), first.text, caret: first.caret, at: t2) | ||
| 53 | ours.append((second.text, first.text)) | ||
| 54 | let out = mode == "cancel" ? "(org-clock-cancel)" : "(org-clock-out)" | ||
| 55 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: """ | ||
| 56 | (progn | ||
| 57 | (require 'org-clock) | ||
| 58 | (setq org-clock-persist nil org-clock-idle-time nil org-clock-auto-clock-resolution nil org-clock-into-drawer t) | ||
| 59 | \(frozenClock(t1, "(org-clock-in)")) | ||
| 60 | (setq orgstar-after-in (buffer-substring-no-properties (point-min) (point-max))) | ||
| 61 | \(frozenClock(t2, out))) | ||
| 62 | """)) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | let emacs = try EmacsOracle.run(cases) | ||
| 66 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | ||
| 67 | #expect(mine.0 == theirs.text, "case \(i) \(cases[i].form.contains("cancel") ? "cancel" : "out")\n\(cases[i].text.debugDescription)\nafter in (ours): \(mine.1.debugDescription)\nours: \(mine.0.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)") | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | |||
| 73 | struct ClockReportTests { | ||
| 74 | static let config = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/doom/config.el") | ||
| 75 | |||
| 76 | static let sample = """ | ||
| 77 | * TODO [#A] Audit work :work: | ||
| 78 | :LOGBOOK: | ||
| 79 | CLOCK: [2026-02-20 Fri 10:00]--[2026-02-20 Fri 16:00] => 6:00 | ||
| 80 | CLOCK: [2026-02-17 Tue 16:22]--[2026-02-17 Tue 16:45] => 0:23 | ||
| 81 | CLOCK: [2026-02-17 Tue 08:15]--[2026-02-17 Tue 08:50] => 0:35 | ||
| 82 | :END: | ||
| 83 | * Meetings | ||
| 84 | :LOGBOOK: | ||
| 85 | CLOCK: [2026-02-24 Tue 09:30]--[2026-02-24 Tue 10:00] => 0:30 | ||
| 86 | CLOCK: [2026-03-02 Mon 09:00] | ||
| 87 | CLOCK: [2026-02-17 Tue 09:28]--[2026-02-17 Tue 09:55] => 0:27 | ||
| 88 | :END: | ||
| 89 | """ | ||
| 90 | |||
| 91 | /// Your own `tr/generate-report`, loaded from config.el in batch Emacs. | ||
| 92 | static func emacsReport(_ text: String, start: String?, end: String?) throws -> String { | ||
| 93 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)") | ||
| 94 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 95 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 96 | let input = folder.appendingPathComponent("time.org") | ||
| 97 | let output = folder.appendingPathComponent("report.org") | ||
| 98 | try text.write(to: input, atomically: true, encoding: .utf8) | ||
| 99 | func lisp(_ s: String?) -> String { s.map { "\"\($0)\"" } ?? "nil" } | ||
| 100 | let script = folder.appendingPathComponent("tr.el") | ||
| 101 | try """ | ||
| 102 | (require 'org) | ||
| 103 | (require 'cl-lib) | ||
| 104 | (require 'subr-x) | ||
| 105 | (with-temp-buffer | ||
| 106 | (insert-file-contents "\(config.path)") | ||
| 107 | (goto-char (point-min)) | ||
| 108 | (condition-case nil | ||
| 109 | (while t | ||
| 110 | (let ((form (read (current-buffer)))) | ||
| 111 | (when (and (eq (car-safe form) 'defun) (string-prefix-p "tr/" (symbol-name (cadr form)))) | ||
| 112 | (eval form t)))) | ||
| 113 | (end-of-file nil))) | ||
| 114 | (tr/generate-report "\(input.path)" "\(output.path)" \(lisp(start)) \(lisp(end))) | ||
| 115 | """.write(to: script, atomically: true, encoding: .utf8) | ||
| 116 | _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"]) | ||
| 117 | return try String(contentsOf: output, encoding: .utf8) | ||
| 118 | } | ||
| 119 | |||
| 120 | @Test(.enabled(if: FileManager.default.fileExists(atPath: ClockReportTests.config.path) && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 121 | func matchesYourReport() throws { | ||
| 122 | var texts = [Self.sample] | ||
| 123 | let work = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("git/cmc/work-notes/time.org") | ||
| 124 | if let real = try? String(contentsOf: work, encoding: .utf8) { texts.append(real) } | ||
| 125 | for text in texts { | ||
| 126 | for (start, end) in [(nil, nil), ("2026-02-18", nil), (nil, "2026-02-20"), ("2026-02-17", "2026-02-17")] as [(String?, String?)] { | ||
| 127 | let ours = ClockReport.timeReport(ClockReport.entries(text), start: start, end: end) | ||
| 128 | let theirs = try Self.emacsReport(text, start: start, end: end) | ||
| 129 | #expect(ours == theirs, "\(start ?? "-")..\(end ?? "-")\nours:\n\(ours)\ntheirs:\n\(theirs)") | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
docs/plans/2026-10-05-clock.md added +24
| @@ -0,0 +1,24 @@ | |||
| 1 | # Clocking Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Clock in, out, cancel and go to, written as Emacs writes them, and the time report your Doom config makes (`SPC z t`), as a window. | ||
| 6 | |||
| 7 | **Architecture:** `OrgCore/Commands/Clock.swift` ports `org-clock-find-position` (an existing LOGBOOK drawer, newest first; a new drawer after the metadata; stray `CLOCK:` lines moved into one), the `CLOCK: [start]` line of `org-clock-in`, `org-clock-out`'s `--[end] => H:MM`, `org-clock-cancel`, and empty-drawer removal. `ClockModel` keeps the running clock (file, start stamp, heading) in `clock.json` and runs the commands through the open buffer or the save path, clocking out a running clock before clocking in. `ClockReport` reads finished clocks as `tr/parse-org-clock-entries` does and writes `tr/generate-report`'s table (date × heading, ISO-week totals). The main toolbar and a menu bar item show the running clock; the Clock Report window chooses files and dates and copies or saves the table. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle; your config.el for the report oracle. | ||
| 10 | |||
| 11 | **Spec:** `docs/design.md`, "Phase 6". | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Clock oracle: clock in, then out or cancel, on nine entry shapes, with the clock frozen. | ||
| 16 | - Report oracle: your own `tr/` functions, loaded from `~/.config/doom/config.el`, on a sample and on work-notes/time.org, four date ranges. | ||
| 17 | - No clock rounding, resume, idle detection or `clocktable` blocks. | ||
| 18 | |||
| 19 | --- | ||
| 20 | |||
| 21 | - [x] Clock commands; oracle. | ||
| 22 | - [x] `ClockModel`; `ClockReport`; tests. | ||
| 23 | - [x] Toolbar, menu bar item, Clock Report window, `C-c C-x C-i/C-o/C-q/C-j`. | ||
| 24 | - [x] Commit "Clocking". | ||