| @@ -0,0 +1,449 @@ |
| |
1 | import Foundation |
| |
2 | |
| |
3 | // Moving subtrees as text and sorting them (Org 9.8.7): `org-copy-subtree`, `org-cut-subtree`, |
| |
4 | // `org-paste-subtree`, `org-clone-subtree-with-time-shift` and `org-sort-entries`. The kill |
| |
5 | // ring is the clipboard: cutting and copying hand the text to the app (`Effect.copy`), and |
| |
6 | // pasting takes the clipboard's text as `text`. |
| |
7 | |
| |
8 | /// C-c C-x M-w and C-c C-x C-w: `org-copy-subtree` and `org-cut-subtree`. |
| |
9 | public struct CopySubtree: OrgCommand { |
| |
10 | public let cut: Bool |
| |
11 | public init(cut: Bool) { self.cut = cut } |
| |
12 | public var id: String { cut ? "org.subtree.cut" : "org.subtree.copy" } |
| |
13 | public var title: String { cut ? "Cut Subtree" : "Copy Subtree" } |
| |
14 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
| |
15 | public func run(in context: EditContext) -> CommandStep { |
| |
16 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) |
| |
17 | guard !buffer.beforeFirstHeading, let range = try? buffer.subtreeRange(), !range.isEmpty else { return .failed("Before first headline") } |
| |
18 | let text = buffer.substring(range.lowerBound, range.upperBound) |
| |
19 | let effects: [Effect] = [.copy(text), .message("\(cut ? "Cut" : "Copied"): Subtree(s) with \(text.count) characters")] |
| |
20 | let edits = cut ? [TextEdit(range: range, replacement: "")] : [] |
| |
21 | let caret = cut ? range.lowerBound : context.caret |
| |
22 | return .commit(EditResult(baseRevision: context.revision, edits: edits, selection: [caret..<caret], effects: effects)) |
| |
23 | } |
| |
24 | } |
| |
25 | |
| |
26 | /// `org-kill-is-subtree-p`: starts with a heading, and no later heading is above it. |
| |
27 | func isSubtree(_ text: String) -> Bool { |
| |
28 | guard let first = text.firstMatch(of: #/^(?:[ \t\n\r]*?\n)?(\*+) /#) else { return false } |
| |
29 | let level = first.1.count |
| |
30 | for match in text.matches(of: #/(?m)^(\*+) /#) where match.1.count < level { return false } |
| |
31 | return true |
| |
32 | } |
| |
33 | |
| |
34 | /// C-c C-x C-y: `org-paste-subtree` with the clipboard's text, its levels shifted to fit |
| |
35 | /// between the headings around point. |
| |
36 | public struct PasteSubtree: OrgCommand { |
| |
37 | public let text: String |
| |
38 | public init(text: String) { self.text = text } |
| |
39 | public var id: String { "org.subtree.paste" } |
| |
40 | public var title: String { "Paste Subtree" } |
| |
41 | public func applies(in context: EditContext) -> Bool { true } |
| |
42 | public func run(in context: EditContext) -> CommandStep { |
| |
43 | guard isSubtree(text) else { return .failed("The kill is not a (set of) tree(s). Use `C-y' to yank anyway") } |
| |
44 | var newLevel = 0 |
| |
45 | let step = commitBuffer(context) { buffer in |
| |
46 | let oldLevel = text.firstMatch(of: #/(?m)^(\*+) /#).map { $0.1.count } ?? -1 |
| |
47 | var indicator: Int? |
| |
48 | var forced: Int? |
| |
49 | if buffer.lookingAtLine("^\\*+[ \\t]*$"), buffer.point >= buffer.length || buffer.character(at: buffer.point) != 42 { |
| |
50 | indicator = buffer.substring(buffer.lineStart(buffer.point), buffer.lineEnd(buffer.point)).prefix { $0 == "*" }.count |
| |
51 | forced = indicator |
| |
52 | } else if buffer.bolp, buffer.lookingAt("\\*+ ") != nil { |
| |
53 | forced = buffer.outlineLevel |
| |
54 | } |
| |
55 | let previous = buffer.saveExcursion { () -> Int in |
| |
56 | if !buffer.atHeading { buffer.point = Self.previousHeading(buffer) ?? buffer.point } |
| |
57 | return buffer.atHeading ? buffer.outlineLevel : 1 |
| |
58 | } |
| |
59 | let next = buffer.saveExcursion { () -> Int in |
| |
60 | buffer.point = Self.nextHeading(buffer) |
| |
61 | return buffer.atHeading ? buffer.outlineLevel : 1 |
| |
62 | } |
| |
63 | newLevel = forced ?? max(0, previous, next) |
| |
64 | let shift = oldLevel == -1 || oldLevel == newLevel ? 0 : newLevel - oldLevel |
| |
65 | if indicator != nil { |
| |
66 | let start = buffer.lineStart(buffer.point) |
| |
67 | buffer.deleteRegion(start, min(buffer.length, buffer.lineEnd(start) + 1)) |
| |
68 | buffer.point = start |
| |
69 | } |
| |
70 | if !(buffer.bolp && buffer.atHeading) { |
| |
71 | buffer.point = Self.nextHeading(buffer) |
| |
72 | if !buffer.bolp { buffer.insert("\n") } |
| |
73 | } |
| |
74 | let begin = buffer.point |
| |
75 | var pasted = text.hasSuffix("\n") ? text : text + "\n" |
| |
76 | if shift != 0 { |
| |
77 | pasted = pasted.components(separatedBy: "\n").map { line in |
| |
78 | guard let stars = line.firstMatch(of: #/^(\*+) /#) else { return line } |
| |
79 | return String(repeating: "*", count: max(1, stars.1.count + shift)) + line.dropFirst(stars.1.count) |
| |
80 | }.joined(separator: "\n") |
| |
81 | } |
| |
82 | buffer.insert(pasted) |
| |
83 | buffer.point = begin |
| |
84 | buffer.skipForward([32, 9, 10, 13]) |
| |
85 | } |
| |
86 | guard case .commit(let result) = step else { return step } |
| |
87 | return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, |
| |
88 | effects: [.message("Clipboard pasted as level \(newLevel) subtree")])) |
| |
89 | } |
| |
90 | |
| |
91 | /// `org-previous-visible-heading` 1: the heading line before point's line. |
| |
92 | static func previousHeading(_ buffer: EmacsBuffer) -> Int? { |
| |
93 | buffer.saveExcursion { |
| |
94 | buffer.point = buffer.lineStart(buffer.point) |
| |
95 | return buffer.searchBackward("^\\*+ ", caseInsensitive: false)?[0]?.lowerBound |
| |
96 | } |
| |
97 | } |
| |
98 | |
| |
99 | /// `org-next-visible-heading` 1: the next heading's start, or the end of the text. |
| |
100 | static func nextHeading(_ buffer: EmacsBuffer) -> Int { |
| |
101 | buffer.saveExcursion { |
| |
102 | buffer.point = buffer.lineEnd(buffer.point) |
| |
103 | return buffer.searchForward("^\\*+ ", caseInsensitive: false)?[0]?.lowerBound ?? buffer.length |
| |
104 | } |
| |
105 | } |
| |
106 | } |
| |
107 | |
| |
108 | /// C-c C-x c: `org-clone-subtree-with-time-shift`. Asks how many clones and, when the subtree |
| |
109 | /// has timestamps, the shift per clone (`+1w`). Clones get new IDs (`org-clone-delete-id` |
| |
110 | /// nil) and no clock lines; a repeating entry ends up after its clones, its dates moved past |
| |
111 | /// them. |
| |
112 | public struct CloneSubtree: OrgCommand { |
| |
113 | let newID: @Sendable () -> String |
| |
114 | public init(newID: @escaping @Sendable () -> String = { UUID().uuidString }) { self.newID = newID } |
| |
115 | public var id: String { "org.subtree.clone" } |
| |
116 | public var title: String { "Clone Subtree with Time Shift" } |
| |
117 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
| |
118 | public func run(in context: EditContext) -> CommandStep { |
| |
119 | let probe = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) |
| |
120 | guard !probe.beforeFirstHeading, let range = try? probe.subtreeRange() else { return .failed("No subtree to clone") } |
| |
121 | guard let countText = context.answers["count"] else { return .prompt(Prompt(key: "count", message: "Number of clones to produce:")) } |
| |
122 | guard let count = Int(countText.trimmingCharacters(in: .whitespaces)), count >= 0 else { |
| |
123 | return .failed("Invalid number of replications \(countText)") |
| |
124 | } |
| |
125 | let hasStamp = probe.saveExcursion { () -> Bool in |
| |
126 | probe.point = range.lowerBound |
| |
127 | return probe.searchForward("[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]", bound: range.upperBound, caseInsensitive: false) != nil |
| |
128 | } |
| |
129 | var shift = "" |
| |
130 | if hasStamp { |
| |
131 | guard let answer = context.answers["shift"] else { |
| |
132 | return .prompt(Prompt(key: "shift", message: "Date shift per clone (e.g. +1w, empty to copy unchanged):")) |
| |
133 | } |
| |
134 | shift = answer |
| |
135 | } |
| |
136 | var shiftN = 0 |
| |
137 | var unit: EmacsBuffer.TimestampUnit? |
| |
138 | if shift.contains(where: { !$0.isWhitespace }) { |
| |
139 | guard let m = shift.wholeMatch(of: /[ \t]*([+-]?[0-9]+)([hdwmy])[ \t]*/) else { return .failed("Invalid shift specification \(shift)") } |
| |
140 | shiftN = Int(m.1.replacingOccurrences(of: "+", with: ""))! |
| |
141 | switch m.2 { |
| |
142 | case "h": unit = .hour |
| |
143 | case "d": unit = .day |
| |
144 | case "w": unit = .day; shiftN *= 7 |
| |
145 | case "m": unit = .month |
| |
146 | default: unit = .year |
| |
147 | } |
| |
148 | } |
| |
149 | return commitBuffer(context) { buffer in |
| |
150 | buffer.point = range.upperBound |
| |
151 | if !buffer.bolp { buffer.insert("\n") } |
| |
152 | var end = buffer.point |
| |
153 | let template = buffer.substring(range.lowerBound, end) |
| |
154 | let hasID = buffer.saveExcursion { () -> Bool in |
| |
155 | buffer.point = range.lowerBound |
| |
156 | return buffer.entryID() != nil |
| |
157 | } |
| |
158 | var first = 1 |
| |
159 | var last = count |
| |
160 | var keepRepeater = -1 |
| |
161 | if unit != nil, template.range(of: "<[^<>\\n]+ [.+]?\\+[0-9]+[hdwmy][^<>\\n]*>", options: .regularExpression) != nil { |
| |
162 | buffer.deleteRegion(range.lowerBound, end) |
| |
163 | end = range.lowerBound |
| |
164 | first = 0 |
| |
165 | last = count + 1 |
| |
166 | keepRepeater = last |
| |
167 | } |
| |
168 | buffer.point = end |
| |
169 | guard first <= last else { return } |
| |
170 | for n in first...last { |
| |
171 | let clone = EmacsBuffer(template, point: 0, settings: buffer.settings, options: buffer.options) |
| |
172 | clone.now = buffer.now |
| |
173 | clone.calendar = buffer.calendar |
| |
174 | if hasID { |
| |
175 | // `org-id-get-create` with FORCE: the old ID goes, a new one is added. |
| |
176 | clone.saveExcursion { |
| |
177 | if let line = clone.searchForward("^[ \\t]*:ID:.*\\n", caseInsensitive: true) { |
| |
178 | clone.deleteRegion(line[0]!.lowerBound, line[0]!.upperBound) |
| |
179 | } |
| |
180 | } |
| |
181 | clone.point = 0 |
| |
182 | clone.entryPut("ID", newID()) |
| |
183 | } |
| |
184 | if n != 0 { |
| |
185 | clone.point = 0 |
| |
186 | while let line = clone.searchForward("^[ \\t]*CLOCK:.*$", caseInsensitive: false) { |
| |
187 | let start = clone.lineStart(line[0]!.lowerBound) |
| |
188 | clone.deleteRegion(start, min(clone.length, clone.lineEnd(start) + 1)) |
| |
189 | clone.point = start |
| |
190 | } |
| |
191 | clone.point = 0 |
| |
192 | while let drawer = clone.searchForward("^[ \\t]*:[\\w-]+:[ \\t]*$", caseInsensitive: false) { |
| |
193 | let at = drawer[0]!.upperBound |
| |
194 | clone.removeEmptyDrawer(at: at) |
| |
195 | clone.point = min(at, clone.length) |
| |
196 | if clone.point < clone.length { clone.point = min(clone.length, clone.lineEnd(clone.point) + 1) } else { break } |
| |
197 | } |
| |
198 | } |
| |
199 | if let unit { |
| |
200 | clone.point = 0 |
| |
201 | while let stamp = clone.searchForward("[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]", caseInsensitive: false) { |
| |
202 | clone.point = stamp[0]!.lowerBound + 1 |
| |
203 | try clone.changeTimestamp(n * shiftN, what: unit) |
| |
204 | clone.point = clone.lineStart(clone.point) |
| |
205 | clone.point = min(clone.length, (clone.searchForward("[\\]>]", bound: clone.lineEnd(clone.point), caseInsensitive: false)?[0]?.upperBound) ?? clone.lineEnd(clone.point)) |
| |
206 | if let after = clone.searchForward("[\\[<][0-9]{4}", bound: clone.lineEnd(clone.point), caseInsensitive: false), after[0]!.lowerBound > 0 { |
| |
207 | clone.point = after[0]!.lowerBound |
| |
208 | } else { |
| |
209 | clone.point = min(clone.length, clone.lineEnd(clone.point) + 1) |
| |
210 | } |
| |
211 | } |
| |
212 | if n != keepRepeater { |
| |
213 | clone.point = 0 |
| |
214 | while let repeater = clone.searchForward("<[^<>\\n]+?( +[.+]?\\+[0-9]+[hdwmy])", caseInsensitive: false) { |
| |
215 | clone.deleteRegion(repeater[1]!.lowerBound, repeater[1]!.upperBound) |
| |
216 | } |
| |
217 | } |
| |
218 | } |
| |
219 | buffer.insert(clone.string) |
| |
220 | } |
| |
221 | buffer.point = range.lowerBound |
| |
222 | } |
| |
223 | } |
| |
224 | } |
| |
225 | |
| |
226 | /// C-c ^ on headings: `org-sort-entries`. The children of the heading at point (all top-level |
| |
227 | /// headings before the first one, or the region's headings) sort by a key: [a]lpha, |
| |
228 | /// [n]umeric, [p]riority, p[r]operty, todo[o]rder, [t]ime, [s]cheduled, [d]eadline, |
| |
229 | /// [c]reated, cloc[k]ing; the capital letter reverses. |
| |
230 | public struct SortEntries: OrgCommand { |
| |
231 | public init() {} |
| |
232 | public var id: String { "org.sort" } |
| |
233 | public var title: String { "Sort Entries" } |
| |
234 | public func applies(in context: EditContext) -> Bool { true } |
| |
235 | |
| |
236 | static let keys: [(Character, String)] = [ |
| |
237 | ("a", "alpha"), ("n", "numeric"), ("p", "priority"), ("r", "property"), ("o", "todo order"), |
| |
238 | ("t", "time"), ("s", "scheduled"), ("d", "deadline"), ("c", "created"), ("k", "clocking"), |
| |
239 | ] |
| |
240 | |
| |
241 | public func run(in context: EditContext) -> CommandStep { |
| |
242 | // `org-sort` sorts table lines and list items there; those aren't here yet. |
| |
243 | if KeyContext.holds("table", in: context) { return .failed("Sorting table lines isn't supported yet") } |
| |
244 | if EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).inItem() != nil { |
| |
245 | return .failed("Sorting list items isn't supported yet") |
| |
246 | } |
| |
247 | let probe = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) |
| |
248 | guard let bounds = Self.bounds(probe, context.selection.first ?? context.caret..<context.caret) else { return .failed("Nothing to sort") } |
| |
249 | guard let answer = context.answers["sort"], let key = answer.first else { |
| |
250 | var items: [FastSelection.Item] = Self.keys.map { .option(key: $0.0, name: $0.1) } |
| |
251 | items.append(.newline) |
| |
252 | items += Self.keys.map { .option(key: Character($0.0.uppercased()), name: $0.1 + " (reversed)") } |
| |
253 | return .prompt(Prompt(key: "sort", message: "Sort \(bounds.what): [a]lpha [n]umeric [p]riority p[r]operty todo[o]rder [t]ime [s]cheduled [d]eadline [c]reated cloc[k]ing; capitals reverse", |
| |
254 | fast: FastSelection(items: items, multiple: false))) |
| |
255 | } |
| |
256 | let kind = Character(key.lowercased()) |
| |
257 | guard Self.keys.contains(where: { $0.0 == kind }) else { return .failed("Invalid sorting type `\(key)'") } |
| |
258 | var property = "" |
| |
259 | if kind == "r" { |
| |
260 | guard let name = context.answers["property"] else { |
| |
261 | return .prompt(Prompt(key: "property", message: "Property:", choices: Self.propertyKeys(context.text))) |
| |
262 | } |
| |
263 | property = name |
| |
264 | } |
| |
265 | return commitBuffer(context) { buffer in |
| |
266 | try Self.sort(buffer, key: kind, reverse: key.isUppercase, property: property, selection: context.selection.first ?? context.caret..<context.caret) |
| |
267 | } |
| |
268 | } |
| |
269 | |
| |
270 | /// `org-buffer-property-keys`, roughly: the property names in the text. |
| |
271 | static func propertyKeys(_ text: String) -> [String] { |
| |
272 | var names: [String] = [] |
| |
273 | for match in text.matches(of: #/(?m)^[ \t]*:([^\s:]+):[ \t]/#) where !names.contains(String(match.1)) && String(match.1).uppercased() != "END" { |
| |
274 | names.append(String(match.1)) |
| |
275 | } |
| |
276 | return names |
| |
277 | } |
| |
278 | |
| |
279 | /// The text to sort and what it is: a region's headings, a heading's children, or the |
| |
280 | /// top-level headings. Nil when there's nothing. |
| |
281 | static func bounds(_ buffer: EmacsBuffer, _ selection: Range<Int>) -> (start: Int, end: Int, first: Int, what: String)? { |
| |
282 | buffer.saveExcursion { |
| |
283 | var start: Int |
| |
284 | var end: Int |
| |
285 | let what: String |
| |
286 | if !selection.isEmpty { |
| |
287 | buffer.point = selection.lowerBound |
| |
288 | if buffer.atHeading { buffer.point = buffer.lineStart(buffer.point) } else { Capture.nextHeading(buffer) } |
| |
289 | start = buffer.point |
| |
290 | buffer.point = selection.upperBound |
| |
291 | if !buffer.beforeFirstHeading, (try? buffer.backToHeading()) != nil { Capture.endOfSubtree(buffer, heading: buffer.point) } |
| |
292 | end = buffer.point |
| |
293 | what = "region" |
| |
294 | } else if !buffer.beforeFirstHeading { |
| |
295 | _ = try? buffer.backToHeading() |
| |
296 | start = buffer.point |
| |
297 | Capture.endOfSubtree(buffer, heading: start) |
| |
298 | end = buffer.point |
| |
299 | // Without the blank lines after the subtree, but with one. |
| |
300 | var back = end |
| |
301 | while back > start, let c = buffer.character(at: back - 1), c == 10 || c == 32 || c == 9 { |
| |
302 | if c == 10, back - 1 > start, buffer.character(at: back - 2) == 10 { back -= 1 } else if c != 10 { back -= 1 } else { break } |
| |
303 | } |
| |
304 | end = back |
| |
305 | what = "children" |
| |
306 | buffer.point = start |
| |
307 | buffer.point = min(buffer.length, buffer.lineEnd(start) + 1) |
| |
308 | if !buffer.atHeading { Capture.nextHeading(buffer) } |
| |
309 | return buffer.point < end ? (start, end, buffer.point, what) : nil |
| |
310 | } else { |
| |
311 | buffer.point = 0 |
| |
312 | if !buffer.atHeading { Capture.nextHeading(buffer) } |
| |
313 | start = buffer.point |
| |
314 | end = buffer.length |
| |
315 | what = "top-level" |
| |
316 | } |
| |
317 | return start < end ? (start, end, start, what) : nil |
| |
318 | } |
| |
319 | } |
| |
320 | |
| |
321 | static func sort(_ buffer: EmacsBuffer, key: Character, reverse: Bool, property: String, selection: Range<Int>) throws { |
| |
322 | guard let bounds = bounds(buffer, selection) else { throw EmacsBuffer.UserError(message: "Nothing to sort") } |
| |
323 | var end = bounds.end |
| |
324 | // The sorted text ends with a newline. |
| |
325 | if end > 0, buffer.character(at: end - 1) != 10 { |
| |
326 | buffer.point = end |
| |
327 | buffer.insert("\n") |
| |
328 | end += 1 |
| |
329 | } |
| |
330 | let begin = bounds.first |
| |
331 | guard begin < end else { throw EmacsBuffer.UserError(message: "Nothing to sort") } |
| |
332 | let stars = buffer.substring(begin, buffer.lineEnd(begin)).prefix { $0 == "*" } |
| |
333 | let level = stars.count |
| |
334 | let text = buffer.substring(begin, end) |
| |
335 | if level > 1, text.range(of: "(?m)^\\*{1,\(level - 1)}[ \\t\\n]", options: .regularExpression) != nil { |
| |
336 | throw EmacsBuffer.UserError(message: "Region to sort contains a level above the first entry") |
| |
337 | } |
| |
338 | // Records: from each heading at the first one's level to the next. |
| |
339 | var starts: [Int] = [] |
| |
340 | buffer.point = begin |
| |
341 | while let match = buffer.searchForward("^\\*{\(level)} +", bound: end, caseInsensitive: false) { |
| |
342 | starts.append(match[0]!.lowerBound) |
| |
343 | buffer.point = match[0]!.upperBound |
| |
344 | } |
| |
345 | guard !starts.isEmpty else { return } |
| |
346 | let records = starts.enumerated().map { i, s in s..<(i + 1 < starts.count ? starts[i + 1] : end) } |
| |
347 | let now = buffer.now.timeIntervalSince1970 |
| |
348 | let keys = records.map { record -> SortKey in |
| |
349 | buffer.point = record.lowerBound |
| |
350 | return sortKey(buffer, kind: key, record: record, property: property, now: now) |
| |
351 | } |
| |
352 | var order = Array(records.indices) |
| |
353 | if reverse { order.reverse() } |
| |
354 | order = order.enumerated().sorted { a, b in |
| |
355 | let less = keys[a.element] < keys[b.element] |
| |
356 | let greater = keys[b.element] < keys[a.element] |
| |
357 | return less != greater ? less : a.offset < b.offset |
| |
358 | }.map(\.element) |
| |
359 | if reverse { order.reverse() } |
| |
360 | let sorted = order.map { buffer.substring(records[$0].lowerBound, records[$0].upperBound) }.joined() |
| |
361 | buffer.deleteRegion(records.first!.lowerBound, end) |
| |
362 | buffer.point = records.first!.lowerBound |
| |
363 | buffer.insert(sorted) |
| |
364 | buffer.point = bounds.start |
| |
365 | } |
| |
366 | |
| |
367 | enum SortKey: Comparable { |
| |
368 | case number(Double) |
| |
369 | case text(String) |
| |
370 | |
| |
371 | static func < (a: SortKey, b: SortKey) -> Bool { |
| |
372 | switch (a, b) { |
| |
373 | case (.number(let x), .number(let y)): x < y |
| |
374 | case (.text(let x), .text(let y)): x < y |
| |
375 | case (.number, .text): true |
| |
376 | case (.text, .number): false |
| |
377 | } |
| |
378 | } |
| |
379 | } |
| |
380 | |
| |
381 | static func sortKey(_ buffer: EmacsBuffer, kind: Character, record: Range<Int>, property: String, now: Double) -> SortKey { |
| |
382 | let entryEnd = buffer.saveExcursion { () -> Int in |
| |
383 | Capture.nextHeading(buffer) |
| |
384 | return min(buffer.point, record.upperBound) |
| |
385 | } |
| |
386 | let match = buffer.lookingAt(buffer.complexHeadingPattern) |
| |
387 | func group(_ i: Int) -> String? { match?[i].map { buffer.substring($0.lowerBound, $0.upperBound) } } |
| |
388 | func seconds(_ pattern: String, group: Int = 0) -> Double { |
| |
389 | buffer.saveExcursion { |
| |
390 | guard let found = buffer.searchForward(pattern, bound: entryEnd, caseInsensitive: false), let range = found[group] else { return now } |
| |
391 | return TagsMatcher.seconds(buffer.substring(range.lowerBound, range.upperBound), now: buffer.now, calendar: buffer.calendar) |
| |
392 | } |
| |
393 | } |
| |
394 | switch kind { |
| |
395 | case "a": |
| |
396 | return .text(visibleTitle(group(4) ?? "").lowercased()) |
| |
397 | case "n": |
| |
398 | return .number(Double(visibleTitle(group(4) ?? "").prefix { $0.isNumber || $0 == "." || $0 == "-" || $0 == "+" }) ?? 0) |
| |
399 | case "p": |
| |
400 | // `org-priority-regexp` anywhere on the heading line. |
| |
401 | let line = buffer.substring(record.lowerBound, buffer.lineEnd(record.lowerBound)) |
| |
402 | let cookie = line.firstMatch(of: /\[#([A-Z]|[0-9]+)\]/).map { String($0.1) } |
| |
403 | let value: String = cookie ?? buffer.settings.priorities.default |
| |
404 | return .number(value.allSatisfy(\.isNumber) ? Double(value)! : Double(value.unicodeScalars.first!.value)) |
| |
405 | case "r": |
| |
406 | let value = buffer.saveExcursion { () -> String in |
| |
407 | buffer.point = record.lowerBound |
| |
408 | guard let drawer = buffer.searchForward("^[ \\t]*:" + NSRegularExpression.escapedPattern(for: property) + ":[ \\t]+(.*?)[ \\t]*$", bound: entryEnd, caseInsensitive: true) else { return "" } |
| |
409 | return drawer[1].map { buffer.substring($0.lowerBound, $0.upperBound) } ?? "" |
| |
410 | } |
| |
411 | return .text(value) |
| |
412 | case "o": |
| |
413 | let keywords = buffer.keywordsInOrder |
| |
414 | guard let todo = group(2), let index = keywords.firstIndex(of: todo) else { return .number(99) } |
| |
415 | let remaining = Double(keywords.count - index) |
| |
416 | return .number(buffer.settings.isDone(todo) ? 99 + remaining : 99 - remaining) |
| |
417 | case "t": |
| |
418 | let active = seconds("<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>") |
| |
419 | if active != now { return .number(active) } |
| |
420 | return .number(seconds("[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*[\\]>]")) |
| |
421 | case "c": |
| |
422 | return .number(seconds("^[ \\t]*\\[[0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]\\n]*\\]")) |
| |
423 | case "s": |
| |
424 | return .number(seconds("SCHEDULED: *(<[^>]+>)", group: 1)) |
| |
425 | case "d": |
| |
426 | return .number(seconds("DEADLINE: *(<[^>]+>)", group: 1)) |
| |
427 | default: |
| |
428 | // Clocked minutes in the whole subtree. |
| |
429 | var minutes = 0.0 |
| |
430 | buffer.saveExcursion { |
| |
431 | buffer.point = record.lowerBound |
| |
432 | while let line = buffer.searchForward("^[ \\t]*CLOCK:.*=>[ \\t]*([0-9]+):([0-9]{2})", bound: record.upperBound, caseInsensitive: false) { |
| |
433 | minutes += Double(buffer.substring(line[1]!.lowerBound, line[1]!.upperBound))! * 60 + Double(buffer.substring(line[2]!.lowerBound, line[2]!.upperBound))! |
| |
434 | } |
| |
435 | } |
| |
436 | return .number(minutes) |
| |
437 | } |
| |
438 | } |
| |
439 | |
| |
440 | /// `org-get-heading t t t t` through `org-sort-remove-invisible`: no COMMENT, links as their |
| |
441 | /// descriptions, emphasis markers gone. |
| |
442 | static func visibleTitle(_ title: String) -> String { |
| |
443 | var t = title |
| |
444 | if t == "COMMENT" { t = "" } else if t.hasPrefix("COMMENT ") { t = String(t.dropFirst(8)) } |
| |
445 | t = Links.displayFormat(t) |
| |
446 | t = t.replacingOccurrences(of: "(^|[\\s(])[*/_+=~]([^\\s*/_+=~](?:.*?[^\\s])?)[*/_+=~](?=$|[\\s.,;:!?)])", with: "$1$2", options: .regularExpression) |
| |
447 | return t |
| |
448 | } |
| |
449 | } |