krz/orgstar

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

Sources/OrgCore/Commands/Timestamps.swift

355 lines · 16237 bytes

22 symbols in this file
  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-at-date-range-p` with INACTIVE-OK: the range around point, groups 1 and 2 the insides
 70    /// of its two timestamps.
 71    func atDateRange() -> [Range<Int>?]? {
 72        let pattern = "[\\[<](" + AgendaSource.tsInternal + ")[\\]>]--?-?[\\[<](" + AgendaSource.tsInternal + ")[\\]>]"
 73        return saveExcursion {
 74            let pos = point
 75            for _ in 0..<2 {
 76                while let c = character(at: point - 1), ![91, 60, 13, 10].contains(c) { point -= 1 }
 77                skipBackward([60, 91])
 78                if let match = lookingAt(pattern), match[0]!.upperBound >= pos { return match }
 79            }
 80            return nil
 81        }
 82    }
 83
 84    /// The timestamp `org-timestamp` takes its defaults from: in a date range the start before
 85    /// the dashes and the end from the second-to-last dash on, otherwise the one at point.
 86    func timestampForUpdate() -> String? {
 87        if let range = atDateRange() {
 88            let stamp = point < range[2]!.lowerBound - 2 ? range[1]! : range[2]!
 89            return substring(stamp.lowerBound, stamp.upperBound)
 90        }
 91        return atTimestamp().map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
 92    }
 93
 94    /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent.
 95    static func parseTimeString(_ text: String) -> DateComponents? {
 96        guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil }
 97        func group(_ i: Int) -> Int? { match.output[i].substring.flatMap { Int($0) } }
 98        return DateComponents(year: group(2), month: group(3), day: group(4), hour: group(7) ?? 0, minute: group(8) ?? 0)
 99    }
100
101    /// `format-time-string` with Org's timestamp formats, plus `extra` before the closing bracket.
102    func formatTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, extra: String? = nil) -> String {
103        let date = calendar.date(from: components)!
104        let formatter = DateFormatter()
105        formatter.locale = Locale(identifier: "en_US_POSIX")
106        formatter.calendar = calendar
107        formatter.timeZone = calendar.timeZone
108        formatter.dateFormat = withTime ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
109        return (inactive ? "[" : "<") + formatter.string(from: date) + (extra ?? "") + (inactive ? "]" : ">")
110    }
111
112    /// `org-insert-timestamp`: inserted before markers, as org does.
113    @discardableResult
114    func insertTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, endTime: String? = nil, extra: String? = nil) -> String {
115        var suffix = extra
116        if let endTime, let match = endTime.firstMatch(of: /([0-9]+):([0-9]+)/) {
117            suffix = String(format: "-%02d:%02d", Int(match.1)!, Int(match.2)!)
118        }
119        let stamp = formatTimestamp(components, withTime: withTime, inactive: inactive, extra: suffix)
120        insertBeforeMarkers(stamp)
121        lastInsertedTimestamp = stamp
122        return stamp
123    }
124
125    /// `org-modify-ts-extra`.
126    static func modifyTimestampExtra(_ extra: String, _ position: Int, _ increments: Int, _ step: Int) -> String {
127        let regex = try! NSRegularExpression(pattern: "(-([012][0-9]):([0-5][0-9]))?( +\\+([0-9]+)([dmwy]))?( +-([0-9]+)([dmwy]))?")
128        let ns = extra as NSString
129        guard let match = regex.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return extra }
130        func range(_ i: Int) -> Range<Int>? {
131            let r = match.range(at: i)
132            return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
133        }
134        func inGroup(_ i: Int) -> Bool { range(i).map { $0.lowerBound <= position && position <= $0.upperBound } ?? false }
135        func text(_ i: Int) -> String { ns.substring(with: match.range(at: i)) }
136        let order: [(String, Int)] = [("d", 0), ("w", 1), ("m", 2), ("y", 3), ("d", -1), ("y", 4)]
137        func shifted(_ unit: String) -> String? {
138            let index = order.first { $0.0 == unit }!.1 + increments
139            return order.first { $0.1 == index }?.0
140        }
141        var group: Int?
142        var new = ""
143        if inGroup(2) || inGroup(3) {
144            var minute = Int(text(3))!, hour = Int(text(2))!
145            if inGroup(2) {
146                hour += increments
147            } else {
148                let n = step * increments
149                let rem = minute % step
150                if rem != 0 { minute += n > 0 ? -rem : step - rem }
151                minute += n
152            }
153            if minute < 0 { minute += 60; hour -= 1 }
154            if minute > 59 { minute -= 60; hour += 1 }
155            hour = ((hour % 24) + 24) % 24
156            group = 1
157            new = String(format: "-%02d:%02d", hour, minute)
158        } else if inGroup(6) {
159            group = 6
160            new = shifted(text(6)) ?? ""
161        } else if inGroup(5) {
162            group = 5
163            new = String(max(1, increments + Int(text(5))!))
164        } else if inGroup(9) {
165            group = 9
166            new = shifted(text(9)) ?? ""
167        } else if inGroup(8) {
168            group = 8
169            new = String(max(0, increments + Int(text(8))!))
170        }
171        guard let group, let target = range(group) else { return extra }
172        return ns.replacingCharacters(in: NSRange(target), with: new)
173    }
174
175    enum TimestampUnit { case minute, hour, day, month, year }
176
177    /// `org-timestamp-change`. `what` nil changes the part at point. On a CLOCK line the
178    /// duration is then updated, unless `updateClock` is false.
179    func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false, updateClock: Bool = true) throws {
180        let origin = point
181        guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") }
182        if what == nil, category == .bracket {
183            toggleTimestampType(match[0]!)
184            return
185        }
186        let target: TimestampPart = what.map { unit in
187            switch unit {
188            case .minute: .minute
189            case .hour: .hour
190            case .day: .day
191            case .month: .month
192            case .year: .year
193            }
194        } ?? category
195        let whole = match[0]!
196        let inactive = character(at: whole.lowerBound) == 91
197        let stamp = substring(whole.lowerBound, whole.upperBound)
198        replace(whole, with: "")
199        var extra: String?
200        if let found = stamp.firstMatch(of: try! Regex("((-[012][0-9]:[0-5][0-9])?( +[.+]?-?[-+][0-9]+[hdwmy](/[0-9]+[hdwmy])?)*)[\\]>]")) {
201            extra = found.output[1].substring.map(String.init)
202            if suppressDelay, let value = extra { extra = value.replacingOccurrences(of: " --[0-9]+[hdwmy]", with: "", options: .regularExpression) }
203        }
204        let withTime = stamp.range(of: "^.{10}.*?[0-9]+:[0-9][0-9]", options: .regularExpression) != nil
205        var time = Self.parseTimeString(stamp)!
206        var step = 5
207        var increment = n
208        if updown, target == .minute {
209            increment = step * n.signum()
210            let rem = time.minute! % step
211            if rem != 0 { time.minute! += n > 0 ? -rem : step - rem }
212        } else {
213            step = 1
214        }
215        switch target {
216        case .minute: time.minute! += increment
217        case .hour: time.hour! += increment
218        case .day: time.day! += increment
219        case .month: time.month! += increment
220        case .year: time.year! += increment
221        default: break
222        }
223        let normalized = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: calendar.date(from: time)!)
224        if target == .hour || target == .minute, let value = extra, value.range(of: "-([012][0-9]):([0-5][0-9])", options: .regularExpression) != nil {
225            extra = Self.modifyTimestampExtra(value, target == .hour ? 2 : 5, n, step)
226        }
227        if case .extra(let position) = target, let value = extra {
228            extra = Self.modifyTimestampExtra(value, position, n, step)
229        }
230        let insertion = point
231        insertTimestamp(normalized, withTime: withTime, inactive: inactive, extra: extra)
232        point = insertion
233        let after = looking(Self.timestampPattern, caseInsensitive: false)!
234        switch category {
235        case .day:
236            let end = after[5].map { $0.upperBound - 1 } ?? after[7]?.lowerBound ?? after[1]!.upperBound - 1
237            point = min(end, origin)
238        case .hour: point = min(after[7]!.upperBound, origin)
239        case .minute: point = min(after[8]!.upperBound - 1, origin)
240        case .extra: point = min(after[0]!.upperBound - 1, origin)
241        case .after: point = after[0]!.upperBound
242        default: point = origin
243        }
244        if updateClock {
245            let before = point
246            clockUpdateTime()
247            if point >= before { point = before }
248        }
249    }
250
251    /// `org-toggle-timestamp-type`.
252    func toggleTimestampType(_ range: Range<Int>) {
253        saveExcursion {
254            for offset in range {
255                let swapped: String? = switch character(at: offset) {
256                case 91: "<"
257                case 93: ">"
258                case 60: "["
259                case 62: "]"
260                default: nil
261                }
262                if let swapped { replace(offset..<(offset + 1), with: swapped) }
263            }
264        }
265    }
266
267    /// `org-get-compact-tod`: the time of day in a timestamp, as the date prompt shows it.
268    static func compactTimeOfDay(_ stamp: String) -> String? {
269        guard let match = stamp.firstMatch(of: /(([012]?[0-9]):([0-5][0-9]))(-(([012]?[0-9]):([0-5][0-9])))?/) else { return nil }
270        let t1 = String(match.1)
271        guard let t2h = match.6, let t2m = match.7 else { return t1 }
272        var dh = Int(t2h)! - Int(match.2)!
273        var dm = Int(t2m)! - Int(match.3)!
274        if dm < 0 { dm += 60; dh -= 1 }
275        return t1 + "+" + String(dh) + (dm != 0 ? String(format: ":%02d", dm) : "")
276    }
277
278    // MARK: - Planning
279
280    static let planningLinePattern = "^[ \\t]*(CLOSED|DEADLINE|SCHEDULED):"
281    static let keywordTimeNotClock = "\\b(CLOSED|DEADLINE|SCHEDULED):[ \\t]*[\\[<]([^\\]>]+)[\\]>]"
282
283    enum PlanningKind {
284        case closed, deadline, scheduled
285
286        var keyword: String {
287            switch self {
288            case .closed: "CLOSED:"
289            case .deadline: "DEADLINE:"
290            case .scheduled: "SCHEDULED:"
291            }
292        }
293
294        var pattern: String {
295            switch self {
296            case .closed: "\\bCLOSED: *\\[([^\\]]+)\\]"
297            case .deadline: "\\bDEADLINE: *<([^>]+)>"
298            case .scheduled: "\\bSCHEDULED: *<([^>]+)>"
299            }
300        }
301    }
302
303    /// The time string of the entry's planning `kind`, as `org-entry-get`.
304    func planningValue(_ kind: PlanningKind) -> String? {
305        saveExcursion {
306            guard (try? backToHeading()) != nil else { return nil }
307            forwardLine(1)
308            guard lookingAt(Self.planningLinePattern) != nil,
309                  let match = searchForward(kind.pattern, bound: lineEnd(point), caseInsensitive: false) else { return nil }
310            return substring(match[1]!.lowerBound, match[1]!.upperBound)
311        }
312    }
313
314    /// `org-add-planning-info` with a resolved time.
315    func addPlanningInfo(_ what: PlanningKind?, time: DateComponents?, withTime: Bool, endTime: String?, remove: [PlanningKind]) throws {
316        try saveExcursion {
317            try backToHeading()
318            let planning = saveExcursion { () -> Bool in
319                forwardLine(1)
320                return lookingAt(Self.planningLinePattern) != nil
321            }
322            if planning {
323                forwardLine(1)
324                skipForward(Self.blanks)
325                for type in (what.map { [$0] } ?? []) + remove {
326                    saveExcursion {
327                        guard let match = searchForward(type.pattern, bound: lineEnd(point), caseInsensitive: false) else { return }
328                        let start = match[0]!.lowerBound
329                        let end = searchForward(Self.keywordTimeNotClock, bound: lineEnd(point), caseInsensitive: false)?[0]!.lowerBound ?? lineEnd(point)
330                        deleteRegion(start, end)
331                    }
332                }
333                if lookingAt("[ \\t]*$") != nil, what == nil {
334                    deleteRegion(lineStart(point) - 1, lineEnd(point))
335                } else {
336                    let p = point
337                    saveExcursion {
338                        endOfLine()
339                        let end = point
340                        while point > p, let c = character(at: point - 1), c == 32 || c == 9 { point -= 1 }
341                        if point < end { deleteRegion(point, end) }
342                    }
343                }
344            } else if what != nil {
345                endOfLine()
346                insert("\n")
347            }
348            if let what, let time {
349                insert(what.keyword + " ")
350                insertTimestamp(time, withTime: withTime || what == .closed, inactive: what == .closed, endTime: endTime)
351                if !eolp { insert(" ") }
352            }
353        }
354    }
355}