Cut, copy, paste, clone and sort subtrees !77

merged merged by cmc on 2026-10-06 03:14 UTC · krz/orgstar:subtree-text into main

7 files changed, +627 −1

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +3
@@ -145,6 +145,8 @@ public enum Effect: Sendable, Equatable {
145 case message(String) 145 case message(String)
146 /// An inserted link was a stored one; it leaves `org-stored-links`. 146 /// An inserted link was a stored one; it leaves `org-stored-links`.
147 case storedLinkUsed(String) 147 case storedLinkUsed(String)
148 /// Text for the clipboard (the kill ring).
149 case copy(String)
148} 150}
149 151
150public struct EditResult: Sendable, Equatable { 152public struct EditResult: Sendable, Equatable {
@@ -211,6 +213,7 @@ public enum Commands {
211 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), 213 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
212 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 214 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
213 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), 215 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
216 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(),
214 ] 217 ]
215 218
216 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } 219 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/SubtreeCommands.swift added +449
@@ -0,0 +1,449 @@
1import 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`.
9public 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.
27func 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.
36public 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.
112public 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.
230public 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}
Sources/OrgCore/Keymap/Presets.swift +27
@@ -190,6 +190,18 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
190 190
191 # Localleader (SPC m) 191 # Localleader (SPC m)
192 [[bind]] 192 [[bind]]
193 keys = "SPC m s c"
194 command = "org.subtree.clone"
195 mode = "normal"
196 [[bind]]
197 keys = "SPC m s d"
198 command = "org.subtree.cut"
199 mode = "normal"
200 [[bind]]
201 keys = "SPC m s S"
202 command = "org.sort"
203 mode = "normal"
204 [[bind]]
193 keys = "SPC m h" 205 keys = "SPC m h"
194 command = "org.heading.toggle" 206 command = "org.heading.toggle"
195 mode = "normal" 207 mode = "normal"
@@ -600,6 +612,21 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
600 keys = "C-c @" 612 keys = "C-c @"
601 command = "org.subtree.mark" 613 command = "org.subtree.mark"
602 [[bind]] 614 [[bind]]
615 keys = "C-c C-x C-w"
616 command = "org.subtree.cut"
617 [[bind]]
618 keys = "C-c C-x M-w"
619 command = "org.subtree.copy"
620 [[bind]]
621 keys = "C-c C-x C-y"
622 command = "org.subtree.paste"
623 [[bind]]
624 keys = "C-c C-x c"
625 command = "org.subtree.clone"
626 [[bind]]
627 keys = "C-c ^"
628 command = "org.sort"
629 [[bind]]
603 keys = "C-x b" 630 keys = "C-x b"
604 command = "app.buffer.switch" 631 command = "app.buffer.switch"
605 [[bind]] 632 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -40,6 +40,7 @@ public enum EditorCommands {
40 EditorCommand(id: "org.link.insert", title: "Insert Link…", action: .app), 40 EditorCommand(id: "org.link.insert", title: "Insert Link…", action: .app),
41 EditorCommand(id: "org.link.store", title: "Store Link", action: .app), 41 EditorCommand(id: "org.link.store", title: "Store Link", action: .app),
42 EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app), 42 EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app),
43 EditorCommand(id: "org.subtree.paste", title: "Paste Subtree", action: .app),
43 44
44 EditorCommand(id: "app.save", title: "Save", action: .app), 45 EditorCommand(id: "app.save", title: "Save", action: .app),
45 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), 46 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
Sources/Orgstar/ContentView.swift +3
@@ -496,6 +496,9 @@ struct ContentView: View {
496 } 496 }
497 case "app.archive": 497 case "app.archive":
498 if let message = session.archive() { commands.show(message) } 498 if let message = session.archive() { commands.show(message) }
499 case "org.subtree.paste":
500 guard let text = NSPasteboard.general.string(forType: .string) else { return commands.show("The clipboard is empty") }
501 commands.editor?.perform(PasteSubtree(text: text))
499 case "org.link.insert": 502 case "org.link.insert":
500 commands.editor?.perform(InsertLink(stored: commands.storedLinks, file: session.path)) 503 commands.editor?.perform(InsertLink(stored: commands.storedLinks, file: session.path))
501 case "org.link.store": 504 case "org.link.store":
Sources/Orgstar/EditorView.swift +7 −1
@@ -98,7 +98,13 @@ struct EditorView: NSViewRepresentable {
98 editor.knownTags = knownTags 98 editor.knownTags = knownTags
99 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } 99 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
100 editor.onEffect = { [weak commands] effect in 100 editor.onEffect = { [weak commands] effect in
101 if case .storedLinkUsed(let link) = effect { commands?.storedLinks.removeAll { $0.link == link } } 101 switch effect {
102 case .storedLinkUsed(let link): commands?.storedLinks.removeAll { $0.link == link }
103 case .copy(let text):
104 NSPasteboard.general.clearContents()
105 NSPasteboard.general.setString(text, forType: .string)
106 case .message: break
107 }
102 } 108 }
103 let scroll = editor.makeScrollView() 109 let scroll = editor.makeScrollView()
104 scroll.autoresizingMask = [.width, .height] 110 scroll.autoresizingMask = [.width, .height]
Tests/OrgCoreTests/SubtreeCommandTests.swift added +137
@@ -0,0 +1,137 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct SubtreeCommandTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let text = """
9 Preamble
10 * Banana [#B] :fruit:
11 SCHEDULED: <2026-10-07 Wed>
12 :PROPERTIES:
13 :SIZE: 3
14 :END:
15 ** Child one
16 ** Child two
17 * TODO apple 10
18 DEADLINE: <2026-10-03 Sat>
19 :LOGBOOK:
20 CLOCK: [2026-10-01 Thu 09:00]--[2026-10-01 Thu 10:30] => 1:30
21 :END:
22 * DONE Cherry 2 [#A]
23 [2026-09-01 Tue]
24 * *Date* 33
25 :PROPERTIES:
26 :SIZE: 1
27 :END:
28 <2026-09-20 Sun>
29 ** NEXT inner
30
31 """
32
33 static func offsets(_ text: String) -> [Int] {
34 let ns = text as NSString
35 var result: [Int] = []
36 var at = 0
37 while at < ns.length {
38 let line = ns.lineRange(for: NSRange(location: at, length: 0))
39 result.append(line.location)
40 result.append(min(line.location + 2, NSMaxRange(line) - 1))
41 at = NSMaxRange(line)
42 }
43 return Array(Set(result)).sorted()
44 }
45
46 static func ours(_ command: any OrgCommand, _ text: String, _ caret: Int, answers: [String: String] = [:]) -> (text: String, caret: Int, copied: String?, failed: Bool) {
47 var utc = Calendar(identifier: .gregorian)
48 utc.timeZone = TimeZone(identifier: "UTC")!
49 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
50 now: Date(timeIntervalSince1970: 1_789_000_000), calendar: utc, answers: answers)
51 guard case .commit(let result) = command.run(in: context) else { return (text, caret, nil, true) }
52 var new = text
53 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
54 let copied = result.effects.compactMap { if case .copy(let s) = $0 { s } else { nil } }.first
55 return (new, result.selection?.first?.lowerBound ?? caret, copied, false)
56 }
57
58 @Test(.enabled(if: oracle))
59 func cutsAndCopiesLikeOrg() throws {
60 for cut in [true, false] {
61 let offsets = Self.offsets(Self.text).filter { $0 >= 9 }
62 let forms = offsets.map { offset in
63 "(progn (erase-buffer) (insert \(lispString(Self.text))) (goto-char \(EmacsOracle.point(offset, in: Self.text))) (setq kill-ring nil) (org-\(cut ? "cut" : "copy")-subtree) (format \"%s|%d|%s\" (buffer-string) (1- (point)) (car kill-ring)))"
64 }
65 let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))")
66 for (offset, expected) in zip(offsets, emacs) {
67 let mine = Self.ours(CopySubtree(cut: cut), Self.text, offset)
68 #expect("\(mine.text)|\(mine.caret)|\(mine.copied ?? "nil")" == expected, "cut \(cut) at \(offset)")
69 }
70 }
71 }
72
73 static let trees = ["* Pasted\nbody\n** Sub\n", "*** Deep\n**** Deeper", "\n** Two\n* One\n"]
74
75 @Test(.enabled(if: oracle))
76 func pastesLikeOrg() throws {
77 let offsets = Self.offsets(Self.text) + [(Self.text as NSString).length]
78 var forms: [String] = []
79 var ours: [String] = []
80 for tree in Self.trees {
81 for offset in offsets {
82 forms.append("(progn (erase-buffer) (insert \(lispString(Self.text))) (goto-char \(EmacsOracle.point(offset, in: Self.text))) (condition-case e (progn (org-paste-subtree nil \(lispString(tree))) (format \"%s|%d\" (buffer-string) (1- (point)))) (error \"error\")))")
83 let mine = Self.ours(PasteSubtree(text: tree), Self.text, offset)
84 ours.append(mine.failed ? "error" : "\(mine.text)|\(EmacsOracle.point(mine.caret, in: mine.text) - 1)")
85 }
86 }
87 let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))")
88 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
89 #expect(mine == theirs, "case \(i)\nours: \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
90 }
91 }
92
93 @Test(.enabled(if: oracle))
94 func clonesLikeOrg() throws {
95 let texts = [
96 "* Task\n:PROPERTIES:\n:ID: old\n:END:\nSCHEDULED: <2026-10-05 Mon>\n:LOGBOOK:\nCLOCK: [2026-10-01 Thu 09:00]--[2026-10-01 Thu 10:00] => 1:00\n:END:\n* Next\n",
97 "* Repeat\nSCHEDULED: <2026-10-05 Mon +1w>\n<2026-10-06 Tue 10:00>\n",
98 "* Plain\nbody\n",
99 ]
100 let runs: [(Int, String)] = [(2, "+1d"), (1, ""), (3, "-2w"), (0, "+1m")]
101 var forms: [String] = []
102 var ours: [String] = []
103 for text in texts {
104 for (count, shift) in runs {
105 forms.append("(progn (erase-buffer) (insert \(lispString(text))) (goto-char 3) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (org-clone-subtree-with-time-shift \(count) \(lispString(shift)))) (buffer-string))")
106 let answers = ["count": String(count), "shift": shift]
107 ours.append(Self.ours(CloneSubtree(newID: { "NEW-ID" }), text, 2, answers: answers).text)
108 }
109 }
110 let emacs = try EmacsOracle.evaluate("", frozenClock(1_789_000_000, "(list \(forms.joined(separator: " ")))"), prelude: "(require 'org-id) (require 'cl-lib)")
111 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
112 #expect(mine == theirs, "case \(i)\nours: \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
113 }
114 }
115
116 @Test(.enabled(if: oracle))
117 func sortsLikeOrgSortEntries() throws {
118 let keys = ["a", "n", "p", "o", "t", "s", "d", "c", "k", "r", "A", "N", "P", "O", "T", "R"]
119 let starts = [0, (Self.text as NSString).range(of: "* Banana").location, (Self.text as NSString).range(of: "* *Date*").location]
120 var forms: [String] = []
121 var ours: [String] = []
122 for start in starts {
123 for key in keys {
124 let property = key.lowercased() == "r" ? " nil nil \"SIZE\"" : ""
125 forms.append("(progn (erase-buffer) (insert \(lispString(Self.text))) (goto-char \(EmacsOracle.point(start, in: Self.text))) (condition-case e (progn (org-sort-entries nil ?\(key)\(property)) (format \"%s|%d\" (buffer-string) (1- (point)))) (error (error-message-string e))))")
126 var answers = ["sort": key]
127 if key.lowercased() == "r" { answers["property"] = "SIZE" }
128 let mine = Self.ours(SortEntries(), Self.text, start, answers: answers)
129 ours.append(mine.failed ? "failed" : "\(mine.text)|\(EmacsOracle.point(mine.caret, in: mine.text) - 1)")
130 }
131 }
132 let emacs = try EmacsOracle.evaluate("", frozenClock(1_789_000_000, "(list \(forms.joined(separator: " ")))"))
133 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
134 #expect(mine == theirs, "case \(i) key \(keys[i % keys.count])\nours: \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
135 }
136 }
137}