krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 9ec764defc

9ec764defc09ce63a1a190a6937cd1e902561afb

parent: 80ccfaf8c9

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:58 UTC

Export macro {{{property(NAME[,SEARCH])}}}

As Org's property macro: org-entry-get with selective inheritance at the
heading holding the macro, or at the entry SEARCH finds (*Title,
#custom-id or a title). ITEM gives the title; before the first heading
it is empty.

Layout: unified · split

Sources/OrgCore/Export/ExportOptions.swift +28 −2
@@ -194,6 +194,28 @@ public enum ExportPreprocess {
194 } 194 }
195 } 195 }
196 var counters: [String: Int] = [:] 196 var counters: [String: Int] = [:]
197 var model: DocumentModel?
198 /// `org-entry-get` with `selective` inheritance at the heading holding `location`, or
199 /// at the entry `search` finds (`*Title`, `#custom-id` or a title).
200 func property(_ name: String, _ search: String?, at location: Int) -> String {
201 if model == nil { model = DocumentModel(tree: OrgParser.parse(text)) }
202 let headings = model!.headings
203 let index: Int?
204 if let search, !search.isEmpty {
205 if search.hasPrefix("#") {
206 let id = String(search.dropFirst())
207 index = headings.firstIndex { $0.properties.contains { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id } }
208 } else {
209 let title = search.hasPrefix("*") ? String(search.dropFirst()) : search
210 index = headings.firstIndex { Links.displayFormat($0.title).trimmingCharacters(in: .whitespaces) == title || $0.title == title }
211 }
212 } else {
213 index = headings.lastIndex { $0.headingRange.lowerBound <= location }
214 }
215 guard let index else { return "" }
216 if name.uppercased() == "ITEM" { return headings[index].title }
217 return model!.property(name, of: index)?.value ?? ""
218 }
197 let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}") 219 let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}")
198 var out = "" 220 var out = ""
199 var at = 0 221 var at = 0
@@ -214,7 +236,10 @@ public enum ExportPreprocess {
214 continue 236 continue
215 } 237 }
216 let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2))) 238 let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2)))
217 out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now) 239 let location = m.range.location
240 out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now) {
241 property($0, $1, at: location)
242 }
218 } 243 }
219 out += ns.substring(from: at) 244 out += ns.substring(from: at)
220 return out 245 return out
@@ -245,7 +270,7 @@ public enum ExportPreprocess {
245 } 270 }
246 271
247 static func expand(_ name: String, _ args: [String], definitions: [String: String], keywords: [String: String], 272 static func expand(_ name: String, _ args: [String], definitions: [String: String], keywords: [String: String],
248 counters: inout [String: Int], path: String?, now: Date) -> String { 273 counters: inout [String: Int], path: String?, now: Date, property: (String, String?) -> String) -> String {
249 if let template = definitions[name] { 274 if let template = definitions[name] {
250 guard !template.hasPrefix("(eval") else { return "" } 275 guard !template.hasPrefix("(eval") else { return "" }
251 var result = template 276 var result = template
@@ -256,6 +281,7 @@ public enum ExportPreprocess {
256 case "title", "author", "date", "email": return keywords[name.uppercased()] ?? "" 281 case "title", "author", "date", "email": return keywords[name.uppercased()] ?? ""
257 case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? "" 282 case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? ""
258 case "input-file": return path.map { ($0 as NSString).lastPathComponent } ?? "" 283 case "input-file": return path.map { ($0 as NSString).lastPathComponent } ?? ""
284 case "property": return args.first.map { property($0, args.count > 1 ? args[1] : nil) } ?? ""
259 case "n": 285 case "n":
260 let counter = args.first ?? "" 286 let counter = args.first ?? ""
261 let action = args.count > 1 ? args[1] : "" 287 let action = args.count > 1 ? args[1] : ""
Tests/OrgCoreTests/ExportOptionsTests.swift +28
@@ -44,6 +44,34 @@ struct ExportOptionsTests {
44 #expect(!selected.contains("<p>a</p>") && selected.contains("<p>b</p>") && selected.contains("<p>c</p>")) 44 #expect(!selected.contains("<p>a</p>") && selected.contains("<p>b</p>") && selected.contains("<p>c</p>"))
45 } 45 }
46 46
47 @Test(.enabled(if: oracle))
48 func expandsPropertyMacroLikeOrg() throws {
49 let text = """
50 #+PROPERTY: color filecolor
51 Top: [{{{property(color)}}}]
52 * One
53 :PROPERTIES:
54 :color: red
55 :Size: big
56 :CUSTOM_ID: one
57 :END:
58 In one: [{{{property(color)}}}] [{{{property(SIZE)}}}] [{{{property(missing)}}}] [{{{property(ITEM)}}}]
59 ** Child
60 Child: [{{{property(color)}}}]
61 * Two
62 Two: [{{{property(color,*One)}}}] [{{{property(color,#one)}}}] [{{{property(Size, One)}}}]
63 """ + "\n"
64 let emacs = try EmacsOracle.evaluate(text, """
65 (progn
66 (require 'ox)
67 (org-macro-initialize-templates)
68 (org-macro-replace-all org-macro-templates)
69 (list (buffer-substring-no-properties (point-min) (point-max))))
70 """)
71 let ours = ExportPreprocess.macros(text, path: nil)
72 #expect(ours.contains("[red] [big] [] [One]") && ours == emacs.first, "ours:\n\(ours)\nemacs:\n\(emacs.first ?? "")")
73 }
74
47 @Test(.enabled(if: oracle)) 75 @Test(.enabled(if: oracle))
48 func expandsIncludesAndMacrosLikeOrg() throws { 76 func expandsIncludesAndMacrosLikeOrg() throws {
49 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-include-\(UUID().uuidString)") 77 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-include-\(UUID().uuidString)")