Property commands and column view !90

merged merged by cmc on 2026-10-06 04:37 UTC · krz/orgstar:properties-columns into main

12 files changed, +1007 −17

Layout: unified · split

Sources/OrgCore/Commands/ClockTable.swift +84 −13
@@ -22,8 +22,8 @@ enum ClockTable {
2222 ":narrow": .symbol("40!"), ":indent": .symbol("t"), ":lang": .string("en"),
2323 ]
2424
25 init(_ line: String) throws {
26 values = Self.defaults
25 init(_ line: String, defaults: [String: Sexp] = Self.defaults) throws {
26 values = defaults
2727 let forms = try LispReader.read("(" + line + ")")
2828 let items = forms.first?.sexp.elements ?? []
2929 var i = 0
@@ -307,14 +307,21 @@ extension EmacsBuffer {
307307 point = begin
308308 guard let start = lookingAt(Self.dblockStart) else { throw UserError(message: "Not at a dynamic block") }
309309 let name = start[1].map { substring($0.lowerBound, $0.upperBound) } ?? ""
310 guard name == "clocktable" else { throw UserError(message: "Dynamic blocks named \(name) aren't supported") }
311310 let paramText = start[3].map { substring($0.lowerBound, $0.upperBound) } ?? ""
312 let params = try ClockTable.Params(paramText)
313 let indentation = currentIndentation
314311 let deleteFrom = start[0]!.upperBound + 1
315312 point = start[0]!.upperBound
316313 guard let end = searchForward("^[ \\t]*#\\+(?:END|end):?", caseInsensitive: false)?[0] else { throw UserError(message: "Dynamic block not terminated") }
317314 let content = substring(min(deleteFrom, end.lowerBound), end.lowerBound)
315 if name == "columnview" {
316 let params = try ClockTable.Params(paramText, defaults: [:])
317 let mark = marker(begin)
318 defer { release(mark) }
319 let table = try writeColumnView(params, at: begin, content: content, tree: OrgParser.parse(string))
320 try replaceDynamicBlockContent(at: mark.position, with: table, recalculate: table.range(of: "(?mi)^[ \\t]*#\\+TBLFM:", options: .regularExpression) != nil)
321 return
322 }
323 guard name == "clocktable" else { throw UserError(message: "Dynamic blocks named \(name) aren't supported") }
324 let params = try ClockTable.Params(paramText)
318325 // The scope's text.
319326 point = begin
320327 let region: Range<Int>
@@ -336,6 +343,18 @@ extension EmacsBuffer {
336343 throw UserError(message: "Clock tables over \(scope) aren't supported yet")
337344 }
338345 let table = try ClockTable.write(self, params: params, region: region, content: content)
346 try replaceDynamicBlockContent(at: begin, with: table)
347 }
348
349 /// Puts `table` between the #+BEGIN and #+END lines of the block at `begin`, indented like
350 /// the #+BEGIN line as `org-update-dblock` does, and aligns it.
351 func replaceDynamicBlockContent(at begin: Int, with table: String, recalculate: Bool = false) throws {
352 point = begin
353 guard let start = lookingAt(Self.dblockStart) else { throw UserError(message: "Not at a dynamic block") }
354 let indentation = currentIndentation
355 let deleteFrom = start[0]!.upperBound + 1
356 point = start[0]!.upperBound
357 guard let end = searchForward("^[ \\t]*#\\+(?:END|end):?", caseInsensitive: false)?[0] else { throw UserError(message: "Dynamic block not terminated") }
339358 // Replace the old contents.
340359 let contentEnd = end.lowerBound
341360 deleteRegion(min(deleteFrom, contentEnd), contentEnd)
@@ -355,6 +374,14 @@ extension EmacsBuffer {
355374 point = deleteFrom
356375 if let bar = searchForward("^[ \\t]*\\|", caseInsensitive: false) {
357376 point = bar[0]!.upperBound
377 // A table kept with formulas is recalculated, as the column view writer does.
378 if recalculate {
379 do {
380 try tableRecalculate(all: true)
381 } catch let error as Calc.Unsupported {
382 throw UserError(message: TableRecalculate.emacsMessage + error.reason)
383 }
384 }
358385 tableAlign()
359386 }
360387 point = begin
@@ -396,20 +423,64 @@ public struct ClockReportTable: OrgCommand {
396423 return
397424 }
398425 let scope = buffer.beforeFirstHeading ? "file" : "subtree"
399 // `org-create-dblock`.
400 if buffer.substring(buffer.lineStart(buffer.point), buffer.lineEnd(buffer.point)).contains(where: { !$0.isWhitespace }) {
401 buffer.point = buffer.lineEnd(buffer.point)
402 buffer.insert("\n")
403 }
404 let column = buffer.currentColumn
405 let begin = buffer.lineStart(buffer.point)
406 buffer.insert("#+BEGIN: clocktable :scope \(scope) :maxlevel 2\n\n" + String(repeating: " ", count: column) + "#+END:\n")
426 let begin = buffer.createDynamicBlock("clocktable :scope \(scope) :maxlevel 2")
407427 try buffer.updateDynamicBlock(at: begin)
408428 buffer.point = begin
409429 }
410430 }
411431}
412432
433extension EmacsBuffer {
434 /// `org-create-dblock`: an empty block named by `header` on a line of its own at point.
435 func createDynamicBlock(_ header: String) -> Int {
436 if substring(lineStart(point), lineEnd(point)).contains(where: { !$0.isWhitespace }) {
437 point = lineEnd(point)
438 insert("\n")
439 }
440 let column = currentColumn
441 let begin = lineStart(point)
442 insert("#+BEGIN: \(header)\n\n" + String(repeating: " ", count: column) + "#+END:\n")
443 return begin
444 }
445}
446
447/// C-c C-x i: `org-columns-insert-dblock`, asking what to capture (this subtree, the file, or an
448/// entry by ID).
449public struct InsertColumnView: OrgCommand {
450 public init() {}
451 public var id: String { "org.columns.insert-dblock" }
452 public var title: String { "Insert Column View Table" }
453 public func applies(in context: EditContext) -> Bool { true }
454 public func run(in context: EditContext) -> CommandStep {
455 guard let answer = context.answers["capture"] else {
456 let ids = SparseTreeCommand.propertyValues("ID", in: context.text)
457 return .prompt(Prompt(key: "capture", message: "Capture columns (local, global, entry with :ID: property) [local]:",
458 choices: ["global", "local"] + ids, requireMatch: false))
459 }
460 let id = answer.isEmpty || answer == "local" ? "local" : answer == "global" ? "global" : answer
461 return commitBuffer(context) { buffer in
462 let begin = buffer.createDynamicBlock("columnview :hlines 1 :id \(id)")
463 try buffer.updateDynamicBlock(at: begin)
464 buffer.point = begin
465 }
466 }
467}
468
469/// C-c C-x C-c: `org-columns`, shown read-only: the entries from the column view's top level
470/// with their values and summaries.
471public struct ShowColumnView: OrgCommand {
472 let global: Bool
473 public init(global: Bool = false) { self.global = global }
474 public var id: String { global ? "org.columns.global" : "org.columns" }
475 public var title: String { global ? "Column View of File" : "Column View" }
476 public func applies(in context: EditContext) -> Bool { true }
477 public func run(in context: EditContext) -> CommandStep {
478 let view = ColumnView.capture(text: context.text, tree: context.tree, at: context.caret, global: global, now: context.now).view
479 guard !view.rows.isEmpty else { return .failed("No entries for column view") }
480 return .external(.columnView(view))
481 }
482}
483
413484/// C-c C-x e: `org-set-effort`. Offers the entry's `Effort_ALL` values; sets `Effort`.
414485public struct SetEffort: OrgCommand {
415486 public init() {}
Sources/OrgCore/Commands/ColumnView.swift added +392
@@ -0,0 +1,392 @@
1import Foundation
2
3// Column view (`org-colview.el`, Org 9.8.7): the column format, values and summaries of each
4// entry, and the `columnview` dynamic block that captures them.
5
6public enum ColumnView {
7 /// One column of `org-columns-compile-format`.
8 public struct Spec: Sendable, Equatable {
9 public let property: String
10 public let title: String
11 public let width: Int?
12 public let summary: String?
13 public let format: String?
14 }
15
16 public struct Row: Sendable, Equatable {
17 public let level: Int
18 /// Start of the heading line.
19 public let start: Int
20 public let values: [String]
21 }
22
23 public struct View: Sendable, Equatable {
24 public let specs: [Spec]
25 public let rows: [Row]
26 }
27
28 public static let defaultFormat = "%25ITEM %TODO %3PRIORITY %TAGS"
29
30 /// `org-columns-compile-format`.
31 public static func compile(_ format: String) -> [Spec] {
32 format.matches(of: /%([0-9]+)?([A-Za-z0-9_-]+)(?:\(([^)]*)\))?(?:\{([^}]*)\})?\s*/).map { m in
33 let property = String(m.2)
34 let title = m.3.map(String.init).flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil } ?? property
35 var summary = m.4.map(String.init).flatMap { $0.contains(where: { !$0.isWhitespace }) ? $0 : nil }
36 var fmt: String?
37 if let s = summary, let semicolon = s.firstIndex(of: ";") {
38 fmt = String(s[s.index(after: semicolon)...])
39 summary = String(s[..<semicolon])
40 }
41 return Spec(property: property.uppercased(), title: title, width: m.1.flatMap { Int($0) }, summary: summary, format: fmt)
42 }
43 }
44
45 // MARK: - Numbers and durations as Emacs Lisp has them
46
47 /// A number from `string-to-number`: an integer unless the text has a fraction or exponent.
48 enum Number {
49 case int(Int)
50 case float(Double)
51
52 init(_ s: String) {
53 let t = s.trimmingCharacters(in: .whitespaces)
54 if let m = t.prefixMatch(of: /[-+]?[0-9]+(?![.eE0-9])/), let i = Int(m.0) { self = .int(i); return }
55 if let m = t.prefixMatch(of: /[-+]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?/), let d = Double(m.0) {
56 self = m.0.contains(where: { $0 == "." || $0 == "e" || $0 == "E" }) ? .float(d) : .int(Int(d)); return
57 }
58 self = .int(0)
59 }
60
61 var double: Double {
62 switch self {
63 case .int(let i): Double(i)
64 case .float(let d): d
65 }
66 }
67
68 /// `format "%s"`.
69 var lisp: String {
70 switch self {
71 case .int(let i): String(i)
72 case .float(let d): d.isFinite && d == d.rounded() && abs(d) < 1e16 ? String(format: "%.1f", d) : "\(d)"
73 }
74 }
75
76 static func + (a: Number, b: Number) -> Number {
77 if case .int(let x) = a, case .int(let y) = b { return .int(x + y) }
78 return .float(a.double + b.double)
79 }
80 }
81
82 /// `format FMT` with a number, for the formats summaries use.
83 static func format(_ fmt: String?, _ n: Number) -> String {
84 guard let fmt else { return n.lisp }
85 if fmt.contains("%d") { return String(format: fmt.replacingOccurrences(of: "%d", with: "%ld"), Int(n.double)) }
86 if fmt.contains("%s") { return fmt.replacingOccurrences(of: "%s", with: n.lisp) }
87 return String(format: fmt, n.double)
88 }
89
90 static let unitMinutes: [(String, Double)] = [("min", 1), ("h", 60), ("d", 1440), ("w", 10080), ("m", 43200), ("y", 525960)]
91
92 /// `org-duration-to-minutes`.
93 static func minutes(_ duration: String) -> Double? {
94 let s = duration.trimmingCharacters(in: .whitespaces)
95 if s.isEmpty { return 0 }
96 if s.wholeMatch(of: /[0-9]+(?::[0-9]{2}){1,2}/) != nil {
97 let parts = s.split(separator: ":").map { Double($0) ?? 0 }
98 return parts[0] * 60 + parts[1] + (parts.count > 2 ? parts[2] / 60 : 0)
99 }
100 let unit = /([0-9]+(?:\.[0-9]*)?)[ \t]*(min|h|d|w|m|y)/
101 if s.wholeMatch(of: /(?:[0-9]+(?:\.[0-9]*)?[ \t]*(?:min|h|d|w|m|y)[ \t]*)+/) != nil {
102 var total = 0.0
103 for m in s.matches(of: unit) {
104 let name = String(m.2)
105 total += (Double(m.1) ?? 0) * (unitMinutes.first { $0.0 == name }?.1 ?? 0)
106 }
107 return total
108 }
109 if let m = s.wholeMatch(of: /((?:[0-9]+(?:\.[0-9]*)?[ \t]*(?:min|h|d|w|m|y)[ \t]*)+)[ \t]*([0-9]+(?::[0-9]{2}){1,2})/) {
110 return (minutes(String(m.1)) ?? 0) + (minutes(String(m.2)) ?? 0)
111 }
112 if s.wholeMatch(of: /[0-9]+(?:\.[0-9]*)?/) != nil { return Double(s) }
113 return nil
114 }
115
116 /// `org-duration-from-minutes` with `h:mm`, or with nil the default format (days, then
117 /// `H:MM`).
118 static func duration(_ minutes: Double, hmmOnly: Bool) -> String {
119 if minutes < 0 { return "-" + duration(-minutes, hmmOnly: hmmOnly) }
120 func hmm(_ m: Double) -> String { String(format: "%d:%02d", Int(m / 60), Int(m.truncatingRemainder(dividingBy: 60))) }
121 if hmmOnly || minutes < 1440 { return hmm(minutes) }
122 let days = (minutes / 1440).rounded(.down)
123 return "\(Int(days))d " + hmm(minutes - days * 1440)
124 }
125
126 /// `org-columns--summarize`: nil for an unknown operator.
127 static func summarize(_ op: String, _ values: [String], fmt: String?, now: Date) -> String? {
128 let numbers = values.map(Number.init)
129 switch op {
130 case "+": return format(fmt, numbers.reduce(.int(0), +))
131 case "$": return String(format: "%.2f", numbers.reduce(0) { $0 + $1.double })
132 case "X":
133 let done = values.filter { $0 == "[X]" }.count
134 return done == values.count ? "[X]" : done > 0 ? "[-]" : "[ ]"
135 case "X/":
136 let done = values.filter { $0 == "[X]" || $0.firstMatch(of: /\[([1-9])\/\1\]/) != nil }.count
137 return "[\(done)/\(values.count)]"
138 case "X%":
139 let done = values.filter { $0 == "[X]" || $0 == "[100%]" }.count
140 return "[\(Int((100.0 * Double(done) / Double(values.count)).rounded()))%]"
141 case "min", "max":
142 var best = numbers[0]
143 for n in numbers.dropFirst() where op == "min" ? n.double < best.double : n.double > best.double { best = n }
144 return format(fmt, best)
145 case "mean": return format(fmt, .float(numbers.reduce(0) { $0 + $1.double } / Double(numbers.count)))
146 case ":", ":min", ":max", ":mean":
147 let times = values.compactMap(minutes)
148 guard !times.isEmpty else { return nil }
149 let hmmOnly = values.allSatisfy { $0.trimmingCharacters(in: .whitespaces).wholeMatch(of: /[0-9]+(?::[0-9]{2}){1,2}/) != nil }
150 let result: Double = switch op {
151 case ":": times.reduce(0, +)
152 case ":min": times.min()!
153 case ":max": times.max()!
154 default: times.reduce(0, +) / Double(times.count)
155 }
156 return duration(result, hmmOnly: hmmOnly)
157 case "est+":
158 var mean = 0.0, variance = 0.0
159 for e in values {
160 let parts = e.split(separator: "-", omittingEmptySubsequences: false).map { Number(String($0)).double }
161 if parts.count == 2 {
162 let m = (parts[0] + parts[1]) / 2
163 mean += m
164 variance += (parts[0] * parts[0] + parts[1] * parts[1]) / 2 - m * m
165 } else if let v = parts.first {
166 mean += v
167 }
168 }
169 let sd = variance.squareRoot()
170 return String(format: "%.0f-%.0f", mean - sd, mean + sd)
171 default: return nil
172 }
173 }
174
175 // MARK: - Values
176
177 static let specials: Set<String> = PropertyValues.special.union(["CATEGORY"])
178
179 /// `org-entry-get` with `selective` inheritance, and the special properties column view
180 /// shows.
181 static func value(_ property: String, heading index: Int, model: DocumentModel, text: NSString, clocksum: [Int: Int]) -> String {
182 let h = model.headings[index]
183 switch property {
184 case "ITEM": return h.title.replacingOccurrences(of: "\t", with: " ")
185 case "TODO": return h.todo ?? ""
186 case "PRIORITY": return h.priority ?? model.orgSettings.priorities.default
187 case "TAGS": return h.localTags.isEmpty ? "" : ":" + h.localTags.joined(separator: ":") + ":"
188 case "ALLTAGS":
189 let tags = model.ancestors(of: index).flatMap { model.headings[$0].localTags } + h.localTags
190 var seen: Set<String> = []
191 let all = (model.fileTags + tags).filter { seen.insert($0).inserted }
192 return all.isEmpty ? "" : ":" + all.joined(separator: ":") + ":"
193 case "DEADLINE", "SCHEDULED", "CLOSED":
194 // From the planning line under the heading.
195 let next = NSMaxRange(text.lineRange(for: NSRange(location: h.headingRange.lowerBound, length: 0)))
196 guard next < text.length else { return "" }
197 let line = text.substring(with: text.lineRange(for: NSRange(location: next, length: 0)))
198 guard line.range(of: "^[ \\t]*(?:SCHEDULED|DEADLINE|CLOSED):", options: .regularExpression) != nil,
199 let m = line.range(of: property + ": *(<[^>\\n]+>|\\[[^\\]\\n]+\\])", options: .regularExpression) else { return "" }
200 return String(line[m]).replacingOccurrences(of: "^" + property + ": *", with: "", options: .regularExpression)
201 case "CLOCKSUM": return clocksum[index].map { ClockTable.duration($0) } ?? ""
202 default:
203 return model.property(property, of: index)?.value ?? ""
204 }
205 }
206
207 /// `org-columns--displayed-value` without the stars on ITEM.
208 static func displayed(_ spec: Spec, _ value: String) -> String {
209 switch spec.property {
210 case "ITEM": return value
211 case "DEADLINE", "SCHEDULED", "TIMESTAMP": return value.replacingOccurrences(of: "<([^>]*)>", with: "[$1]", options: .regularExpression)
212 default: return spec.format.map { format($0, Number(value)) } ?? value
213 }
214 }
215
216 // MARK: - The view
217
218 /// `org-columns` and `org-columns--capture-view` at `offset`: the format, the entries from the
219 /// top level (the first ancestor with COLUMNS, else the entry; the file before the first
220 /// heading or with `global`), their values with summaries. `updates` are the parents whose
221 /// summarized property org rewrites.
222 public static func capture(text: String, tree: OrgTree, at offset: Int, global: Bool, format override: String? = nil,
223 maxLevel: Int? = nil, skipEmpty: Bool = false, excludeTags: [String] = [], now: Date = Date())
224 -> (view: View, updates: [(start: Int, property: String, value: String)]) {
225 let model = DocumentModel(tree: tree)
226 let ns = text as NSString
227 let headings = model.headings
228 let here = global ? nil : headings.lastIndex { $0.headingRange.lowerBound <= offset }
229 // The top level and the format.
230 var top: Int?
231 var format = override
232 if let here {
233 for h in [here] + model.ancestors(of: here).reversed() {
234 if let columns = headings[h].properties.last(where: { $0.key.uppercased() == "COLUMNS" }) {
235 top = h
236 if format == nil { format = columns.value }
237 break
238 }
239 }
240 if top == nil { top = here }
241 }
242 if format == nil, let keyword = text.firstMatch(of: #/(?mi)^[ \t]*#\+COLUMNS:[ \t]+(.+?)[ \t]*$/#) { format = String(keyword.1) }
243 let specs = compile(format ?? defaultFormat)
244 // The entries in scope, without archived or commented subtrees.
245 var scope: [Int] = []
246 let end: Int = top.map { t in headings[(t + 1)...].firstIndex { $0.level <= headings[t].level } ?? headings.count } ?? headings.count
247 var skipBelow: Int?
248 for i in (top ?? 0)..<end {
249 if let level = skipBelow, headings[i].level > level { continue }
250 skipBelow = nil
251 let title = headings[i].title
252 if headings[i].localTags.contains("ARCHIVE") || title == "COMMENT" || title.hasPrefix("COMMENT ") {
253 skipBelow = headings[i].level
254 continue
255 }
256 scope.append(i)
257 }
258 // Clocked minutes per subtree, as `org-clock-sum`.
259 var clocksum: [Int: Int] = [:]
260 if specs.contains(where: { $0.property == "CLOCKSUM" }) {
261 for i in headings.indices {
262 let own = headings[i].clocks.compactMap(\.minutes).reduce(0, +)
263 guard own > 0 else { continue }
264 for h in [i] + model.ancestors(of: i) { clocksum[h, default: 0] += own }
265 }
266 }
267 // Summaries, bottom up through the whole top-level subtree.
268 var summaries: [Int: [Int: String]] = [:]
269 var updates: [(Int, String, String)] = []
270 var seen: Set<String> = []
271 for (column, spec) in specs.enumerated() {
272 let first = seen.insert(spec.property).inserted
273 guard let op = spec.summary, !PropertyValues.special.contains(spec.property) else { continue }
274 var accumulators = [[String]](repeating: [], count: 31)
275 var level = 0
276 var lastLevel = 30
277 for i in ((top ?? 0)..<end).reversed() {
278 if level != 0 { lastLevel = level }
279 level = min(30, headings[i].level)
280 let own = headings[i].properties.last { $0.key.uppercased() == spec.property }?.value
281 let set = own.map { $0.contains { !$0.isWhitespace } } ?? false
282 if level < lastLevel {
283 let below = accumulators[(level + 1)...].flatMap { $0 }
284 let summary = below.isEmpty ? nil : summarize(op, below, fmt: spec.format, now: now)
285 if let summary {
286 summaries[i, default: [:]][column] = summary
287 let trimmed = summary.trimmingCharacters(in: .whitespaces)
288 if first, let own, own != trimmed { updates.append((headings[i].headingRange.lowerBound, spec.property, trimmed)) }
289 }
290 if let value = summary ?? (set ? own : nil) { accumulators[level].append(value) }
291 for l in (level + 1)...30 { accumulators[l] = [] }
292 } else if set, let own {
293 accumulators[level].append(own)
294 }
295 }
296 }
297 // Rows.
298 var rows: [Row] = []
299 for i in scope {
300 let level = headings[i].level
301 if let maxLevel, level > maxLevel { continue }
302 let values = specs.enumerated().map { column, spec -> String in
303 let raw = summaries[i]?[column] ?? value(spec.property, heading: i, model: model, text: ns, clocksum: clocksum)
304 return displayed(spec, raw)
305 }
306 if skipEmpty {
307 let others = Set(zip(specs, values).filter { $0.0.property != "ITEM" }.map(\.1)).subtracting([""])
308 if others.isEmpty { continue }
309 }
310 if !excludeTags.isEmpty {
311 let tags = model.ancestors(of: i).flatMap { headings[$0].localTags } + headings[i].localTags + model.fileTags
312 if tags.contains(where: excludeTags.contains) { continue }
313 }
314 rows.append(Row(level: level, start: headings[i].headingRange.lowerBound, values: values))
315 }
316 return (View(specs: specs, rows: rows), updates)
317 }
318
319 /// `org-columns--clean-item`: no footnote references, targets, statistics cookies or inline
320 /// source blocks, and `|` as `\vert`.
321 static func cleanItem(_ item: String) -> String {
322 var s = item
323 s = s.replacingOccurrences(of: "\\[fn:[^\\]]*\\]", with: "", options: .regularExpression)
324 s = s.replacingOccurrences(of: "<<<?([^<>]*)>>>?", with: "", options: .regularExpression)
325 s = s.replacingOccurrences(of: "\\[[0-9]*(?:%|/[0-9]*)\\]", with: "", options: .regularExpression)
326 s = s.replacingOccurrences(of: "src_[^\\s{\\[]+(?:\\[[^\\]]*\\])?\\{[^}]*\\}", with: "", options: .regularExpression)
327 return s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "|", with: "\\vert")
328 }
329
330 /// `org-columns-dblock-write-default`'s rows, before alignment.
331 static func dblockTable(_ view: View, hlines: Int?, hlinesAll: Bool, indent: Bool) -> [[String]?] {
332 var table: [[String]?] = [view.specs.map(\.title), nil]
333 let item = view.specs.firstIndex { $0.property == "ITEM" }
334 for row in view.rows {
335 if table.last! != nil, hlinesAll || hlines.map({ row.level <= $0 }) == true { table.append(nil) }
336 var values = row.values
337 if let item {
338 let cleaned = cleanItem(values[item])
339 values[item] = indent && row.level > 1 ? "\\_" + String(repeating: " ", count: 2 * (row.level - 1)) + cleaned : cleaned
340 }
341 table.append(values)
342 }
343 if view.specs.contains(where: { $0.width != nil }) {
344 table.insert(view.specs.map { $0.width.map { "<\($0)>" } ?? "" }, at: 0)
345 }
346 return table
347 }
348}
349
350extension EmacsBuffer {
351 /// `org-dblock-write:columnview` and the default writer, at point (the line after #+BEGIN).
352 func writeColumnView(_ params: ClockTable.Params, at begin: Int, content: String, tree: OrgTree) throws -> String {
353 let id = params.values[":id"]
354 var offset = begin
355 var global = false
356 if let id {
357 switch id.symbol ?? id.string ?? "" {
358 case "global": global = true
359 case "local", "nil", "": break
360 case let value:
361 if value.hasPrefix("file:") { throw UserError(message: "Column views of other files aren't supported yet") }
362 guard let match = searchForwardFromStart("^[ \\t]*:ID:[ \\t]+" + NSRegularExpression.escapedPattern(for: value) + "[ \\t]*$") else {
363 throw UserError(message: "Cannot find entry with :ID: \(value)")
364 }
365 offset = match
366 }
367 }
368 let hlines = params.values[":hlines"]
369 let excluded = params.values[":exclude-tags"]?.elements?.compactMap { $0.string ?? $0.symbol } ?? []
370 let captured = ColumnView.capture(text: string, tree: tree, at: offset, global: global, format: params.values[":format"]?.string,
371 maxLevel: params.int(":maxlevel"), skipEmpty: params.flag(":skip-empty-rows"),
372 excludeTags: excluded, now: now)
373 for update in captured.updates.sorted(by: { $0.start > $1.start }) {
374 point = update.start
375 entryPut(update.property, update.value)
376 }
377 let table = ColumnView.dblockTable(captured.view, hlines: hlines?.integer, hlinesAll: hlines?.symbol == "t",
378 indent: params.flag(":indent"))
379 var lines = table.map { row in row.map { "|" + $0.joined(separator: "|") + "|" } ?? "|-|" }
380 for line in content.split(separator: "\n") where line.range(of: "^[ \\t]*#\\+TBLFM:", options: [.regularExpression, .caseInsensitive]) != nil {
381 lines.append(line.trimmingCharacters(in: .whitespaces))
382 }
383 return lines.joined(separator: "\n")
384 }
385
386 private func searchForwardFromStart(_ pattern: String) -> Int? {
387 saveExcursion {
388 point = 0
389 return searchForward(pattern, caseInsensitive: true)?[0]?.lowerBound
390 }
391 }
392}
Sources/OrgCore/Commands/Command.swift +3 −1
@@ -210,6 +210,8 @@ public enum ExternalRequest: Sendable, Equatable {
210210 case openLink(OrgLink)
211211 /// Edit a block's text apart (`org-edit-special`); `ReplaceBlockLines` puts it back.
212212 case editBlock(BlockEdit)
213 /// Show a column view.
214 case columnView(ColumnView.View)
213215}
214216
215217/// A named operation. Keys, menus, the palette and touch controls all run commands.
@@ -239,7 +241,7 @@ public enum Commands {
239241 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
240242 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
241243 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
242 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "),
244 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true),
243245 ]
244246
245247 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/CtrlCCtrlC.swift +3 −1
@@ -64,7 +64,9 @@ public struct CtrlCCtrlC: OrgCommand {
6464 if buffer.lookingAtLine("^[ \\t]*#\\+[A-Za-z_]+:") {
6565 return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message("Local setup has been refreshed")]))
6666 }
67 if buffer.lookingAtLine("^[ \\t]*:[^\\s:]+:") { return .failed("Property actions aren't supported yet") }
67 if buffer.propertyAtPoint() != nil || buffer.lookingAtLine("^[ \\t]*:(?:PROPERTIES|END):[ \\t]*$") && buffer.existingPropertyBlock() != nil {
68 return PropertyAction().run(in: context)
69 }
6870 return Self.nothing
6971 }
7072
Sources/OrgCore/Commands/PropertyCommands.swift added +272
@@ -0,0 +1,272 @@
1import Foundation
2
3// Property commands (Org 9.8.7): `org-set-property` with its completion, `org-delete-property`,
4// `org-delete-property-globally`, `org-property-action` (C-c C-c in a property drawer) and
5// `org-property-next-allowed-value` (S-right and S-left on a property line).
6
7extension EmacsBuffer {
8 /// `org-get-property-block` without FORCE: the body of the entry's property drawer.
9 func existingPropertyBlock() -> Range<Int>? {
10 saveExcursion {
11 backToHeadingOrPointMin()
12 if beforeFirstHeading {
13 while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
14 } else {
15 forwardLine(1)
16 if lookingAt(planningPattern) != nil { forwardLine(1) }
17 }
18 guard let match = looking(Self.propertyDrawerPattern) else { return nil }
19 forwardLine(1)
20 return point..<lineStart(match[0]!.upperBound)
21 }
22 }
23
24 /// `org-at-property-p`: a property line inside a property drawer; its key.
25 func propertyAtPoint() -> String? {
26 guard let block = existingPropertyBlock(), block.contains(lineStart(point)) else { return nil }
27 let line = substring(lineStart(point), lineEnd(point))
28 return line.firstMatch(of: /^[ \t]*:(\S+?)\+?:(?:[ \t]|$)/).map { String($0.1) }
29 }
30
31 /// `org-entry-delete`: true when a property was removed.
32 @discardableResult
33 func entryDelete(_ name: String) -> Bool {
34 guard let block = existingPropertyBlock() else { return false }
35 let begin = block.lowerBound
36 let end = marker(block.upperBound)
37 defer { release(end) }
38 let origin = block.upperBound
39 point = begin
40 let pattern = "^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + "\\+?:(?:[ \\t].*?)?[ \\t]*$"
41 while let match = searchForward(pattern, bound: end.position, caseInsensitive: true) {
42 let lineEnd = min(length, self.lineEnd(match[0]!.lowerBound) + 1)
43 deleteRegion(match[0]!.lowerBound, lineEnd)
44 point = match[0]!.lowerBound
45 }
46 if begin == end.position {
47 point = begin
48 let previous = saveExcursion { () -> Int in forwardLine(-1); return point }
49 let next = min(length, lineEnd(begin) + 1)
50 deleteRegion(previous, next)
51 }
52 return end.position != origin
53 }
54}
55
56/// The values a property may take (`org-property-get-allowed-values`): `KEY_ALL` from the
57/// entry or above it, TODO keywords, priorities. `unrestricted` when `:ETC` allows others.
58public enum PropertyValues {
59 static let special: Set<String> = ["ALLTAGS", "BLOCKED", "CLOCKSUM", "CLOCKSUM_T", "CLOSED", "DEADLINE", "FILE", "ITEM", "PRIORITY",
60 "SCHEDULED", "TAGS", "TIMESTAMP", "TIMESTAMP_IA", "TODO"]
61 static let defaults = ["EFFORT", "ARCHIVE", "CATEGORY", "SUMMARY", "DESCRIPTION", "CUSTOM_ID", "LOCATION", "LOGGING", "COLUMNS", "VISIBILITY",
62 "TABLE_EXPORT_FORMAT", "TABLE_EXPORT_FILE", "EXPORT_OPTIONS", "EXPORT_TEXT", "EXPORT_FILE_NAME", "EXPORT_TITLE",
63 "EXPORT_AUTHOR", "EXPORT_DATE", "UNNUMBERED", "ORDERED", "NOBLOCKING", "COOKIE_DATA", "LOG_INTO_DRAWER",
64 "REPEAT_TO_STATE", "CLOCK_MODELINE_TOTAL", "STYLE", "HTML_CONTAINER_CLASS", "ORG-IMAGE-ACTUAL-WIDTH"]
65
66 public static func allowed(_ key: String, at offset: Int, text: String, tree: OrgTree) -> (values: [String], unrestricted: Bool)? {
67 let upper = key.uppercased()
68 let settings = tree.settings
69 if upper == "TODO" {
70 return (settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) } + [""], false)
71 }
72 if upper == "PRIORITY" {
73 guard let high = PriorityChange.value(settings.priorities.highest), let low = PriorityChange.value(settings.priorities.lowest), high <= low else { return nil }
74 let numeric = Int(settings.priorities.lowest) != nil
75 return ((high...low).map { PriorityChange.label($0, numeric: numeric) }, false)
76 }
77 if upper == "CATEGORY" || special.contains(upper) { return nil }
78 let model = DocumentModel(tree: tree)
79 let name = (key + "_ALL").uppercased()
80 var raw: String?
81 if let index = model.headings.lastIndex(where: { $0.headingRange.lowerBound <= offset }) {
82 for h in [index] + model.ancestors(of: index).reversed() {
83 if let p = model.headings[h].properties.last(where: { $0.key.uppercased() == name }) {
84 raw = p.value
85 break
86 }
87 }
88 }
89 if raw == nil { raw = model.fileProperties.last { $0.key.uppercased() == name }?.value }
90 guard let raw, raw.contains(where: { !$0.isWhitespace }) else { return nil }
91 var values = readList(raw)
92 let unrestricted = values.contains(":ETC")
93 values.removeAll { $0 == ":ETC" }
94 return values.isEmpty ? nil : (values, unrestricted)
95 }
96
97 /// `(read-from-string (concat "(" vals ")"))` as strings: quoted strings, numbers, symbols.
98 static func readList(_ s: String) -> [String] {
99 var values: [String] = []
100 for m in s.matches(of: /"((?:[^"\\]|\\.)*)"|(\S+)/) {
101 if let quoted = m.1 {
102 values.append(String(quoted).replacingOccurrences(of: "\\\"", with: "\"").replacingOccurrences(of: "\\\\", with: "\\"))
103 } else if let token = m.2 {
104 let t = String(token)
105 values.append(Int(t).map(String.init) ?? t)
106 }
107 }
108 return values
109 }
110
111 /// `org-buffer-property-keys` with DEFAULTS and COLUMNS.
112 public static func keys(in text: String) -> [String] {
113 var keys = defaults
114 for m in text.matches(of: #/(?mi)^[ \t]*#\+PROPERTY:[ \t]+(\S+)/#) { keys.append(String(m.1)) }
115 for m in text.matches(of: #/(?m)^[ \t]*:([^\s:]+?)\+?:[ \t]/#) where String(m.1).uppercased() != "END" && String(m.1).uppercased() != "PROPERTIES" {
116 keys.append(String(m.1))
117 }
118 for m in text.matches(of: #/(?mi)^[ \t]*(?:#\+|:)COLUMNS:(.*)$/#) {
119 for c in String(m.1).matches(of: /%[0-9]*([A-Za-z0-9_-]+)(?:\([^)]+\))?(?:\{[^}]+\})?/) { keys.append(String(c.1)) }
120 }
121 var seen: Set<String> = []
122 return keys.filter { seen.insert($0).inserted }.sorted { $0.lowercased() < $1.lowercased() }
123 }
124}
125
126/// C-c C-x p: `org-set-property`, asking for the property (the one at point by default) and its
127/// value, offering the allowed values or those in use.
128public struct ReadAndSetProperty: OrgCommand {
129 public init() {}
130 public var id: String { "org.property.read-and-set" }
131 public var title: String { "Set Property…" }
132 public func applies(in context: EditContext) -> Bool { true }
133
134 public func run(in context: EditContext) -> CommandStep {
135 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
136 let atPoint = buffer.propertyAtPoint()
137 guard let typed = context.answers["property"] else {
138 return .prompt(Prompt(key: "property", message: "Property" + (atPoint.map { " [\($0)]" } ?? "") + ":",
139 choices: PropertyValues.keys(in: context.text), requireMatch: false))
140 }
141 let key = typed.trimmingCharacters(in: .whitespaces).isEmpty ? atPoint ?? "" : typed.trimmingCharacters(in: .whitespaces)
142 guard !key.isEmpty, key.range(of: "^[^\\s]+$", options: .regularExpression) != nil else { return .failed("Invalid property name: \"\(key)\"") }
143 let current = buffer.saveExcursion { () -> String? in
144 buffer.backToHeadingOrPointMin()
145 guard let block = buffer.existingPropertyBlock() else { return nil }
146 buffer.point = block.lowerBound
147 guard let m = buffer.searchForward("^[ \\t]*:" + NSRegularExpression.escapedPattern(for: key) + ":[ \\t]*(.*?)[ \\t]*$", bound: block.upperBound) else { return nil }
148 return buffer.substring(m[1]!.lowerBound, m[1]!.upperBound)
149 }
150 guard let answer = context.answers["value"] else {
151 let allowed = PropertyValues.allowed(key, at: context.caret, text: context.text, tree: context.tree)
152 let message = "\(key) value" + (current.map { $0.isEmpty ? "" : " [\($0)]" } ?? "") + ":"
153 if let allowed {
154 return .prompt(Prompt(key: "value", message: message, choices: allowed.values, requireMatch: !allowed.unrestricted))
155 }
156 return .prompt(Prompt(key: "value", message: message, choices: SparseTreeCommand.propertyValues(key, in: context.text), requireMatch: false))
157 }
158 let allowed = PropertyValues.allowed(key, at: context.caret, text: context.text, tree: context.tree)
159 var value = answer.trimmingCharacters(in: .whitespaces)
160 if value.isEmpty, allowed == nil { value = current ?? "" }
161 guard current != value else { return .commit(EditResult(baseRevision: context.revision, edits: [])) }
162 return SetProperty(key: key, value: value).run(in: context)
163 }
164}
165
166/// `org-delete-property` and `org-delete-property-globally`.
167public struct DeleteProperty: OrgCommand {
168 let globally: Bool
169 public init(globally: Bool = false) { self.globally = globally }
170 public var id: String { globally ? "org.property.delete-globally" : "org.property.delete" }
171 public var title: String { globally ? "Delete Property Everywhere…" : "Delete Property…" }
172 public func applies(in context: EditContext) -> Bool { true }
173
174 public func run(in context: EditContext) -> CommandStep {
175 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
176 if globally {
177 guard let key = context.answers["property"] else {
178 return .prompt(Prompt(key: "property", message: "Globally remove property:", choices: PropertyValues.keys(in: context.text), requireMatch: false))
179 }
180 var count = 0
181 let step = commitBuffer(context) { buffer in
182 buffer.point = 0
183 let pattern = "^[ \\t]*:" + NSRegularExpression.escapedPattern(for: key) + "\\+?:(?:[ \\t].*?)?[ \\t]*$"
184 while let match = buffer.searchForward(pattern, caseInsensitive: true) {
185 buffer.point = match[0]!.upperBound
186 let resume = buffer.marker(buffer.lineStart(buffer.point))
187 if buffer.saveExcursion({ buffer.entryDelete(key) }) { count += 1 }
188 buffer.point = resume.position
189 buffer.release(resume)
190 if buffer.lookingAt(pattern) == nil, buffer.forwardLine(1) != 0 { break }
191 }
192 }
193 return with(step, message: "Property \"\(key)\" removed from \(count) entries")
194 }
195 let keys = buffer.saveExcursion { () -> [String] in
196 guard let block = buffer.existingPropertyBlock() else { return [] }
197 return buffer.substring(block.lowerBound, block.upperBound).matches(of: #/(?m)^[ \t]*:([^\s:]+?)\+?:/#).map { String($0.1) }
198 }
199 let key: String
200 if keys.count > 1 {
201 guard let answer = context.answers["property"] else {
202 return .prompt(Prompt(key: "property", message: "Property:", choices: keys))
203 }
204 key = answer
205 } else if let only = keys.first {
206 key = only
207 } else {
208 return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message("No property to delete in this entry")]))
209 }
210 return with(commitBuffer(context) { buffer in buffer.saveExcursion { buffer.entryDelete(key) } }, message: "Property \"\(key)\" deleted")
211 }
212
213 private func with(_ step: CommandStep, message: String) -> CommandStep {
214 guard case .commit(let result) = step else { return step }
215 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, effects: result.effects + [.message(message)]))
216 }
217}
218
219/// C-c C-c in a property drawer: `org-property-action`.
220public struct PropertyAction: OrgCommand {
221 public init() {}
222 public var id: String { "org.property.action" }
223 public var title: String { "Property Action…" }
224 public func applies(in context: EditContext) -> Bool { true }
225
226 static let menu = FastSelection(items: [
227 .option(key: "s", name: "set"), .option(key: "d", name: "delete"), .option(key: "D", name: "delete globally"),
228 ], multiple: false)
229
230 public func run(in context: EditContext) -> CommandStep {
231 guard let key = context.answers["action"] else {
232 return .prompt(Prompt(key: "action", message: "Property Action: [s]et [d]elete [D]elete globally", fast: Self.menu))
233 }
234 switch key {
235 case "s": return ReadAndSetProperty().run(in: context)
236 case "d": return DeleteProperty().run(in: context)
237 case "D": return DeleteProperty(globally: true).run(in: context)
238 default: return .failed("No such property action \(key)")
239 }
240 }
241}
242
243/// S-right and S-left on a property line: `org-property-next-allowed-value`.
244public struct NextAllowedValue: OrgCommand {
245 let previous: Bool
246 public init(previous: Bool) { self.previous = previous }
247 public var id: String { previous ? "org.property.previous-value" : "org.property.next-value" }
248 public var title: String { previous ? "Previous Allowed Value" : "Next Allowed Value" }
249 public func applies(in context: EditContext) -> Bool { EmacsBuffer(context.text, point: context.caret).propertyAtPoint() != nil }
250
251 public func run(in context: EditContext) -> CommandStep {
252 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
253 guard let key = buffer.propertyAtPoint() else { return .failed("Not at a property") }
254 let line = buffer.substring(buffer.lineStart(context.caret), buffer.lineEnd(context.caret))
255 let value = line.firstMatch(of: /^[ \t]*:\S+?:(?:[ \t]+(.*?))?[ \t]*$/)?.1.map(String.init) ?? ""
256 let allowed = PropertyValues.allowed(key, at: context.caret, text: context.text, tree: context.tree)?.values
257 ?? (["[ ]", "[-]", "[X]"].contains(value) ? ["[ ]", "[X]"] : nil)
258 guard var values = allowed else { return .failed("Allowed values for this property have not been defined") }
259 if previous { values.reverse() }
260 var next = values.firstIndex(of: value).flatMap { $0 + 1 < values.count ? values[$0 + 1] : nil }
261 next = next ?? values.first
262 guard let new = next, new != value else { return .failed("Only one allowed value for this property") }
263 return commitBuffer(context) { buffer in
264 let start = buffer.lineStart(context.caret)
265 buffer.replace(start..<buffer.lineEnd(context.caret), with: " :\(key): \(new)")
266 buffer.point = start
267 buffer.alignNodeProperty()
268 buffer.point = buffer.lineStart(start)
269 buffer.skipForward([32, 9])
270 }
271 }
272}
Sources/OrgCore/Commands/Table.swift +3 −1
@@ -10,7 +10,9 @@ extension EmacsBuffer {
1010 /// `org-at-table-p`: an org table line outside blocks.
1111 var atTable: Bool {
1212 guard lookingAtLine("^[ \\t]*\\|") else { return false }
13 return element(at: lineStart(point))?.kind == .table || element(at: lineStart(point))?.kind == .tableRow
13 // Dynamic blocks hold elements in Org; their tables count.
14 let kind = element(at: lineStart(point))?.kind
15 return kind == .table || kind == .tableRow || kind == .dynamicBlock
1416 }
1517
1618 var atTableHline: Bool { lookingAtLine("^[ \\t]*\\|-") }
Sources/OrgCore/Keymap/Keymap.swift +3 −1
@@ -131,7 +131,7 @@ public struct Keymap: Sendable, Equatable {
131131
132132/// Context names a binding's `when` can use.
133133public enum KeyContext {
134 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line", "dblock", "speed"]
134 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line", "dblock", "speed", "property"]
135135
136136 public static func holds(_ name: String, in context: EditContext) -> Bool {
137137 switch name {
@@ -148,6 +148,8 @@ public enum KeyContext {
148148 case "dblock":
149149 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
150150 return buffer.lookingAtLine(EmacsBuffer.dblockStart)
151 // A property line in a property drawer.
152 case "property": return EmacsBuffer(context.text, point: context.caret).propertyAtPoint() != nil
151153 // `org-use-speed-commands`: at the very start of a heading line.
152154 case "speed":
153155 guard context.options.useSpeedCommands, context.selection.allSatisfy(\.isEmpty) else { return false }
Sources/OrgCore/Keymap/Presets.swift +21
@@ -370,6 +370,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
370370 command = "org.priority.set"
371371 mode = "normal"
372372 [[bind]]
373 keys = "SPC m o"
374 command = "org.property.read-and-set"
375 mode = "normal"
376 [[bind]]
373377 keys = "SPC m p u"
374378 command = "org.priority.up"
375379 mode = "normal"
@@ -709,6 +713,23 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
709713 keys = "C-c ,"
710714 command = "org.priority.set"
711715 [[bind]]
716 keys = "C-c C-x p"
717 command = "org.property.read-and-set"
718 [[bind]]
719 keys = "C-c C-x i"
720 command = "org.columns.insert-dblock"
721 [[bind]]
722 keys = "C-c C-x C-c"
723 command = "org.columns"
724 [[bind]]
725 keys = "S-<right>"
726 command = "org.property.next-value"
727 when = "property"
728 [[bind]]
729 keys = "S-<left>"
730 command = "org.property.previous-value"
731 when = "property"
732 [[bind]]
712733 keys = "C-c C-x C-r"
713734 command = "org.clock.report"
714735 [[bind]]
Sources/Orgstar/ColumnViewSheet.swift added +62
@@ -0,0 +1,62 @@
1import OrgCore
2import SwiftUI
3
4/// `org-columns`, read-only: one row per entry, one column per column-format field, with
5/// summaries. Clicking a row goes to its heading.
6struct ColumnViewSheet: View {
7 let view: ColumnView.View
8 let jump: (Int) -> Void
9 @Environment(\.dismiss) private var dismiss
10
11 var body: some View {
12 VStack(alignment: .leading, spacing: 0) {
13 HStack {
14 Text("Column View").font(.headline)
15 Spacer()
16 Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
17 }
18 .padding(10)
19 Divider()
20 ScrollView([.horizontal, .vertical]) {
21 Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 4) {
22 GridRow {
23 ForEach(view.specs.indices, id: \.self) { i in
24 Text(view.specs[i].title).bold().foregroundStyle(.secondary)
25 }
26 }
27 Divider()
28 ForEach(view.rows.indices, id: \.self) { r in
29 let row = view.rows[r]
30 GridRow {
31 ForEach(view.specs.indices, id: \.self) { i in
32 Text(cell(row, i))
33 .lineLimit(1)
34 .frame(maxWidth: view.specs[i].width.map { CGFloat($0) * 8.5 }, alignment: .leading)
35 }
36 }
37 .contentShape(Rectangle())
38 .onTapGesture {
39 jump(row.start)
40 dismiss()
41 }
42 }
43 }
44 .font(.system(.body, design: .monospaced))
45 .padding(10)
46 }
47 .defaultScrollAnchor(.topLeading)
48 }
49 .frame(minWidth: 640, minHeight: 360)
50 }
51
52 /// ITEM with the heading's stars, as org shows it.
53 private func cell(_ row: ColumnView.Row, _ i: Int) -> String {
54 guard view.specs[i].property == "ITEM" else { return row.values[i] }
55 return String(repeating: "*", count: row.level) + " " + row.values[i]
56 }
57}
58
59struct ColumnViewRequest: Identifiable {
60 let id = UUID()
61 let view: ColumnView.View
62}
Sources/Orgstar/ContentView.swift +6
@@ -52,6 +52,7 @@ struct ContentView: View {
5252 }
5353 /// A block being edited apart (`org-edit-special`).
5454 @State private var blockEdit: BlockEditRequest?
55 @State private var columnView: ColumnViewRequest?
5556
5657 @State private var selectedFile: String?
5758 /// A file that isn't text, shown as a preview instead of the open document.
@@ -207,6 +208,9 @@ struct ContentView: View {
207208 .onChange(of: options, initial: true) { _, options in session.options = options }
208209 .onChange(of: preset, initial: true) { loadKeymap() }
209210 .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) }
211 .sheet(item: $columnView) { request in
212 ColumnViewSheet(view: request.view) { offset in commands.editor?.reveal(offset) }
213 }
210214 .sheet(item: $blockEdit) { request in
211215 BlockEditorView(edit: request.edit) { content in
212216 blockEdit = nil
@@ -373,6 +377,8 @@ struct ContentView: View {
373377 if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) }
374378 case .editBlock(let edit):
375379 blockEdit = BlockEditRequest(edit: edit)
380 case .columnView(let view):
381 columnView = ColumnViewRequest(view: view)
376382 case .emacsTable(let table, let point, let form, let reason, let lisp):
377383 let run = {
378384 commands.show("Recalculating in Emacs (\(reason))…")
Tests/OrgCoreTests/ColumnViewTests.swift added +86
@@ -0,0 +1,86 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ColumnViewTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let text = """
9 #+COLUMNS: %25ITEM %TODO %3PRIORITY %Effort{:} %Cost{+;%.1f} %Done{X/} %TAGS
10 * Project :work:
11 :PROPERTIES:
12 :Effort: 9:00
13 :END:
14 ** TODO [#A] Design <<target>> [1/2]
15 :PROPERTIES:
16 :Effort: 2:30
17 :Cost: 10
18 :Done: [X]
19 :END:
20 ** DONE Build | it
21 DEADLINE: <2026-10-01 Thu> SCHEDULED: <2026-09-20 Sun>
22 :PROPERTIES:
23 :Effort: 1d
24 :Cost: 2.5
25 :Done: [ ]
26 :END:
27 *** Sub task :home:
28 :PROPERTIES:
29 :Effort: 0:45
30 :END:
31 ** Archived :ARCHIVE:
32 :PROPERTIES:
33 :Effort: 5:00
34 :END:
35 * COMMENT Hidden
36 * Other
37 :PROPERTIES:
38 :COLUMNS: %ITEM(Task) %SCHEDULED %DEADLINE %ALLTAGS %Cost{max} %Cost{mean}
39 :END:
40 ** One thing
41 :PROPERTIES:
42 :Cost: 4
43 :END:
44 ** Two things
45 SCHEDULED: <2026-11-01 Sun 10:00>
46 :PROPERTIES:
47 :Cost: 7
48 :END:
49 """ + "\n"
50
51 @Test(.enabled(if: oracle))
52 func writesColumnViewBlocksLikeOrg() throws {
53 let blocks = [
54 ("Project", "#+BEGIN: columnview :hlines 1 :id local\n#+END:\n"),
55 ("Other", "#+BEGIN: columnview :hlines 1 :id local\n#+END:\n"),
56 ("Project", "#+BEGIN: columnview :id global :indent t :hlines t\n#+END:\n"),
57 ("Project", "#+BEGIN: columnview :id local :maxlevel 2 :skip-empty-rows t :exclude-tags (\"home\")\n#+END:\n"),
58 ("Project", "#+BEGIN: columnview :id local :format \"%ITEM %Effort{:max} %Effort{:mean} %Cost{$}\"\n| x |\n#+TBLFM: $4=$4*2\n#+END:\n"),
59 ]
60 var cases: [EmacsOracle.Case] = []
61 var texts: [String] = []
62 for (heading, block) in blocks {
63 // The block goes under the heading's drawer.
64 let ns = Self.text as NSString
65 let start = ns.range(of: "* " + heading).location
66 let drawerEnd = ns.range(of: ":END:\n", range: NSRange(location: start, length: ns.length - start))
67 let at = NSMaxRange(drawerEnd)
68 let text = ns.replacingCharacters(in: NSRange(location: at, length: 0), with: block)
69 texts.append(text)
70 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(at, in: text), form: "(org-update-dblock)"))
71 }
72 let emacs = try EmacsOracle.run(cases)
73 for (i, text) in texts.enumerated() {
74 let at = Int(cases[i].point) - 1
75 let ours = runCommand(UpdateDynamicBlock(), text, caret: EmacsOracle.offset(at + 1, in: text))
76 #expect(ours.text == emacs[i].text, "\(blocks[i].1)\nours:\n\(ours.text)\n\(ours.failure ?? "")\nemacs:\n\(emacs[i].text)\n\(emacs[i].error)")
77 }
78 }
79
80 @Test func compilesFormats() {
81 let specs = ColumnView.compile("%25ITEM(Task) %Effort{:} %3PRIORITY %Cost{+;%.2f}")
82 #expect(specs.map(\.property) == ["ITEM", "EFFORT", "PRIORITY", "COST"])
83 #expect(specs[0].title == "Task" && specs[0].width == 25)
84 #expect(specs[3].summary == "+" && specs[3].format == "%.2f")
85 }
86}
Tests/OrgCoreTests/PropertyCommandTests.swift added +72
@@ -0,0 +1,72 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct PropertyCommandTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let text = """
9 #+PROPERTY: Color_ALL red green "dark blue" :ETC
10 * Parent
11 :PROPERTIES:
12 :Size_ALL: S M L 010
13 :Size: M
14 :END:
15 ** Child
16 :PROPERTIES:
17 :Size: L
18 :Color: green
19 :Done: [ ]
20 :END:
21 body
22 ** Bare
23 SCHEDULED: <2026-10-05 Mon>
24 * Other
25 :PROPERTIES:
26 :Size: S
27 :END:
28 """ + "\n"
29
30 func compare(_ cases: [(at: String, command: any OrgCommand, answers: [String: String], form: String)]) throws {
31 let ns = Self.text as NSString
32 let emacs = try EmacsOracle.run(cases.map { c in
33 EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(ns.range(of: c.at).location, in: Self.text), form: c.form)
34 })
35 for (c, theirs) in zip(cases, emacs) {
36 let ours = runCommand(c.command, Self.text, caret: ns.range(of: c.at).location, answers: c.answers)
37 #expect(ours.text == theirs.text, "\(c.form) at \(c.at)\nours: \(ours.text.debugDescription) \(ours.failure ?? "")\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
38 #expect((ours.failure != nil) == !theirs.error.isEmpty, "\(c.form): \(ours.failure ?? "") vs \(theirs.error)")
39 }
40 }
41
42 @Test(.enabled(if: oracle))
43 func setsAndDeletesLikeOrg() throws {
44 try compare([
45 ("Child", ReadAndSetProperty(), ["property": "Color", "value": "red"], "(org-set-property \"Color\" \"red\")"),
46 ("Bare", ReadAndSetProperty(), ["property": "Size", "value": "S"], "(org-set-property \"Size\" \"S\")"),
47 ("Other", ReadAndSetProperty(), ["property": "Size", "value": "S"], "(org-set-property \"Size\" \"S\")"),
48 ("Child", DeleteProperty(), ["property": "Color"], "(org-delete-property \"Color\")"),
49 ("Other", DeleteProperty(), [:], "(org-delete-property \"Size\")"),
50 ("Parent", DeleteProperty(globally: true), ["property": "Size"], "(org-delete-property-globally \"Size\")"),
51 (":Size: L", NextAllowedValue(previous: false), [:], "(org-property-next-allowed-value)"),
52 (":Size: L", NextAllowedValue(previous: true), [:], "(org-property-next-allowed-value t)"),
53 (":Size: M", NextAllowedValue(previous: false), [:], "(org-property-next-allowed-value)"),
54 (":Color: green", NextAllowedValue(previous: false), [:], "(org-property-next-allowed-value)"),
55 (":Done:", NextAllowedValue(previous: false), [:], "(org-property-next-allowed-value)"),
56 (":Size: S", NextAllowedValue(previous: false), [:], "(org-property-next-allowed-value)"),
57 ])
58 }
59
60 @Test(.enabled(if: oracle))
61 func findsAllowedValuesLikeOrg() throws {
62 let ns = Self.text as NSString
63 let spots = [("Child", "Size"), ("Child", "Color"), ("Other", "Size"), ("Child", "TODO"), ("Child", "PRIORITY"), ("Bare", "Shape")]
64 let forms = spots.map { at, key in
65 "(progn (goto-char \(EmacsOracle.point(ns.range(of: at).location, in: Self.text))) (mapconcat #'identity (org-property-get-allowed-values (point) \(lispString(key))) \"|\"))"
66 }
67 let emacs = try EmacsOracle.evaluate(Self.text, "(with-current-buffer (find-file-noselect \"oracle.org\") (list \(forms.joined(separator: " "))))")
68 let tree = OrgParser.parse(Self.text)
69 let ours = spots.map { at, key in PropertyValues.allowed(key, at: ns.range(of: at).location, text: Self.text, tree: tree)?.values.joined(separator: "|") ?? "" }
70 #expect(ours == emacs)
71 }
72}