Archive tag toggle, archive sibling, datetree archive locations !91

merged merged by cmc on 2026-10-06 04:41 UTC · krz/orgstar:archive-more into main

9 files changed, +213 −18

Layout: unified · split

Sources/OrgCore/Commands/Archive.swift +44 −12
@@ -239,8 +239,24 @@ public enum Archive {
239 let step = commitBuffer(context) { source in 239 let step = commitBuffer(context) { source in
240 let target = archive ?? source 240 let target = archive ?? source
241 let level: Int 241 let level: Int
242 let headingText: String? 242 var headingText: String?
243 if let heading = destination.heading, let stars = heading.range(of: "^\\*+", options: .regularExpression) { 243 var heading = destination.heading
244 // `datetree/`: three levels of date headings, stars after it a heading below them.
245 var datetree: DateComponents?
246 var datetreeSubheading = false
247 if let h = heading, let m = h.firstMatch(of: /^datetree\/(\**)/) {
248 let nsub = m.1.count
249 heading = String(repeating: "*", count: 3 + nsub) + h[m.range.upperBound...]
250 datetreeSubheading = nsub > 0
251 let closed = source.saveExcursion { () -> String? in
252 _ = try? source.backToHeading()
253 source.forwardLine(1)
254 guard let line = source.lookingAt("^[ \\t]*(?:SCHEDULED|DEADLINE|CLOSED):.*$") else { return nil }
255 return source.substring(line[0]!.lowerBound, line[0]!.upperBound).firstMatch(of: /CLOSED: *\[([^\]]+)\]/).map { String($0.1) }
256 }
257 datetree = EmacsBuffer.parseTimeString(closed ?? time)
258 }
259 if let heading, let stars = heading.range(of: "^\\*+", options: .regularExpression) {
244 level = heading[stars].count 260 level = heading[stars].count
245 headingText = heading 261 headingText = heading
246 } else { 262 } else {
@@ -264,28 +280,44 @@ public enum Archive {
264 target.point = target.length 280 target.point = target.length
265 target.insert("\nArchived entries from file \(sourcePath)\n\n") 281 target.insert("\nArchived entries from file \(sourcePath)\n\n")
266 } 282 }
267 target.point = 0 283 // The date's entry, as `org-datetree-find-date-create` then `org-narrow-to-subtree`.
268 if let headingText { 284 let low = target.marker(0)
285 let high = target.marker(target.length, advances: true)
286 defer {
287 target.release(low)
288 target.release(high)
289 }
290 if let date = datetree {
291 let day = Datetree.findCreate(target, under: nil, tree: .day, date: date)
292 low.position = day
293 high.position = Datetree.narrowEnd(target, day, limit: target.length)
294 }
295 target.point = low.position
296 if let headingText, !(datetree != nil && !datetreeSubheading) {
269 let pattern = "^" + NSRegularExpression.escapedPattern(for: headingText) + "([ \\t]+:([[:alnum:]_@#%]+:)+)?[ \\t]*$" 297 let pattern = "^" + NSRegularExpression.escapedPattern(for: headingText) + "([ \\t]+:([[:alnum:]_@#%]+:)+)?[ \\t]*$"
270 if let match = target.searchForward(pattern) { 298 if let match = target.searchForward(pattern, bound: high.position) {
271 target.point = match[0]!.upperBound 299 target.point = match[0]!.upperBound
272 } else { 300 } else {
273 target.point = target.length 301 target.point = high.position
274 if !target.bolp { target.insert("\n") } 302 if !target.bolp { target.insert("\n") }
275 target.insert("\n" + headingText + "\n") 303 target.insert((datetree != nil ? "" : "\n") + headingText + "\n")
276 target.forwardLine(-1) 304 target.forwardLine(-1)
277 target.endOfLine() 305 target.endOfLine()
278 } 306 }
279 target.endOfSubtree() 307 target.endOfSubtree()
308 target.point = min(target.point, high.position)
280 target.skipBackward(EmacsBuffer.whitespace) 309 target.skipBackward(EmacsBuffer.whitespace)
310 target.point = max(target.point, low.position)
281 let blanks = target.looking("[ \\t\\r\\n]*")! 311 let blanks = target.looking("[ \\t\\r\\n]*")!
282 target.replace(blanks[0]!, with: "\n\n") 312 let clipped = blanks[0]!.lowerBound..<min(blanks[0]!.upperBound, high.position)
283 target.point = blanks[0]!.lowerBound + 2 313 let separator = datetree != nil ? "\n" : "\n\n"
314 target.replace(clipped, with: separator)
315 target.point = clipped.lowerBound + separator.count
284 } else { 316 } else {
285 target.point = target.length 317 target.point = high.position
286 target.insert("\n") 318 if !(datetree != nil && target.bolp) { target.insert("\n") }
287 } 319 }
288 try target.pasteSubtree(level: max(1, level + (headingText == nil ? 0 : 1)), tree: tree) 320 try target.pasteSubtree(level: max(1, level + (headingText == nil ? 0 : 1)), tree: tree, limit: datetree != nil ? high.position : nil)
289 if !inherited.isEmpty, destination.isSameFile { 321 if !inherited.isEmpty, destination.isSameFile {
290 let all = inherited + local 322 let all = inherited + local
291 target.saveExcursion { 323 target.saveExcursion {
Sources/OrgCore/Commands/ArchiveCommands.swift added +94
@@ -0,0 +1,94 @@
1import Foundation
2
3// Archiving in place (org-archive.el, Org 9.8.7): `org-toggle-archive-tag` and
4// `org-archive-to-archive-sibling` with `org-archive-sibling-heading` "Archive" and
5// `org-archive-reversed-order` nil.
6
7/// C-c C-x a: `org-toggle-archive-tag`. Setting the tag folds the subtree.
8public struct ToggleArchiveTag: OrgCommand {
9 public init() {}
10 public var id: String { "org.archive.toggle-tag" }
11 public var title: String { "Toggle ARCHIVE Tag" }
12 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
13 public func run(in context: EditContext) -> CommandStep {
14 guard let heading = entryHeading(at: context.caret, in: context.tree) else { return .failed("Before first headline") }
15 var tags = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)).split(separator: ":").map(String.init) } ?? []
16 let set = !tags.contains("ARCHIVE")
17 if set { tags.append("ARCHIVE") } else { tags.removeAll { $0 == "ARCHIVE" } }
18 guard case .commit(let result) = SetTags.set(tags, in: context) else { return SetTags.set(tags, in: context) }
19 let start = heading.range.lowerBound
20 var effects = result.effects + [.message("Subtree \(set ? "archived" : "unarchived")")]
21 var selection = [context.caret..<context.caret]
22 if set {
23 effects.insert(.fold(start), at: 0)
24 let line = (context.text as NSString).lineRange(for: NSRange(location: context.caret, length: 0)).location
25 selection = [line..<line]
26 }
27 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: selection, effects: effects))
28 }
29}
30
31/// C-c C-x A: `org-archive-to-archive-sibling`.
32public struct ArchiveToSibling: OrgCommand {
33 public init() {}
34 public var id: String { "org.archive.to-sibling" }
35 public var title: String { "Archive to Archive Sibling" }
36 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
37
38 public func run(in context: EditContext) -> CommandStep {
39 let formatter = DateFormatter()
40 formatter.locale = Locale(identifier: "en_US_POSIX")
41 formatter.calendar = context.calendar
42 formatter.timeZone = context.calendar.timeZone
43 formatter.dateFormat = "yyyy-MM-dd EEE HH:mm"
44 let time = formatter.string(from: context.now)
45 var sibling = 0
46 let step = commitBuffer(context) { buffer in
47 try buffer.backToHeading()
48 let level = buffer.outlineLevel
49 let leader = String(repeating: "*", count: level) + " "
50 let pos = buffer.marker(buffer.point, advances: true)
51 defer { buffer.release(pos) }
52 var end: Int?
53 if buffer.upHeadingSafe() == nil {
54 end = buffer.length
55 buffer.point = 0
56 }
57 let begin = buffer.point
58 let e = buffer.marker(end ?? buffer.saveExcursion { buffer.endOfSubtree(toHeading: true) })
59 defer { buffer.release(e) }
60 buffer.point = begin
61 let pattern = "^" + NSRegularExpression.escapedPattern(for: leader) + "[ \\t]*Archive[ \\t]*:ARCHIVE:"
62 if buffer.searchForward(pattern, bound: e.position, caseInsensitive: false) == nil {
63 buffer.point = e.position
64 if !buffer.bolp { buffer.insert("\n") }
65 buffer.insert(leader + "Archive\n")
66 buffer.forwardLine(-1)
67 buffer.endOfLine()
68 buffer.insert(" :ARCHIVE:")
69 buffer.alignTags(onLineAt: buffer.point)
70 }
71 buffer.beginningOfLine()
72 let archive = buffer.marker(buffer.point)
73 defer { buffer.release(archive) }
74 buffer.endOfSubtree(toHeading: true)
75 let target = buffer.marker(buffer.point)
76 defer { buffer.release(target) }
77 // `org-cut-subtree` at the entry, then `org-paste-subtree` one level below.
78 buffer.point = pos.position
79 let tree = try buffer.copySubtree()
80 let range = try buffer.subtreeRange()
81 buffer.deleteRegion(range.lowerBound, range.upperBound)
82 buffer.point = target.position
83 try buffer.pasteSubtree(level: level + 1, tree: tree)
84 buffer.entryPut("ARCHIVE_TIME", time)
85 buffer.point = archive.position
86 sibling = archive.position
87 buffer.updateParentTodoStatistics()
88 buffer.point = pos.position
89 if buffer.lookingAt("[ \\t]*$") != nil, let next = buffer.nextHeadingStart(after: buffer.point) { buffer.point = next }
90 }
91 guard case .commit(let result) = step else { return step }
92 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, effects: result.effects + [.fold(sibling)]))
93 }
94}
Sources/OrgCore/Commands/Command.swift +3 −1
@@ -170,6 +170,8 @@ public enum Effect: Sendable, Equatable {
170 /// Show a sparse tree: the context of each point, the rest folded, and highlights until 170 /// Show a sparse tree: the context of each point, the rest folded, and highlights until
171 /// the next edit or C-c C-c. 171 /// the next edit or C-c C-c.
172 case sparseTree(show: [Int], highlights: [Range<Int>]) 172 case sparseTree(show: [Int], highlights: [Range<Int>])
173 /// Fold the subtree of the heading starting here (in the new text).
174 case fold(Int)
173} 175}
174 176
175public struct EditResult: Sendable, Equatable { 177public struct EditResult: Sendable, Equatable {
@@ -241,7 +243,7 @@ public enum Commands {
241 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 243 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
242 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), 244 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
243 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), 245 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
244 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true), 246 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true), ToggleArchiveTag(), ArchiveToSibling(),
245 ] 247 ]
246 248
247 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } 249 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/Refile.swift +3 −3
@@ -32,13 +32,13 @@ extension EmacsBuffer {
32 } 32 }
33 } 33 }
34 34
35 /// `org-paste-subtree` with a numeric level. 35 /// `org-paste-subtree` with a numeric level; `limit` ends the buffer there, as narrowing does.
36 func pasteSubtree(level: Int, tree: String) throws { 36 func pasteSubtree(level: Int, tree: String, limit: Int? = nil) throws {
37 let oldLevel = tree.range(of: "(?m)^\\*+ ", options: .regularExpression).map { tree[$0].count - 1 } ?? -1 37 let oldLevel = tree.range(of: "(?m)^\\*+ ", options: .regularExpression).map { tree[$0].count - 1 } ?? -1
38 let shift = oldLevel == -1 || oldLevel == level ? 0 : level - oldLevel 38 let shift = oldLevel == -1 || oldLevel == level ? 0 : level - oldLevel
39 if !(bolp && atHeading) { 39 if !(bolp && atHeading) {
40 endOfLine() 40 endOfLine()
41 point = nextHeadingStart(after: point) ?? length 41 point = min(nextHeadingStart(after: point) ?? length, limit ?? length)
42 if !bolp { insert("\n") } 42 if !bolp { insert("\n") }
43 } 43 }
44 var begin = point 44 var begin = point
Sources/OrgCore/Keymap/Presets.swift +10
@@ -326,6 +326,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
326 command = "app.archive" 326 command = "app.archive"
327 mode = "normal" 327 mode = "normal"
328 [[bind]] 328 [[bind]]
329 keys = "SPC m s a"
330 command = "org.archive.toggle-tag"
331 mode = "normal"
332 [[bind]]
329 keys = "SPC m s h" 333 keys = "SPC m s h"
330 command = "org.subtree.promote" 334 command = "org.subtree.promote"
331 mode = "normal" 335 mode = "normal"
@@ -719,6 +723,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
719 keys = "C-c C-x i" 723 keys = "C-c C-x i"
720 command = "org.columns.insert-dblock" 724 command = "org.columns.insert-dblock"
721 [[bind]] 725 [[bind]]
726 keys = "C-c C-x a"
727 command = "org.archive.toggle-tag"
728 [[bind]]
729 keys = "C-c C-x A"
730 command = "org.archive.to-sibling"
731 [[bind]]
722 keys = "C-c C-x C-c" 732 keys = "C-c C-x C-c"
723 command = "org.columns" 733 command = "org.columns"
724 [[bind]] 734 [[bind]]
Sources/OrgEditorAppKit/OrgEditor.swift +4
@@ -460,6 +460,10 @@ public final class OrgEditor: NSObject {
460 switch effect { 460 switch effect {
461 case .message(let text): onMessage?(text) 461 case .message(let text): onMessage?(text)
462 case .sparseTree(let points, let highlights): showSparseTree(points, highlights: highlights) 462 case .sparseTree(let points, let highlights): showSparseTree(points, highlights: highlights)
463 case .fold(let start):
464 var state = foldState
465 state.folds.insert(start)
466 setFolds(state)
463 default: onEffect?(effect) 467 default: onEffect?(effect)
464 } 468 }
465 } 469 }
Sources/Orgstar/EditorView.swift +1 −1
@@ -103,7 +103,7 @@ struct EditorView: NSViewRepresentable {
103 case .copy(let text): 103 case .copy(let text):
104 NSPasteboard.general.clearContents() 104 NSPasteboard.general.clearContents()
105 NSPasteboard.general.setString(text, forType: .string) 105 NSPasteboard.general.setString(text, forType: .string)
106 case .message, .sparseTree: break 106 case .message, .sparseTree, .fold: break
107 } 107 }
108 } 108 }
109 let scroll = editor.makeScrollView() 109 let scroll = editor.makeScrollView()
Tests/OrgCoreTests/ArchiveCommandTests.swift added +49
@@ -0,0 +1,49 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ArchiveCommandTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 @Test(.enabled(if: oracle))
9 func archivesInPlaceLikeOrg() throws {
10 let texts = [
11 "* P [0/2]\n** TODO a :x:\nbody\n** TODO b\n* Q\n",
12 "* P\n** a\n** Archive :ARCHIVE:\n*** old\n** b\n",
13 "* top\n* other :ARCHIVE:\n",
14 "* P\n** a\n\n** b\n",
15 ]
16 var cases: [EmacsOracle.Case] = []
17 var ours: [(String, Int, String?)] = []
18 for text in texts {
19 let ns = text as NSString
20 var starts: [Int] = []
21 var offset = 0
22 while offset < ns.length {
23 if ns.character(at: offset) == 42 { starts.append(offset) }
24 offset = NSMaxRange(ns.lineRange(for: NSRange(location: offset, length: 0)))
25 }
26 for start in starts {
27 for (command, form) in [(ToggleArchiveTag() as any OrgCommand, "(org-toggle-archive-tag)"), (ArchiveToSibling(), "(org-archive-to-archive-sibling)")] {
28 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(start + 1, in: text), form: frozenClock(1_789_000_000, form)))
29 var utc = Calendar(identifier: .gregorian)
30 utc.timeZone = TimeZone(identifier: "UTC")!
31 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [(start + 1)..<(start + 1)],
32 now: Date(timeIntervalSince1970: 1_789_000_000), calendar: utc)
33 switch command.run(in: context) {
34 case .commit(let result):
35 let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
36 ours.append((new, result.selection?.first?.lowerBound ?? start + 1, nil))
37 case .failed(let message): ours.append((text, start + 1, message))
38 default: ours.append((text, start + 1, "other"))
39 }
40 }
41 }
42 }
43 for (i, theirs) in try EmacsOracle.run(cases).enumerated() {
44 let caret = EmacsOracle.offset(theirs.point, in: theirs.text)
45 #expect(ours[i].0 == theirs.text && ours[i].1 == caret && (ours[i].2 != nil) == !theirs.error.isEmpty,
46 "\(cases[i].form) on \(cases[i].text.debugDescription) @\(cases[i].point)\nours: \(ours[i].0.debugDescription) @\(ours[i].1) \(ours[i].2 ?? "")\nemacs: \(theirs.text.debugDescription) @\(caret) \(theirs.error)")
47 }
48 }
49}
Tests/OrgCoreTests/ArchiveTests.swift +5 −1
@@ -43,8 +43,12 @@ struct ArchiveTests {
43 "#+ARCHIVE: ::* Archive\n* a :x:\n* Archive\n** old\n", 43 "#+ARCHIVE: ::* Archive\n* a :x:\n* Archive\n** old\n",
44 "* P\n:PROPERTIES:\n:ARCHIVE: ::* Old :t:\n:CATEGORY: c\n:END:\n** a\n* Old :t:\n", 44 "* P\n:PROPERTIES:\n:ARCHIVE: ::* Old :t:\n:CATEGORY: c\n:END:\n** a\n* Old :t:\n",
45 "* a\nSCHEDULED: <2026-01-01 Thu>\n:PROPERTIES:\n:ID: 1\n:END:\n* b", 45 "* a\nSCHEDULED: <2026-01-01 Thu>\n:PROPERTIES:\n:ID: 1\n:END:\n* b",
46 "#+ARCHIVE: ::datetree/\n* a\nbody\n* b\n",
47 "#+ARCHIVE: %s_archive::datetree/\n* DONE a\nCLOSED: [2026-08-15 Sat 09:00]\n* b\n",
48 "#+ARCHIVE: ::datetree/* Notes\n* a\n** sub\n* b\n",
46 ] 49 ]
47 static let archives: [String?] = [nil, "", "* existing\n", "\n#+TITLE: arch\n\n* x\n:PROPERTIES:\n:ARCHIVE_TIME: t\n:END:\n\n"] 50 static let archives: [String?] = [nil, "", "* existing\n", "\n#+TITLE: arch\n\n* x\n:PROPERTIES:\n:ARCHIVE_TIME: t\n:END:\n\n",
51 "* 2026\n** 2026-09 September\n*** 2026-09-10 Thursday\n**** prior\n*** 2026-09-12 Saturday\n"]
48 52
49 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 53 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
50 func archiveMatchesEmacs() throws { 54 func archiveMatchesEmacs() throws {