| 1 | import Foundation |
| 2 | |
| 3 | // π€ The native port of remind.js's parseReminder β the pure "<text> <when>" |
| 4 | // splitter behind /remind and the Reminders default command. Same three shapes, |
| 5 | // same order, same rejections (ReminderParseTests pins parity against the web |
| 6 | // test vectors): |
| 7 | // "β¦ | 2026-08-01[ 14:00]" explicit date/time after a bar |
| 8 | // "β¦ in 2h" / "in 45m β¦" relative from now (time may lead or trail) |
| 9 | // "β¦ at 15:30" / "at 9:00 β¦" next occurrence of that clock time |
| 10 | // Times are epoch MILLISECONDS (the web's currency); `now` is injectable so |
| 11 | // tests are deterministic. Clock/date shapes resolve in LOCAL time, like the |
| 12 | // web ("at 9:00" means the user's 9am). |
| 13 | // |
| 14 | // Port gotchas this file deliberately handles (each caught by review): |
| 15 | // - Digits are [0-9], NOT \d: ICU \d matches every Unicode decimal digit |
| 16 | // ("Ω₯"), which Swift's numeric inits then reject β JS \d is ASCII-only, and |
| 17 | // a parser must never be able to crash the app, so no force-unwraps either. |
| 18 | // - Years below 100 are rejected: JS new Date(26,β¦) means 1926 and fails the |
| 19 | // round-trip check, so "0026-08-01" must not become 26 AD here. |
| 20 | // - Interior trims strip newlines too (JS .trim() does): a multi-line draft |
| 21 | // must split identically on both platforms. |
| 22 | public enum ReminderParse { |
| 23 | public struct Parsed: Equatable { |
| 24 | public let text: String |
| 25 | public let when: Double // epoch ms |
| 26 | } |
| 27 | |
| 28 | // remind.js REMIND_UNIT, verbatim. |
| 29 | static let unitPattern = "seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w" |
| 30 | |
| 31 | static func unitMs(_ u: String) -> Double { |
| 32 | let l = u.lowercased() |
| 33 | if l.range(of: "^(seconds?|secs?|s)$", options: .regularExpression) != nil { return 1000 } |
| 34 | if l.range(of: "^(minutes?|mins?|m)$", options: .regularExpression) != nil { return 60000 } |
| 35 | if l.range(of: "^(hours?|hrs?|h)$", options: .regularExpression) != nil { return 3_600_000 } |
| 36 | if l.range(of: "^(days?|d)$", options: .regularExpression) != nil { return 86_400_000 } |
| 37 | if l.range(of: "^(weeks?|w)$", options: .regularExpression) != nil { return 604_800_000 } |
| 38 | return 0 |
| 39 | } |
| 40 | |
| 41 | // The next wall-clock occurrence of h:m β today if still ahead, else tomorrow. |
| 42 | static func nextClockTime(h: Int, m: Int, now: Double) -> Double? { |
| 43 | guard h <= 23, m <= 59 else { return nil } |
| 44 | let cal = Calendar.current |
| 45 | let base = Date(timeIntervalSince1970: now / 1000) |
| 46 | guard let at = cal.date(bySettingHour: h, minute: m, second: 0, of: base) else { return nil } |
| 47 | var t = at.timeIntervalSince1970 * 1000 |
| 48 | if t <= now { t += 86_400_000 } |
| 49 | return t |
| 50 | } |
| 51 | |
| 52 | // "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" (T or space), local time; bare dates |
| 53 | // default to 09:00. Out-of-range parts are REJECTED, never rolled over |
| 54 | // (2026-06-31 must not become July 1) β verified by round-tripping the |
| 55 | // components, the same rigor as the web's Date checks. |
| 56 | static func parseAbsolute(_ str: String) -> Double? { |
| 57 | guard let m = firstMatch(#"^([0-9]{4})-([0-9]{2})-([0-9]{2})(?:[ T]([0-9]{1,2}):([0-9]{2}))?$"#, in: str), |
| 58 | let y = Int(m[1]), let mo = Int(m[2]), let d = Int(m[3]) else { return nil } |
| 59 | let h = m[4].isEmpty ? 9 : (Int(m[4]) ?? -1) |
| 60 | let min = m[5].isEmpty ? 0 : (Int(m[5]) ?? -1) |
| 61 | // y >= 100: JS's new Date(y,β¦) maps 0β99 to 1900+y and then fails its |
| 62 | // round-trip check β a sub-100 year is a typo to reject, not 26 AD. |
| 63 | guard y >= 100, (1...12).contains(mo), (1...31).contains(d), |
| 64 | (0...23).contains(h), (0...59).contains(min) else { return nil } |
| 65 | var comps = DateComponents() |
| 66 | comps.year = y; comps.month = mo; comps.day = d; comps.hour = h; comps.minute = min |
| 67 | let cal = Calendar.current |
| 68 | guard let dt = cal.date(from: comps) else { return nil } |
| 69 | let back = cal.dateComponents([.year, .month, .day], from: dt) |
| 70 | guard back.year == y, back.month == mo, back.day == d else { return nil } |
| 71 | return dt.timeIntervalSince1970 * 1000 |
| 72 | } |
| 73 | |
| 74 | public static func parse(_ raw: String, now: Double = Date().timeIntervalSince1970 * 1000) -> Parsed? { |
| 75 | var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| 76 | guard !s.isEmpty else { return nil } |
| 77 | // Slack phrasing is "/remind me β¦" β drop a leading standalone "me" |
| 78 | // (whole word: "meeting" keeps its me-). |
| 79 | s = s.replacingOccurrences(of: #"^me\b\s*"#, with: "", options: [.regularExpression, .caseInsensitive]) |
| 80 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| 81 | guard !s.isEmpty else { return nil } |
| 82 | |
| 83 | // Explicit "<text> | <absolute>" form. |
| 84 | if let bar = s.firstIndex(of: "|") { |
| 85 | let text = String(s[..<bar]).trimmingCharacters(in: .whitespacesAndNewlines) |
| 86 | let when = parseAbsolute(String(s[s.index(after: bar)...]).trimmingCharacters(in: .whitespacesAndNewlines)) |
| 87 | guard !text.isEmpty, let when else { return nil } |
| 88 | return Parsed(text: text, when: when) |
| 89 | } |
| 90 | |
| 91 | // The "when" can lead ("in 10s to do X") or trail ("do X in 10s"). |
| 92 | var when: Double? |
| 93 | var rest: String? |
| 94 | if let m = firstMatch("^in\\s+([0-9]+)\\s*(\(unitPattern))\\b\\s*(.*)$", in: s), let n = Double(m[1]) { |
| 95 | let ms = unitMs(m[2]) |
| 96 | guard ms > 0 else { return nil } |
| 97 | when = now + n * ms |
| 98 | rest = m[3] |
| 99 | } else if let m = firstMatch("^(.*?)\\s+in\\s+([0-9]+)\\s*(\(unitPattern))\\s*$", in: s), let n = Double(m[2]) { |
| 100 | let ms = unitMs(m[3]) |
| 101 | guard ms > 0 else { return nil } |
| 102 | when = now + n * ms |
| 103 | rest = m[1] |
| 104 | } else if let m = firstMatch(#"^at\s+([0-9]{1,2}):([0-9]{2})\b\s*(.*)$"#, in: s), |
| 105 | let h = Int(m[1]), let min = Int(m[2]) { |
| 106 | when = nextClockTime(h: h, m: min, now: now) |
| 107 | rest = m[3] |
| 108 | } else if let m = firstMatch(#"^(.*?)\s+at\s+([0-9]{1,2}):([0-9]{2})\s*$"#, in: s), |
| 109 | let h = Int(m[2]), let min = Int(m[3]) { |
| 110 | when = nextClockTime(h: h, m: min, now: now) |
| 111 | rest = m[1] |
| 112 | } |
| 113 | |
| 114 | // isFinite: an absurd digit string overflows Double to +inf, which the |
| 115 | // web ends up dropping in the fold (JSON null) β natively we reject it |
| 116 | // here so the composer asks for a real time instead of no-opping. |
| 117 | guard let when, when.isFinite, var text = rest else { return nil } |
| 118 | text = text.replacingOccurrences(of: #"^to\s+"#, with: "", options: [.regularExpression, .caseInsensitive]) |
| 119 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| 120 | return text.isEmpty ? nil : Parsed(text: text, when: when) |
| 121 | } |
| 122 | |
| 123 | // Case-insensitive regex helper: group strings by index ("" for no-match groups). |
| 124 | private static func firstMatch(_ pattern: String, in s: String) -> [String]? { |
| 125 | guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } |
| 126 | let ns = s as NSString |
| 127 | guard let m = re.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil } |
| 128 | return (0..<m.numberOfRanges).map { i in |
| 129 | let r = m.range(at: i) |
| 130 | return r.location == NSNotFound ? "" : ns.substring(with: r) |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |