Property commands and column view !90
12 files changed, +1007 −17
Layout: unified · split
Sources/OrgCore/Commands/ClockTable.swift +84 −13
| @@ -22,8 +22,8 @@ enum ClockTable { | ||
| 22 | 22 | ":narrow": .symbol("40!"), ":indent": .symbol("t"), ":lang": .string("en"), |
| 23 | 23 | ] |
| 24 | 24 | |
| 25 | init(_ line: String) throws { | |
| 26 | values = Self.defaults | |
| 25 | init(_ line: String, defaults: [String: Sexp] = Self.defaults) throws { | |
| 26 | values = defaults | |
| 27 | 27 | let forms = try LispReader.read("(" + line + ")") |
| 28 | 28 | let items = forms.first?.sexp.elements ?? [] |
| 29 | 29 | var i = 0 |
| @@ -307,14 +307,21 @@ extension EmacsBuffer { | ||
| 307 | 307 | point = begin |
| 308 | 308 | guard let start = lookingAt(Self.dblockStart) else { throw UserError(message: "Not at a dynamic block") } |
| 309 | 309 | 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") } | |
| 311 | 310 | let paramText = start[3].map { substring($0.lowerBound, $0.upperBound) } ?? "" |
| 312 | let params = try ClockTable.Params(paramText) | |
| 313 | let indentation = currentIndentation | |
| 314 | 311 | let deleteFrom = start[0]!.upperBound + 1 |
| 315 | 312 | point = start[0]!.upperBound |
| 316 | 313 | guard let end = searchForward("^[ \\t]*#\\+(?:END|end):?", caseInsensitive: false)?[0] else { throw UserError(message: "Dynamic block not terminated") } |
| 317 | 314 | 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) | |
| 318 | 325 | // The scope's text. |
| 319 | 326 | point = begin |
| 320 | 327 | let region: Range<Int> |
| @@ -336,6 +343,18 @@ extension EmacsBuffer { | ||
| 336 | 343 | throw UserError(message: "Clock tables over \(scope) aren't supported yet") |
| 337 | 344 | } |
| 338 | 345 | 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") } | |
| 339 | 358 | // Replace the old contents. |
| 340 | 359 | let contentEnd = end.lowerBound |
| 341 | 360 | deleteRegion(min(deleteFrom, contentEnd), contentEnd) |
| @@ -355,6 +374,14 @@ extension EmacsBuffer { | ||
| 355 | 374 | point = deleteFrom |
| 356 | 375 | if let bar = searchForward("^[ \\t]*\\|", caseInsensitive: false) { |
| 357 | 376 | 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 | } | |
| 358 | 385 | tableAlign() |
| 359 | 386 | } |
| 360 | 387 | point = begin |
| @@ -396,20 +423,64 @@ public struct ClockReportTable: OrgCommand { | ||
| 396 | 423 | return |
| 397 | 424 | } |
| 398 | 425 | 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") | |
| 407 | 427 | try buffer.updateDynamicBlock(at: begin) |
| 408 | 428 | buffer.point = begin |
| 409 | 429 | } |
| 410 | 430 | } |
| 411 | 431 | } |
| 412 | 432 | |
| 433 | extension 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). | |
| 449 | public 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. | |
| 471 | public 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 | ||
| 413 | 484 | /// C-c C-x e: `org-set-effort`. Offers the entry's `Effort_ALL` values; sets `Effort`. |
| 414 | 485 | public struct SetEffort: OrgCommand { |
| 415 | 486 | public init() {} |
Sources/OrgCore/Commands/ColumnView.swift added +392
| @@ -0,0 +1,392 @@ | ||
| 1 | import 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 | ||
| 6 | public 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 | ||
| 350 | extension 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 { | ||
| 210 | 210 | case openLink(OrgLink) |
| 211 | 211 | /// Edit a block's text apart (`org-edit-special`); `ReplaceBlockLines` puts it back. |
| 212 | 212 | case editBlock(BlockEdit) |
| 213 | /// Show a column view. | |
| 214 | case columnView(ColumnView.View) | |
| 213 | 215 | } |
| 214 | 216 | |
| 215 | 217 | /// A named operation. Keys, menus, the palette and touch controls all run commands. |
| @@ -239,7 +241,7 @@ public enum Commands { | ||
| 239 | 241 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 240 | 242 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 241 | 243 | 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), | |
| 243 | 245 | ] |
| 244 | 246 | |
| 245 | 247 | 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 { | ||
| 64 | 64 | if buffer.lookingAtLine("^[ \\t]*#\\+[A-Za-z_]+:") { |
| 65 | 65 | return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message("Local setup has been refreshed")])) |
| 66 | 66 | } |
| 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 | } | |
| 68 | 70 | return Self.nothing |
| 69 | 71 | } |
| 70 | 72 | |
Sources/OrgCore/Commands/PropertyCommands.swift added +272
| @@ -0,0 +1,272 @@ | ||
| 1 | import 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 | ||
| 7 | extension 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. | |
| 58 | public 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. | |
| 128 | public 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`. | |
| 167 | public 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`. | |
| 220 | public 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`. | |
| 244 | public 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 { | ||
| 10 | 10 | /// `org-at-table-p`: an org table line outside blocks. |
| 11 | 11 | var atTable: Bool { |
| 12 | 12 | 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 | |
| 14 | 16 | } |
| 15 | 17 | |
| 16 | 18 | var atTableHline: Bool { lookingAtLine("^[ \\t]*\\|-") } |
Sources/OrgCore/Keymap/Keymap.swift +3 −1
| @@ -131,7 +131,7 @@ public struct Keymap: Sendable, Equatable { | ||
| 131 | 131 | |
| 132 | 132 | /// Context names a binding's `when` can use. |
| 133 | 133 | public 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"] | |
| 135 | 135 | |
| 136 | 136 | public static func holds(_ name: String, in context: EditContext) -> Bool { |
| 137 | 137 | switch name { |
| @@ -148,6 +148,8 @@ public enum KeyContext { | ||
| 148 | 148 | case "dblock": |
| 149 | 149 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) |
| 150 | 150 | 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 | |
| 151 | 153 | // `org-use-speed-commands`: at the very start of a heading line. |
| 152 | 154 | case "speed": |
| 153 | 155 | 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 { | ||
| 370 | 370 | command = "org.priority.set" |
| 371 | 371 | mode = "normal" |
| 372 | 372 | [[bind]] |
| 373 | keys = "SPC m o" | |
| 374 | command = "org.property.read-and-set" | |
| 375 | mode = "normal" | |
| 376 | [[bind]] | |
| 373 | 377 | keys = "SPC m p u" |
| 374 | 378 | command = "org.priority.up" |
| 375 | 379 | mode = "normal" |
| @@ -709,6 +713,23 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 709 | 713 | keys = "C-c ," |
| 710 | 714 | command = "org.priority.set" |
| 711 | 715 | [[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]] | |
| 712 | 733 | keys = "C-c C-x C-r" |
| 713 | 734 | command = "org.clock.report" |
| 714 | 735 | [[bind]] |
Sources/Orgstar/ColumnViewSheet.swift added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | import OrgCore | |
| 2 | import 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. | |
| 6 | struct 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 | ||
| 59 | struct ColumnViewRequest: Identifiable { | |
| 60 | let id = UUID() | |
| 61 | let view: ColumnView.View | |
| 62 | } | |
Sources/Orgstar/ContentView.swift +6
| @@ -52,6 +52,7 @@ struct ContentView: View { | ||
| 52 | 52 | } |
| 53 | 53 | /// A block being edited apart (`org-edit-special`). |
| 54 | 54 | @State private var blockEdit: BlockEditRequest? |
| 55 | @State private var columnView: ColumnViewRequest? | |
| 55 | 56 | |
| 56 | 57 | @State private var selectedFile: String? |
| 57 | 58 | /// A file that isn't text, shown as a preview instead of the open document. |
| @@ -207,6 +208,9 @@ struct ContentView: View { | ||
| 207 | 208 | .onChange(of: options, initial: true) { _, options in session.options = options } |
| 208 | 209 | .onChange(of: preset, initial: true) { loadKeymap() } |
| 209 | 210 | .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) } |
| 211 | .sheet(item: $columnView) { request in | |
| 212 | ColumnViewSheet(view: request.view) { offset in commands.editor?.reveal(offset) } | |
| 213 | } | |
| 210 | 214 | .sheet(item: $blockEdit) { request in |
| 211 | 215 | BlockEditorView(edit: request.edit) { content in |
| 212 | 216 | blockEdit = nil |
| @@ -373,6 +377,8 @@ struct ContentView: View { | ||
| 373 | 377 | if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } |
| 374 | 378 | case .editBlock(let edit): |
| 375 | 379 | blockEdit = BlockEditRequest(edit: edit) |
| 380 | case .columnView(let view): | |
| 381 | columnView = ColumnViewRequest(view: view) | |
| 376 | 382 | case .emacsTable(let table, let point, let form, let reason, let lisp): |
| 377 | 383 | let run = { |
| 378 | 384 | commands.show("Recalculating in Emacs (\(reason))…") |
Tests/OrgCoreTests/ColumnViewTests.swift added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | } | |