krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 2e50d15064

2e50d150645045338c8026719a350801b4edfc44

parent: 313945d32b

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 07:42 UTC

Clocking

Clock in, out, cancel and go to, matching org-clock in batch Emacs,
with the running clock kept across launches and shown in the toolbar
and menu bar. The Clock Report window writes the table your
tr/generate-report makes, checked against it.

Ref plan docs/plans/2026-10-05-clock.md

Layout: unified · split

Sources/OrgApp/ClockModel.swift added +88
@@ -0,0 +1,88 @@
1import Foundation
2import Observation
3import 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
9public 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 @@
1import 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
7extension 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.
166public 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`.
191public 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 @@
1import Foundation
2
3/// Time spent per day and heading, from finished `CLOCK:` lines.
4public 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 {
157157 keys = "C-c c"
158158 command = "app.capture"
159159 [[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]]
160172 keys = "C-c C-e h h"
161173 command = "app.export.html"
162174 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +5
@@ -44,6 +44,11 @@ public enum EditorCommands {
4444 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
4545 EditorCommand(id: "app.agenda", title: "Agenda", action: .app),
4646 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),
4752 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app),
4853 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app),
4954 EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app),
Sources/Orgstar/ClockViews.swift added +141
@@ -0,0 +1,141 @@
1import AppKit
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// Elapsed time of the running clock, `H:MM`.
7func 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
14final 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.
67struct 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 {
1010 let session: DocumentSession
1111 @Bindable var commands: AppCommands
1212 let scheduler: ReminderScheduler?
13 let clock: ClockModel
1314 @AppStorage("reminders") private var remindersEnabled = true
1415 @AppStorage("reminderLead") private var reminderLead = 12
1516 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
1617 @State private var reminderAgenda: AgendaModel?
1718 @State private var trust = TrustStore()
1819 @State private var hotKey: GlobalHotKey?
20 @State private var statusItem: ClockStatusItem?
1921 @AppStorage("globalCapture") private var globalCapture = true
2022 @State private var hour = 0
2123 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
@@ -76,6 +78,18 @@ struct ContentView: View {
7678 .searchFocused($searchFocused)
7779 .toolbar {
7880 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 }
7993 if session.conflict != nil {
8094 Button("Conflict", systemImage: "exclamationmark.triangle") { commands.showingConflict = true }
8195 .help("The file changed on disk, and the changes conflict with yours")
@@ -116,6 +130,10 @@ struct ContentView: View {
116130
117131 var body: some View {
118132 main
133 .onChange(of: clock.running, initial: true) { _, running in
134 if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } }
135 statusItem?.update(running)
136 }
119137 .onChange(of: globalCapture, initial: true) { _, enabled in
120138 hotKey?.unregister()
121139 hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil
@@ -315,6 +333,16 @@ struct ContentView: View {
315333 if let message = session.archive() { commands.show(message) }
316334 case "app.agenda":
317335 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")
318346 case "app.capture":
319347 NSApp.activate()
320348 openWindow(id: "capture")
Sources/Orgstar/OrgstarApp.swift +8 −1
@@ -134,6 +134,7 @@ struct OrgstarApp: App {
134134 try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings))
135135 }
136136 @State private var commands = AppCommands()
137 @State private var clock = ClockModel()
137138 @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil
138139 @State private var session: DocumentSession = {
139140 let session = DocumentSession()
@@ -145,7 +146,7 @@ struct OrgstarApp: App {
145146 Window("Orgstar", id: "main") {
146147 switch workspace {
147148 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)
149150 .onAppear {
150151 delegate.session = session
151152 delegate.commands = commands
@@ -161,6 +162,12 @@ struct OrgstarApp: App {
161162 }
162163 }
163164 .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)
164171 Window("Capture", id: "capture") {
165172 if case .success(let model) = workspace {
166173 CaptureView(workspace: model, session: session, commands: commands)
Tests/OrgAppTests/AppTests.swift +30
@@ -517,3 +517,33 @@ struct CaptureAppTests {
517517 #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n")
518518 }
519519}
520
521@MainActor
522struct 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 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct 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
73struct 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".