Refile and archive !15

merged merged by cmc on 2026-10-05 04:50 UTC · krz/orgstar:phase2-refile into main

23 files changed, +1207 −7

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +99
@@ -5,6 +5,36 @@ import OrgDocument
5import OrgIndex 5import OrgIndex
6import OrgPresentation 6import OrgPresentation
7 7
8/// A heading, or a whole file, a subtree can be refiled to.
9public struct RefileTarget: Sendable, Hashable {
10 /// `file.org/Parent/Heading`, as the prompt lists it.
11 public let label: String
12 public let path: String
13 /// Start of the heading when the file had `contentHash`; nil refiles to the end of the file.
14 public let start: Int?
15 public let title: String
16 public let level: Int
17 public let contentHash: String
18
19 public init(label: String, path: String, start: Int?, title: String, level: Int, contentHash: String) {
20 self.label = label
21 self.path = path
22 self.start = start
23 self.title = title
24 self.level = level
25 self.contentHash = contentHash
26 }
27
28 /// The heading's start in `state`: its indexed offset if the file hasn't changed, else the
29 /// first heading with the same title and level. Nil for the end of the file.
30 func locate(in state: DocumentState) -> Int?? {
31 guard let start else { return .some(nil) }
32 if FileRecord.hash(state.mergeBase) == contentHash { return .some(start) }
33 let entry = Presentation.outline(state.tree).first { $0.title == title && $0.level == level }
34 return entry.map { .some($0.start) }
35 }
36}
37
8public enum SaveMode: String, Sendable, CaseIterable { 38public enum SaveMode: String, Sendable, CaseIterable {
9 /// Save shortly after typing stops. 39 /// Save shortly after typing stops.
10 case automatic 40 case automatic
@@ -161,6 +191,75 @@ public final class DocumentSession {
161 refresh() 191 refresh()
162 } 192 }
163 193
194 // MARK: - Moving subtrees
195
196 /// The current context of the buffer, as commands see it.
197 private func editContext(now: Date) -> EditContext? {
198 guard let buffer else { return nil }
199 let document = buffer.document
200 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now)
201 }
202
203 /// Moves the subtree at the caret under `target`. For another file, that file is written
204 /// first, through the save path; the subtree leaves this buffer only once it has. Returns a
205 /// message for the echo area.
206 @discardableResult
207 public func refile(to target: RefileTarget, now: Date = Date()) -> String? {
208 guard let buffer, let path, let context = editContext(now: now) else { return nil }
209 if target.path == path {
210 let step = buffer.perform(RefileWithinFile(), now: now, answers: ["refile": target.start.map(String.init) ?? ""])
211 if case .failed(let message) = step { return message }
212 bufferChanged()
213 return "Refiled to \(target.label)"
214 }
215 let url = URL(fileURLWithPath: target.path)
216 do {
217 guard let bytes = try fileSystem.read(url) else { return "\((target.path as NSString).lastPathComponent) no longer exists." }
218 var state = DocumentState(bytes: bytes)
219 guard state.isEditable else { return "\((target.path as NSString).lastPathComponent) isn't UTF-8, so it can't be changed." }
220 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
221 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
222 try state.apply([result.target], baseRevision: state.revision)
223 if case .conflict = try saver.save(&state, to: url) { return "\((target.path as NSString).lastPathComponent) changed on disk; nothing was refiled." }
224 if case .failed(let message) = buffer.perform(PrecomputedEdit(result.source), now: now, answers: [:]) { return message }
225 bufferChanged()
226 return "Refiled to \(target.label)"
227 } catch {
228 return "Can't refile: \(error.localizedDescription)"
229 }
230 }
231
232 /// Archives the subtree at the caret where `org-archive-subtree` would. Returns a message.
233 @discardableResult
234 public func archive(now: Date = Date()) -> String? {
235 guard let buffer, let path, let context = editContext(now: now) else { return nil }
236 let destination: Archive.Destination
237 switch Archive.destination(context, sourcePath: path) {
238 case .success(let value): destination = value
239 case .failure(.message(let message)): return message
240 }
241 do {
242 let url = URL(fileURLWithPath: destination.path)
243 let existing = destination.isSameFile ? nil : try fileSystem.read(url)
244 var state = DocumentState(bytes: existing ?? [])
245 if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." }
246 let edits: Archive.Edits
247 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) {
248 case .success(let value): edits = value
249 case .failure(.message(let message)): return message
250 }
251 if let archiveEdit = edits.archive {
252 try state.apply([archiveEdit], baseRevision: state.revision)
253 if case .conflict = try saver.save(&state, to: url) { return "\(url.lastPathComponent) changed on disk; nothing was archived." }
254 }
255 if case .failed(let message) = buffer.perform(PrecomputedEdit(edits.source), now: now, answers: [:]) { return message }
256 bufferChanged()
257 return destination.isSameFile ? "Archived under \(destination.heading ?? "the end of the file")" : "Archived to \(url.lastPathComponent)"
258 } catch {
259 return "Can't archive: \(error.localizedDescription)"
260 }
261 }
262
164 // MARK: - Locations 263 // MARK: - Locations
165 264
166 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets 265 /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets
Sources/OrgApp/WorkspaceModel.swift +31
@@ -1,6 +1,7 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3import OrgDocument 3import OrgDocument
4import OrgPresentation
4import OrgIndex 5import OrgIndex
5import OrgWorkspace 6import OrgWorkspace
6 7
@@ -208,6 +209,36 @@ public final class WorkspaceModel {
208 return [path: record] 209 return [path: record]
209 } 210 }
210 211
212 /// Headings down to level 3 in every org file, and each file itself. The open file's come
213 /// from its buffer, so they match unsaved edits.
214 public func refileTargets(openPath: String?, document: DocumentState?) -> [RefileTarget] {
215 let indexed = (try? index.outline(maxLevel: 3)) ?? []
216 var targets: [RefileTarget] = []
217 for file in files where file.kind == .org {
218 let name = file.relativePath
219 if file.path == openPath, let document {
220 let hash = FileRecord.hash(document.mergeBase)
221 targets.append(RefileTarget(label: name, path: file.path, start: nil, title: "", level: 0, contentHash: hash))
222 var stack: [String] = []
223 for entry in Presentation.outline(document.tree) {
224 stack = Array(stack.prefix(entry.level - 1)) + [entry.title]
225 guard entry.level <= 3 else { continue }
226 targets.append(RefileTarget(label: name + "/" + stack.joined(separator: "/"), path: file.path, start: entry.start, title: entry.title, level: entry.level, contentHash: hash))
227 }
228 continue
229 }
230 let headings = indexed.filter { $0.location.path == file.path }
231 targets.append(RefileTarget(label: name, path: file.path, start: nil, title: "", level: 0, contentHash: headings.first?.location.contentHash ?? ""))
232 for heading in headings {
233 targets.append(RefileTarget(
234 label: name + "/" + heading.outlinePath.joined(separator: "/"), path: file.path, start: heading.location.start,
235 title: heading.location.title, level: heading.level, contentHash: heading.location.contentHash
236 ))
237 }
238 }
239 return targets
240 }
241
211 public func quickOpen(_ query: String) -> [FileEntry] { 242 public func quickOpen(_ query: String) -> [FileEntry] {
212 FuzzyMatch.rank(query, files.filter { $0.kind != .conflict }) 243 FuzzyMatch.rank(query, files.filter { $0.kind != .conflict })
213 } 244 }
Sources/OrgCore/Commands/Archive.swift added +332
@@ -0,0 +1,332 @@
1import Foundation
2
3// Archiving (`org-archive-subtree`, Org 9.8.7) with the defaults: location "%s_archive::",
4// context info time, file, olpath, category, todo and itags, inherited tags added only within
5// the same file, no marking DONE, the archive file's header for a new file.
6
7extension EmacsBuffer {
8 /// The first value of `#+NAME:` in the buffer, as `org-collect-keywords` with UNIQUE.
9 func keyword(_ name: String) -> String? {
10 saveExcursion {
11 point = 0
12 guard let match = searchForward("^[ \\t]*#\\+" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]*(.*?)[ \\t]*$") else { return nil }
13 return substring(match[1]!.lowerBound, match[1]!.upperBound)
14 }
15 }
16
17 /// `org-back-to-heading-or-point-min`.
18 func backToHeadingOrPointMin() {
19 if beforeFirstHeading { point = 0 } else { _ = try? backToHeading() }
20 }
21
22 static let propertyDrawerPattern = "^[ \\t]*:PROPERTIES:[ \\t]*\\n(?:[ \\t]*:\\S+:(?:[ \\t].*)?[ \\t]*\\n)*?[ \\t]*:END:[ \\t]*$"
23
24 /// `org-insert-property-drawer`.
25 func insertPropertyDrawer() {
26 saveExcursion {
27 backToHeadingOrPointMin()
28 if beforeFirstHeading {
29 while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
30 } else {
31 forwardLine(1)
32 if lookingAt(planningPattern) != nil { forwardLine(1) }
33 }
34 guard looking(Self.propertyDrawerPattern, caseInsensitive: true, setsMatch: false) == nil else { return }
35 if bolp, point > 0 { point -= 1 }
36 let begin = bobp ? point : point + 1
37 if !bobp { insert("\n") }
38 insert(":PROPERTIES:\n:END:")
39 if eobp || begin == 0 { insert("\n") }
40 }
41 }
42
43 /// `org-get-property-block` with FORCE: the body range of the entry's property drawer.
44 func propertyBlock(_ begin: Int) -> Range<Int> {
45 saveExcursion {
46 point = begin
47 if beforeFirstHeading {
48 while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
49 } else {
50 forwardLine(1)
51 if lookingAt(planningPattern) != nil { forwardLine(1) }
52 }
53 if let match = looking(Self.propertyDrawerPattern) {
54 forwardLine(1)
55 return point..<lineStart(match[0]!.upperBound)
56 }
57 point = begin
58 insertPropertyDrawer()
59 let match = searchForward(Self.propertyDrawerPattern)!
60 let at = lineStart(match[0]!.upperBound)
61 return at..<at
62 }
63 }
64
65 /// `org-entry-put` for an ordinary property, with `org-property-format` "%-10s %s".
66 func entryPut(_ name: String, _ value: String) {
67 saveExcursion {
68 backToHeadingOrPointMin()
69 let range = propertyBlock(point)
70 point = range.lowerBound
71 let end = range.upperBound
72 if let match = searchForward("^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + ":(?:[ \\t]+.*?)?[ \\t]*$", bound: end) {
73 deleteRegion(match[0]!.lowerBound, match[0]!.upperBound)
74 point = match[0]!.lowerBound
75 } else {
76 point = end
77 insert("\n")
78 point -= 1
79 }
80 insert(":" + name + ":")
81 insert(" " + value)
82 alignNodeProperty()
83 }
84 }
85
86 /// `org--align-node-property`, with the indentation `org-indent-line` gives property lines
87 /// when `org-adapt-indentation` is nil.
88 func alignNodeProperty() {
89 let start = lineStart(point)
90 let line = substring(start, lineEnd(point))
91 guard let match = line.range(of: "^([ \\t]*)(:\\S+:)(?:[ \\t]+(.*?))?[ \\t]*$", options: .regularExpression) else { return }
92 let parts = line[match]
93 let regex = try! NSRegularExpression(pattern: "^([ \\t]*)(:\\S+:)(?:[ \\t]+(.*?))?[ \\t]*$")
94 let groups = regex.firstMatch(in: String(parts), range: NSRange(location: 0, length: (String(parts) as NSString).length))!
95 let ns = String(parts) as NSString
96 let key = ns.substring(with: groups.range(at: 2))
97 let value = groups.range(at: 3).location == NSNotFound ? "" : ns.substring(with: groups.range(at: 3))
98 let padded = key.padding(toLength: max(10, (key as NSString).length), withPad: " ", startingAt: 0)
99 let new = (padded + " " + value).trimmingCharacters(in: .whitespaces)
100 let end = lineEnd(point)
101 let lineStartOffset = start
102 point = lineStartOffset
103 deleteRegion(lineStartOffset, end)
104 insert(new)
105 }
106
107 /// The entry's tags, and its inherited tags (file tags and ancestors' tags), in org's order.
108 func tags() -> (local: [String], inherited: [String]) {
109 saveExcursion {
110 guard (try? backToHeading()) != nil else { return ([], []) }
111 func lineTags(_ at: Int) -> [String] {
112 saveExcursion {
113 point = at
114 guard let match = lookingAt(SetTags.tagLine), let tags = match[2] else { return [] }
115 return substring(tags.lowerBound, tags.upperBound).split(separator: ":").map(String.init)
116 }
117 }
118 let local = lineTags(point)
119 var ancestors: [[String]] = []
120 while upHeadingSafe() != nil { ancestors.insert(lineTags(point), at: 0) }
121 var inherited: [String] = []
122 let fileTags = (keyword("FILETAGS") ?? "").split(whereSeparator: { $0 == ":" || $0 == " " || $0 == "\t" }).map(String.init)
123 for tag in fileTags + ancestors.flatMap({ $0 }) where !inherited.contains(tag) && !local.contains(tag) { inherited.append(tag) }
124 return (local, inherited)
125 }
126 }
127
128 /// `org-get-outline-path`: ancestors' titles without statistics cookies, links shown as
129 /// their descriptions.
130 func outlinePath() -> [String] {
131 saveExcursion {
132 guard (try? backToHeading()) != nil else { return [] }
133 var path: [String] = []
134 while upHeadingSafe() != nil {
135 let title = saveExcursion { () -> String in
136 guard let match = lookingAt(complexHeadingPattern), let title = match[4] else { return "" }
137 var text = substring(title.lowerBound, title.upperBound)
138 text = text.replacingOccurrences(of: "\\[[0-9]+%\\]|\\[[0-9]+/[0-9]+\\]", with: "", options: .regularExpression)
139 text = text.replacingOccurrences(of: "\\[\\[([^\\]]*)\\]\\[([^\\]]*)\\]\\]", with: "$2", options: .regularExpression)
140 text = text.replacingOccurrences(of: "\\[\\[([^\\]]*)\\]\\]", with: "$1", options: .regularExpression)
141 return text.trimmingCharacters(in: .whitespacesAndNewlines)
142 }
143 path.insert(title, at: 0)
144 }
145 return path
146 }
147 }
148
149 /// `org-update-statistics-cookies` with point on a heading.
150 func updateStatisticsCookies() {
151 guard atHeading else {
152 updateCheckboxCount()
153 return
154 }
155 saveExcursion {
156 let start = try! backToHeading()
157 let level = outlineLevel
158 var childLevel: Int?
159 let end = saveExcursion { () -> Int in
160 if outlineNextHeading() { childLevel = outlineLevel }
161 return point
162 }
163 let hasBoxes = saveExcursion { searchForward("^[ \\t]*([-+*]|[0-9]+[.)]) \\[[- X]\\]", bound: end) != nil }
164 let todoData = saveExcursion { searchForward(":COOKIE_DATA:.*\\btodo\\b", bound: end) != nil }
165 if hasBoxes, !todoData {
166 updateCheckboxCount()
167 } else if let childLevel, childLevel > level {
168 point = end
169 updateParentTodoStatistics()
170 } else {
171 point = start
172 while let match = searchForward("((\\[[0-9]*%\\])|(\\[[0-9]*/[0-9]*\\]))", bound: lineEnd(point)) {
173 replace(match[1]!, with: match[2] != nil ? "[100%]" : "[0/0]")
174 }
175 }
176 }
177 }
178}
179
180public enum Archive {
181 /// Where the entry at the caret goes: the archive file (the source itself for an in-file
182 /// location) and the heading to file it under, if any.
183 public struct Destination: Sendable, Equatable {
184 public let path: String
185 public let heading: String?
186 public var isSameFile: Bool
187 }
188
189 public static func destination(_ context: EditContext, sourcePath: String) -> Result<Destination, ArchiveError> {
190 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
191 guard !buffer.beforeFirstHeading else { return .failure(.message("Before first headline")) }
192 let spec = buffer.inheritedProperty("ARCHIVE")?.value ?? buffer.keyword("ARCHIVE") ?? "%s_archive::"
193 guard let separator = spec.range(of: "::") else { return .failure(.message("Invalid archive location: \"\(spec)\"")) }
194 let name = (sourcePath as NSString).lastPathComponent
195 let fileFormat = String(spec[..<separator.lowerBound])
196 let headingFormat = String(spec[separator.upperBound...])
197 let path: String
198 if fileFormat.contains(where: { !$0.isWhitespace }) {
199 let file = fileFormat.replacingOccurrences(of: "%s", with: name)
200 let directory = (sourcePath as NSString).deletingLastPathComponent
201 path = ((file as NSString).isAbsolutePath ? file : (directory as NSString).appendingPathComponent(file)).replacingOccurrences(of: "~", with: NSHomeDirectory(), options: .anchored)
202 } else {
203 path = sourcePath
204 }
205 let heading = headingFormat.replacingOccurrences(of: "%s", with: name)
206 return .success(Destination(path: (path as NSString).standardizingPath, heading: heading.isEmpty ? nil : heading, isSameFile: path == sourcePath))
207 }
208
209 public enum ArchiveError: Error, Equatable {
210 case message(String)
211 }
212
213 public struct Edits: Sendable, Equatable {
214 /// For the source; for an in-file archive, everything happens here.
215 public let source: EditResult
216 /// For the archive file, against its text (empty when it doesn't exist yet).
217 public let archive: TextEdit?
218 }
219
220 /// `~/` for paths in the home folder, as `abbreviate-file-name`.
221 static func abbreviate(_ path: String) -> String {
222 let home = NSHomeDirectory()
223 return path.hasPrefix(home + "/") ? "~" + path.dropFirst(home.count) : path
224 }
225
226 /// Archives the subtree at the caret. `archiveText` is the archive file's text, or nil
227 /// when it doesn't exist; it's ignored for an in-file destination.
228 public static func run(
229 _ context: EditContext, sourcePath: String, destination: Destination, archiveText: String?
230 ) -> Result<Edits, ArchiveError> {
231 let formatter = DateFormatter()
232 formatter.locale = Locale(identifier: "en_US_POSIX")
233 formatter.calendar = context.calendar
234 formatter.timeZone = context.calendar.timeZone
235 formatter.dateFormat = "yyyy-MM-dd EEE HH:mm"
236 let time = formatter.string(from: context.now)
237 let archive = destination.isSameFile ? nil : EmacsBuffer(archiveText ?? "", point: 0, settings: context.tree.settings)
238 var archiveEdit: TextEdit?
239 let step = commitBuffer(context) { source in
240 let target = archive ?? source
241 let level: Int
242 let headingText: String?
243 if let heading = destination.heading, let stars = heading.range(of: "^\\*+", options: .regularExpression) {
244 level = heading[stars].count
245 headingText = heading
246 } else {
247 level = 0
248 headingText = nil
249 }
250 let origin = source.marker(source.point)
251 defer { source.release(origin) }
252 try source.backToHeading()
253 let (local, inherited) = source.tags()
254 let category = source.inheritedProperty("CATEGORY")?.value ?? source.keyword("CATEGORY")
255 ?? ((sourcePath as NSString).lastPathComponent as NSString).deletingPathExtension
256 let todo = source.lookingAt(source.todoLinePattern)?[2].map { source.substring($0.lowerBound, $0.upperBound) }
257 let context: [(String, String)] = [
258 ("TIME", time), ("FILE", abbreviate(sourcePath)), ("OLPATH", source.outlinePath().joined(separator: "/")),
259 ("CATEGORY", category), ("TODO", todo ?? ""), ("ITAGS", inherited.joined(separator: " ")),
260 ]
261 let tree = try source.copySubtree()
262 try target.saveExcursion {
263 if !destination.isSameFile, archiveText == nil {
264 target.point = target.length
265 target.insert("\nArchived entries from file \(sourcePath)\n\n")
266 }
267 target.point = 0
268 if let headingText {
269 let pattern = "^" + NSRegularExpression.escapedPattern(for: headingText) + "([ \\t]+:([[:alnum:]_@#%]+:)+)?[ \\t]*$"
270 if let match = target.searchForward(pattern) {
271 target.point = match[0]!.upperBound
272 } else {
273 target.point = target.length
274 if !target.bolp { target.insert("\n") }
275 target.insert("\n" + headingText + "\n")
276 target.forwardLine(-1)
277 target.endOfLine()
278 }
279 target.endOfSubtree()
280 target.skipBackward(EmacsBuffer.whitespace)
281 let blanks = target.looking("[ \\t\\r\\n]*")!
282 target.replace(blanks[0]!, with: "\n\n")
283 target.point = blanks[0]!.lowerBound + 2
284 } else {
285 target.point = target.length
286 target.insert("\n")
287 }
288 try target.pasteSubtree(level: max(1, level + (headingText == nil ? 0 : 1)), tree: tree)
289 if !inherited.isEmpty, destination.isSameFile {
290 let all = inherited + local
291 target.saveExcursion {
292 let line = target.saveExcursion { target.lookingAt(SetTags.tagLine) }
293 if let tags = line?[1] {
294 target.point = tags.lowerBound
295 target.skipBackward(EmacsBuffer.blanks)
296 target.deleteRegion(target.point, target.lineEnd(target.point))
297 } else {
298 target.endOfLine()
299 }
300 target.saveExcursion { target.insert(" :" + all.joined(separator: ":") + ":") }
301 }
302 target.alignTags(onLineAt: target.point)
303 }
304 for (key, value) in context where value.contains(where: { !$0.isWhitespace }) {
305 target.entryPut("ARCHIVE_" + key, value)
306 }
307 }
308 source.point = origin.position
309 let range = try source.subtreeRange()
310 source.deleteRegion(range.lowerBound, range.upperBound)
311 source.saveExcursion {
312 source.upHeadingSafe()
313 source.updateStatisticsCookies()
314 }
315 if source.looking("^[ \\t]*$", setsMatch: false) != nil {
316 source.endOfLine()
317 source.point = source.nextHeadingStart(after: source.point) ?? source.length
318 }
319 }
320 if let archive {
321 let original = archiveText ?? ""
322 if case .commit(let result) = commitBuffer(EditContext(revision: 0, text: original, tree: OrgParser.parse(original), selection: [0..<0]), { $0.replace(0..<$0.length, with: archive.string) }) {
323 archiveEdit = result.edits.first
324 }
325 }
326 switch step {
327 case .commit(let result): return .success(Edits(source: result, archive: archiveEdit))
328 case .failed(let message): return .failure(.message(message))
329 case .prompt: return .failure(.message("unexpected prompt"))
330 }
331 }
332}
Sources/OrgCore/Commands/Command.swift +17 −1
@@ -40,11 +40,27 @@ public struct Prompt: Sendable, Equatable {
40 public let message: String 40 public let message: String
41 /// Text the answer field starts with. 41 /// Text the answer field starts with.
42 public let initial: String 42 public let initial: String
43 /// When set, the answer is one of these.
44 public let choices: [String]
43 45
44 public init(key: String, message: String, initial: String = "") { 46 public init(key: String, message: String, initial: String = "", choices: [String] = []) {
45 self.key = key 47 self.key = key
46 self.message = message 48 self.message = message
47 self.initial = initial 49 self.initial = initial
50 self.choices = choices
51 }
52}
53
54/// Edits computed elsewhere (refiling to another file, archiving), applied like any command's
55/// result. Fails if the document changed since they were computed.
56public struct PrecomputedEdit: OrgCommand {
57 public let result: EditResult
58 public init(_ result: EditResult) { self.result = result }
59 public var id: String { "internal.precomputed" }
60 public var title: String { "Apply Edit" }
61 public func applies(in context: EditContext) -> Bool { context.revision == result.baseRevision }
62 public func run(in context: EditContext) -> CommandStep {
63 context.revision == result.baseRevision ? .commit(result) : .failed("The file changed; try again.")
48 } 64 }
49} 65}
50 66
Sources/OrgCore/Commands/EmacsBuffer.swift +9 −4
@@ -9,7 +9,12 @@ import Foundation
9final class EmacsBuffer { 9final class EmacsBuffer {
10 final class Marker { 10 final class Marker {
11 var position: Int 11 var position: Int
12 init(_ position: Int) { self.position = position } 12 /// Inserting at the marker moves it past the text, as `set-marker-insertion-type` t.
13 let advances: Bool
14 init(_ position: Int, advances: Bool = false) {
15 self.position = position
16 self.advances = advances
17 }
13 } 18 }
14 19
15 struct UserError: Error { 20 struct UserError: Error {
@@ -61,8 +66,8 @@ final class EmacsBuffer {
61 66
62 // MARK: - Markers 67 // MARK: - Markers
63 68
64 func marker(_ position: Int) -> Marker { 69 func marker(_ position: Int, advances: Bool = false) -> Marker {
65 let marker = Marker(position) 70 let marker = Marker(position, advances: advances)
66 markers.append(marker) 71 markers.append(marker)
67 return marker 72 return marker
68 } 73 }
@@ -91,7 +96,7 @@ final class EmacsBuffer {
91 touched(point..<point) 96 touched(point..<point)
92 text.insert(string, at: point) 97 text.insert(string, at: point)
93 cachedString = nil 98 cachedString = nil
94 for marker in markers where marker.position > point { marker.position += count } 99 for marker in markers where marker.position > point || (marker.advances && marker.position == point) { marker.position += count }
95 point += count 100 point += count
96 } 101 }
97 102
Sources/OrgCore/Commands/HeadingCommands.swift +5
@@ -220,7 +220,12 @@ public struct TodoCycle: OrgCommand {
220 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are 220 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are
221 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags 221 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags
222 /// are aligned. 222 /// are aligned.
223 /// `org-todo`, then the parent's TODO statistics.
223 public func run(in context: EditContext) -> CommandStep { 224 public func run(in context: EditContext) -> CommandStep {
225 followUp(context, cycle(in: context)) { $0.updateParentTodoStatistics() }
226 }
227
228 private func cycle(in context: EditContext) -> CommandStep {
224 guard let heading = entryHeading(at: context.caret, in: context.tree) else { 229 guard let heading = entryHeading(at: context.caret, in: context.tree) else {
225 return .failed("Before first headline") 230 return .failed("Before first headline")
226 } 231 }
Sources/OrgCore/Commands/Refile.swift added +144
@@ -0,0 +1,144 @@
1import Foundation
2
3// Refiling, ported from org-refile.el and org.el (Org 9.8.7) with `org-log-refile` nil,
4// `org-reverse-note-order` nil and `org-refile-keep` nil.
5
6extension EmacsBuffer {
7 /// `org-copy-subtree` called from Lisp: the subtree at point, with the blank lines after it.
8 func copySubtree() throws -> String {
9 try saveExcursion {
10 let begin = try backToHeading()
11 skipForward(Self.whitespace)
12 endOfSubtree(toHeading: true)
13 return point > begin ? substring(begin, point) : ""
14 }
15 }
16
17 /// `org-map-region`.
18 func mapRegion(_ begin: Int, _ end: Int, _ body: () throws -> Void) rethrows {
19 try saveExcursion {
20 let end = marker(end)
21 defer { release(end) }
22 point = begin
23 if let heading = searchHeadingForward(), point < end.position {
24 point = heading
25 try body()
26 }
27 while true {
28 outlineNextHeading()
29 guard point < end.position, !eobp else { break }
30 try body()
31 }
32 }
33 }
34
35 /// `org-paste-subtree` with a numeric level.
36 func pasteSubtree(level: Int, tree: String) throws {
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
39 if !(bolp && atHeading) {
40 endOfLine()
41 point = nextHeadingStart(after: point) ?? length
42 if !bolp { insert("\n") }
43 }
44 var begin = point
45 insert(tree)
46 if !tree.hasSuffix("\n") { insert("\n") }
47 let end = marker(point)
48 defer { release(end) }
49 point = begin
50 skipForward(Self.whitespace)
51 begin = point
52 var remaining = shift
53 while remaining != 0 {
54 try mapRegion(begin, end.position) { try changeLevel(by: remaining > 0 ? 1 : -1) }
55 remaining += remaining > 0 ? -1 : 1
56 }
57 point = begin
58 }
59
60 /// Inserts a refiled subtree as the last child of the heading at `target`, or at the end of
61 /// the buffer as a top-level entry when `target` is nil.
62 func refileInsert(_ tree: String, under target: Int?) throws {
63 try saveExcursion {
64 let level: Int
65 if let target {
66 point = target
67 level = outlineLevel + 1
68 let sibling = saveExcursion { nextSibling() }
69 if let sibling { point = sibling } else { endOfSubtree(toHeading: true) }
70 } else {
71 level = 1
72 point = length
73 }
74 if !bolp { insert("\n") }
75 try pasteSubtree(level: level, tree: tree)
76 alignTags(onLineAt: point)
77 }
78 }
79
80 /// Range of the subtree at point with the blank lines after it, as org-refile deletes it.
81 func subtreeRange() throws -> Range<Int> {
82 try saveExcursion {
83 let begin = try backToHeading()
84 let end = endOfSubtree(toHeading: true)
85 return begin..<end
86 }
87 }
88
89 /// `org-refile` to a heading in this buffer, or to its end when `target` is nil.
90 func refileWithinBuffer(to target: Int?) throws {
91 let subtree = try subtreeRange()
92 if let target, subtree.contains(target) {
93 throw UserError(message: "Cannot refile to position inside the tree or region")
94 }
95 let tree = try copySubtree()
96 let origin = marker(point, advances: true)
97 defer { release(origin) }
98 try refileInsert(tree, under: target)
99 point = origin.position
100 let range = try subtreeRange()
101 deleteRegion(range.lowerBound, range.upperBound)
102 }
103}
104
105/// C-c C-w within one file: moves the subtree at the caret under the heading starting at the
106/// answer's offset, or to the end of the file for an empty answer.
107public struct RefileWithinFile: OrgCommand {
108 public init() {}
109 public var id: String { "org.refile.local" }
110 public var title: String { "Refile Within File" }
111 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
112
113 public func run(in context: EditContext) -> CommandStep {
114 guard let answer = context.answers["refile"] else {
115 return .prompt(Prompt(key: "refile", message: "Refile to:"))
116 }
117 return commitBuffer(context) { try $0.refileWithinBuffer(to: Int(answer)) }
118 }
119}
120
121/// Refiling into another file: the edit to the source and the target's new text.
122public enum Refile {
123 public struct Result: Sendable, Equatable {
124 public let source: EditResult
125 public let target: TextEdit
126 }
127
128 public static func toOtherFile(source context: EditContext, target text: String, under heading: Int?) -> Result? {
129 let target = EmacsBuffer(text, point: 0)
130 var tree = ""
131 let step = commitBuffer(context) { buffer in
132 tree = try buffer.copySubtree()
133 try target.refileInsert(tree, under: heading)
134 let range = try buffer.subtreeRange()
135 buffer.deleteRegion(range.lowerBound, range.upperBound)
136 }
137 guard case .commit(let source) = step else { return nil }
138 let edit = commitBuffer(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [0..<0])) { buffer in
139 buffer.replace(0..<buffer.length, with: target.string)
140 }
141 guard case .commit(let result) = edit else { return nil }
142 return Result(source: source, target: result.edits.first ?? TextEdit(range: 0..<0, replacement: ""))
143 }
144}
Sources/OrgCore/Commands/Statistics.swift added +137
@@ -0,0 +1,137 @@
1import Foundation
2
3// TODO statistics cookies (`org-update-parent-todo-statistics`, Org 9.8.7) with
4// `org-provide-todo-statistics` t and `org-hierarchical-todo-statistics` t.
5
6extension EmacsBuffer {
7 /// A property of the entry at `heading` from its property drawer, or nil.
8 func localProperty(_ name: String, heading: Int) -> String? {
9 saveExcursion {
10 point = heading
11 forwardLine(1)
12 if lookingAt(planningPattern) != nil { forwardLine(1) }
13 guard lookingAt("^[ \\t]*:PROPERTIES:[ \\t]*$") != nil else { return nil }
14 forwardLine(1)
15 while !eobp, lookingAt("^[ \\t]*:END:[ \\t]*$") == nil {
16 if let match = lookingAt("^[ \\t]*:([^:\\s]+):(?:[ \\t]+(.*?))?[ \\t]*$"),
17 substring(match[1]!.lowerBound, match[1]!.upperBound).caseInsensitiveCompare(name) == .orderedSame {
18 return match[2].map { substring($0.lowerBound, $0.upperBound) } ?? ""
19 }
20 if forwardLine(1) != 0 { break }
21 }
22 return nil
23 }
24 }
25
26 var planningPattern: String { "^[ \\t]*(?:SCHEDULED|DEADLINE|CLOSED):" }
27
28 /// A property with inheritance: the entry, then its ancestors. Returns the value and the
29 /// heading it came from.
30 func inheritedProperty(_ name: String) -> (value: String, from: Int)? {
31 saveExcursion {
32 guard (try? backToHeading()) != nil else { return nil }
33 while true {
34 if let value = localProperty(name, heading: point) { return (value, point) }
35 let level = outlineLevel
36 guard level > 1, let parent = saveExcursion({ () -> Int? in
37 while let previous = previousHeadingStart(before: point) {
38 point = previous
39 if headingLevel(atLine: previous)! < level { return previous }
40 }
41 return nil
42 }) else { return nil }
43 point = parent
44 }
45 }
46 }
47
48 /// `org-up-heading-safe`: the parent heading's level, with point on it, or nil.
49 @discardableResult
50 func upHeadingSafe() -> Int? {
51 guard let start = try? backToHeading() else { return nil }
52 let level = headingLevel(atLine: start)!
53 var at = start
54 while let previous = previousHeadingStart(before: at) {
55 at = previous
56 if let parentLevel = headingLevel(atLine: previous), parentLevel < level {
57 point = previous
58 return parentLevel
59 }
60 }
61 point = start
62 return nil
63 }
64
65 /// `org-update-parent-todo-statistics` for the heading at point.
66 func updateParentTodoStatistics() {
67 let property = saveExcursion { () -> (value: String, from: Int)? in
68 upHeadingSafe()
69 return inheritedProperty("COOKIE_DATA")
70 }
71 let recursive = property.map { $0.value.range(of: "\\brecursive\\b", options: .regularExpression) != nil } ?? false
72 let limit = property?.from ?? 0
73 var first = true
74 saveExcursion {
75 beginningOfLine()
76 let toggleLevel = outlineLevel
77 while let level = upHeadingSafe(), recursive || first, point >= limit {
78 first = false
79 let data = (localProperty("COOKIE_DATA", heading: point) ?? "").lowercased()
80 if data.range(of: "\\bcheckbox\\b", options: .regularExpression) != nil { return }
81 let headingStart = point
82 while let match = searchForward("((\\[[0-9]*%\\])|(\\[[0-9]*/[0-9]*\\]))", bound: lineEnd(point)) {
83 let cookieStart = match[0]!.lowerBound
84 let isPercent = match[2] != nil
85 guard isStatisticsCookie(at: cookieStart, headingStart: headingStart) else { continue }
86 var all = 0, done = 0
87 let after = point
88 point = headingStart
89 guard outlineNextHeading() else { return }
90 while let heading = lookingAt(complexHeadingPattern), let l1 = heading[1].map({ $0.count }), l1 > level {
91 let keyword = (recursive || l1 == toggleLevel) ? heading[2].map { substring($0.lowerBound, $0.upperBound) } : nil
92 if let keyword {
93 all += 1
94 if settings.isDone(keyword) { done += 1 }
95 }
96 outlineNextHeading()
97 }
98 point = after
99 let new = isPercent ? "[\(Int((100.0 * Double(done) / Double(max(1, all))).rounded(.down)))%]" : "[\(done)/\(all)]"
100 if new != substring(cookieStart, match[0]!.upperBound) {
101 let length = match[0]!.upperBound - cookieStart
102 point = cookieStart
103 insert(new)
104 deleteRegion(point, point + length)
105 let tags = saveExcursion { () -> [Range<Int>?]? in
106 beginningOfLine()
107 return lookingAt(SetTags.tagLine)
108 }
109 if let tagsRange = tags?[1], point < tagsRange.lowerBound { alignTags(onLineAt: point) }
110 }
111 }
112 point = headingStart
113 }
114 }
115 }
116
117 /// Whether the cookie text at `offset` on a heading line parses as a statistics cookie.
118 private func isStatisticsCookie(at offset: Int, headingStart: Int) -> Bool {
119 let line = substring(headingStart, lineEnd(headingStart)) + "\n"
120 let tree = OrgParser.parse(line, defaults: settings)
121 return tree.root.descendants().contains { $0.kind == .statisticsCookie && $0.range.contains(offset - headingStart) }
122 }
123}
124
125/// Applies a command's result to a buffer, runs `then` on it, and commits the combined change:
126/// for commands org follows with more edits, such as statistics updates.
127func followUp(_ context: EditContext, _ step: CommandStep, _ then: (EmacsBuffer) throws -> Void) -> CommandStep {
128 guard case .commit(let result) = step, !result.edits.isEmpty else { return step }
129 let caret = result.selection?.first?.lowerBound ?? context.caret
130 return commitBuffer(context) { buffer in
131 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
132 buffer.replace(edit.range, with: edit.replacement)
133 }
134 buffer.point = caret
135 try then(buffer)
136 }
137}
Sources/OrgCore/Commands/StructureCommands.swift +1
@@ -299,6 +299,7 @@ public struct InsertTodoHeading: OrgCommand {
299 buffer.point = stars[0]!.upperBound 299 buffer.point = stars[0]!.upperBound
300 buffer.insert(keyword + " ") 300 buffer.insert(keyword + " ")
301 } 301 }
302 buffer.updateParentTodoStatistics()
302 } 303 }
303 } 304 }
304} 305}
Sources/OrgCore/Keymap/Presets.swift +15
@@ -245,6 +245,15 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
245 [[bind]] 245 [[bind]]
246 keys = "C-c C-x C-b" 246 keys = "C-c C-x C-b"
247 command = "org.checkbox.toggle" 247 command = "org.checkbox.toggle"
248 [[bind]]
249 keys = "C-c C-w"
250 command = "app.refile"
251 [[bind]]
252 keys = "C-c C-x C-s"
253 command = "app.archive"
254 [[bind]]
255 keys = "C-c $"
256 command = "app.archive"
248 257
249 """ 258 """
250 259
@@ -331,6 +340,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
331 [[bind]] 340 [[bind]]
332 keys = "C-s-c" 341 keys = "C-s-c"
333 command = "org.checkbox.toggle" 342 command = "org.checkbox.toggle"
343 [[bind]]
344 keys = "C-s-w"
345 command = "app.refile"
346 [[bind]]
347 keys = "C-s-a"
348 command = "app.archive"
334 349
335 """ 350 """
336} 351}
Sources/OrgDocument/Buffer.swift +16
@@ -1,10 +1,15 @@
1import Foundation 1import Foundation
2import OrgCore
2 3
3/// Where an open document's live state is kept: the editor while one shows it, a 4/// Where an open document's live state is kept: the editor while one shows it, a
4/// `StateBuffer` before that. 5/// `StateBuffer` before that.
5@MainActor 6@MainActor
6public protocol DocumentBuffer: AnyObject { 7public protocol DocumentBuffer: AnyObject {
7 var document: DocumentState { get } 8 var document: DocumentState { get }
9 /// Selected ranges; the first holds the caret.
10 var selection: [Range<Int>] { get }
11 /// Runs a command at the selection as one undoable change.
12 func perform(_ command: any OrgCommand, now: Date, answers: [String: String]) -> CommandStep
8 /// Applies a change made outside typing (save, disk change, revert) and updates whatever 13 /// Applies a change made outside typing (save, disk change, revert) and updates whatever
9 /// shows the document. 14 /// shows the document.
10 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T 15 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T
@@ -13,11 +18,22 @@ public protocol DocumentBuffer: AnyObject {
13@MainActor 18@MainActor
14public final class StateBuffer: DocumentBuffer { 19public final class StateBuffer: DocumentBuffer {
15 public private(set) var document: DocumentState 20 public private(set) var document: DocumentState
21 public var selection: [Range<Int>] = [0..<0]
16 22
17 public init(_ document: DocumentState) { 23 public init(_ document: DocumentState) {
18 self.document = document 24 self.document = document
19 } 25 }
20 26
27 public func perform(_ command: any OrgCommand, now: Date, answers: [String: String]) -> CommandStep {
28 do {
29 let step = try document.run(command, selection: selection, now: now, answers: answers)
30 if case .commit(let result) = step, let new = result.selection { selection = new }
31 return step
32 } catch {
33 return .failed("\(error)")
34 }
35 }
36
21 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { 37 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
22 try change(&document) 38 try change(&document)
23 } 39 }
Sources/OrgEditorAppKit/EditorCommands.swift +2
@@ -40,6 +40,8 @@ public enum EditorCommands {
40 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), 40 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
41 EditorCommand(id: "app.search", title: "Search Notes", action: .app), 41 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app), 42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app),
43 EditorCommand(id: "app.refile", title: "Refile…", action: .app),
44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
43 45
44 movement("forward-char", "Forward Character", "moveForward:"), 46 movement("forward-char", "Forward Character", "moveForward:"),
45 movement("backward-char", "Backward Character", "moveBackward:"), 47 movement("backward-char", "Backward Character", "moveBackward:"),
Sources/OrgEditorAppKit/OrgEditor.swift +5
@@ -585,6 +585,11 @@ public final class OrgEditor: NSObject {
585} 585}
586 586
587extension OrgEditor: DocumentBuffer { 587extension OrgEditor: DocumentBuffer {
588 public var selection: [Range<Int>] {
589 let selected = textView.selectedRange()
590 return [selected.location..<NSMaxRange(selected)]
591 }
592
588 /// Runs `change`; if it changed the text, reloads the view with folds and the selection 593 /// Runs `change`; if it changed the text, reloads the view with folds and the selection
589 /// mapped through the change. Text-view undo refers to the old text, so it is cleared. 594 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
590 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T { 595 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
Sources/OrgIndex/IndexStore.swift +28
@@ -12,6 +12,13 @@ public struct FileState: Sendable, Equatable {
12 12
13/// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to, 13/// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to,
14/// so a caller can tell whether they still apply to an open buffer. 14/// so a caller can tell whether they still apply to an open buffer.
15public struct IndexedHeading: Sendable, Hashable {
16 public let location: HeadingLocation
17 public let level: Int
18 /// Titles from the top-level ancestor down to the heading itself.
19 public let outlinePath: [String]
20}
21
15public struct HeadingLocation: Sendable, Hashable { 22public struct HeadingLocation: Sendable, Hashable {
16 public let path: String 23 public let path: String
17 public let ordinal: Int 24 public let ordinal: Int
@@ -262,6 +269,27 @@ public final class IndexStore: Sendable {
262 return Array((live + indexed.filter { overlay[$0.path] == nil }).prefix(limit)) 269 return Array((live + indexed.filter { overlay[$0.path] == nil }).prefix(limit))
263 } 270 }
264 271
272 /// Headings down to `maxLevel` in org files (not archives or conflict copies), with their
273 /// levels and outline paths, in file order: refile targets.
274 public func outline(maxLevel: Int) throws -> [IndexedHeading] {
275 try database.read { db in
276 try Row.fetchAll(
277 db,
278 sql: """
279 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset, h.level, h.outline_path
280 FROM headings h JOIN files f ON f.id = h.file_id
281 WHERE h.level <= ? AND f.kind = 'org' ORDER BY f.path, h.ordinal
282 """,
283 arguments: [maxLevel]
284 ).map { row in
285 IndexedHeading(
286 location: location(row), level: row["level"],
287 outlinePath: (row["outline_path"] as String).split(separator: "\u{1F}", omittingEmptySubsequences: false).map(String.init)
288 )
289 }
290 }
291 }
292
265 /// Headings with `:ID: id`. More than one means the ID is duplicated. 293 /// Headings with `:ID: id`. More than one means the ID is duplicated.
266 public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] { 294 public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] {
267 let indexed = try database.read { db in 295 let indexed = try database.read { db in
Sources/Orgstar/ContentView.swift +8
@@ -140,6 +140,14 @@ struct ContentView: View {
140 case "app.reload-keymap": 140 case "app.reload-keymap":
141 loadKeymap() 141 loadKeymap()
142 commands.show("Keymap reloaded") 142 commands.show("Keymap reloaded")
143 case "app.refile":
144 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
145 commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
146 guard let answer, let target = targets.first(where: { $0.label == answer }) else { return }
147 if let message = session.refile(to: target) { commands.show(message) }
148 }
149 case "app.archive":
150 if let message = session.archive() { commands.show(message) }
143 default: 151 default:
144 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } 152 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
145 } 153 }
Sources/Orgstar/Keys.swift +34 −1
@@ -31,6 +31,23 @@ struct EchoArea: View {
31 .padding(10) 31 .padding(10)
32 } 32 }
33 if let request = commands.prompt { 33 if let request = commands.prompt {
34 if !request.prompt.choices.isEmpty {
35 Divider()
36 let matches = filteredChoices(request.prompt.choices)
37 ScrollView {
38 VStack(alignment: .leading, spacing: 2) {
39 ForEach(matches.prefix(12), id: \.self) { choice in
40 Button(choice) { commands.answer(choice) }
41 .buttonStyle(.plain)
42 .font(.system(.callout, design: .monospaced))
43 .foregroundStyle(choice == matches.first ? .primary : .secondary)
44 }
45 }
46 .frame(maxWidth: .infinity, alignment: .leading)
47 .padding(10)
48 }
49 .frame(maxHeight: 200)
50 }
34 Divider() 51 Divider()
35 HStack(spacing: 6) { 52 HStack(spacing: 6) {
36 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) 53 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
@@ -38,7 +55,13 @@ struct EchoArea: View {
38 .textFieldStyle(.plain) 55 .textFieldStyle(.plain)
39 .font(.system(.callout, design: .monospaced)) 56 .font(.system(.callout, design: .monospaced))
40 .focused($answerFocused) 57 .focused($answerFocused)
41 .onSubmit { commands.answer(answer) } 58 .onSubmit {
59 if request.prompt.choices.isEmpty {
60 commands.answer(answer)
61 } else if let first = filteredChoices(request.prompt.choices).first {
62 commands.answer(first)
63 }
64 }
42 .onExitCommand { commands.answer(nil) } 65 .onExitCommand { commands.answer(nil) }
43 } 66 }
44 .padding(.horizontal, 10) 67 .padding(.horizontal, 10)
@@ -68,6 +91,16 @@ struct EchoArea: View {
68 } 91 }
69} 92}
70 93
94extension EchoArea {
95 /// Choices matching what's typed, best first.
96 func filteredChoices(_ choices: [String]) -> [String] {
97 guard !answer.isEmpty else { return choices }
98 return choices.compactMap { choice in FuzzyMatch.score(answer, choice).map { (choice, $0) } }
99 .sorted { $0.1 > $1.1 }
100 .map(\.0)
101 }
102}
103
71/// M-x and ⇧⌘P: every command by title, with its keys. 104/// M-x and ⇧⌘P: every command by title, with its keys.
72struct PaletteView: View { 105struct PaletteView: View {
73 let keymap: Keymap 106 let keymap: Keymap
Tests/OrgAppTests/AppTests.swift +47
@@ -226,3 +226,50 @@ struct KeymapLoaderTests {
226 #expect(KeymapLoader.load(.mac, userFile: broken).problems == ["line 1: unclosed string"]) 226 #expect(KeymapLoader.load(.mac, userFile: broken).problems == ["line 1: unclosed string"])
227 } 227 }
228} 228}
229
230@MainActor
231struct MoveSubtreeTests {
232 @Test func refileWithinAndAcrossFiles() async throws {
233 let notes = try Folder()
234 let file = try notes.write("a.org", "* one\nbody\n* two\n")
235 let other = try notes.write("b.org", "* inbox\n")
236 let workspace = try WorkspaceModel(directory: nil)
237 try await workspace.addRoot(notes.url)
238 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
239 session.saveMode = .explicit
240 session.open(file.path)
241 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
242 #expect(targets.map(\.label) == ["a.org", "a.org/one", "a.org/two", "b.org", "b.org/inbox"])
243
244 #expect(session.refile(to: targets[2]) == "Refiled to a.org/two")
245 #expect(session.document?.text == "* two\n** one\nbody\n")
246
247 let inbox = try #require(workspace.refileTargets(openPath: session.path, document: session.document).first { $0.label == "b.org/inbox" })
248 StateBufferCaret.set(session, 6)
249 #expect(session.refile(to: inbox) == "Refiled to b.org/inbox")
250 #expect(try String(contentsOf: other, encoding: .utf8) == "* inbox\n** one\nbody\n")
251 #expect(session.document?.text == "* two\n")
252 }
253
254 @Test func archiveToTheArchiveFile() throws {
255 let notes = try Folder()
256 let file = try notes.write("a.org", "* done\n* keep\n")
257 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
258 session.saveMode = .explicit
259 session.open(file.path)
260 #expect(session.archive(now: Date(timeIntervalSince1970: 0)) == "Archived to a.org_archive")
261 #expect(session.document?.text == "* keep\n")
262 let archived = try String(contentsOf: notes.url.appendingPathComponent("a.org_archive"), encoding: .utf8)
263 #expect(archived.contains("* done\n:PROPERTIES:\n:ARCHIVE_TIME:"))
264 }
265}
266
267/// Moves the caret of a session's `StateBuffer`, as clicking in the editor would.
268@MainActor
269enum StateBufferCaret {
270 static func set(_ session: DocumentSession, _ offset: Int) {
271 let buffer = StateBuffer(session.document!)
272 buffer.selection = [offset..<offset]
273 session.attach(buffer)
274 }
275}
Tests/OrgCoreTests/ArchiveTests.swift added +112
@@ -0,0 +1,112 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ArchiveTests {
6 static let now = Date(timeIntervalSince1970: 1_789_000_000)
7 static var utc: Calendar {
8 var calendar = Calendar(identifier: .gregorian)
9 calendar.timeZone = TimeZone(identifier: "UTC")!
10 return calendar
11 }
12
13 func context(_ text: String, _ caret: Int) -> EditContext {
14 EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Self.now, calendar: Self.utc)
15 }
16
17 @Test func destinations() throws {
18 let plain = try Archive.destination(context("* a\n", 0), sourcePath: "/n/a.org").get()
19 #expect(plain == Archive.Destination(path: "/n/a.org_archive", heading: nil, isSameFile: false))
20 let keyword = try Archive.destination(context("#+ARCHIVE: done.org::* %s\n* a\n", 27), sourcePath: "/n/a.org").get()
21 #expect(keyword == Archive.Destination(path: "/n/done.org", heading: "* a.org", isSameFile: false))
22 let local = try Archive.destination(context("* a\n:PROPERTIES:\n:ARCHIVE: ::* Old\n:END:\n", 0), sourcePath: "/n/a.org").get()
23 #expect(local.isSameFile && local.heading == "* Old")
24 }
25
26 @Test func archivesToANewFile() throws {
27 let text = "* P\n** TODO a :t:\nbody\n** b\n"
28 let ctx = context(text, 6)
29 let destination = try Archive.destination(ctx, sourcePath: "/n/a.org").get()
30 let edits = try Archive.run(ctx, sourcePath: "/n/a.org", destination: destination, archiveText: nil).get()
31 #expect(edits.source.edits.reduce(text) { $1.apply(to: $0) } == "* P\n** b\n")
32 let archived = try #require(edits.archive).apply(to: "")
33 #expect(archived.hasPrefix("\nArchived entries from file /n/a.org\n\n\n* TODO a"))
34 #expect(archived.contains(":ARCHIVE_OLPATH: P\n"))
35 #expect(archived.contains(":ARCHIVE_TIME: 2026-09-10 Thu 00:26\n"))
36 }
37
38 static let sources = [
39 "* a\nbody\n* b\n",
40 "#+FILETAGS: :f:\n* P :p:\n** TODO a :t:\nbody\n** DONE b\n",
41 "* P [1/2]\n** TODO a\n** DONE b\n",
42 "#+CATEGORY: work\n* a [[https://x.org][link]]\n* b\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",
45 "* a\nSCHEDULED: <2026-01-01 Thu>\n:PROPERTIES:\n:ID: 1\n:END:\n* b",
46 ]
47 static let archives: [String?] = [nil, "", "* existing\n", "\n#+TITLE: arch\n\n* x\n:PROPERTIES:\n:ARCHIVE_TIME: t\n:END:\n\n"]
48
49 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
50 func archiveMatchesEmacs() throws {
51 try #require(EmacsOracle.isAvailable)
52 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-archive-\(UUID().uuidString)")
53 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
54 defer { try? FileManager.default.removeItem(at: folder) }
55 var cases: [EmacsOracle.Case] = []
56 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
57 var index = 0
58 for source in Self.sources {
59 let tree = OrgParser.parse(source)
60 let carets = tree.root.descendants().filter { $0.kind == .heading }.flatMap { [$0.range.lowerBound, $0.range.lowerBound + 2] }
61 for caret in carets {
62 for archiveText in Self.archives {
63 index += 1
64 let path = folder.appendingPathComponent("s\(index).org").path
65 let ctx = context(source, caret)
66 guard case .success(let destination) = Archive.destination(ctx, sourcePath: path) else { continue }
67 if destination.isSameFile, archiveText != nil { continue }
68 let archivePath = destination.path
69 let escaped = { (s: String) in s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") }
70 let writeArchive = archiveText.map { "(with-temp-file \"\(escaped(archivePath))\" (insert \"\(escaped($0))\"))" } ?? "(when (file-exists-p \"\(escaped(archivePath))\") (delete-file \"\(escaped(archivePath))\"))"
71 let form = """
72 (let ((p (point)) (src (buffer-string)) s1 s2)
73 \(writeArchive)
74 (with-temp-file "\(escaped(path))" (insert src))
75 (with-current-buffer (find-file-noselect "\(escaped(path))")
76 (org-fold-show-all) (goto-char p)
77 (cl-letf* ((orig (symbol-function 'format-time-string))
78 ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall orig f (or time 1789000000) zone))))
79 (org-archive-subtree))
80 (setq p (point) s1 (buffer-string))
81 (set-buffer-modified-p nil) (kill-buffer))
82 (let ((b (get-file-buffer "\(escaped(archivePath))")))
83 (when b (with-current-buffer b (setq s2 (buffer-string)) (set-buffer-modified-p nil) (kill-buffer))))
84 (erase-buffer) (insert s1 "\\n@@@@\\n" (or s2 "")) (goto-char p))
85 """
86 cases.append(EmacsOracle.Case(text: source, point: EmacsOracle.point(caret, in: source), form: form))
87 let label = "\(caret) in \(source.debugDescription) to \(archiveText.debugDescription)"
88 switch Archive.run(ctx, sourcePath: path, destination: destination, archiveText: archiveText) {
89 case .success(let edits):
90 let newSource = edits.source.edits.reduce(source) { $1.apply(to: $0) }
91 let newArchive = destination.isSameFile ? "" : (edits.archive.map { $0.apply(to: archiveText ?? "") } ?? (archiveText ?? ""))
92 ours.append((label, newSource + "\n@@@@\n" + newArchive, edits.source.selection?.first?.lowerBound ?? caret, false))
93 case .failure:
94 ours.append((label, "", 0, true))
95 }
96 }
97 }
98 }
99 var mismatches = 0
100 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
101 let failed = !emacs.error.isEmpty
102 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
103 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
104 mismatches += 1
105 if mismatches <= 8 {
106 let (a, b) = excerpts(mine.text, emacs.text)
107 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(a.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(b.debugDescription) @\(caret)")")
108 }
109 }
110 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
111 }
112}
Tests/OrgCoreTests/EmacsOracle.swift +3 −1
@@ -37,6 +37,8 @@ enum EmacsOracle {
37 org-priority-default ?B) 37 org-priority-default ?B)
38 ;; Buffer-local when set, so set the default for the case buffers. 38 ;; Buffer-local when set, so set the default for the case buffers.
39 (setq-default indent-tabs-mode nil) 39 (setq-default indent-tabs-mode nil)
40 ;; Archive files open in Org mode, as in usual configurations.
41 (add-to-list 'auto-mode-alist '("\\.org_archive\\'" . org-mode))
40 (let* ((input (with-temp-buffer 42 (let* ((input (with-temp-buffer
41 (let ((coding-system-for-read 'utf-8-unix)) 43 (let ((coding-system-for-read 'utf-8-unix))
42 (insert-file-contents (getenv "ORACLE_INPUT"))) 44 (insert-file-contents (getenv "ORACLE_INPUT")))
@@ -78,7 +80,7 @@ enum EmacsOracle {
78 let output = folder.appendingPathComponent("output.json") 80 let output = folder.appendingPathComponent("output.json")
79 try script.write(to: scriptURL, atomically: true, encoding: .utf8) 81 try script.write(to: scriptURL, atomically: true, encoding: .utf8)
80 try JSONEncoder().encode(cases).write(to: input) 82 try JSONEncoder().encode(cases).write(to: input)
81 _ = try run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path]) 83 _ = try run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path, "TZ": "UTC"])
82 return try JSONDecoder().decode([Result].self, from: Data(contentsOf: output)) 84 return try JSONDecoder().decode([Result].self, from: Data(contentsOf: output))
83 } 85 }
84 86
Tests/OrgCoreTests/HeadingCommandTests.swift +2
@@ -48,6 +48,8 @@ struct HeadingOracleTests {
48 "#+TODO: NEXT WAIT | DONE CANCELED\n* WAIT a :t:\n", 48 "#+TODO: NEXT WAIT | DONE CANCELED\n* WAIT a :t:\n",
49 "* " + String(repeating: "long ", count: 16) + "title :t:\n", 49 "* " + String(repeating: "long ", count: 16) + "title :t:\n",
50 "* TODO\ttab\n", 50 "* TODO\ttab\n",
51 "* P [1/2]\n** TODO a\n** DONE b\n",
52 "* P [50%] :t:\n** TODO a\n*** TODO deep\n** b\n",
51 ] 53 ]
52 54
53 /// Commands, their Emacs forms, and whether they only act on heading lines. 55 /// Commands, their Emacs forms, and whether they only act on heading lines.
Tests/OrgCoreTests/RefileTests.swift added +126
@@ -0,0 +1,126 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct RefileTests {
6 @Test func withinFile() {
7 let text = "* a\nx\n* b\n** c\n"
8 #expect(runCommand(RefileWithinFile(), text, caret: 0, answers: ["refile": "6"]).text == "* b\n** c\n** a\nx\n")
9 #expect(runCommand(RefileWithinFile(), text, caret: 0, answers: ["refile": "0"]).failure != nil)
10 #expect(runCommand(RefileWithinFile(), "* a\n* b\n", caret: 0, answers: ["refile": ""]).text == "* b\n* a\n")
11 }
12
13 @Test func toAnotherFile() throws {
14 let source = "* a :t:\nbody\n* b\n"
15 let context = EditContext(revision: 0, text: source, tree: OrgParser.parse(source), selection: [0..<0])
16 let result = try #require(Refile.toOtherFile(source: context, target: "* inbox\n", under: 0))
17 #expect(result.target.apply(to: "* inbox\n").hasPrefix("* inbox\n** a"))
18 #expect(result.source.edits.first?.apply(to: source) == "* b\n")
19 }
20
21 static let variants = [
22 "* a\nbody\n* b\n** c\n* d\n",
23 "* a\n\n* b\n\n** c\n\n* d\n",
24 "intro\n* TODO a :x:\n** sub\n* b\n",
25 "* a\n** b\n*** c\n* d",
26 ]
27
28 /// Emacs needs files to refile; each case writes the text to a temporary file and refiles there.
29 static func sameFileForm(_ target: Int?) -> String {
30 """
31 (let* ((f (make-temp-file "orgstar" nil ".org")) (p (point)) s)
32 (write-region (point-min) (point-max) f)
33 (with-current-buffer (find-file-noselect f)
34 (org-fold-show-all) (goto-char p)
35 (org-refile nil nil (list "t" f nil \(target.map { String($0 + 1) } ?? "nil")))
36 (setq p (point) s (buffer-string))
37 (set-buffer-modified-p nil) (kill-buffer))
38 (delete-file f)
39 (erase-buffer) (insert s) (goto-char p))
40 """
41 }
42
43 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
44 func refileWithinFileMatchesEmacs() throws {
45 try #require(EmacsOracle.isAvailable)
46 var cases: [EmacsOracle.Case] = []
47 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
48 for text in Self.variants {
49 let tree = OrgParser.parse(text)
50 let targets: [Int?] = [nil] + tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
51 for caret in EmacsOracle.positions(text) where entryHeading(at: caret, in: tree) != nil {
52 for target in targets {
53 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: Self.sameFileForm(target)))
54 let result = runCommand(RefileWithinFile(), text, caret: caret, answers: ["refile": target.map(String.init) ?? ""])
55 ours.append(("to \(target.map(String.init) ?? "end") at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
56 }
57 }
58 }
59 var mismatches = 0
60 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
61 let failed = !emacs.error.isEmpty
62 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
63 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
64 mismatches += 1
65 if mismatches <= 10 {
66 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
67 }
68 }
69 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
70 }
71
72 static let separator = "\n@@@@\n"
73
74 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
75 func refileToAnotherFileMatchesEmacs() throws {
76 try #require(EmacsOracle.isAvailable)
77 let targetTexts = ["* inbox\n", "* p\n** q\n* r :t:\n", "", "text only\n", "* p\n\n* r\n"]
78 var cases: [EmacsOracle.Case] = []
79 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
80 for source in Self.variants {
81 let tree = OrgParser.parse(source)
82 for caret in EmacsOracle.positions(source) where entryHeading(at: caret, in: tree) != nil {
83 for targetText in targetTexts {
84 let targetTree = OrgParser.parse(targetText)
85 let headings: [Int?] = [nil] + targetTree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
86 for heading in headings {
87 let escaped = targetText.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
88 let form = """
89 (let* ((f1 (make-temp-file "orgstar" nil ".org")) (f2 (make-temp-file "orgstar" nil ".org" "\(escaped)")) (p (point)) s1 s2)
90 (write-region (point-min) (point-max) f1)
91 (let ((b2 (find-file-noselect f2)))
92 (with-current-buffer (find-file-noselect f1)
93 (org-fold-show-all) (goto-char p)
94 (org-refile nil nil (list "t" f2 nil \(heading.map { String($0 + 1) } ?? "nil")))
95 (setq p (point) s1 (buffer-string))
96 (set-buffer-modified-p nil) (kill-buffer))
97 (with-current-buffer b2 (setq s2 (buffer-string)) (set-buffer-modified-p nil) (kill-buffer)))
98 (delete-file f1) (delete-file f2)
99 (erase-buffer) (insert s1 "\(Self.separator.replacingOccurrences(of: "\n", with: "\\n"))" s2) (goto-char p))
100 """
101 cases.append(EmacsOracle.Case(text: source, point: EmacsOracle.point(caret, in: source), form: form))
102 let context = EditContext(revision: 0, text: source, tree: tree, selection: [caret..<caret])
103 if let result = Refile.toOtherFile(source: context, target: targetText, under: heading) {
104 let newSource = result.source.edits.reduce(source) { $1.apply(to: $0) }
105 let caretAfter = result.source.selection?.first?.lowerBound ?? caret
106 ours.append(("to \(heading.map(String.init) ?? "end") of \(targetText.debugDescription) at \(caret) in \(source.debugDescription)", newSource + Self.separator + result.target.apply(to: targetText), caretAfter, false))
107 } else {
108 ours.append(("failed", "", 0, true))
109 }
110 }
111 }
112 }
113 }
114 var mismatches = 0
115 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
116 let failed = !emacs.error.isEmpty
117 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
118 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
119 mismatches += 1
120 if mismatches <= 10 {
121 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
122 }
123 }
124 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
125 }
126}
Tests/OrgCoreTests/StructureCommandTests.swift +1
@@ -49,6 +49,7 @@ struct StructureOracleTests {
49 "\n* a\n\nbody\n\n* b\n", 49 "\n* a\n\nbody\n\n* b\n",
50 "* TODO [#A] title here :t:\nbody\n", 50 "* TODO [#A] title here :t:\nbody\n",
51 "* a 😀 b\n** c\n", 51 "* a 😀 b\n** c\n",
52 "* P [0/1]\n** TODO a\n",
52 ] 53 ]
53 54
54 static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [ 55 static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [
docs/plans/2026-10-05-refile-archive.md added +33
@@ -0,0 +1,33 @@
1# Refile and Archive Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Refile (C-c C-w) to any heading down to level 3 in the workspace or to the end of a file, archive (C-c C-x C-s, C-c $) where `org-archive-subtree` would, and the TODO statistics updates org makes after TODO changes, all byte- and caret-exact against Emacs.
6
7**Architecture:** `OrgCore` ports `org-copy-subtree`, `org-paste-subtree`, `org-map-region` and the insert/delete steps of `org-refile` (`Refile.swift`); `org-archive-subtree` with `org-archive--compute-location`, context properties through `org-entry-put` (property drawer creation, `org-property-format` alignment), and `org-update-statistics-cookies` (`Archive.swift`); `org-update-parent-todo-statistics` (`Statistics.swift`), now run after TODO cycling and TODO-heading insertion through `followUp`. `DocumentSession.refile(to:)` and `archive()` handle a second file: it is read and written through the save path first, and the subtree leaves the buffer (`PrecomputedEdit`, one undo step) only once that write succeeds. Refile targets come from the index (`IndexStore.outline(maxLevel:)`) and, for the open file, from its buffer. Prompts gain choices, filtered as you type in the echo area.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle.
10
11**Spec:** `docs/design.md`, "Commands and keymaps", "Saving".
12
13## Global Constraints
14
15- Org defaults: `org-log-refile` nil, `org-reverse-note-order` nil, `org-refile-keep` nil, archive location `%s_archive::`, context info time/file/olpath/category/todo/itags, inherited tags added only in-file, no DONE marking, `org-provide-todo-statistics` t, hierarchical statistics.
16- The oracle visits real temporary files, freezes `format-time-string` and runs with `TZ=UTC`; `.org_archive` files open in Org mode.
17
18## Defaults chosen (user may change)
19
20- Refile targets: every org file itself plus its headings to level 3 (org's default is level-1 headings of the current file).
21- Mac preset: ⌃⌘W refile, ⌃⌘A archive.
22
23---
24
25### Task 1: Core
26
27- [ ] Port refile, archive and statistics; oracles: refile within a file and to another file (4 sources × every caret × every target, 5 target files), archive (7 sources × 4 archive files), TODO cycle and TODO-heading insert with parent cookies.
28- [ ] Commit "Port refile, archive and TODO statistics from org".
29
30### Task 2: Session and app
31
32- [ ] `DocumentBuffer.selection` and `perform`; `PrecomputedEdit`; `DocumentSession.refile(to:)`, `archive()`; `WorkspaceModel.refileTargets`; prompt choices; keys and palette entries.
33- [ ] Commit "Refile and archive in the app".