krz/orgstar

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

Sources/OrgCore/Commands/Repeat.swift

c1295824f55c7bba681fca8338bf08167e110105
orgstar/Sources/OrgCore/Commands/Repeat.swift history · blame · raw

162 lines · 7990 bytes

  1import Foundation
  2
  3// Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and where state notes go
  4// (`org-log-beginning`), with `org-todo-repeat-to-state` nil.
  5
  6extension EmacsBuffer {
  7    static let repeatPattern = "<[0-9]{4}-[0-9][0-9]-[0-9][0-9] [^>\\n]*?([.+]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?)"
  8
  9    /// `org-entry-end-position`: the next heading, or the end of the buffer.
 10    var entryEnd: Int {
 11        saveExcursion {
 12            _ = try? backToHeading()
 13            outlineNextHeading()
 14            return point
 15        }
 16    }
 17
 18    /// `org-get-repeat`.
 19    func repeatCookie() -> String? {
 20        saveExcursion {
 21            guard (try? backToHeading()) != nil else { return nil }
 22            let end = entryEnd
 23            return searchForward(Self.repeatPattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
 24        }
 25    }
 26
 27    /// The TODO keyword of the heading at point, if any.
 28    var todoKeyword: String? {
 29        saveExcursion {
 30            guard (try? backToHeading()) != nil, let match = lookingAt(todoLinePattern), let keyword = match[2] else { return nil }
 31            return substring(keyword.lowerBound, keyword.upperBound)
 32        }
 33    }
 34
 35    /// The keyword change `org-todo` makes, with its tag alignment and statistics update.
 36    func setTodoKeyword(_ keyword: String?) {
 37        saveExcursion {
 38            guard (try? backToHeading()) != nil, let stars = lookingAt("\\*+ ") else { return }
 39            point = stars[0]!.upperBound - 1
 40            let names = keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
 41            let match = lookingAt(" +(" + names + ")( +|[ \\t]*$)") ?? lookingAt("(?: *|[ \\t]*$)")!
 42            replace(match[0]!, with: "")
 43            point = match[0]!.lowerBound
 44            insertBeforeMarkers(keyword.map { " " + $0 + " " } ?? " ")
 45            alignTags(onLineAt: point)
 46            updateParentTodoStatistics()
 47        }
 48    }
 49
 50    /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the state note the
 51    /// repeat logs, if any.
 52    func autoRepeat(doneWord: String, previous: String?, log: LogSettings) throws -> LogSetup? {
 53        guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil }
 54        let end = marker(entryEnd)
 55        defer { release(end) }
 56        let sequence = settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == previous } }
 57        let head = sequence.flatMap { ($0.active + $0.done).first?.name }
 58        let propertyState = inheritedProperty("REPEAT_TO_STATE")?.value
 59        let toState = propertyState.flatMap { keywordsInOrder.contains($0) ? $0 : nil }
 60            ?? (sequence?.kind == .type ? previous : head)
 61        setTodoKeyword(toState)
 62        try backToHeading()
 63        try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed])
 64        let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true)
 65        if log.repeatHow != nil { entryPut("LAST_REPEAT", stamp) }
 66        if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil {
 67            saveExcursion { removePlanning(.scheduled) }
 68        }
 69        let units: [String: TimestampUnit] = ["h": .hour, "d": .day, "m": .month, "y": .year]
 70        let today = calendar.startOfDay(for: now)
 71        while let match = searchForward(Self.repeatPattern, bound: end.position, caseInsensitive: false) {
 72            let stamp = substring(match[0]!.lowerBound, match[0]!.upperBound)
 73            guard let parts = stamp.firstMatch(of: /([.+])?(\+[0-9]+)([hdwmy])/) else { continue }
 74            var n = Int(parts.2.dropFirst())!
 75            var what = String(parts.3)
 76            if what == "w" { n *= 7; what = "d" }
 77            guard let unit = units[what] else { continue }
 78            if what == "h", stamp.range(of: "[0-9]{1,2}:[0-9]{2}", options: .regularExpression) == nil {
 79                throw UserError(message: "Cannot repeat in \(n) hour(s) because no hour has been set")
 80            }
 81            if let kind = parts.1 {
 82                let time = Self.parseTimeString(stamp).map(date)!
 83                if kind == "." {
 84                    if what == "h" {
 85                        try changeTimestamp(Int((now.timeIntervalSince(time) / 60).rounded(.down)), what: .minute)
 86                    } else {
 87                        let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: time), to: today).day!
 88                        try changeTimestamp(days, what: .day)
 89                    }
 90                } else {
 91                    var shifts = 0
 92                    var current = time
 93                    while shifts == 0 || (what == "h" ? current <= now : calendar.startOfDay(for: current) <= today) {
 94                        shifts += 1
 95                        if shifts > 100_000 { break }
 96                        try changeTimestamp(n, what: unit)
 97                        let found = inRegexp(Self.timestampPattern)!
 98                        current = Self.parseTimeString(substring(found[1]!.lowerBound, found[1]!.upperBound)).map(date)!
 99                    }
100                    try changeTimestamp(-n, what: unit)
101                }
102            }
103            try saveExcursion { try changeTimestamp(n, what: unit, suppressDelay: true) }
104        }
105        return log.repeatHow.map { LogSetup(purpose: .state, state: doneWord, previous: previous, how: $0, drawer: log.drawer) }
106    }
107
108    /// `org-end-of-meta-data` without FULL.
109    func endOfMetaData() {
110        _ = try? backToHeading()
111        forwardLine(1)
112        if lookingAt(Self.planningLinePattern) != nil { forwardLine(1) }
113        if let drawer = looking(Self.propertyDrawerPattern) {
114            point = drawer[0]!.upperBound
115            forwardLine(1)
116        }
117    }
118
119    /// `org-log-beginning` with `org-log-states-order-reversed` t: the newest note comes first.
120    /// With `drawer`, notes go at the top of that drawer, which `create` adds when missing.
121    func logBeginning(drawer: String? = nil, create: Bool = false) -> Int {
122        saveExcursion {
123            if let drawer { return drawerLogBeginning(drawer, create: create) }
124            endOfMetaData()
125            let endpos = point
126            skipForward([32, 9, 10])
127            forwardLine(0)
128            if point < endpos { point = endpos }
129            if bolp { return point }
130            let end = lineEnd(point)
131            return end < length ? end + 1 : length
132        }
133    }
134
135    private func drawerLogBeginning(_ drawer: String, create: Bool) -> Int {
136        endOfMetaData()
137        let start = point
138        let end = atHeading ? point : saveExcursion { outlineNextHeading(); return point }
139        let name = NSRegularExpression.escapedPattern(for: drawer)
140        while let match = searchForward("^[ \\t]*:" + name + ":[ \\t]*$", bound: end, caseInsensitive: true) {
141            let closed = saveExcursion { searchForward("^[ \\t]*:END:[ \\t]*$", bound: end, caseInsensitive: true) != nil }
142            if closed {
143                point = match[0]!.upperBound
144                return bolp ? point : lineEnd(point) < length ? lineEnd(point) + 1 : length
145            }
146        }
147        point = start
148        guard create else { return bolp ? point : lineEnd(point) < length ? lineEnd(point) + 1 : length }
149        if !eobp, atHeading { point -= 1 }
150        let atBlankLine = lookingAt("^[ \\t]*$") != nil
151        let atNonBlankLineStart = bolp && !eolp
152        if !bolp { insert("\n") }
153        insert(":" + drawer + ":")
154        insert("\n:END:")
155        if atBlankLine || atNonBlankLineStart {
156            insert("\n")
157            point -= 1
158        }
159        point = lineStart(point) - 1
160        return lineStart(point) == point ? point : lineEnd(point) + 1
161    }
162}