eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
22public 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