// Package timeparsing provides layered time parsing for relative date/time expressions. // // The parsing follows a layered architecture (ADR-001): // 1. Compact duration (+6h, -1d, +2w) // 2. Natural language (tomorrow, next monday) // 3. Absolute timestamp (RFC3339, date-only) package timeparsing import ( "fmt" "regexp" "strconv" "time" "github.com/olebedev/when" "github.com/olebedev/when/rules/common" "github.com/olebedev/when/rules/en" ) // compactDurationRe matches compact duration patterns: [+-]?(\d+)([hdwmy]) // Examples: +6h, -1d, +2w, 3m, 1y var compactDurationRe = regexp.MustCompile(`^([+-]?)(\d+)([hdwmy])$`) // ParseCompactDuration parses compact duration syntax and returns the resulting time. // // Format: [+-]?(\d+)([hdwmy]) // // Units: // - h = hours // - d = days // - w = weeks // - m = months // - y = years // // Examples: // - "+6h" -> now + 6 hours // - "-1d" -> now - 1 day // - "+2w" -> now + 2 weeks // - "3m" -> now + 3 months (no sign = positive) // - "1y" -> now + 1 year // // Returns error if input doesn't match the compact duration pattern. func ParseCompactDuration(s string, now time.Time) (time.Time, error) { matches := compactDurationRe.FindStringSubmatch(s) if matches == nil { return time.Time{}, fmt.Errorf("not a compact duration: %q", s) } sign := matches[1] amountStr := matches[2] unit := matches[3] amount, err := strconv.Atoi(amountStr) if err != nil { // Should not happen given regex ensures digits, but handle gracefully return time.Time{}, fmt.Errorf("invalid duration amount: %q", amountStr) } // Apply sign (default positive) if sign == "-" { amount = -amount } return applyDuration(now, amount, unit), nil } // applyDuration applies the given amount and unit to the base time. func applyDuration(base time.Time, amount int, unit string) time.Time { switch unit { case "h": return base.Add(time.Duration(amount) * time.Hour) case "d": return base.AddDate(0, 0, amount) case "w": return base.AddDate(0, 0, amount*7) case "m": return base.AddDate(0, amount, 0) case "y": return base.AddDate(amount, 0, 0) default: // Should not happen given regex, but return base unchanged return base } } // IsCompactDuration returns true if the string matches compact duration syntax. func IsCompactDuration(s string) bool { return compactDurationRe.MatchString(s) } // nlpParser is the singleton natural language parser (olebedev/when). // Initialized lazily on first use. var nlpParser *when.Parser // getNLPParser returns the singleton NLP parser, initializing it if needed. func getNLPParser() *when.Parser { if nlpParser == nil { nlpParser = when.New(nil) nlpParser.Add(en.All...) nlpParser.Add(common.All...) } return nlpParser } // ParseNaturalLanguage parses natural language time expressions using olebedev/when. // // Examples: // - "tomorrow" -> tomorrow at current time // - "next monday" -> next Monday at current time // - "next monday at 9am" -> next Monday at 9:00 // - "in 3 days" -> now + 3 days // - "3 days ago" -> now - 3 days // // Known Issues (olebedev/when): // - Month name "September" may not parse correctly in some contexts. // Workaround: Use date format "2025-09-15" instead of "September 15" or "Sep 15". // This is a known issue in the olebedev/when library. // // Returns error if input cannot be parsed as natural language. func ParseNaturalLanguage(s string, now time.Time) (time.Time, error) { parser := getNLPParser() result, err := parser.Parse(s, now) if err != nil { return time.Time{}, fmt.Errorf("NLP parse error: %w", err) } if result == nil { return time.Time{}, fmt.Errorf("not a natural language time expression: %q", s) } return result.Time, nil } // dateOnlyRe matches date-only format YYYY-MM-DD to avoid NLP misinterpretation. var dateOnlyRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // ParseRelativeTime parses a time expression using the layered architecture (ADR-001). // // Parsing order: // 1. Compact duration (+6h, -1d, +2w) // 2. Absolute formats (date-only, RFC3339) - checked before NLP to avoid misinterpretation // 3. Natural language (tomorrow, next monday) // // Returns the parsed time or an error if no layer could parse the input. func ParseRelativeTime(s string, now time.Time) (time.Time, error) { // Layer 1: Compact duration if t, err := ParseCompactDuration(s, now); err == nil { return t, nil } // Layer 2: Absolute formats (must be checked before NLP to avoid misinterpretation) // NLP parser can incorrectly parse "2025-02-01" as a time, so we check date formats first. // Try date-only format (YYYY-MM-DD) if dateOnlyRe.MatchString(s) { if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { return t, nil } } // Try RFC3339 format (2025-01-15T10:00:00Z) if t, err := time.Parse(time.RFC3339, s); err == nil { return t, nil } // Try ISO 8601 datetime without timezone (2025-01-15T10:00:00) if t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local); err == nil { return t, nil } // Try datetime with space (2025-01-15 10:00:00) if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { return t, nil } // Layer 3: Natural language (after absolute formats to avoid misinterpretation) if t, err := ParseNaturalLanguage(s, now); err == nil { return t, nil } return time.Time{}, fmt.Errorf("cannot parse time expression: %q (examples: +6h, tomorrow, 2025-01-15)", s) }