krz/orgstar

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

Sources/OrgCore/Time/ReadDate.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgCore/Time/ReadDate.swift history · blame · raw

297 lines · 16071 bytes

  1import Foundation
  2
  3/// Org's date prompt (`org-read-date-analyze`, Org 9.8.7) and the `parse-time-string` it uses,
  4/// with `org-read-date-prefer-future` t. `now` and `defaultDate` stand in for the clock.
  5public enum ReadDate {
  6    public struct Answer: Sendable, Equatable {
  7        public var year: Int, month: Int, day: Int, hour: Int, minute: Int
  8        /// The answer included a time (`org-time-was-given`).
  9        public var timeGiven: Bool
 10        /// An end time such as `11:00`, from `10:00-11:00` (`org-end-time-was-given`).
 11        public var endTime: String?
 12
 13        /// Normalized like `encode-time` (day 32 becomes the next month).
 14        public func components(_ calendar: Calendar) -> DateComponents {
 15            var components = DateComponents(year: year, month: month, day: 1, hour: 0, minute: 0)
 16            let base = calendar.date(from: components)!
 17            let date = calendar.date(byAdding: DateComponents(month: 0, day: day - 1, hour: hour, minute: minute), to: base)!
 18            components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .weekday], from: date)
 19            return components
 20        }
 21    }
 22
 23    static let weekdays: [(String, Int)] = [
 24        ("sun", 0), ("mon", 1), ("tue", 2), ("wed", 3), ("thu", 4), ("fri", 5), ("sat", 6),
 25        ("sunday", 0), ("monday", 1), ("tuesday", 2), ("wednesday", 3), ("thursday", 4), ("friday", 5), ("saturday", 6),
 26    ]
 27    static let months: [(String, Int)] = [
 28        ("jan", 1), ("feb", 2), ("mar", 3), ("apr", 4), ("may", 5), ("jun", 6), ("jul", 7), ("aug", 8), ("sep", 9),
 29        ("oct", 10), ("nov", 11), ("dec", 12), ("january", 1), ("february", 2), ("march", 3), ("april", 4), ("june", 6),
 30        ("july", 7), ("august", 8), ("september", 9), ("october", 10), ("november", 11), ("december", 12),
 31    ]
 32
 33    enum Token: Equatable {
 34        case number(Int)
 35        case word(String)
 36    }
 37
 38    /// `parse-time-tokenize` on the downcased string.
 39    static func tokenize(_ string: String) -> [Token] {
 40        let chars = Array(string.lowercased().unicodeScalars)
 41        func kind(_ c: Unicode.Scalar) -> Character? {
 42            switch c {
 43            case "a"..."z": "a"
 44            case "0"..."9": "0"
 45            case "+", "-", ":": Character(c)
 46            default: nil
 47            }
 48        }
 49        var tokens: [Token] = []
 50        var index = 0
 51        while index < chars.count {
 52            while index < chars.count, kind(chars[index]) == nil { index += 1 }
 53            guard index < chars.count else { break }
 54            let start = index
 55            var digits = kind(chars[index]) == "0"
 56            index += 1
 57            while index < chars.count, let k = kind(chars[index]) {
 58                digits = digits && k == "0"
 59                index += 1
 60            }
 61            let text = String(String.UnicodeScalarView(chars[start..<index]))
 62            tokens.append(digits ? .number(Int(text) ?? 0) : .word(text))
 63        }
 64        return tokens
 65    }
 66
 67    /// `parse-time-string`'s fallback: (sec, min, hour, day, month, year, weekday).
 68    static func parseTime(_ string: String) -> [Int?] {
 69        var time: [Int?] = Array(repeating: nil, count: 9)
 70        func digits(_ text: String, _ range: Range<Int>) -> Int? {
 71            let chars = Array(text)
 72            guard range.upperBound <= chars.count else { return nil }
 73            return Int(String(chars[range]))
 74        }
 75        for token in tokenize(string) {
 76            if time[6] == nil, case .word(let w) = token, let day = weekdays.first(where: { $0.0 == w }) {
 77                time[6] = day.1
 78            } else if time[3] == nil, case .number(let n) = token, (1...31).contains(n) {
 79                time[3] = n
 80            } else if time[4] == nil, case .word(let w) = token, let month = months.first(where: { $0.0 == w }) {
 81                time[4] = month.1
 82            } else if time[5] == nil, case .number(let n) = token, n >= 100 {
 83                time[5] = n
 84            } else if time[2] == nil, case .word(let w) = token, w.count == 8, Array(w)[2] == ":", Array(w)[5] == ":" {
 85                (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), digits(w, 6..<8))
 86            } else if time[5] == nil, case .word(let w) = token, w.count == 10, Array(w)[4] == "-", Array(w)[7] == "-" {
 87                (time[5], time[4], time[3]) = (digits(w, 0..<4), digits(w, 5..<7), digits(w, 8..<10))
 88            } else if time[2] == nil, case .word(let w) = token, w.count == 5, Array(w)[2] == ":" {
 89                (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), 0)
 90            } else if time[2] == nil, case .word(let w) = token, w.count == 4, Array(w)[1] == ":" {
 91                (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), 0)
 92            } else if time[2] == nil, case .word(let w) = token, w.count == 7, Array(w)[1] == ":" {
 93                (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), digits(w, 5..<7))
 94            } else if time[5] == nil, case .number(let n) = token, (50...110).contains(n) {
 95                time[5] = 1900 + n
 96            } else if time[5] == nil, case .number(let n) = token, (0...49).contains(n) {
 97                time[5] = 2000 + n
 98            }
 99        }
