krz/orgstar

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

Sources/OrgCore/Commands/Timestamps.swift

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

324 lines · 14779 bytes

  1import Foundation
  2
  3// Timestamps and planning, ported from Org 9.8.7: `org-at-timestamp-p`, `org-timestamp`,
  4// `org-timestamp-change`, `org-modify-ts-extra`, `org-add-planning-info`,
  5// `org--deadline-or-schedule`. `org-time-stamp-rounding-minutes` (0 5), `org-adapt-indentation`
  6// nil, `org-log-reschedule` and `org-log-redeadline` nil.
  7
  8extension EmacsBuffer {
  9    static let timestampCore = "(([0-9]{4})-([0-9]{2})-([0-9]{2})(?: *([^\\]+0-9>\\r\\n -]+))?( ([0-9]{1,2}):([0-9]{2}))?)"
 10    /// `org-ts-regexp3`: groups 2 year, 3 month, 4 day, 5 day name, 7 hour, 8 minute.
 11    static let timestampPattern = "[\\[<]" + timestampCore + "[^\\]>\\n]{0,16}[\\]>]"
 12    /// `org-ts-regexp0`, for time strings.
 13    static let timeStringPattern = "(([0-9]{4})-([0-9]{2})-([0-9]{2})( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))?)"
 14
 15    /// `org-in-regexp` on the current line: the match that contains point.
 16    func inRegexp(_ pattern: String) -> [Range<Int>?]? {
 17        let regex = Self.regex(pattern, caseInsensitive: false)
 18        let lineRange = NSRange(lineStart(point)..<lineEnd(point))
 19        for match in regex.matches(in: string, options: Self.searchOptions, range: lineRange) {
 20            let range = match.range.location..<NSMaxRange(match.range)
 21            guard range.lowerBound <= point else { break }
 22            if range.upperBound >= point {
 23                let groups = (0..<match.numberOfRanges).map { i -> Range<Int>? in
 24                    let r = match.range(at: i)
 25                    return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
 26                }
 27                lastMatch = groups
 28                return groups
 29            }
 30        }
 31        return nil
 32    }
 33
 34    enum TimestampPart: Equatable {
 35        case bracket, after, year, month, hour, minute, day
 36        /// Characters past the minutes or day name, in the repeater or end time.
 37        case extra(Int)
 38    }
 39
 40    /// `org-at-timestamp-p` with `lax`: where in a timestamp point is, and the match.
 41    func atTimestamp() -> (part: TimestampPart, match: [Range<Int>?])? {
 42        guard let match = inRegexp(Self.timestampPattern) else { return nil }
 43        let pos = point
 44        func inGroup(_ i: Int) -> Bool { match[i].map { $0.lowerBound <= pos && pos <= $0.upperBound } ?? false }
 45        let whole = match[0]!
 46        let part: TimestampPart
 47        if pos == whole.lowerBound || pos == whole.upperBound - 1 {
 48            part = .bracket
 49        } else if pos == whole.upperBound {
 50            part = .after
 51        } else if inGroup(2) {
 52            part = .year
 53        } else if inGroup(3) {
 54            part = .month
 55        } else if inGroup(7) {
 56            part = .hour
 57        } else if inGroup(8) {
 58            part = .minute
 59        } else if inGroup(4) || inGroup(5) {
 60            part = .day
 61        } else if let last = match[8]?.upperBound ?? match[5]?.upperBound, pos > last, pos < whole.upperBound {
 62            part = .extra(pos - last)
 63        } else {
 64            part = .day
 65        }
 66        return (part, match)
 67    }
 68
 69    /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent.
 70    static func parseTimeString(_ text: String) -> DateComponents? {
 71        guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil }
 72        func group(_ i: Int) -> Int? { match.output[i].substring.flatMap { Int($0) } }
 73        return DateComponents(year: group(2), month: group(3), day: group(4), hour: group(7) ?? 0, minute: group(8) ?? 0)
 74    }
 75
 76    /// `format-time-string` with Org's timestamp formats, plus `extra` before the closing bracket.
 77    func formatTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, extra: String? = nil) -> String {
 78        let date = calendar.date(from: components)!
 79        let formatter = DateFormatter()
 80        formatter.locale = Locale(identifier: "en_US_POSIX")
 81        formatter.calendar = calendar
 82        formatter.timeZone = calendar.timeZone
 83        formatter.dateFormat = withTime ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
 84        return (inactive ? "[" : "<") + formatter.string(from: date) + (extra ?? "") + (inactive ? "]" : ">")
 85    }
 86
 87    /// `org-insert-timestamp`: inserted before markers, as org does.
 88    @discardableResult
 89    func insertTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, endTime: String? = nil, extra: String? = nil) -> String {
 90        var suffix = extra
 91        if let endTime, let match = endTime.firstMatch(of: /([0-9]+):([0-9]+)/) {
 92            suffix = String(format: "-%02d:%02d", Int(match.1)!, Int(match.2)!)
 93        }
 94        let stamp = formatTimestamp(components, withTime: withTime, inactive: inactive, extra: suffix)
 95        insertBeforeMarkers(stamp)
 96        lastInsertedTimestamp = stamp
 97        return stamp
 98    }
 99
