learning_ai_clock/ios/ChronoMind/Shared/TimerEngine/NLParser.swift
saravanakumardb1 11e50295ea feat: fix web build, add repo infra, port iOS engine modules, add routine screens
- fix(web): cast window through unknown in platform-sync.ts (TS2352)
- docs: add AGENTS.md, README.md, CLAUDE.md, .windsurfrules, .cursorrules, env.example
- feat(ios): port Recurrence.swift from web/src/lib/recurrence.ts
- feat(ios): port NLParser.swift from web/src/lib/nl-parser.ts
- feat(ios): port ContextMessages.swift from web/src/lib/context-messages.ts
- feat(ios): add CMRoutine model + Routines.swift engine with state machine + templates
- feat(ios): add RoutineListView, RoutineRunnerView, RoutineEditorView
- feat(android): add RoutineScreen.kt with list, runner, templates, step controls

Web: 373 tests passing, build succeeds with --webpack flag
2026-02-28 01:50:35 -08:00

480 lines
17 KiB
Swift

// 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..<match.numberOfRanges {
if let r = Range(match.range(at: i), in: text) {
groups.append(String(text[r]))
} else {
groups.append("")
}
}
return groups
}
// MARK: - Label Extraction
private let labelStripPatterns: [NSRegularExpression] = {
let patterns = [
#"\bin\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b"#,
#"\bin\s+(?:a\s+)?half\s+(?:an?\s+)?hour\b"#,
#"\bat\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?\b"#,
#"\bfor\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b"#,
#"\bpomodoro\s+\d+\s*(?:rounds?|x|times?)\b"#,
#"\b\d+\s*(?:pomodoros?|poms?)\b"#,
#"\bpomodoro\b"#,
#"\bfocus\s+session\b"#,
#"\b(?:timer|alarm|reminder|countdown)\b"#,
#"\b(?:set|create|start|make|add)\s+(?:a\s+)?"#,
#"\b(?:remind\s+me\s+(?:to\s+)?)"#,
#"\b(?:in|at|for)\s*$"#,
#"\bhalf\s+(?:an?\s+)?hour\b"#,
#"\bquarter\s+(?:of\s+)?(?:an?\s+)?hour\b"#,
#"\d{1,2}:\d{2}\s*(?:am|pm)?"#,
#"\d{1,2}\s*(?:am|pm)"#,
]
return patterns.map { try! NSRegularExpression(pattern: $0, options: .caseInsensitive) }
}()
private func extractLabel(from input: String) -> 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 <time>" patterns (alarm)
let atRegex = try! NSRegularExpression(pattern: #"\bat\s+"#, options: .caseInsensitive)
if matchGroups(atRegex, in: trimmed) != nil {
for tp in timePatterns {
if let groups = matchGroups(tp.pattern, in: trimmed), let time = tp.extractTime(groups) {
let now = Date()
var components = Calendar.current.dateComponents([.year, .month, .day], from: now)
components.hour = time.hours
components.minute = time.minutes
components.second = 0
guard var target = Calendar.current.date(from: components) else { continue }
if target <= now {
target = Calendar.current.date(byAdding: .day, value: 1, to: target) ?? target
}
let label = extractLabel(from: trimmed).isEmpty ? "Alarm" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .alarm,
label: label,
durationSeconds: nil,
targetTime: target,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.95,
raw: trimmed
),
error: nil
)
}
}
}
// 3. Try relative time "in X minutes" (countdown)
for rp in relativePatterns {
if let groups = matchGroups(rp.pattern, in: trimmed) {
let seconds = rp.extractSeconds(groups)
if seconds > 0 {
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .countdown,
label: label,
durationSeconds: seconds,
targetTime: nil,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.9,
raw: trimmed
),
error: nil
)
}
}
}
// 4. Try "for X minutes" duration (countdown)
let forRegex = try! NSRegularExpression(pattern: #"\bfor\s+"#, options: .caseInsensitive)
if matchGroups(forRegex, in: trimmed) != nil {
for dp in durationPatterns {
if let groups = matchGroups(dp.pattern, in: trimmed) {
let seconds = dp.extractSeconds(groups)
if seconds > 0 {
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .countdown,
label: label,
durationSeconds: seconds,
targetTime: nil,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.85,
raw: trimmed
),
error: nil
)
}
}
}
}
// 5. Try bare duration "30 minutes", "1h", etc.
for dp in durationPatterns {
if let groups = matchGroups(dp.pattern, in: trimmed) {
let seconds = dp.extractSeconds(groups)
if seconds > 0 {
let label = extractLabel(from: trimmed).isEmpty ? "Timer" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .countdown,
label: label,
durationSeconds: seconds,
targetTime: nil,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.7,
raw: trimmed
),
error: nil
)
}
}
}
// 6. Try bare absolute time without "at" (lower confidence)
for tp in timePatterns {
if let groups = matchGroups(tp.pattern, in: trimmed), let time = tp.extractTime(groups) {
let now = Date()
var components = Calendar.current.dateComponents([.year, .month, .day], from: now)
components.hour = time.hours
components.minute = time.minutes
components.second = 0
guard var target = Calendar.current.date(from: components) else { continue }
if target <= now {
target = Calendar.current.date(byAdding: .day, value: 1, to: target) ?? target
}
let label = extractLabel(from: trimmed).isEmpty ? "Alarm" : extractLabel(from: trimmed)
return ParseResult(
success: true,
timer: ParsedTimer(
type: .alarm,
label: label,
durationSeconds: nil,
targetTime: target,
urgency: urgency,
cascade: cascade,
pomodoroRounds: nil,
confidence: 0.6,
raw: trimmed
),
error: nil
)
}
}
return ParseResult(
success: false,
timer: nil,
error: "Could not parse: \"\(trimmed)\". Try \"meeting in 30 minutes\" or \"alarm at 3pm\"."
)
}