100        return time
101    }
102
103    /// `org-small-year-to-year`.
104    static func smallYear(_ year: Int, currentYear: Int) -> Int {
105        guard year < 100 else { return year }
106        let century = currentYear / 100
107        let offset = year - currentYear % 100
108        if offset > 30 { return (century - 1) * 100 + year }
109        if offset > -70 { return century * 100 + year }
110        return (century + 1) * 100 + year
111    }
112
113    /// Replaces the first match of `pattern` in `string`, with `$n` group references.
114    private static func replaceFirst(_ string: inout String, _ match: NSTextCheckingResult, _ template: String) {
115        let ns = string as NSString
116        var result = ""
117        var chars = Array(template)
118        var i = 0
119        while i < chars.count {
120            if chars[i] == "\\", i + 1 < chars.count, let n = chars[i + 1].wholeNumberValue {
121                let range = match.range(at: n)
122                if range.location != NSNotFound { result += ns.substring(with: range) }
123                i += 2
124            } else {
125                result.append(chars[i])
126                i += 1
127            }
128        }
129        chars = []
130        string = ns.replacingCharacters(in: match.range, with: result)
131    }
132
133    private static func firstMatch(_ pattern: String, _ string: String, caseInsensitive: Bool = false) -> NSTextCheckingResult? {
134        let regex = try! NSRegularExpression(pattern: pattern, options: caseInsensitive ? [.caseInsensitive] : [])
135        return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length))
136    }
137
138    private static func group(_ match: NSTextCheckingResult, _ i: Int, _ string: String) -> String? {
139        let range = match.range(at: i)
140        return range.location == NSNotFound ? nil : (string as NSString).substring(with: range)
141    }
142
143    /// `org-read-date-get-relative`: (shift, unit, relative to the default).
144    static func relative(_ string: String, today: DateComponents, default def: DateComponents) -> (Int, String, Bool, NSTextCheckingResult)? {
145        let names = weekdays.map(\.0).joined(separator: "|")
146        guard let match = firstMatch("^[ \\t]*([-+]{0,2})([0-9]+)?([hdwmy]|(" + names + "))?([ \\t]|$)", string, caseInsensitive: true) else { return nil }
147        let sign = group(match, 1, string) ?? ""
148        guard !sign.isEmpty || match.range(at: 4).location != NSNotFound else { return nil }
149        let direction: Character = sign.last ?? "+"
150        let relativeToDefault = sign.count == 2
151        let n = group(match, 2, string).flatMap { Int($0) } ?? 1
152        let what = group(match, 3, string) ?? "d"
153        let date = relativeToDefault ? def : today
154        if let target = weekdays.first(where: { $0.0 == what.lowercased() })?.1 {
155            let weekday = (date.weekday ?? 1) - 1
156            var delta = (7 + target - weekday) % 7
157            if delta == 0 { delta = 7 }
158            if direction == "-" {
159                delta -= 7
160                if delta == 0 { delta = -7 }
161            }
162            if n > 1 { delta += (n - 1) * (direction == "-" ? -7 : 7) }
163            return (delta, "d", relativeToDefault, match)
164        }
165        return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match)
166    }
167
168    /// `org-read-date-analyze` with `org-read-date-prefer-future` t.
169    public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer {
170        let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday]
171        let def = calendar.dateComponents(units, from: defaultDate)
172        let today = calendar.dateComponents(units, from: now)
173        let currentYear = today.year!
174        var ans = input
175        if firstMatch("^[ \\t]*\\.[ \\t]*$", ans) != nil { ans = "+0" }
176        var delta: (Int, String, Bool)?
177        if let relative = relative(ans, today: today, default: def) {
178            delta = (relative.0, relative.1, relative.2)
179            ans = (ans as NSString).replacingCharacters(in: relative.3.range, with: "")
180        }
181        var isoYear: Int?, isoWeekday: Int?, isoWeek: Int?
182        if let match = firstMatch("\\b(?:([0-9]+)-)?[wW]([0-9]{1,2})(?:-([0-6]))?([ \\t]|$)", ans) {
183            isoYear = group(match, 1, ans).flatMap { Int($0) }.map { smallYear($0, currentYear: currentYear) }
184            isoWeekday = group(match, 3, ans).flatMap { Int($0) }
185            isoWeek = group(match, 2, ans).flatMap { Int($0) }
186            ans = (ans as NSString).replacingCharacters(in: match.range, with: "")
187        }
188        var killYear = false
189        if let match = firstMatch("^ *(([0-9]+)-)?([0-1]?[0-9])-([0-3]?[0-9])([^-0-9]|$)", ans) {
190            var year = group(match, 2, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }()
191            year = smallYear(year, currentYear: currentYear)
192            let month = Int(group(match, 3, ans)!)!, day = Int(group(match, 4, ans)!)!
193            replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5")
194        }
195        if let match = firstMatch("^ *(3[01]|0?[1-9]|[12][0-9])\\. ?(0?[1-9]|1[012])\\.( ?[1-9][0-9]{3})?", ans) {
196            let year = group(match, 3, ans).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? { killYear = true; return currentYear }()
197            let day = Int(group(match, 1, ans)!)!, month = Int(group(match, 2, ans)!)!
198            replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day))
199        }
200        if let match = firstMatch("^ *(0?[1-9]|1[012])/(0?[1-9]|[12][0-9]|3[01])(/([0-9]+))?([^/0-9]|$)", ans) {
201            var year = group(match, 4, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }()
202            year = smallYear(year, currentYear: currentYear)
203            let month = Int(group(match, 1, ans)!)!, day = Int(group(match, 2, ans)!)!
204            replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5")
205        }
206        let plainTime = "(^|[^+])[012]?[0-9]:[0-9][0-9]([ \\t\\n]|$)"
207        for _ in 0..<2 where firstMatch(plainTime, ans) == nil {
208            guard let match = firstMatch("([012]?[0-9])(:([0-5][0-9]))?(am|AM|pm|PM)\\b", ans) else { break }
209            var hour = Int(group(match, 1, ans)!)!
210            let minute = group(match, 3, ans).flatMap { Int($0) } ?? 0
211            let pm = group(match, 4, ans)!.lowercased() == "pm"
212            if hour == 12, !pm { hour = 0 } else if pm, hour < 12 { hour += 12 }
213            ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute))
214        }
215        for _ in 0..<2 where firstMatch(plainTime, ans) == nil {
216            guard let match = firstMatch("(?:([012]?[0-9])?h([0-5][0-9]))|(?:([012]?[0-9])h([0-5][0-9])?)\\b", ans) else { break }
217            let hour = (group(match, 1, ans) ?? group(match, 3, ans)).flatMap { Int($0) } ?? 0
218            let minute = (group(match, 2, ans) ?? group(match, 4, ans)).flatMap { Int($0) } ?? 0
219            ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute))
220        }
221        if let match = firstMatch("([012]?[0-9]):([0-6][0-9])\\+([012]?[0-9])(:([0-5][0-9]))?", ans) {
222            let hour = Int(group(match, 1, ans)!)!, minute = Int(group(match, 2, ans)!)!
223            var h2 = hour + Int(group(match, 3, ans)!)!
224            var m2 = minute + (group(match, 5, ans).flatMap { Int($0) } ?? 0)
225            if m2 >= 60 { h2 += 1; m2 -= 60 }
226            ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d-%02d:%02d", hour, minute, h2, m2))
227        }
228        var timeGiven = false
229        var endTime: String?
230        let timeOfDay = "(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b)(--?(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b))?"
231        if let match = firstMatch(timeOfDay, ans), let end = group(match, 8, ans) {
232            endTime = end
233            let range = match.range(at: 7)
234            ans = (ans as NSString).replacingCharacters(in: range, with: "")
235        }
236        let tl = parseTime(ans)
237        var futurep = false
238        var day = tl[3] ?? def.day!
239        let month: Int
240        if let m = tl[4] {
241            month = m
242        } else if tl[3] != nil {
243            futurep = true
244            month = day < today.day! ? today.month! + 1 : today.month!
245        } else {
246            month = def.month!
247        }
248        var year: Int
249        if !killYear, let y = tl[5] {
250            year = y
251        } else if futurep {
252            year = (month > today.month! || day >= today.day!) ? today.year! : today.year! + 1
253        } else if tl[4] != nil {
254            futurep = true
255            if month > today.month! { year = today.year! } else if month < today.month! { year = today.year! + 1 } else if day < today.day! { year = today.year! + 1 } else { year = today.year! }
256        } else {
257            year = def.year!
258        }
259        var month2 = month
260        var hour = tl[2] ?? def.hour!
261        let minute = tl[1] ?? def.minute!
262        var wday = tl[6]
263        if let isoWeek {
264            let isoYearValue = isoYear ?? year
265            let weekday = isoWeekday ?? wday ?? 1
266            wday = nil
267            var iso = Calendar(identifier: .iso8601)
268            iso.timeZone = calendar.timeZone
269            let isoComponents = DateComponents(weekday: weekday == 0 ? 1 : weekday + 1, weekOfYear: isoWeek, yearForWeekOfYear: isoYearValue)
270            if let date = iso.date(from: isoComponents) {
271                let c = iso.dateComponents([.year, .month, .day], from: date)
272                (year, month2, day) = (c.year!, c.month!, c.day!)
273            }
274        } else if let (n, unit, relativeToDefault) = delta {
275            if !relativeToDefault { (day, month2, year) = (today.day!, today.month!, today.year!) }
276            switch unit.lowercased() {
277            case "h", "":
278                timeGiven = true
279                hour += n
280            case "d": day += n
281            case "w": day += 7 * n
282            case "m": month2 += n
283            case "y": year += n
284            default: break
285            }
286        } else if let target = wday, tl[3] == nil {
287            let base = calendar.date(from: DateComponents(year: year, month: month2, day: 1))!
288            let date = calendar.date(byAdding: .day, value: day - 1, to: base)!
289            let current = calendar.component(.weekday, from: date) - 1
290            if target != current { day += ((target - current + 7) % 7 + 7) % 7 }
291        }
292        if tl[2] != nil { timeGiven = true }
293        if year < 100 { year += 2000 }
294        _ = futurep
295        return Answer(year: year, month: month2, day: day, hour: hour, minute: minute, timeGiven: timeGiven, endTime: endTime)
296    }
297}