- 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
480 lines
17 KiB
Swift
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\"."
|
|
)
|
|
}
|