krz/orgstar

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

Sources/OrgCore/Commands/PropertyCommands.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgCore/Commands/PropertyCommands.swift history · blame · raw

272 lines · 15367 bytes

20 symbols in this file
  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}