Commit 039021ab78
Verified · cmc
Layout: unified · split
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/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 |
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)] = [ |