krz/orgstar

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

Commit 2e692ba274

2e692ba274ccc7e69b6b3cfc00edeafbbeef638a

parent: 1262bf4eab

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 03:13 UTC

Cut, copy, paste, clone and sort subtrees

- C-c C-x C-w / M-w (SPC m s d): org-cut-subtree, org-copy-subtree; the
  subtree goes to the clipboard (Effect.copy).
- C-c C-x C-y: org-paste-subtree with the clipboard, its levels fitted
  between the headings around the caret, or to an empty heading's stars.
- C-c C-x c (SPC m s c): org-clone-subtree-with-time-shift: clones get
  new IDs and no clock lines; a repeating entry follows its clones with
  its dates moved past them.
- C-c ^ (SPC m s S): org-sort-entries by alpha, numeric, priority,
  property, TODO order, time, scheduled, deadline, created or clocked
  time, capitals reversed. Tables and lists say they aren't sortable yet.

Oracle tests against Org 9.8.7.

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +3
@@ -145,6 +145,8 @@ public enum Effect: Sendable, Equatable {
145145 case message(String)
146146 /// An inserted link was a stored one; it leaves `org-stored-links`.
147147 case storedLinkUsed(String)
148 /// Text for the clipboard (the kill ring).
149 case copy(String)
148150}
149151
150152public struct EditResult: Sendable, Equatable {
@@ -211,6 +213,7 @@ public enum Commands {
211213 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
212214 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
213215 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
216 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(),
214217 ]
215218
216219 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 {
190190
191191 # Localleader (SPC m)
192192 [[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]]
193205 keys = "SPC m h"
194206 command = "org.heading.toggle"
195207 mode = "normal"
@@ -600,6 +612,21 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
600612 keys = "C-c @"
601613 command = "org.subtree.mark"
602614 [[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]]
603630 keys = "C-x b"
604631 command = "app.buffer.switch"
605632 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -40,6 +40,7 @@ public enum EditorCommands {
4040 EditorCommand(id: "org.link.insert", title: "Insert Link…", action: .app),
4141 EditorCommand(id: "org.link.store", title: "Store Link", action: .app),
4242 EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app),
43 EditorCommand(id: "org.subtree.paste", title: "Paste Subtree", action: .app),
4344
4445 EditorCommand(id: "app.save", title: "Save", action: .app),
4546 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
Sources/Orgstar/ContentView.swift +3
@@ -496,6 +496,9 @@ struct ContentView: View {
496496 }
497497 case "app.archive":
498498 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))
499502 case "org.link.insert":
500503 commands.editor?.perform(InsertLink(stored: commands.storedLinks, file: session.path))
501504 case "org.link.store":
Sources/Orgstar/EditorView.swift +7 −1
@@ -98,7 +98,13 @@ struct EditorView: NSViewRepresentable {
9898 editor.knownTags = knownTags
9999 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
100100 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 }
102108 }
103109 let scroll = editor.makeScrollView()
104110 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}