Table and kanban views !30
9 files changed, +444 −0
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +6
| @@ -82,6 +82,12 @@ public final class AgendaModel { | |||
| 82 | return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value | 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 | /// The agenda files' sources: the open file's from its text, the others from the cache | 91 | /// The agenda files' sources: the open file's from its text, the others from the cache |
| 86 | /// while their modification date and size are unchanged. | 92 | /// while their modification date and size are unchanged. |
| 87 | private func sources(open: (path: String, text: String)?) async -> [AgendaSource] { | 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 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), | 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), |
| 46 | EditorCommand(id: "app.capture", title: "Capture…", action: .app), | 46 | EditorCommand(id: "app.capture", title: "Capture…", action: .app), |
| 47 | EditorCommand(id: "app.board", title: "Board", action: .app), | ||
| 47 | EditorCommand(id: "app.clock.in", title: "Clock In", action: .app), | 48 | 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.out", title: "Clock Out", action: .app), |
| 49 | EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), | 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 | if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") } | 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": | 344 | case "app.clock.report": |
| 345 | openWindow(id: "clock-report") | 345 | openWindow(id: "clock-report") |
| 346 | case "app.board": | ||
| 347 | openWindow(id: "board") | ||
| 346 | case "app.capture": | 348 | case "app.capture": |
| 347 | NSApp.activate() | 349 | NSApp.activate() |
| 348 | openWindow(id: "capture") | 350 | openWindow(id: "capture") |
Sources/Orgstar/OrgstarApp.swift +8
| @@ -162,6 +162,12 @@ struct OrgstarApp: App { | |||
| 162 | } | 162 | } |
| 163 | } | 163 | } |
| 164 | .defaultSize(width: 760, height: 720) | 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 | Window("Clock Report", id: "clock-report") { | 171 | Window("Clock Report", id: "clock-report") { |
| 166 | if case .success(let model) = workspace { | 172 | if case .success(let model) = workspace { |
| 167 | ClockReportView(workspace: model) | 173 | ClockReportView(workspace: model) |
| @@ -232,6 +238,8 @@ struct AgendaMenuItem: View { | |||
| 232 | var body: some View { | 238 | var body: some View { |
| 233 | Button("Agenda") { openWindow(id: "agenda") } | 239 | Button("Agenda") { openWindow(id: "agenda") } |
| 234 | .keyboardShortcut("a", modifiers: [.command, .shift]) | 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". | ||