krz/orgstar

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

Sources/OrgCore/Commands/Repeat.swift

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

162 lines · 7569 bytes

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