Commit d3f871e3e7
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +6
| @@ -82,6 +82,12 @@ public final class AgendaModel { | ||
| 82 | 82 | return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | /// Entries for the table and kanban views, and the keywords their files use. | |
| 86 | public func board(open: (path: String, text: String)?, match: String) async -> (entries: [BoardEntry], keywords: [String]) { | |
| 87 | let all = await sources(open: open) | |
| 88 | return await Task.detached { (Board.entries(all, match: match), Board.keywords(all)) }.value | |
| 89 | } | |
| 90 | ||
| 85 | 91 | /// The agenda files' sources: the open file's from its text, the others from the cache |
| 86 | 92 | /// while their modification date and size are unchanged. |
| 87 | 93 | private func sources(open: (path: String, text: String)?) async -> [AgendaSource] { |
Sources/OrgCore/Agenda/Board.swift added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// An entry as the table and kanban views show it. | |
| 4 | public struct BoardEntry: Sendable, Equatable, Identifiable { | |
| 5 | public let path: String | |
| 6 | public let headingOffset: Int | |
| 7 | /// The heading line, to check the offset still holds before editing. | |
| 8 | public let headingLine: String | |
| 9 | public let title: String | |
| 10 | public let todo: String? | |
| 11 | public let isDone: Bool | |
| 12 | public let priority: String? | |
| 13 | public let tags: [String] | |
| 14 | public let category: String | |
| 15 | /// Planning timestamps without brackets. | |
| 16 | public let scheduled: String? | |
| 17 | public let deadline: String? | |
| 18 | /// Upper-cased keys of the entry's own drawer. | |
| 19 | public let properties: [String: String] | |
| 20 | ||
| 21 | public var id: String { "\(path)#\(headingOffset)" } | |
| 22 | } | |
| 23 | ||
| 24 | public enum Board { | |
| 25 | /// Entries of `sources` that match `match` (a tags/property/TODO match, as for `m` in the | |
| 26 | /// agenda), or every entry with a TODO keyword when `match` is empty. | |
| 27 | public static func entries(_ sources: [AgendaSource], match: String, now: Date = Date(), calendar: Calendar = .current) -> [BoardEntry] { | |
| 28 | let trimmed = match.trimmingCharacters(in: .whitespaces) | |
| 29 | let matcher = trimmed.isEmpty ? nil : TagsMatcher(trimmed) | |
| 30 | var result: [BoardEntry] = [] | |
| 31 | for source in sources { | |
| 32 | for heading in source.headings where !heading.skipped { | |
| 33 | if let matcher { | |
| 34 | guard matcher.matches(heading, in: source, now: now, calendar: calendar) else { continue } | |
| 35 | } else if heading.todo == nil { | |
| 36 | continue | |
| 37 | } | |
| 38 | result.append(BoardEntry( | |
| 39 | path: source.path, headingOffset: heading.start, headingLine: heading.line, title: heading.title, | |
| 40 | todo: heading.todo, isDone: heading.isDone, priority: heading.priority, tags: heading.tags.map(\.name), | |
| 41 | category: heading.category, scheduled: heading.scheduled?.raw, deadline: heading.deadline?.raw, | |
| 42 | properties: heading.properties | |
| 43 | )) | |
| 44 | } | |
| 45 | } | |
| 46 | return result | |
| 47 | } | |
| 48 | ||
| 49 | /// The keywords for kanban columns, in sequence order: each file's keywords, first come | |
| 50 | /// first. | |
| 51 | public static func keywords(_ sources: [AgendaSource]) -> [String] { | |
| 52 | var seen: [String] = [] | |
| 53 | for source in sources { | |
| 54 | for keyword in source.keywords where !seen.contains(keyword) { seen.append(keyword) } | |
| 55 | } | |
| 56 | return seen | |
| 57 | } | |
| 58 | } | |
Sources/OrgCore/Commands/Properties.swift added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// `org-set-property` / `org-entry-put` for one entry. | |
| 4 | public struct SetProperty: OrgCommand { | |
| 5 | public let key: String | |
| 6 | public let value: String | |
| 7 | public init(key: String, value: String) { | |
| 8 | self.key = key | |
| 9 | self.value = value | |
| 10 | } | |
| 11 | public var id: String { "org.property.set" } | |
| 12 | public var title: String { "Set Property" } | |
| 13 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } | |
| 14 | public func run(in context: EditContext) -> CommandStep { | |
| 15 | if key.uppercased() == "TODO" { return SetTodo(keyword: value.isEmpty ? nil : value).run(in: context) } | |
| 16 | guard key.range(of: "^[^\\s:]+$", options: .regularExpression) != nil else { return .failed("Invalid property name: \"\(key)\"") } | |
| 17 | return commitBuffer(context) { buffer in | |
| 18 | buffer.saveExcursion { buffer.entryPut(key, value) } | |
| 19 | } | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | /// `org-todo` with a keyword (nil for none), with the statistics and repeat it brings. | |
| 24 | public struct SetTodo: OrgCommand { | |
| 25 | public let keyword: String? | |
| 26 | public init(keyword: String?) { self.keyword = keyword } | |
| 27 | public var id: String { "org.todo.set" } | |
| 28 | public var title: String { "Set TODO Keyword" } | |
| 29 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } | |
| 30 | public func run(in context: EditContext) -> CommandStep { | |
| 31 | guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") } | |
| 32 | let settings = context.tree.settings | |
| 33 | if let keyword, !settings.todoSequences.contains(where: { ($0.active + $0.done).contains { $0.name == keyword } }) { | |
| 34 | return .failed("\"\(keyword)\" is not a valid TODO state") | |
| 35 | } | |
| 36 | return commitBuffer(context) { buffer in | |
| 37 | try buffer.saveExcursion { | |
| 38 | let previous = buffer.todoKeyword | |
| 39 | buffer.setTodoKeyword(keyword) | |
| 40 | guard previous != keyword else { return } | |
| 41 | if let keyword, settings.isDone(keyword), !(previous.map(settings.isDone) ?? false), | |
| 42 | let (marker, note) = try buffer.autoRepeat(doneWord: keyword, previous: previous) { | |
| 43 | buffer.storeNote(marker, note) | |
| 44 | } | |
| 45 | } | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -44,6 +44,7 @@ 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.board", title: "Board", action: .app), | |
| 47 | 48 | EditorCommand(id: "app.clock.in", title: "Clock In", action: .app), |
| 48 | 49 | EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app), |
| 49 | 50 | EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), |
Sources/Orgstar/BoardView.swift added +229
| @@ -0,0 +1,229 @@ | ||
| 1 | import OrgApp | |
| 2 | import OrgCore | |
| 3 | import SwiftUI | |
| 4 | import UniformTypeIdentifiers | |
| 5 | ||
| 6 | /// Entries of the agenda files as a sortable table with property columns, or as a kanban | |
| 7 | /// board by TODO keyword. Dragging a card to a column sets its keyword; edits go through the | |
| 8 | /// open buffer or the file. | |
| 9 | struct BoardView: View { | |
| 10 | let workspace: WorkspaceModel | |
| 11 | let session: DocumentSession | |
| 12 | let commands: AppCommands | |
| 13 | @State private var agenda: AgendaModel | |
| 14 | @State private var entries: [BoardEntry] = [] | |
| 15 | @State private var keywords: [String] = [] | |
| 16 | @State private var selection: Set<String> = [] | |
| 17 | @State private var sortOrder = [KeyPathComparator(\BoardRow.todoOrder), KeyPathComparator(\BoardRow.title)] | |
| 18 | @State private var message: String? | |
| 19 | @State private var tick = 0 | |
| 20 | @AppStorage("boardKanban") private var kanban = false | |
| 21 | @AppStorage("boardMatch") private var match = "" | |
| 22 | @AppStorage("boardColumns") private var columnText = "EFFORT" | |
| 23 | @AppStorage("agendaSubfolders") private var includeSubfolders = false | |
| 24 | @Environment(\.openWindow) private var openWindow | |
| 25 | ||
| 26 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { | |
| 27 | self.workspace = workspace | |
| 28 | self.session = session | |
| 29 | self.commands = commands | |
| 30 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) | |
| 31 | } | |
| 32 | ||
| 33 | private var propertyColumns: [String] { | |
| 34 | columnText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).uppercased() }.filter { !$0.isEmpty } | |
| 35 | } | |
| 36 | ||
| 37 | private var rows: [BoardRow] { | |
| 38 | entries.map { BoardRow(entry: $0, todoOrder: $0.todo.flatMap { keywords.firstIndex(of: $0) } ?? Int.max) }.sorted(using: sortOrder) | |
| 39 | } | |
| 40 | ||
| 41 | private struct RefreshKey: Hashable { | |
| 42 | var match: String | |
| 43 | var subfolders: Bool | |
| 44 | var files: Int | |
| 45 | var changed: Int? | |
| 46 | var edits: Int | |
| 47 | var path: String? | |
| 48 | var tick: Int | |
| 49 | } | |
| 50 | ||
| 51 | var body: some View { | |
| 52 | Group { | |
| 53 | if kanban { board } else { table } | |
| 54 | } | |
| 55 | .safeAreaInset(edge: .bottom, spacing: 0) { | |
| 56 | if let message { | |
| 57 | Text(message).font(.callout).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.vertical, 6).background(.bar) | |
| 58 | } | |
| 59 | } | |
| 60 | .toolbar { | |
| 61 | ToolbarItemGroup(placement: .navigation) { | |
| 62 | Picker("View", selection: $kanban) { | |
| 63 | Label("Table", systemImage: "tablecells").tag(false) | |
| 64 | Label("Kanban", systemImage: "rectangle.split.3x1").tag(true) | |
| 65 | } | |
| 66 | .pickerStyle(.segmented) | |
| 67 | TextField("Match: +work/!TODO", text: $match) | |
| 68 | .frame(minWidth: 200) | |
| 69 | if !kanban { | |
| 70 | TextField("Property columns", text: $columnText) | |
| 71 | .frame(minWidth: 140) | |
| 72 | .help("Property names to show as columns, separated by commas") | |
| 73 | } | |
| 74 | } | |
| 75 | } | |
| 76 | .navigationTitle(kanban ? "Kanban" : "Entries") | |
| 77 | .task(id: RefreshKey(match: match, subfolders: includeSubfolders, files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, path: session.path, tick: tick)) { | |
| 78 | agenda.includeSubfolders = includeSubfolders | |
| 79 | let open = session.path.flatMap { path in session.document.map { (path, $0.text) } } | |
| 80 | let result = await agenda.board(open: open, match: match) | |
| 81 | entries = result.entries | |
| 82 | keywords = result.keywords | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | // MARK: - Table | |
| 87 | ||
| 88 | private var table: some View { | |
| 89 | Table(rows, selection: $selection, sortOrder: $sortOrder) { | |
| 90 | TableColumn("TODO", value: \.todoOrder) { row in | |
| 91 | Text(row.entry.todo ?? "").foregroundStyle(row.entry.isDone ? .green : .red).bold() | |
| 92 | } | |
| 93 | .width(min: 50, ideal: 60, max: 90) | |
| 94 | TableColumn("P", value: \.priority) { row in Text(row.priority) } | |
| 95 | .width(min: 20, ideal: 24, max: 40) | |
| 96 | TableColumn("Title", value: \.title) { row in Text(row.title).lineLimit(1) } | |
| 97 | .width(min: 160, ideal: 320) | |
| 98 | TableColumn("Tags", value: \.tags) { row in Text(row.tags).foregroundStyle(.secondary).lineLimit(1) } | |
| 99 | .width(min: 60, ideal: 120) | |
| 100 | TableColumn("Scheduled", value: \.scheduled) { row in Text(row.scheduled).monospacedDigit() } | |
| 101 | .width(min: 80, ideal: 110) | |
| 102 | TableColumn("Deadline", value: \.deadline) { row in Text(row.deadline).monospacedDigit() } | |
| 103 | .width(min: 80, ideal: 110) | |
| 104 | TableColumnForEach(propertyColumns, id: \.self) { key in | |
| 105 | TableColumn(key.capitalized) { (row: BoardRow) in Text(row.entry.properties[key] ?? "") } | |
| 106 | .width(min: 50, ideal: 80) | |
| 107 | } | |
| 108 | TableColumn("File", value: \.file) { row in Text(row.file).foregroundStyle(.secondary).lineLimit(1) } | |
| 109 | .width(min: 60, ideal: 120) | |
| 110 | } | |
| 111 | .contextMenu(forSelectionType: String.self) { ids in | |
| 112 | if let entry = entries.first(where: { ids.contains($0.id) }) { | |
| 113 | Menu("Set TODO") { | |
| 114 | ForEach(keywords, id: \.self) { keyword in Button(keyword) { setTodo(entry, keyword) } } | |
| 115 | Divider() | |
| 116 | Button("None") { setTodo(entry, nil) } | |
| 117 | } | |
| 118 | Button("Set Property…") { askProperty(entry) } | |
| 119 | Button("Open") { jump(entry) } | |
| 120 | } | |
| 121 | } primaryAction: { ids in | |
| 122 | if let entry = entries.first(where: { ids.contains($0.id) }) { jump(entry) } | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | // MARK: - Kanban | |
| 127 | ||
| 128 | private var board: some View { | |
| 129 | ScrollView(.horizontal) { | |
| 130 | HStack(alignment: .top, spacing: 12) { | |
| 131 | ForEach(keywords, id: \.self) { keyword in | |
| 132 | column(keyword) | |
| 133 | } | |
| 134 | } | |
| 135 | .padding(12) | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | private func column(_ keyword: String) -> some View { | |
| 140 | let cards = entries.filter { $0.todo == keyword } | |
| 141 | return VStack(alignment: .leading, spacing: 8) { | |
| 142 | HStack { | |
| 143 | Text(keyword).font(.headline) | |
| 144 | Text("\(cards.count)").foregroundStyle(.secondary) | |
| 145 | } | |
| 146 | ScrollView { | |
| 147 | VStack(alignment: .leading, spacing: 6) { | |
| 148 | ForEach(cards) { entry in | |
| 149 | card(entry) | |
| 150 | .draggable(entry.id) | |
| 151 | .onTapGesture(count: 2) { jump(entry) } | |
| 152 | } | |
| 153 | } | |
| 154 | } | |
| 155 | } | |
| 156 | .frame(width: 240, alignment: .topLeading) | |
| 157 | .frame(maxHeight: .infinity, alignment: .top) | |
| 158 | .padding(8) | |
| 159 | .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.08))) | |
| 160 | .dropDestination(for: String.self) { ids, _ in | |
| 161 | for id in ids { | |
| 162 | if let entry = entries.first(where: { $0.id == id }), entry.todo != keyword { setTodo(entry, keyword) } | |
| 163 | } | |
| 164 | return true | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | private func card(_ entry: BoardEntry) -> some View { | |
| 169 | VStack(alignment: .leading, spacing: 4) { | |
| 170 | Text((entry.priority.map { "[#\($0)] " } ?? "") + entry.title).lineLimit(3) | |
| 171 | HStack(spacing: 6) { | |
| 172 | Text(entry.category).font(.caption).foregroundStyle(.secondary) | |
| 173 | if !entry.tags.isEmpty { | |
| 174 | Text(entry.tags.joined(separator: " ")).font(.caption).foregroundStyle(.secondary).lineLimit(1) | |
| 175 | } | |
| 176 | } | |
| 177 | if let deadline = entry.deadline { | |
| 178 | Text("Deadline \(deadline)").font(.caption).foregroundStyle(.red) | |
| 179 | } else if let scheduled = entry.scheduled { | |
| 180 | Text("Scheduled \(scheduled)").font(.caption).foregroundStyle(.secondary) | |
| 181 | } | |
| 182 | } | |
| 183 | .padding(8) | |
| 184 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 185 | .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .controlBackgroundColor))) | |
| 186 | } | |
| 187 | ||
| 188 | // MARK: - Editing | |
| 189 | ||
| 190 | private func perform(_ command: any OrgCommand, on entry: BoardEntry) { | |
| 191 | switch session.perform(command, path: entry.path, at: entry.headingOffset, expect: (entry.headingOffset, entry.headingLine)) { | |
| 192 | case .failed(let text): message = text | |
| 193 | default: message = nil | |
| 194 | } | |
| 195 | tick += 1 | |
| 196 | } | |
| 197 | ||
| 198 | private func setTodo(_ entry: BoardEntry, _ keyword: String?) { | |
| 199 | perform(SetTodo(keyword: keyword), on: entry) | |
| 200 | } | |
| 201 | ||
| 202 | private func askProperty(_ entry: BoardEntry) { | |
| 203 | commands.ask(Prompt(key: "property", message: "Property (NAME value):")) { answer in | |
| 204 | guard let answer, let space = answer.firstIndex(of: " ") else { return } | |
| 205 | let key = String(answer[..<space]) | |
| 206 | let value = String(answer[answer.index(after: space)...]).trimmingCharacters(in: .whitespaces) | |
| 207 | perform(SetProperty(key: key, value: value), on: entry) | |
| 208 | } | |
| 209 | openWindow(id: "main") | |
| 210 | } | |
| 211 | ||
| 212 | private func jump(_ entry: BoardEntry) { | |
| 213 | commands.jump(path: entry.path, offset: entry.headingOffset) | |
| 214 | openWindow(id: "main") | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | /// A table row with sortable values. | |
| 219 | struct BoardRow: Identifiable { | |
| 220 | let entry: BoardEntry | |
| 221 | let todoOrder: Int | |
| 222 | var id: String { entry.id } | |
| 223 | var title: String { entry.title } | |
| 224 | var priority: String { entry.priority ?? "" } | |
| 225 | var tags: String { entry.tags.joined(separator: " ") } | |
| 226 | var scheduled: String { entry.scheduled ?? "" } | |
| 227 | var deadline: String { entry.deadline ?? "" } | |
| 228 | var file: String { (entry.path as NSString).lastPathComponent } | |
| 229 | } | |
Sources/Orgstar/ContentView.swift +2
| @@ -343,6 +343,8 @@ struct ContentView: View { | ||
| 343 | 343 | if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") } |
| 344 | 344 | case "app.clock.report": |
| 345 | 345 | openWindow(id: "clock-report") |
| 346 | case "app.board": | |
| 347 | openWindow(id: "board") | |
| 346 | 348 | case "app.capture": |
| 347 | 349 | NSApp.activate() |
| 348 | 350 | openWindow(id: "capture") |
Sources/Orgstar/OrgstarApp.swift +8
| @@ -162,6 +162,12 @@ struct OrgstarApp: App { | ||
| 162 | 162 | } |
| 163 | 163 | } |
| 164 | 164 | .defaultSize(width: 760, height: 720) |
| 165 | Window("Board", id: "board") { | |
| 166 | if case .success(let model) = workspace { | |
| 167 | BoardView(workspace: model, session: session, commands: commands) | |
| 168 | } | |
| 169 | } | |
| 170 | .defaultSize(width: 1100, height: 700) | |
| 165 | 171 | Window("Clock Report", id: "clock-report") { |
| 166 | 172 | if case .success(let model) = workspace { |
| 167 | 173 | ClockReportView(workspace: model) |
| @@ -232,6 +238,8 @@ struct AgendaMenuItem: View { | ||
| 232 | 238 | var body: some View { |
| 233 | 239 | Button("Agenda") { openWindow(id: "agenda") } |
| 234 | 240 | .keyboardShortcut("a", modifiers: [.command, .shift]) |
| 241 | Button("Board") { openWindow(id: "board") } | |
| 242 | .keyboardShortcut("b", modifiers: [.command, .shift]) | |
| 235 | 243 | } |
| 236 | 244 | } |
| 237 | 245 | |
Tests/OrgCoreTests/PropertyTests.swift added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct PropertyTests { | |
| 6 | static let texts = [ | |
| 7 | "* TODO Task\n", | |
| 8 | "* TODO Task\nBody\n", | |
| 9 | "* Task\nSCHEDULED: <2026-10-05 Mon>\nBody\n", | |
| 10 | "* Task\n:PROPERTIES:\n:OWNER: alice\n:SIZE: 3\n:END:\nBody\n", | |
| 11 | "* Task :tag:\n:PROPERTIES:\n:ID: x\n:END:\n** Child\n", | |
| 12 | "* DONE Old\n* TODO Repeat\nSCHEDULED: <2026-10-05 Mon +1w>\n", | |
| 13 | "* Task", | |
| 14 | ] | |
| 15 | ||
| 16 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 17 | func matchesEmacs() throws { | |
| 18 | let edits: [(any OrgCommand, String)] = [ | |
| 19 | (SetProperty(key: "OWNER", value: "bob"), "(org-entry-put nil \"OWNER\" \"bob\")"), | |
| 20 | (SetProperty(key: "Effort", value: "1:30"), "(org-entry-put nil \"Effort\" \"1:30\")"), | |
| 21 | (SetProperty(key: "LONGPROPERTYNAME", value: "v"), "(org-entry-put nil \"LONGPROPERTYNAME\" \"v\")"), | |
| 22 | (SetProperty(key: "SIZE", value: ""), "(org-entry-put nil \"SIZE\" \"\")"), | |
| 23 | (SetTodo(keyword: "DONE"), "(org-todo \"DONE\")"), | |
| 24 | (SetTodo(keyword: "TODO"), "(org-todo \"TODO\")"), | |
| 25 | (SetTodo(keyword: nil), "(org-todo 'none)"), | |
| 26 | ] | |
| 27 | var cases: [EmacsOracle.Case] = [] | |
| 28 | var ours: [(text: String, caret: Int, failure: String?)] = [] | |
| 29 | for text in Self.texts { | |
| 30 | for heading in [(text as NSString).range(of: "Task").location, (text as NSString).range(of: "Repeat").location] where heading != NSNotFound { | |
| 31 | for (command, form) in edits { | |
| 32 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: frozenClock(1_789_000_000, "(progn \(form) (run-hooks 'post-command-hook))"))) | |
| 33 | var utc = Calendar(identifier: .gregorian) | |
| 34 | utc.timeZone = TimeZone(identifier: "UTC")! | |
| 35 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [heading..<heading], now: Date(timeIntervalSince1970: 1_789_000_000), calendar: utc) | |
| 36 | switch command.run(in: context) { | |
| 37 | case .commit(let result): | |
| 38 | var new = text | |
| 39 | for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) } | |
| 40 | ours.append((new, heading, nil)) | |
| 41 | case .failed(let message): ours.append((text, heading, message)) | |
| 42 | default: ours.append((text, heading, "other")) | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | } | |
| 47 | let emacs = try EmacsOracle.run(cases) | |
| 48 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | |
| 49 | #expect(mine.text == theirs.text, "\(cases[i].form.split(separator: "\n").last ?? "") on \(cases[i].text.debugDescription)\nours: \(mine.text.debugDescription) \(mine.failure ?? "")\nemacs: \(theirs.text.debugDescription) \(theirs.error)") | |
| 50 | } | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | struct BoardTests { | |
| 55 | @Test func entriesAndKeywords() { | |
| 56 | let source = AgendaSource(path: "/n/a.org", text: """ | |
| 57 | #+TODO: TODO NEXT | DONE | |
| 58 | * TODO [#A] One :work: | |
| 59 | :PROPERTIES: | |
| 60 | :EFFORT: 1:00 | |
| 61 | :END: | |
| 62 | * Plain | |
| 63 | * DONE Two | |
| 64 | DEADLINE: <2026-10-05 Mon> | |
| 65 | * ARCHIVED TODO :ARCHIVE: | |
| 66 | ** TODO Hidden | |
| 67 | """) | |
| 68 | let all = Board.entries([source], match: "") | |
| 69 | #expect(all.map(\.title) == ["One", "Two"]) | |
| 70 | #expect(all[0].properties["EFFORT"] == "1:00" && all[0].priority == "A" && all[0].tags == ["work"]) | |
| 71 | #expect(all[1].deadline == "2026-10-05 Mon") | |
| 72 | #expect(Board.entries([source], match: "+work").map(\.title) == ["One"]) | |
| 73 | #expect(Board.keywords([source]) == ["TODO", "NEXT", "DONE"]) | |
| 74 | } | |
| 75 | } | |
docs/plans/2026-10-05-board.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # Table and Kanban Views 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:** Entries of the agenda files as a sortable table with chosen property columns, and as a kanban board by TODO keyword, both editing the files. | |
| 6 | ||
| 7 | **Architecture:** `Board.entries` selects headings from the agenda sources with the agenda's tags/property match (or every entry with a keyword), carrying TODO, priority, tags, category, planning and own properties. `SetTodo` (`org-todo` with a keyword, with its repeat handling) and `SetProperty` (`org-entry-put`) write changes, checked against Emacs. The Board window (⇧⌘B) shows a `Table` with sortable columns and property columns named in the toolbar, or kanban columns per keyword where dropping a card sets its keyword; edits go through the open buffer or the save path and are refused if the heading moved. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle. | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Phase 6". | |
| 12 | ||
| 13 | --- | |
| 14 | ||
| 15 | - [x] `Board`, `SetTodo`, `SetProperty`; oracle and tests. | |
| 16 | - [x] Board window: table, kanban, context menu, drag to set keyword. | |
| 17 | - [x] Commit "Table and kanban views". | |