Refile and archive !15
23 files changed, +1207 −7
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +99
| @@ -5,6 +5,36 @@ import OrgDocument | |||
| 5 | import OrgIndex | 5 | import OrgIndex |
| 6 | import OrgPresentation | 6 | import OrgPresentation |
| 7 | 7 | ||
| 8 | /// A heading, or a whole file, a subtree can be refiled to. | ||
| 9 | public 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 | |||
| 8 | public enum SaveMode: String, Sendable, CaseIterable { | 38 | public 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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | import OrgDocument | 3 | import OrgDocument |
| 4 | import OrgPresentation | ||
| 4 | import OrgIndex | 5 | import OrgIndex |
| 5 | import OrgWorkspace | 6 | import 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 @@ | |||
| 1 | import 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 | |||
| 7 | extension 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 | |||
| 180 | public 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. | ||
| 56 | public 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 | |||
| 9 | final class EmacsBuffer { | 9 | final 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 @@ | |||
| 1 | import 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 | |||
| 6 | extension 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. | ||
| 107 | public 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. | ||
| 122 | public 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 @@ | |||
| 1 | import 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 | |||
| 6 | extension 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. | ||
| 127 | func 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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import 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 |
| 6 | public protocol DocumentBuffer: AnyObject { | 7 | public 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 |
| 14 | public final class StateBuffer: DocumentBuffer { | 19 | public 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 | ||
| 587 | extension OrgEditor: DocumentBuffer { | 587 | extension 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. |
| 15 | public 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 | |||
| 15 | public struct HeadingLocation: Sendable, Hashable { | 22 | public 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 | ||
| 94 | extension 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. |
| 72 | struct PaletteView: View { | 105 | struct 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 | ||
| 231 | struct 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 | ||
| 269 | enum 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct 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". | ||