Commit 2e50d15064
Verified · cmc
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 | 157 | keys = "C-c c" |
| 158 | 158 | command = "app.capture" |
| 159 | 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 | 172 | keys = "C-c C-e h h" |
| 161 | 173 | command = "app.export.html" |
| 162 | 174 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +5
| @@ -44,6 +44,11 @@ public enum EditorCommands { | ||
| 44 | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), |
| 46 | 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 | 52 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), |
| 48 | 53 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), |
| 49 | 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 | 10 | let session: DocumentSession |
| 11 | 11 | @Bindable var commands: AppCommands |
| 12 | 12 | let scheduler: ReminderScheduler? |
| 13 | let clock: ClockModel | |
| 13 | 14 | @AppStorage("reminders") private var remindersEnabled = true |
| 14 | 15 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 15 | 16 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 16 | 17 | @State private var reminderAgenda: AgendaModel? |
| 17 | 18 | @State private var trust = TrustStore() |
| 18 | 19 | @State private var hotKey: GlobalHotKey? |
| 20 | @State private var statusItem: ClockStatusItem? | |
| 19 | 21 | @AppStorage("globalCapture") private var globalCapture = true |
| 20 | 22 | @State private var hour = 0 |
| 21 | 23 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| @@ -76,6 +78,18 @@ struct ContentView: View { | ||
| 76 | 78 | .searchFocused($searchFocused) |
| 77 | 79 | .toolbar { |
| 78 | 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 | 93 | if session.conflict != nil { |
| 80 | 94 | Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true } |
| 81 | 95 | .help("The file changed on disk, and the changes conflict with yours") |
| @@ -116,6 +130,10 @@ struct ContentView: View { | ||
| 116 | 130 | |
| 117 | 131 | var body: some View { |
| 118 | 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 | 137 | .onChange(of: globalCapture, initial: true) { _, enabled in |
| 120 | 138 | hotKey?.unregister() |
| 121 | 139 | hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil |
| @@ -315,6 +333,16 @@ struct ContentView: View { | ||
| 315 | 333 | if let message = session.archive() { commands.show(message) } |
| 316 | 334 | case "app.agenda": |
| 317 | 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 | 346 | case "app.capture": |
| 319 | 347 | NSApp.activate() |
| 320 | 348 | openWindow(id: "capture") |
Sources/Orgstar/OrgstarApp.swift +8 −1
| @@ -134,6 +134,7 @@ struct OrgstarApp: App { | ||
| 134 | 134 | try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings)) |
| 135 | 135 | } |
| 136 | 136 | @State private var commands = AppCommands() |
| 137 | @State private var clock = ClockModel() | |
| 137 | 138 | @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil |
| 138 | 139 | @State private var session: DocumentSession = { |
| 139 | 140 | let session = DocumentSession() |
| @@ -145,7 +146,7 @@ struct OrgstarApp: App { | ||
| 145 | 146 | Window("Orgstar", id: "main") { |
| 146 | 147 | switch workspace { |
| 147 | 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 | 150 | .onAppear { |
| 150 | 151 | delegate.session = session |
| 151 | 152 | delegate.commands = commands |
| @@ -161,6 +162,12 @@ struct OrgstarApp: App { | ||
| 161 | 162 | } |
| 162 | 163 | } |
| 163 | 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 | 171 | Window("Capture", id: "capture") { |
| 165 | 172 | if case .success(let model) = workspace { |
| 166 | 173 | CaptureView(workspace: model, session: session, commands: commands) |
Tests/OrgAppTests/AppTests.swift +30
| @@ -517,3 +517,33 @@ struct CaptureAppTests { | ||
| 517 | 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". | |