|
|
@@ -0,0 +1,199 @@
|
|
|
+package parser
|
|
|
+
|
|
|
+import (
|
|
|
+ "fmt"
|
|
|
+ "regexp"
|
|
|
+ "strconv"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+)
|
|
|
+
|
|
|
+// ParseTime converts natural language or ISO 8601 timestamps to ISO 8601 format
|
|
|
+func ParseTime(input string) (string, error) {
|
|
|
+ input = strings.TrimSpace(strings.ToLower(input))
|
|
|
+
|
|
|
+ // Try parsing as ISO 8601 first
|
|
|
+ formats := []string{
|
|
|
+ time.RFC3339,
|
|
|
+ "2006-01-02T15:04:05Z",
|
|
|
+ "2006-01-02 15:04:05",
|
|
|
+ "2006-01-02 15:04",
|
|
|
+ "2006-01-02T15:04",
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, format := range formats {
|
|
|
+ if t, err := time.Parse(format, input); err == nil {
|
|
|
+ return t.UTC().Format(time.RFC3339), nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ now := time.Now().UTC()
|
|
|
+
|
|
|
+ // "in X minutes/hours/days"
|
|
|
+ if strings.HasPrefix(input, "in ") {
|
|
|
+ return parseRelativeTime(input, now)
|
|
|
+ }
|
|
|
+
|
|
|
+ // "tomorrow at HH:MM"
|
|
|
+ if strings.HasPrefix(input, "tomorrow") {
|
|
|
+ return parseTomorrow(input, now)
|
|
|
+ }
|
|
|
+
|
|
|
+ // "next monday/tuesday/etc at HH:MM"
|
|
|
+ if strings.HasPrefix(input, "next ") {
|
|
|
+ return parseNextDay(input, now)
|
|
|
+ }
|
|
|
+
|
|
|
+ // "now"
|
|
|
+ if input == "now" {
|
|
|
+ return now.Format(time.RFC3339), nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return "", fmt.Errorf("unable to parse time: %s\n\nSupported formats:\n - ISO 8601: 2025-11-12T19:30:00Z\n - Relative: in 5 minutes, in 2 hours, in 3 days\n - Tomorrow: tomorrow at 9am, tomorrow at 14:30\n - Next day: next monday at 3pm, next friday at 10:00\n - Now: now", input)
|
|
|
+}
|
|
|
+
|
|
|
+func parseRelativeTime(input string, now time.Time) (string, error) {
|
|
|
+ // "in 5 minutes", "in 2 hours", "in 3 days"
|
|
|
+ re := regexp.MustCompile(`^in (\d+)\s*(minute|minutes|min|hour|hours|hr|hrs|h|day|days|d)s?$`)
|
|
|
+ matches := re.FindStringSubmatch(input)
|
|
|
+
|
|
|
+ if len(matches) != 3 {
|
|
|
+ return "", fmt.Errorf("invalid relative time format: %s", input)
|
|
|
+ }
|
|
|
+
|
|
|
+ value, _ := strconv.Atoi(matches[1])
|
|
|
+ unit := matches[2]
|
|
|
+
|
|
|
+ var t time.Time
|
|
|
+ switch {
|
|
|
+ case strings.HasPrefix(unit, "min"):
|
|
|
+ t = now.Add(time.Duration(value) * time.Minute)
|
|
|
+ case strings.HasPrefix(unit, "h"):
|
|
|
+ t = now.Add(time.Duration(value) * time.Hour)
|
|
|
+ case strings.HasPrefix(unit, "d"):
|
|
|
+ t = now.AddDate(0, 0, value)
|
|
|
+ default:
|
|
|
+ return "", fmt.Errorf("unknown time unit: %s", unit)
|
|
|
+ }
|
|
|
+
|
|
|
+ return t.Format(time.RFC3339), nil
|
|
|
+}
|
|
|
+
|
|
|
+func parseTomorrow(input string, now time.Time) (string, error) {
|
|
|
+ // "tomorrow" or "tomorrow at 9am" or "tomorrow at 14:30"
|
|
|
+ tomorrow := now.AddDate(0, 0, 1)
|
|
|
+
|
|
|
+ if input == "tomorrow" {
|
|
|
+ // Default to 9am tomorrow
|
|
|
+ tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.UTC)
|
|
|
+ return tomorrow.Format(time.RFC3339), nil
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse "tomorrow at HH:MM" or "tomorrow at 9am"
|
|
|
+ atIndex := strings.Index(input, " at ")
|
|
|
+ if atIndex == -1 {
|
|
|
+ return "", fmt.Errorf("expected 'at' in: %s", input)
|
|
|
+ }
|
|
|
+
|
|
|
+ timeStr := strings.TrimSpace(input[atIndex+4:])
|
|
|
+ hour, minute, err := parseTimeOfDay(timeStr)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+
|
|
|
+ t := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), hour, minute, 0, 0, time.UTC)
|
|
|
+ return t.Format(time.RFC3339), nil
|
|
|
+}
|
|
|
+
|
|
|
+func parseNextDay(input string, now time.Time) (string, error) {
|
|
|
+ // "next monday at 3pm", "next friday at 10:00"
|
|
|
+ re := regexp.MustCompile(`^next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+(.+)$`)
|
|
|
+ matches := re.FindStringSubmatch(input)
|
|
|
+
|
|
|
+ if len(matches) != 3 {
|
|
|
+ return "", fmt.Errorf("expected format 'next DAY at TIME': %s", input)
|
|
|
+ }
|
|
|
+
|
|
|
+ dayName := matches[1]
|
|
|
+ timeStr := matches[2]
|
|
|
+
|
|
|
+ // Find target weekday
|
|
|
+ targetWeekday := parseWeekday(dayName)
|
|
|
+ currentWeekday := now.Weekday()
|
|
|
+
|
|
|
+ daysUntil := int(targetWeekday - currentWeekday)
|
|
|
+ if daysUntil <= 0 {
|
|
|
+ daysUntil += 7
|
|
|
+ }
|
|
|
+
|
|
|
+ targetDate := now.AddDate(0, 0, daysUntil)
|
|
|
+
|
|
|
+ hour, minute, err := parseTimeOfDay(timeStr)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+
|
|
|
+ t := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), hour, minute, 0, 0, time.UTC)
|
|
|
+ return t.Format(time.RFC3339), nil
|
|
|
+}
|
|
|
+
|
|
|
+func parseTimeOfDay(input string) (hour int, minute int, err error) {
|
|
|
+ input = strings.TrimSpace(strings.ToLower(input))
|
|
|
+
|
|
|
+ // Handle special cases
|
|
|
+ switch input {
|
|
|
+ case "noon", "12pm":
|
|
|
+ return 12, 0, nil
|
|
|
+ case "midnight", "12am":
|
|
|
+ return 0, 0, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse "3pm", "9am"
|
|
|
+ re := regexp.MustCompile(`^(\d+)(am|pm)$`)
|
|
|
+ matches := re.FindStringSubmatch(input)
|
|
|
+ if len(matches) == 3 {
|
|
|
+ h, _ := strconv.Atoi(matches[1])
|
|
|
+ if matches[2] == "pm" && h != 12 {
|
|
|
+ h += 12
|
|
|
+ }
|
|
|
+ if matches[2] == "am" && h == 12 {
|
|
|
+ h = 0
|
|
|
+ }
|
|
|
+ return h, 0, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse "14:30", "9:15"
|
|
|
+ re = regexp.MustCompile(`^(\d+):(\d+)$`)
|
|
|
+ matches = re.FindStringSubmatch(input)
|
|
|
+ if len(matches) == 3 {
|
|
|
+ h, _ := strconv.Atoi(matches[1])
|
|
|
+ m, _ := strconv.Atoi(matches[2])
|
|
|
+ if h > 23 || m > 59 {
|
|
|
+ return 0, 0, fmt.Errorf("invalid time: %s", input)
|
|
|
+ }
|
|
|
+ return h, m, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return 0, 0, fmt.Errorf("unable to parse time of day: %s", input)
|
|
|
+}
|
|
|
+
|
|
|
+func parseWeekday(day string) time.Weekday {
|
|
|
+ switch strings.ToLower(day) {
|
|
|
+ case "sunday":
|
|
|
+ return time.Sunday
|
|
|
+ case "monday":
|
|
|
+ return time.Monday
|
|
|
+ case "tuesday":
|
|
|
+ return time.Tuesday
|
|
|
+ case "wednesday":
|
|
|
+ return time.Wednesday
|
|
|
+ case "thursday":
|
|
|
+ return time.Thursday
|
|
|
+ case "friday":
|
|
|
+ return time.Friday
|
|
|
+ case "saturday":
|
|
|
+ return time.Saturday
|
|
|
+ default:
|
|
|
+ return time.Monday
|
|
|
+ }
|
|
|
+}
|