100    /// `org-modify-ts-extra`.
101    static func modifyTimestampExtra(_ extra: String, _ position: Int, _ increments: Int, _ step: Int) -> String {
102        let regex = try! NSRegularExpression(pattern: "(-([012][0-9]):([0-5][0-9]))?( +\\+([0-9]+)([dmwy]))?( +-([0-9]+)([dmwy]))?")
103        let ns = extra as NSString
104        guard let match = regex.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return extra }
105        func range(_ i: Int) -> Range<Int>? {
106            let r = match.range(at: i)
107            return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
108        }
109        func inGroup(_ i: Int) -> Bool { range(i).map { $0.lowerBound <= position && position <= $0.upperBound } ?? false }
110        func text(_ i: Int) -> String { ns.substring(with: match.range(at: i)) }
111        let order: [(String, Int)] = [("d", 0), ("w", 1), ("m", 2), ("y", 3), ("d", -1), ("y", 4)]
112        func shifted(_ unit: String) -> String? {
113            let index = order.first { $0.0 == unit }!.1 + increments
114            return order.first { $0.1 == index }?.0
115        }
116        var group: Int?
117        var new = ""
118        if inGroup(2) || inGroup(3) {
119            var minute = Int(text(3))!, hour = Int(text(2))!
120            if inGroup(2) {
121                hour += increments
122            } else {
123                let n = step * increments
124                let rem = minute % step
125                if rem != 0 { minute += n > 0 ? -rem : step - rem }
126                minute += n
127            }
128            if minute < 0 { minute += 60; hour -= 1 }
129            if minute > 59 { minute -= 60; hour += 1 }
130            hour = ((hour % 24) + 24) % 24
131            group = 1
132            new = String(format: "-%02d:%02d", hour, minute)
133        } else if inGroup(6) {
134            group = 6
135            new = shifted(text(6)) ?? ""
136        } else if inGroup(5) {
137            group = 5
138            new = String(max(1, increments + Int(text(5))!))
139        } else if inGroup(9) {
140            group = 9
141            new = shifted(text(9)) ?? ""
142        } else if inGroup(8) {
143            group = 8
144            new = String(max(0, increments + Int(text(8))!))
145        }
146        guard let group, let target = range(group) else { return extra }
147        return ns.replacingCharacters(in: NSRange(target), with: new)
148    }
149
150    enum TimestampUnit { case minute, hour, day, month, year }
151
152    /// `org-timestamp-change`. `what` nil changes the part at point.
153    func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false) throws {
154        let origin = point
155        guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") }
156        if what == nil, category == .bracket {
157            toggleTimestampType(match[0]!)
158            return
159        }
160        let target: TimestampPart = what.map { unit in
161            switch unit {
162            case .minute: .minute
163            case .hour: .hour
164            case .day: .day
165            case .month: .month
166            case .year: .year
167            }
168        } ?? category
169        let whole = match[0]!
170        let inactive = character(at: whole.lowerBound) == 91
171        let stamp = substring(whole.lowerBound, whole.upperBound)
172        replace(whole, with: "")
173        var extra: String?
174        if let found = stamp.firstMatch(of: try! Regex("((-[012][0-9]:[0-5][0-9])?( +[.+]?-?[-+][0-9]+[hdwmy](/[0-9]+[hdwmy])?)*)[\\]>]")) {
175            extra = found.output[1].substring.map(String.init)
176            if suppressDelay, let value = extra { extra = value.replacingOccurrences(of: " --[0-9]+[hdwmy]", with: "", options: .regularExpression) }
177        }
178        let withTime = stamp.range(of: "^.{10}.*?[0-9]+:[0-9][0-9]", options: .regularExpression) != nil
179        var time = Self.parseTimeString(stamp)!
180        var step = 5
181        var increment = n
182        if updown, target == .minute {
183            increment = step * n.signum()
184            let rem = time.minute! % step
185            if rem != 0 { time.minute! += n > 0 ? -rem : step - rem }
186        } else {
187            step = 1
188        }
189        switch target {
190        case .minute: time.minute! += increment
191        case .hour: time.hour! += increment
192        case .day: time.day! += increment
193        case .month: time.month! += increment
194        case .year: time.year! += increment
195        default: break
196        }
197        let normalized = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: calendar.date(from: time)!)
198        if target == .hour || target == .minute, let value = extra, value.range(of: "-([012][0-9]):([0-5][0-9])", options: .regularExpression) != nil {
199            extra = Self.modifyTimestampExtra(value, target == .hour ? 2 : 5, n, step)
200        }
201        if case .extra(let position) = target, let value = extra {
202            extra = Self.modifyTimestampExtra(value, position, n, step)
203        }
204        let insertion = point
205        insertTimestamp(normalized, withTime: withTime, inactive: inactive, extra: extra)
206        point = insertion
207        let after = looking(Self.timestampPattern, caseInsensitive: false)!
208        switch category {
209        case .day:
210            let end = after[5].map { $0.upperBound - 1 } ?? after[7]?.lowerBound ?? after[1]!.upperBound - 1
211            point = min(end, origin)
212        case .hour: point = min(after[7]!.upperBound, origin)
213        case .minute: point = min(after[8]!.upperBound - 1, origin)
214        case .extra: point = min(after[0]!.upperBound - 1, origin)
215        case .after: point = after[0]!.upperBound
216        default: point = origin
217        }
218    }
219
220    /// `org-toggle-timestamp-type`.
221    func toggleTimestampType(_ range: Range<Int>) {
222        saveExcursion {
223            for offset in range {
224                let swapped: String? = switch character(at: offset) {
225                case 91: "<"
226                case 93: ">"
227                case 60: "["
228                case 62: "]"
229                default: nil
230                }
231                if let swapped { replace(offset..<(offset + 1), with: swapped) }
232            }
233        }
234    }
235
236    /// `org-get-compact-tod`: the time of day in a timestamp, as the date prompt shows it.
237    static func compactTimeOfDay(_ stamp: String) -> String? {
238        guard let match = stamp.firstMatch(of: /(([012]?[0-9]):([0-5][0-9]))(-(([012]?[0-9]):([0-5][0-9])))?/) else { return nil }
239        let t1 = String(match.1)
240        guard let t2h = match.6, let t2m = match.7 else { return t1 }
241        var dh = Int(t2h)! - Int(match.2)!
242        var dm = Int(t2m)! - Int(match.3)!
243        if dm < 0 { dm += 60; dh -= 1 }
244        return t1 + "+" + String(dh) + (dm != 0 ? String(format: ":%02d", dm) : "")
245    }
246
247    // MARK: - Planning
248
249    static let planningLinePattern = "^[ \\t]*(CLOSED|DEADLINE|SCHEDULED):"
250    static let keywordTimeNotClock = "\\b(CLOSED|DEADLINE|SCHEDULED):[ \\t]*[\\[<]([^\\]>]+)[\\]>]"
251
252    enum PlanningKind {
253        case closed, deadline, scheduled
254
255        var keyword: String {
256            switch self {
257            case .closed: "CLOSED:"
258            case .deadline: "DEADLINE:"
259            case .scheduled: "SCHEDULED:"
260            }
261        }
262
263        var pattern: String {
264            switch self {
265            case .closed: "\\bCLOSED: *\\[([^\\]]+)\\]"
266            case .deadline: "\\bDEADLINE: *<([^>]+)>"
267            case .scheduled: "\\bSCHEDULED: *<([^>]+)>"
268            }
269        }
270    }
271
272    /// The time string of the entry's planning `kind`, as `org-entry-get`.
273    func planningValue(_ kind: PlanningKind) -> String? {
274        saveExcursion {
275            guard (try? backToHeading()) != nil else { return nil }
276            forwardLine(1)
277            guard lookingAt(Self.planningLinePattern) != nil,
278                  let match = searchForward(kind.pattern, bound: lineEnd(point), caseInsensitive: false) else { return nil }
279            return substring(match[1]!.lowerBound, match[1]!.upperBound)
280        }
281    }
282
283    /// `org-add-planning-info` with a resolved time.
284    func addPlanningInfo(_ what: PlanningKind?, time: DateComponents?, withTime: Bool, endTime: String?, remove: [PlanningKind]) throws {
285        try saveExcursion {
286            try backToHeading()
287            let planning = saveExcursion { () -> Bool in
288                forwardLine(1)
289                return lookingAt(Self.planningLinePattern) != nil
290            }
291            if planning {
292                forwardLine(1)
293                skipForward(Self.blanks)
294                for type in (what.map { [$0] } ?? []) + remove {
295                    saveExcursion {
296                        guard let match = searchForward(type.pattern, bound: lineEnd(point), caseInsensitive: false) else { return }
297                        let start = match[0]!.lowerBound
298                        let end = searchForward(Self.keywordTimeNotClock, bound: lineEnd(point), caseInsensitive: false)?[0]!.lowerBound ?? lineEnd(point)
299                        deleteRegion(start, end)
300                    }
301                }
302                if lookingAt("[ \\t]*$") != nil, what == nil {
303                    deleteRegion(lineStart(point) - 1, lineEnd(point))
304                } else {
305                    let p = point
306                    saveExcursion {
307                        endOfLine()
308                        let end = point
309                        while point > p, let c = character(at: point - 1), c == 32 || c == 9 { point -= 1 }
310                        if point < end { deleteRegion(point, end) }
311                    }
312                }
313            } else if what != nil {
314                endOfLine()
315                insert("\n")
316            }
317            if let what, let time {
318                insert(what.keyword + " ")
319                insertTimestamp(time, withTime: withTime || what == .closed, inactive: what == .closed, endTime: endTime)
320                if !eolp { insert(" ") }
321            }
322        }
323    }
324}