Commit daac3706d5
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/ClockReport.swift +15 −2
| @@ -8,6 +8,8 @@ public enum ClockReport { | ||
| 8 | 8 | /// The heading's title, without keyword, priority, tags or COMMENT. |
| 9 | 9 | public let heading: String |
| 10 | 10 | public let minutes: Int |
| 11 | /// Where the heading starts. | |
| 12 | public let start: Int | |
| 11 | 13 | } |
| 12 | 14 | |
| 13 | 15 | static let clockLine = try! NSRegularExpression(pattern: |
| @@ -32,12 +34,23 @@ public enum ClockReport { | ||
| 32 | 34 | var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces) |
| 33 | 35 | if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) } |
| 34 | 36 | 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)) | |
| 37 | result.append(Entry(date: ns.substring(with: m.range(at: 1)), heading: title, minutes: minutes, start: starts[low - 1])) | |
| 36 | 38 | } |
| 37 | 39 | return result |
| 38 | 40 | } |
| 39 | 41 | |
| 40 | static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) } | |
| 42 | /// Minutes per heading between `from` and `to` (inclusive, `YYYY-MM-DD`), in file order. | |
| 43 | public static func byHeading(_ entries: [Entry], from: String? = nil, to: String? = nil) -> [(start: Int, heading: String, minutes: Int)] { | |
| 44 | var totals: [Int: (heading: String, minutes: Int)] = [:] | |
| 45 | for entry in entries { | |
| 46 | if let from, entry.date < from { continue } | |
| 47 | if let to, entry.date > to { continue } | |
| 48 | totals[entry.start, default: (entry.heading, 0)].minutes += entry.minutes | |
| 49 | } | |
| 50 | return totals.keys.sorted().map { (start: $0, heading: totals[$0]!.heading, minutes: totals[$0]!.minutes) } | |
| 51 | } | |
| 52 | ||
| 53 | public static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) } | |
| 41 | 54 | |
| 42 | 55 | /// Emacs `format "%-Ns"` pads by characters. |
| 43 | 56 | static func pad(_ s: String, _ width: Int) -> String { |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -69,6 +69,7 @@ public enum EditorCommands { | ||
| 69 | 69 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), |
| 70 | 70 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
| 71 | 71 | EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app), |
| 72 | EditorCommand(id: "app.toggle-inspector", title: "Show or Hide Columns and Clock", action: .app), | |
| 72 | 73 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
| 73 | 74 | EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), |
| 74 | 75 | EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app), |
Sources/Orgstar/ContentView.swift +6
| @@ -69,6 +69,7 @@ struct ContentView: View { | ||
| 69 | 69 | @AppStorage("outlineShown") private var orgOutlineShown = true |
| 70 | 70 | @AppStorage("outlineWidth") private var outlineWidth = 220.0 |
| 71 | 71 | @AppStorage("backlinksShown") private var backlinksShown = true |
| 72 | @AppStorage("inspectorShown") private var inspectorShown = false | |
| 72 | 73 | @AppStorage("showMarkup") private var showMarkup = false |
| 73 | 74 | @AppStorage("showLineNumbers") private var showLineNumbers = true |
| 74 | 75 | @AppStorage("showHiddenFiles") private var showHiddenFiles = true |
| @@ -196,6 +197,10 @@ struct ContentView: View { | ||
| 196 | 197 | } |
| 197 | 198 | detail |
| 198 | 199 | .frame(maxWidth: .infinity) |
| 200 | .inspector(isPresented: $inspectorShown) { | |
| 201 | InspectorPane(session: session, commands: commands) | |
| 202 | .inspectorColumnWidth(min: 240, ideal: 340, max: 600) | |
| 203 | } | |
| 199 | 204 | } |
| 200 | 205 | } |
| 201 | 206 | .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar") |
| @@ -547,6 +552,7 @@ struct ContentView: View { | ||
| 547 | 552 | if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } |
| 548 | 553 | case "app.toggle-outline": toggleOutline() |
| 549 | 554 | case "app.toggle-backlinks": backlinksShown.toggle() |
| 555 | case "app.toggle-inspector": inspectorShown.toggle() | |
| 550 | 556 | case "app.toggle-markup": showMarkup.toggle() |
| 551 | 557 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
| 552 | 558 | case "app.import-emacs": showingImport = true |
Sources/Orgstar/InspectorPane.swift added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | import OrgApp | |
| 2 | import OrgCore | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// The inspector beside the editor: column view and clocked time for the open file, kept | |
| 6 | /// current as it changes. | |
| 7 | struct InspectorPane: View { | |
| 8 | let session: DocumentSession | |
| 9 | let commands: AppCommands | |
| 10 | @AppStorage("inspectorTab") private var tab = Tab.columns | |
| 11 | @AppStorage("inspectorColumnsScope") private var subtree = false | |
| 12 | @AppStorage("inspectorClockSpan") private var span = Span.week | |
| 13 | @State private var columns: ColumnView.View? | |
| 14 | @State private var clocked: [(start: Int, heading: String, minutes: Int)] = [] | |
| 15 | ||
| 16 | enum Tab: String, CaseIterable { case columns = "Columns", clock = "Clock" } | |
| 17 | enum Span: String, CaseIterable { case today = "Today", week = "This Week", all = "All" } | |
| 18 | ||
| 19 | private struct Key: Equatable { | |
| 20 | var path: String? | |
| 21 | var version: Int | |
| 22 | var caret: Int? | |
| 23 | var subtree: Bool | |
| 24 | var span: Span | |
| 25 | } | |
| 26 | ||
| 27 | /// The heading at the caret, for a subtree's columns and the current row. | |
| 28 | private var heading: Int? { session.outline.last { $0.start <= commands.selection.location }?.start } | |
| 29 | ||
| 30 | var body: some View { | |
| 31 | VStack(spacing: 0) { | |
| 32 | Picker("", selection: $tab) { | |
| 33 | ForEach(Tab.allCases, id: \.self) { Text($0.rawValue) } | |
| 34 | } | |
| 35 | .pickerStyle(.segmented) | |
| 36 | .labelsHidden() | |
| 37 | .padding(8) | |
| 38 | Divider() | |
| 39 | switch tab { | |
| 40 | case .columns: columnsView | |
| 41 | case .clock: clockView | |
| 42 | } | |
| 43 | } | |
| 44 | .task(id: Key(path: session.path, version: session.editVersion, caret: subtree ? heading : nil, subtree: subtree, span: span)) { refresh() } | |
| 45 | } | |
| 46 | ||
| 47 | @ViewBuilder | |
| 48 | private var columnsView: some View { | |
| 49 | Picker("Scope", selection: $subtree) { | |
| 50 | Text("File").tag(false) | |
| 51 | Text("Subtree").tag(true) | |
| 52 | } | |
| 53 | .pickerStyle(.segmented) | |
| 54 | .padding(.horizontal, 8) | |
| 55 | .padding(.bottom, 6) | |
| 56 | if let columns, !columns.rows.isEmpty { | |
| 57 | ScrollView([.horizontal, .vertical]) { | |
| 58 | Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 3) { | |
| 59 | GridRow { | |
| 60 | ForEach(columns.specs.indices, id: \.self) { i in | |
| 61 | Text(columns.specs[i].title).bold().foregroundStyle(.secondary) | |
| 62 | } | |
| 63 | } | |
| 64 | Divider() | |
| 65 | ForEach(columns.rows.indices, id: \.self) { r in | |
| 66 | let row = columns.rows[r] | |
| 67 | GridRow { | |
| 68 | ForEach(columns.specs.indices, id: \.self) { i in | |
| 69 | Text(columns.specs[i].property == "ITEM" ? String(repeating: "*", count: row.level) + " " + row.values[i] : row.values[i]) | |
| 70 | .lineLimit(1) | |
| 71 | .fontWeight(row.start == heading ? .semibold : .regular) | |
| 72 | } | |
| 73 | } | |
| 74 | .contentShape(Rectangle()) | |
| 75 | .onTapGesture { session.reveal(row.start) } | |
| 76 | } | |
| 77 | } | |
| 78 | .font(.system(.callout, design: .monospaced)) | |
| 79 | .padding(8) | |
| 80 | } | |
| 81 | .defaultScrollAnchor(.topLeading) | |
| 82 | } else { | |
| 83 | ContentUnavailableView("No entries", systemImage: "tablecells") | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | @ViewBuilder | |
| 88 | private var clockView: some View { | |
| 89 | Picker("Span", selection: $span) { | |
| 90 | ForEach(Span.allCases, id: \.self) { Text($0.rawValue) } | |
| 91 | } | |
| 92 | .pickerStyle(.segmented) | |
| 93 | .padding(.horizontal, 8) | |
| 94 | .padding(.bottom, 6) | |
| 95 | if clocked.isEmpty { | |
| 96 | ContentUnavailableView("No clocked time", systemImage: "clock") | |
| 97 | } else { | |
| 98 | List { | |
| 99 | ForEach(clocked, id: \.start) { entry in | |
| 100 | HStack { | |
| 101 | Text(entry.heading.isEmpty ? "(untitled)" : entry.heading).lineLimit(1) | |
| 102 | Spacer() | |
| 103 | Text(ClockReport.hours(entry.minutes)).monospacedDigit() | |
| 104 | } | |
| 105 | .fontWeight(entry.start == heading ? .semibold : .regular) | |
| 106 | .contentShape(Rectangle()) | |
| 107 | .onTapGesture { session.reveal(entry.start) } | |
| 108 | } | |
| 109 | HStack { | |
| 110 | Text("Total").bold() | |
| 111 | Spacer() | |
| 112 | Text(ClockReport.hours(clocked.reduce(0) { $0 + $1.minutes })).bold().monospacedDigit() | |
| 113 | } | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | private func refresh() { | |
| 119 | guard session.isOrg, let document = session.document else { | |
| 120 | columns = nil | |
| 121 | clocked = [] | |
| 122 | return | |
| 123 | } | |
| 124 | let text = document.text | |
| 125 | columns = ColumnView.capture(text: text, tree: document.tree, at: heading ?? 0, global: !subtree || heading == nil).view | |
| 126 | let formatter = DateFormatter() | |
| 127 | formatter.locale = Locale(identifier: "en_US_POSIX") | |
| 128 | formatter.dateFormat = "yyyy-MM-dd" | |
| 129 | // Weeks start on Monday, as org's do. | |
| 130 | var calendar = Calendar.current | |
| 131 | calendar.firstWeekday = 2 | |
| 132 | let today = Date() | |
| 133 | let from: Date? = switch span { | |
| 134 | case .today: today | |
| 135 | case .week: calendar.dateInterval(of: .weekOfYear, for: today)?.start | |
| 136 | case .all: nil | |
| 137 | } | |
| 138 | clocked = ClockReport.byHeading(ClockReport.entries(text), from: from.map(formatter.string), to: span == .all ? nil : formatter.string(from: today)) | |
| 139 | } | |
| 140 | } | |
Sources/Orgstar/OrgstarApp.swift +2
| @@ -350,6 +350,8 @@ struct OrgstarApp: App { | ||
| 350 | 350 | CommandGroup(after: .sidebar) { |
| 351 | 351 | Button("Show or Hide Outline") { commands.run("app.toggle-outline") } |
| 352 | 352 | Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } |
| 353 | Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") } | |
| 354 | .keyboardShortcut("i", modifiers: [.command, .option]) | |
| 353 | 355 | .keyboardShortcut("o", modifiers: [.command, .option]) |
| 354 | 356 | MarkupToggle() |
| 355 | 357 | } |
Tests/OrgCoreTests/ClockTests.swift +8
| @@ -88,6 +88,14 @@ struct ClockReportTests { | ||
| 88 | 88 | :END: |
| 89 | 89 | """ |
| 90 | 90 | |
| 91 | @Test func totalsPerHeading() { | |
| 92 | let entries = ClockReport.entries(Self.sample) | |
| 93 | let all = ClockReport.byHeading(entries) | |
| 94 | #expect(all.map(\.heading) == ["Audit work", "Meetings"] && all.map(\.minutes) == [418, 57]) | |
| 95 | #expect(all[1].start == (Self.sample as NSString).range(of: "* Meetings").location) | |
| 96 | #expect(ClockReport.byHeading(entries, from: "2026-02-18", to: "2026-02-24").map(\.minutes) == [360, 30]) | |
| 97 | } | |
| 98 | ||
| 91 | 99 | /// Your own `tr/generate-report`, loaded from config.el in batch Emacs. |
| 92 | 100 | static func emacsReport(_ text: String, start: String?, end: String?) throws -> String { |
| 93 | 101 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)") |