// ── Natural Language Timer Parser ────────────────────────────── // Regex-based parser for natural time expressions. No LLM needed. // Supports: relative times, absolute times, durations, labels, urgency hints, pomodoro. // Ported from web/src/lib/nl-parser.ts import Foundation // MARK: - Parsed Timer Types enum ParsedTimerType: String { case alarm case countdown case pomodoro } struct ParsedTimer { let type: ParsedTimerType let label: String let durationSeconds: TimeInterval? // for countdown let targetTime: Date? // for alarm let urgency: UrgencyLevel let cascade: CascadePreset let pomodoroRounds: Int? // for pomodoro let confidence: Double // 0-1 let raw: String } struct ParseResult { let success: Bool let timer: ParsedTimer? let error: String? } // MARK: - Urgency Keywords private let urgencyKeywords: [(String, UrgencyLevel)] = [ // Critical ("critical", .critical), ("urgent", .critical), ("emergency", .critical), ("flight", .critical), ("interview", .critical), ("exam", .critical), // Important ("important", .important), ("meeting", .important), ("appointment", .important), ("doctor", .important), ("dentist", .important), ("standup", .important), ("call", .important), // Gentle ("gentle", .gentle), ("casual", .gentle), ("maybe", .gentle), ("check", .gentle), // Passive ("passive", .passive), ] // MARK: - Duration Extraction private struct DurationPattern { let pattern: NSRegularExpression let extractSeconds: ([String]) -> TimeInterval } private let durationPatterns: [DurationPattern] = { func regex(_ pattern: String) -> NSRegularExpression { try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) } return [ // "1h 30m", "1 hour 30 minutes" DurationPattern( pattern: regex(#"(\d+)\s*(?:hours?|hrs?|h)\s*(\d+)\s*(?:minutes?|mins?|m\b)"#), extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 + (Double(groups[2]) ?? 0) * 60 } ), // "30 minutes", "30m", "30 min" DurationPattern( pattern: regex(#"(\d+)\s*(?:minutes?|mins?|m\b)"#), extractSeconds: { groups in (Double(groups[1]) ?? 0) * 60 } ), // "2 hours", "2h" DurationPattern( pattern: regex(#"(\d+)\s*(?:hours?|hrs?|h\b)"#), extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 } ), // "30 seconds", "30s" DurationPattern( pattern: regex(#"(\d+)\s*(?:seconds?|secs?|s\b)"#), extractSeconds: { groups in Double(groups[1]) ?? 0 } ), // "half hour" DurationPattern( pattern: regex(#"half\s+(?:an?\s+)?hour"#), extractSeconds: { _ in 30 * 60 } ), // "quarter hour" DurationPattern( pattern: regex(#"quarter\s+(?:of\s+)?(?:an?\s+)?hour"#), extractSeconds: { _ in 15 * 60 } ), ] }() // MARK: - Time Extraction (absolute) private struct TimePattern { let pattern: NSRegularExpression let extractTime: ([String]) -> (hours: Int, minutes: Int)? } private let timePatterns: [TimePattern] = { func regex(_ pattern: String) -> NSRegularExpression { try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) } return [ // "3:30pm", "3:30 PM" TimePattern( pattern: regex(#"(\d{1,2}):(\d{2})\s*(am|pm)"#), extractTime: { groups in var hours = Int(groups[1]) ?? 0 let minutes = Int(groups[2]) ?? 0 let ampm = groups[3].lowercased() if ampm == "pm" && hours != 12 { hours += 12 } if ampm == "am" && hours == 12 { hours = 0 } return (hours, minutes) } ), // "3pm", "3 pm" TimePattern( pattern: regex(#"(\d{1,2})\s*(am|pm)"#), extractTime: { groups in var hours = Int(groups[1]) ?? 0 let ampm = groups[2].lowercased() if ampm == "pm" && hours != 12 { hours += 12 } if ampm == "am" && hours == 12 { hours = 0 } return (hours, 0) } ), // "15:30" (24-hour) TimePattern( pattern: regex(#"(\d{1,2}):(\d{2})(?!\s*(?:am|pm))"#), extractTime: { groups in let hours = Int(groups[1]) ?? 0 let minutes = Int(groups[2]) ?? 0 if hours > 23 || minutes > 59 { return nil } return (hours, minutes) } ), ] }() // MARK: - Relative Time Patterns private let relativePatterns: [DurationPattern] = { func regex(_ pattern: String) -> NSRegularExpression { try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) } return [ DurationPattern( pattern: regex(#"in\s+(\d+)\s*(?:hours?|hrs?|h)\s+(?:and\s+)?(\d+)\s*(?:minutes?|mins?|m\b)"#), extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 + (Double(groups[2]) ?? 0) * 60 } ), DurationPattern( pattern: regex(#"in\s+(\d+)\s*(?:minutes?|mins?|m\b)"#), extractSeconds: { groups in (Double(groups[1]) ?? 0) * 60 } ), DurationPattern( pattern: regex(#"in\s+(\d+)\s*(?:hours?|hrs?|h\b)"#), extractSeconds: { groups in (Double(groups[1]) ?? 0) * 3600 } ), DurationPattern( pattern: regex(#"in\s+(\d+)\s*(?:seconds?|secs?|s\b)"#), extractSeconds: { groups in Double(groups[1]) ?? 0 } ), DurationPattern( pattern: regex(#"in\s+(?:a\s+)?half\s+(?:an?\s+)?hour"#), extractSeconds: { _ in 30 * 60 } ), ] }() // MARK: - Pomodoro Patterns private struct PomodoroPattern { let pattern: NSRegularExpression let extractRounds: ([String]) -> Int } private let pomodoroPatterns: [PomodoroPattern] = { func regex(_ pattern: String) -> NSRegularExpression { try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) } return [ PomodoroPattern( pattern: regex(#"pomodoro\s+(\d+)\s*(?:rounds?|x\b|times?)"#), extractRounds: { groups in Int(groups[1]) ?? 4 } ), PomodoroPattern( pattern: regex(#"(\d+)\s*(?:pomodoros?|poms?)"#), extractRounds: { groups in Int(groups[1]) ?? 4 } ), PomodoroPattern( pattern: regex(#"\bpomodoro\b"#), extractRounds: { _ in 4 } ), PomodoroPattern( pattern: regex(#"\bfocus\s+session\b"#), extractRounds: { _ in 4 } ), ] }() // MARK: - Regex Helpers private func matchGroups(_ regex: NSRegularExpression, in text: String) -> [String]? { let range = NSRange(text.startIndex..., in: text) guard let match = regex.firstMatch(in: text, range: range) else { return nil } var groups: [String] = [] for i in 0.. String { var label = input.trimmingCharacters(in: .whitespaces) for regex in labelStripPatterns { let range = NSRange(label.startIndex..., in: label) label = regex.stringByReplacingMatches(in: label, range: range, withTemplate: " ") } // Clean up extra whitespace label = label.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) label = label.trimmingCharacters(in: CharacterSet.whitespaces.union(CharacterSet(charactersIn: ",-—"))) // Capitalize first letter if let first = label.first { label = first.uppercased() + label.dropFirst() } return label } // MARK: - Urgency Detection private func detectUrgency(from input: String) -> UrgencyLevel { let lower = input.lowercased() for (keyword, level) in urgencyKeywords { if lower.contains(keyword) { return level } } return .standard } // MARK: - Cascade Selection private func selectCascade(for urgency: UrgencyLevel) -> CascadePreset { switch urgency { case .critical: return .aggressive case .important: return .standard case .standard: return .standard case .gentle: return .minimal case .passive: return .none } } // MARK: - Main Parser func parseNaturalLanguage(_ input: String) -> ParseResult { let trimmed = input.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return ParseResult(success: false, timer: nil, error: "Empty input") } let urgency = detectUrgency(from: trimmed) let cascade = selectCascade(for: urgency) // 1. Try pomodoro patterns first for pomo in pomodoroPatterns { if let groups = matchGroups(pomo.pattern, in: trimmed) { let rounds = pomo.extractRounds(groups) let label = extractLabel(from: trimmed).isEmpty ? "Focus Session" : extractLabel(from: trimmed) return ParseResult( success: true, timer: ParsedTimer( type: .pomodoro, label: label, durationSeconds: nil, targetTime: nil, urgency: .standard, cascade: .minimal, pomodoroRounds: min(max(rounds, 1), 12), confidence: 0.9, raw: trimmed ), error: nil ) } } // 2. Try "at