krz/orgstar

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

Sources/OrgCore/Commands/Archive.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgCore/Commands/Archive.swift history · blame · raw

367 lines · 18886 bytes

17 symbols in this file
  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 {
 13                return SetupFile.keywords(settings.setupKeywords).last { $0.key == name.uppercased() }?.value.trimmingCharacters(in: .whitespaces)
 14            }
 15            return substring(match[1]!.lowerBound, match[1]!.upperBound)
 16        }
 17    }
 18
 19    /// `org-back-to-heading-or-point-min`.
 20    func backToHeadingOrPointMin() {
 21        if beforeFirstHeading { point = 0 } else { _ = try? backToHeading() }
 22    }
 23
 24    static let propertyDrawerPattern = "^[ \\t]*:PROPERTIES:[ \\t]*\\n(?:[ \\t]*:\\S+:(?:[ \\t].*)?[ \\t]*\\n)*?[ \\t]*:END:[ \\t]*$"
 25
 26    /// `org-insert-property-drawer`.
 27    func insertPropertyDrawer() {
 28        saveExcursion {
 29            backToHeadingOrPointMin()
 30            if beforeFirstHeading {
 31                while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
 32            } else {
 33                forwardLine(1)
 34                if lookingAt(planningPattern) != nil { forwardLine(1) }
 35            }
 36            guard looking(Self.propertyDrawerPattern, caseInsensitive: true, setsMatch: false) == nil else { return }
 37            if bolp, point > 0 { point -= 1 }
 38            let begin = bobp ? point : point + 1
 39            if !bobp { insert("\n") }
 40            insert(":PROPERTIES:\n:END:")
 41            if eobp || begin == 0 { insert("\n") }
 42        }
 43    }
 44
 45    /// `org-get-property-block` with FORCE: the body range of the entry's property drawer.
 46    func propertyBlock(_ begin: Int) -> Range<Int> {
 47        saveExcursion {
 48            point = begin
 49            if beforeFirstHeading {
 50                while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
 51            } else {
 52                forwardLine(1)
 53                if lookingAt(planningPattern) != nil { forwardLine(1) }
 54            }
 55            if let match = looking(Self.propertyDrawerPattern) {
 56                forwardLine(1)
 57                return point..<lineStart(match[0]!.upperBound)
 58            }
 59            point = begin
 60            insertPropertyDrawer()
 61            let match = searchForward(Self.propertyDrawerPattern)!
 62            let at = lineStart(match[0]!.upperBound)
 63            return at..<at
 64        }
 65    }
 66
 67    /// `org-entry-put` for an ordinary property, with `org-property-format` "%-10s %s".
 68    func entryPut(_ name: String, _ value: String) {
 69        saveExcursion {
 70            backToHeadingOrPointMin()
 71            let range = propertyBlock(point)
 72            point = range.lowerBound
 73            let end = range.upperBound
 74            if let match = searchForward("^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + ":(?:[ \\t]+.*?)?[ \\t]*$", bound: end) {
 75                deleteRegion(match[0]!.lowerBound, match[0]!.upperBound)
 76                point = match[0]!.lowerBound
 77            } else {
 78                point = end
 79                insert("\n")
 80                point -= 1
 81            }
 82            insert(":" + name + ":")
 83            insert(" " + value)
 84            alignNodeProperty()
 85        }
 86    }
 87
 88    /// `org--align-node-property`, with the indentation `org-indent-line` gives property lines
 89    /// when `org-adapt-indentation` is nil.
 90    func alignNodeProperty() {
 91        let start = lineStart(point)
 92        let line = substring(start, lineEnd(point))
 93        guard let match = line.range(of: "^([ \\t]*)(:\\S+:)(?:[ \\t]+(.*?))?[ \\t]*$", options: .regularExpression) else { return }
 94        let parts = line[match]
 95        let regex = try! NSRegularExpression(pattern: "^([ \\t]*)(:\\S+:)(?:[ \\t]+(.*?))?[ \\t]*$")
 96        let groups = regex.firstMatch(in: String(parts), range: NSRange(location: 0, length: (String(parts) as NSString).length))!
 97        let ns = String(parts) as NSString
 98        let key = ns.substring(with: groups.range(at: 2))
 99        let value = groups.range(at: 3).location == NSNotFound ? "" : ns.substring(with: groups.range(at: 3))
100        let padded = key.padding(toLength: max(10, (key as NSString).length), withPad: " ", startingAt: 0)
101        let new = (padded + " " + value).trimmingCharacters(in: .whitespaces)
102        let end = lineEnd(point)
103        let lineStartOffset = start
104        point = lineStartOffset
105        deleteRegion(lineStartOffset, end)
106        insert(new)
107    }
108
109    /// The entry's tags, and its inherited tags (file tags and ancestors' tags), in org's order.
110    func tags() -> (local: [String], inherited: [String]) {
111        saveExcursion {
112            guard (try? backToHeading()) != nil else { return ([], []) }
113            func lineTags(_ at: Int) -> [String] {
114                saveExcursion {
115                    point = at
116                    guard let match = lookingAt(SetTags.tagLine), let tags = match[2] else { return [] }
117                    return substring(tags.lowerBound, tags.upperBound).split(separator: ":").map(String.init)
118                }
119            }
120            let local = lineTags(point)
121            var ancestors: [[String]] = []
122            while upHeadingSafe() != nil { ancestors.insert(lineTags(point), at: 0) }
123            var inherited: [String] = []
124            let fileTags = (keyword("FILETAGS") ?? "").split(whereSeparator: { $0 == ":" || $0 == " " || $0 == "\t" }).map(String.init)
125            for tag in fileTags + ancestors.flatMap({ $0 }) where !inherited.contains(tag) && !local.contains(tag) { inherited.append(tag) }
126            return (local, inherited)
127        }
128    }
129
130    /// `org-get-outline-path`: ancestors' titles without statistics cookies, links shown as
131    /// their descriptions.
132    func outlinePath() -> [String] {
133        saveExcursion {
134            guard (try? backToHeading()) != nil else { return [] }
135            var path: [String] = []
136            while upHeadingSafe() != nil {
137                let title = saveExcursion { () -> String in
138                    guard let match = lookingAt(complexHeadingPattern), let title = match[4] else { return "" }
139                    var text = substring(title.lowerBound, title.upperBound)
140                    text = text.replacingOccurrences(of: "\\[[0-9]+%\\]|\\[[0-9]+/[0-9]+\\]", with: "", options: .regularExpression)
141                    text = text.replacingOccurrences(of: "\\[\\[([^\\]]*)\\]\\[([^\\]]*)\\]\\]", with: "$2", options: .regularExpression)
142                    text = text.replacingOccurrences(of: "\\[\\[([^\\]]*)\\]\\]", with: "$1", options: .regularExpression)
143                    return text.trimmingCharacters(in: .whitespacesAndNewlines)
144                }
145                path.insert(title, at: 0)
146            }
147            return path
148        }
149    }
150
151    /// `org-update-statistics-cookies` with point on a heading.
152    func updateStatisticsCookies() {
153        guard atHeading else {
154            updateCheckboxCount()
155            return
156        }
157        saveExcursion {
158            let start = try! backToHeading()
159            let level = outlineLevel
160            var childLevel: Int?
161            let end = saveExcursion { () -> Int in
162                if outlineNextHeading() { childLevel = outlineLevel }
163                return point
164            }
165            let hasBoxes = saveExcursion { searchForward("^[ \\t]*([-+*]|[0-9]+[.)]) \\[[- X]\\]", bound: end) != nil }
166            let todoData = saveExcursion { searchForward(":COOKIE_DATA:.*\\btodo\\b", bound: end) != nil }
167            if hasBoxes, !todoData {
168                updateCheckboxCount()
169            } else if let childLevel, childLevel > level {
170                point = end
171                updateParentTodoStatistics()
172            } else {
173                point = start
174                while let match = searchForward("((\\[[0-9]*%\\])|(\\[[0-9]*/[0-9]*\\]))", bound: lineEnd(point)) {
175                    replace(match[1]!, with: match[2] != nil ? "[100%]" : "[0/0]")
176                }
177            }
178        }
179    }
180}
181
182public enum Archive {
183    /// Where the entry at the caret goes: the archive file (the source itself for an in-file
184    /// location) and the heading to file it under, if any.
185    public struct Destination: Sendable, Equatable {
186        public let path: String
187        public let heading: String?
188        public var isSameFile: Bool
189    }
190
191    public static func destination(_ context: EditContext, sourcePath: String) -> Result<Destination, ArchiveError> {
192        let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
193        guard !buffer.beforeFirstHeading else { return .failure(.message("Before first headline")) }
194        let spec = buffer.inheritedProperty("ARCHIVE")?.value ?? buffer.keyword("ARCHIVE") ?? "%s_archive::"
195        guard let separator = spec.range(of: "::") else { return .failure(.message("Invalid archive location: \"\(spec)\"")) }
196        let name = (sourcePath as NSString).lastPathComponent
197        let fileFormat = String(spec[..<separator.lowerBound])
198        let headingFormat = String(spec[separator.upperBound...])
199        let path: String
200        if fileFormat.contains(where: { !$0.isWhitespace }) {
201            let file = fileFormat.replacingOccurrences(of: "%s", with: name)
202            let directory = (sourcePath as NSString).deletingLastPathComponent
203            path = ((file as NSString).isAbsolutePath ? file : (directory as NSString).appendingPathComponent(file)).replacingOccurrences(of: "~", with: NSHomeDirectory(), options: .anchored)
204        } else {
205            path = sourcePath
206        }
207        let heading = headingFormat.replacingOccurrences(of: "%s", with: name)
208        return .success(Destination(path: (path as NSString).standardizingPath, heading: heading.isEmpty ? nil : heading, isSameFile: path == sourcePath))
209    }
210
211    public enum ArchiveError: Error, Equatable {
212        case message(String)
213    }
214
215    public struct Edits: Sendable, Equatable {
216        /// For the source; for an in-file archive, everything happens here.
217        public let source: EditResult
218        /// For the archive file, against its text (empty when it doesn't exist yet).
219        public let archive: TextEdit?
220    }
221
222    /// `~/` for paths in the home folder, as `abbreviate-file-name`.
223    static func abbreviate(_ path: String) -> String {
224        let home = NSHomeDirectory()
225        return path.hasPrefix(home + "/") ? "~" + path.dropFirst(home.count) : path
226    }
227
228    /// Archives the subtree at the caret. `archiveText` is the archive file's text, or nil
229    /// when it doesn't exist; it's ignored for an in-file destination.
230    public static func run(
231        _ context: EditContext, sourcePath: String, destination: Destination, archiveText: String?
232    ) -> Result<Edits, ArchiveError> {
233        let formatter = DateFormatter()
234        formatter.locale = Locale(identifier: "en_US_POSIX")
235        formatter.calendar = context.calendar
236        formatter.timeZone = context.calendar.timeZone
237        formatter.dateFormat = "yyyy-MM-dd EEE HH:mm"
238        let time = formatter.string(from: context.now)
239        let archive = destination.isSameFile ? nil : EmacsBuffer(archiveText ?? "", point: 0, settings: context.tree.settings, options: context.options)
240        var archiveEdit: TextEdit?
241        let step = commitBuffer(context) { source in
242            let target = archive ?? source
243            let level: Int
244            var headingText: String?
245            var heading = destination.heading
246            // `datetree/`: three levels of date headings, stars after it a heading below them.
247            var datetree: DateComponents?
248            var datetreeSubheading = false
249            if let h = heading, let m = h.firstMatch(of: /^datetree\/(\**)/) {
250                let nsub = m.1.count
251                heading = String(repeating: "*", count: 3 + nsub) + h[m.range.upperBound...]
252                datetreeSubheading = nsub > 0
253                let closed = source.saveExcursion { () -> String? in
254                    _ = try? source.backToHeading()
255                    source.forwardLine(1)
256                    guard let line = source.lookingAt("^[ \\t]*(?:SCHEDULED|DEADLINE|CLOSED):.*$") else { return nil }
257                    return source.substring(line[0]!.lowerBound, line[0]!.upperBound).firstMatch(of: /CLOSED: *\[([^\]]+)\]/).map { String($0.1) }
258                }
259                datetree = EmacsBuffer.parseTimeString(closed ?? time)
260            }
261            if let heading, let stars = heading.range(of: "^\\*+", options: .regularExpression) {
262                level = heading[stars].count
263                headingText = heading
264            } else {
265                level = 0
266                headingText = nil
267            }
268            let origin = source.marker(source.point)
269            defer { source.release(origin) }
270            try source.backToHeading()
271            let (local, inherited) = source.tags()
272            let category = source.inheritedProperty("CATEGORY")?.value ?? source.keyword("CATEGORY")
273                ?? ((sourcePath as NSString).lastPathComponent as NSString).deletingPathExtension
274            let todo = source.lookingAt(source.todoLinePattern)?[2].map { source.substring($0.lowerBound, $0.upperBound) }
275            let context: [(String, String)] = [
276                ("TIME", time), ("FILE", abbreviate(sourcePath)), ("OLPATH", source.outlinePath().joined(separator: "/")),
277                ("CATEGORY", category), ("TODO", todo ?? ""), ("ITAGS", inherited.joined(separator: " ")),
278            ]
279            let tree = try source.copySubtree()
280            try target.saveExcursion {
281                if !destination.isSameFile, archiveText == nil {
282                    target.point = target.length
283                    target.insert("\nArchived entries from file \(sourcePath)\n\n")
284                }
285                // The date's entry, as `org-datetree-find-date-create` then `org-narrow-to-subtree`.
286                let low = target.marker(0)
287                let high = target.marker(target.length, advances: true)
288                defer {
289                    target.release(low)
290                    target.release(high)
291                }
292                if let date = datetree {
293                    let day = Datetree.findCreate(target, under: nil, tree: .day, date: date)
294                    low.position = day
295                    high.position = Datetree.narrowEnd(target, day, limit: target.length)
296                }
297                target.point = low.position
298                if let headingText, !(datetree != nil && !datetreeSubheading) {
299                    let pattern = "^" + NSRegularExpression.escapedPattern(for: headingText) + "([ \\t]+:([[:alnum:]_@#%]+:)+)?[ \\t]*$"
300                    if let match = target.searchForward(pattern, bound: high.position) {
301                        target.point = match[0]!.upperBound
302                    } else {
303                        target.point = high.position
304                        if !target.bolp { target.insert("\n") }
305                        target.insert((datetree != nil ? "" : "\n") + headingText + "\n")
306                        target.forwardLine(-1)
307                        target.endOfLine()
308                    }
309                    target.endOfSubtree()
310                    target.point = min(target.point, high.position)
311                    target.skipBackward(EmacsBuffer.whitespace)
312                    target.point = max(target.point, low.position)
313                    let blanks = target.looking("[ \\t\\r\\n]*")!
314                    let clipped = blanks[0]!.lowerBound..<min(blanks[0]!.upperBound, high.position)
315                    let separator = datetree != nil ? "\n" : "\n\n"
316                    target.replace(clipped, with: separator)
317                    target.point = clipped.lowerBound + separator.count
318                } else {
319                    target.point = high.position
320                    if !(datetree != nil && target.bolp) { target.insert("\n") }
321                }
322                try target.pasteSubtree(level: max(1, level + (headingText == nil ? 0 : 1)), tree: tree, limit: datetree != nil ? high.position : nil)
323                if !inherited.isEmpty, destination.isSameFile {
324                    let all = inherited + local
325                    target.saveExcursion {
326                        let line = target.saveExcursion { target.lookingAt(SetTags.tagLine) }
327                        if let tags = line?[1] {
328                            target.point = tags.lowerBound
329                            target.skipBackward(EmacsBuffer.blanks)
330                            target.deleteRegion(target.point, target.lineEnd(target.point))
331                        } else {
332                            target.endOfLine()
333                        }
334                        target.saveExcursion { target.insert(" :" + all.joined(separator: ":") + ":") }
335                    }
336                    target.alignTags(onLineAt: target.point)
337                }
338                for (key, value) in context where value.contains(where: { !$0.isWhitespace }) {
339                    target.entryPut("ARCHIVE_" + key, value)
340                }
341            }
342            source.point = origin.position
343            let range = try source.subtreeRange()
344            source.deleteRegion(range.lowerBound, range.upperBound)
345            source.saveExcursion {
346                source.upHeadingSafe()
347                source.updateStatisticsCookies()
348            }
349            if source.looking("^[ \\t]*$", setsMatch: false) != nil {
350                source.endOfLine()
351                source.point = source.nextHeadingStart(after: source.point) ?? source.length
352            }
353        }
354        if let archive {
355            let original = archiveText ?? ""
356            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) }) {
357                archiveEdit = result.edits.first
358            }
359        }
360        switch step {
361        case .commit(let result): return .success(Edits(source: result, archive: archiveEdit))
362        case .failed(let message): return .failure(.message(message))
363        case .prompt: return .failure(.message("unexpected prompt"))
364        case .external: return .failure(.message("unexpected external request"))
365        }
366    }
367}