Sources/OrgCore/Commands/PropertyCommands.swift
272 lines · 15367 bytes
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}