krz/orgstar

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

Sources/OrgCore/Commands/Archive.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/OrgCore/Commands/Archive.swift history · blame · raw

333 lines · 16622 bytes

  1import Foundation
  2
  3// Archiving (`org-archive-subtree`, Org 9.8.7) with the defaults: location "%s_archive::",
  4// context info time, file, olpath, category, todo and itags, inherited tags added only within
  5// the same file, no marking DONE, the archive file's header for a new file.
  6
  7extension EmacsBuffer {
  8    /// The first value of `#+NAME:` in the buffer, as `org-collect-keywords` with UNIQUE.
  9    func keyword(_ name: String) -> String? {
 10        saveExcursion {
 11            point = 0
 12            guard let match = searchForward("^[ \\t]*#\\+" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]*(.*?)[ \\t]*$") else { return nil }
 13            return substring(match[1]!.lowerBound, match[1]!.upperBound)
 14        }
 15    }
 16
 17    /// `org-back-to-heading-or-point-min`.
 18    func backToHeadingOrPointMin() {
 19        if beforeFirstHeading { point = 0 } else { _ = try? backToHeading() }
 20    }
 21
 22    static let propertyDrawerPattern = "^[ \\t]*:PROPERTIES:[ \\t]*\\n(?:[ \\t]*:\\S+:(?:[ \\t].*)?[ \\t]*\\n)*?[ \\t]*:END:[ \\t]*$"
 23
 24    /// `org-insert-property-drawer`.
 25    func insertPropertyDrawer() {
 26        saveExcursion {
 27            backToHeadingOrPointMin()
 28            if beforeFirstHeading {
 29                while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
 30            } else {
 31                forwardLine(1)
 32                if lookingAt(planningPattern) != nil { forwardLine(1) }
 33            }
 34            guard looking(Self.propertyDrawerPattern, caseInsensitive: true, setsMatch: false) == nil else { return }
 35            if bolp, point > 0 { point -= 1 }
 36            let begin = bobp ? point : point + 1
 37            if !bobp { insert("\n") }
 38            insert(":PROPERTIES:\n:END:")
 39            if eobp || begin == 0 { insert("\n") }
 40        }
 41    }
 42
 43    /// `org-get-property-block` with FORCE: the body range of the entry's property drawer.
 44    func propertyBlock(_ begin: Int) -> Range<Int> {
 45        saveExcursion {
 46            point = begin
 47            if beforeFirstHeading {
 48                while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
 49            } else {
 50                forwardLine(1)
 51                if lookingAt(planningPattern) != nil { forwardLine(1) }
 52            }
 53            if let match = looking(Self.propertyDrawerPattern) {
 54                forwardLine(1)
 55                return point..<lineStart(match[0]!.upperBound)
 56            }
 57            point = begin
 58            insertPropertyDrawer()
 59            let match = searchForward(Self.propertyDrawerPattern)!
 60            let at = lineStart(match[0]!.upperBound)
 61            return at..<at
 62        }
 63    }
 64
 65    /// `org-entry-put` for an ordinary property, with `org-property-format` "%-10s %s".
 66    func entryPut(_ name: String, _ value: String) {
 67        saveExcursion {
 68            backToHeadingOrPointMin()
 69            let range = propertyBlock(point)
 70            point = range.lowerBound
 71            let end = range.upperBound
 72            if let match = searchForward("^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + ":(?:[ \\t]+.*?)?[ \\t]*$", bound: end) {
 73                deleteRegion(match[0]!.lowerBound, match[0]!.upperBound)
 74                point = match[0]!.lowerBound
 75            } else {
 76                point = end
 77                insert("\n")
 78                point -= 1
 79            }
 80            insert(":" + name + ":")
 81            insert(" " + value)
 82            alignNodeProperty()
 83        }
 84    }
 85
 86    /// `org--align-node-property`, with the indentation `org-indent-line` gives property lines
 87    /// when `org-adapt-indentation` is nil.
 88    func alignNodeProperty() {
 89        let start = lineStart(point)
 90        let line = substring(start, lineEnd(point))
 91        guard let match = line.range(of: "^([ \\t]*)(:\\S+:)(?:[ \\t]+(.*?))?[ \\t]*$", options: .regularExpression) else { return }
 92        let parts = line[match]
 93        let regex = try! NSRegularExpression(pattern: "^([ \\t]*)(:\\S+:)(?:[ \\t]+(.*?))?[ \\t]*$")
 94        let groups = regex.firstMatch(in: String(parts), range: NSRange(location: 0, length: (String(parts) as NSString).length))!
 95        let ns = String(parts) as NSString
 96        let key = ns.substring(with: groups.range(at: 2))
 97        let value = groups.range(at: 3).location == NSNotFound ? "" : ns.substring(with: groups.range(at: 3))
 98        let padded = key.padding(toLength: max(10, (key as NSString).length), withPad: " ", startingAt: 0)
 99        let new = (padded + " " + value).trimmingCharacters(in: .whitespaces)
100        let end = lineEnd(point)
101        let lineStartOffset = start
102        point = lineStartOffset
103        deleteRegion(lineStartOffset, end)
104        insert(new)
105    }
106
107    /// The entry's tags, and its inherited tags (file tags and ancestors' tags), in org's order.
108    func tags() -> (local: [String], inherited: [String]) {
109        saveExcursion {
110            guard (try? backToHeading()) != nil else { return ([], []) }
111            func lineTags(_ at: Int) -> [String] {
112                saveExcursion {
113                    point = at
114                    guard let match = lookingAt(SetTags.tagLine), let tags = match[2] else { return [] }
115                    return substring(tags.lowerBound, tags.upperBound).split(separator: ":").map(String.init)
116                }
117            }
118            let local = lineTags(point)
119            var ancestors: [[String]] = []
120            while upHeadingSafe() != nil { ancestors.insert(lineTags(point), at: 0) }
121            var inherited: [String] = []
122            let fileTags = (keyword("FILETAGS") ?? "").split(whereSeparator: { $0 == ":" || $0 == " " || $0 == "\t" }).map(String.init)
123            for tag in fileTags + ancestors.flatMap({ $0 }) where !inherited.contains(tag) && !local.contains(tag) { inherited.append(tag) }
124            return (local, inherited)
125        }
126    }
127
128    /// `org-get-outline-path`: ancestors' titles without statistics cookies, links shown as
129    /// their descriptions.
130    func outlinePath() -> [String] {
131        saveExcursion {
132            guard (try? backToHeading()) != nil else { return [] }
133            var path: [String] = []
134            while upHeadingSafe() != nil {
135                let title = saveExcursion { () -> String in
136                    guard let match = lookingAt(complexHeadingPattern), let title = match[4] else { return "" }
137                    var text = substring(title.lowerBound, title.upperBound)
138                    text = text.replacingOccurrences(of: "\\[[0-9]+%\\]|\\[[0-9]+/[0-9]+\\]", with: "", options: .regularExpression)
139                    text = text.replacingOccurrences(of: "\\[\\[([^\\]]*)\\]\\[([^\\]]*)\\]\\]", with: "$2", options: .regularExpression)
140                    text = text.replacingOccurrences(of: "\\[\\[([^\\]]*)\\]\\]", with: "$1", options: .regularExpression)
141                    return text.trimmingCharacters(in: .whitespacesAndNewlines)
142                }
143                path.insert(title, at: 0)
144            }
145            return path
146        }
147    }
148
149    /// `org-update-statistics-cookies` with point on a heading.
150    func updateStatisticsCookies() {
151        guard atHeading else {
152            updateCheckboxCount()
153            return
154        }
155        saveExcursion {
156            let start = try! backToHeading()
157            let level = outlineLevel
158            var childLevel: Int?
159            let end = saveExcursion { () -> Int in
160                if outlineNextHeading() { childLevel = outlineLevel }
161                return point
162            }
163            let hasBoxes = saveExcursion { searchForward("^[ \\t]*([-+*]|[0-9]+[.)]) \\[[- X]\\]", bound: end) != nil }
164            let todoData = saveExcursion { searchForward(":COOKIE_DATA:.*\\btodo\\b", bound: end) != nil }
165            if hasBoxes, !todoData {
166                updateCheckboxCount()
167            } else if let childLevel, childLevel > level {
168                point = end
169                updateParentTodoStatistics()
170            } else {
171                point = start
172                while let match = searchForward("((\\[[0-9]*%\\])|(\\[[0-9]*/[0-9]*\\]))", bound: lineEnd(point)) {
173                    replace(match[1]!, with: match[2] != nil ? "[100%]" : "[0/0]")
174                }
175            }
176        }
177    }
178}
179
180public enum Archive {
181    /// Where the entry at the caret goes: the archive file (the source itself for an in-file
182    /// location) and the heading to file it under, if any.
183    public struct Destination: Sendable, Equatable {
184        public let path: String
185        public let heading: String?
186        public var isSameFile: Bool
187    }
188
189    public static func destination(_ context: EditContext, sourcePath: String) -> Result<Destination, ArchiveError> {
190        let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
191        guard !buffer.beforeFirstHeading else { return .failure(.message("Before first headline")) }
192        let spec = buffer.inheritedProperty("ARCHIVE")?.value ?? buffer.keyword("ARCHIVE") ?? "%s_archive::"
193        guard let separator = spec.range(of: "::") else { return .failure(.message("Invalid archive location: \"\(spec)\"")) }
194        let name = (sourcePath as NSString).lastPathComponent
195        let fileFormat = String(spec[..<separator.lowerBound])
196        let headingFormat = String(spec[separator.upperBound...])
197        let path: String
198        if fileFormat.contains(where: { !$0.isWhitespace }) {
199            let file = fileFormat.replacingOccurrences(of: "%s", with: name)
200            let directory = (sourcePath as NSString).deletingLastPathComponent
201            path = ((file as NSString).isAbsolutePath ? file : (directory as NSString).appendingPathComponent(file)).replacingOccurrences(of: "~", with: NSHomeDirectory(), options: .anchored)
202        } else {
203            path = sourcePath
204        }
205        let heading = headingFormat.replacingOccurrences(of: "%s", with: name)
206        return .success(Destination(path: (path as NSString).standardizingPath, heading: heading.isEmpty ? nil : heading, isSameFile: path == sourcePath))
207    }
208
209    public enum ArchiveError: Error, Equatable {
210        case message(String)
211    }
212
213    public struct Edits: Sendable, Equatable {
214        /// For the source; for an in-file archive, everything happens here.
215        public let source: EditResult
216        /// For the archive file, against its text (empty when it doesn't exist yet).
217        public let archive: TextEdit?
218    }
219
220    /// `~/` for paths in the home folder, as `abbreviate-file-name`.
221    static func abbreviate(_ path: String) -> String {
222        let home = NSHomeDirectory()
223        return path.hasPrefix(home + "/") ? "~" + path.dropFirst(home.count) : path
224    }
225
226    /// Archives the subtree at the caret. `archiveText` is the archive file's text, or nil
227    /// when it doesn't exist; it's ignored for an in-file destination.
228    public static func run(
229        _ context: EditContext, sourcePath: String, destination: Destination, archiveText: String?
230    ) -> Result<Edits, ArchiveError> {
231        let formatter = DateFormatter()
232        formatter.locale = Locale(identifier: "en_US_POSIX")
233        formatter.calendar = context.calendar
234        formatter.timeZone = context.calendar.timeZone
235        formatter.dateFormat = "yyyy-MM-dd EEE HH:mm"
236        let time = formatter.string(from: context.now)
237        let archive = destination.isSameFile ? nil : EmacsBuffer(archiveText ?? "", point: 0, settings: context.tree.settings, options: context.options)
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        case .external: return .failure(.message("unexpected external request"))
331        }
332    }
333}