// Package cron fournit un scheduler pour les tâches planifiées. // Supporte le format cron standard (* * * * *) avec timezone. package cron import ( "fmt" "strconv" "strings" "time" ) // Schedule représente une expression cron parsée. type Schedule struct { Minute []int // 0-59 Hour []int // 0-23 DayOfMonth []int // 1-31 Month []int // 1-12 DayOfWeek []int // 0-6 (0=dimanche) Location *time.Location } // ParseSchedule parse une expression cron standard. // Format: "minute hour day month weekday" // Exemples: // - "0 8 * * 1-5" : 8h00 du lundi au vendredi // - "*/15 * * * *" : toutes les 15 minutes // - "0 9 1 * *" : 9h00 le premier de chaque mois func ParseSchedule(expr string, location *time.Location) (*Schedule, error) { if location == nil { location = time.UTC } parts := strings.Fields(expr) if len(parts) != 5 { return nil, fmt.Errorf("invalid cron expression: expected 5 fields, got %d", len(parts)) } s := &Schedule{Location: location} var err error // Minute (0-59) s.Minute, err = parseField(parts[0], 0, 59) if err != nil { return nil, fmt.Errorf("minute: %w", err) } // Hour (0-23) s.Hour, err = parseField(parts[1], 0, 23) if err != nil { return nil, fmt.Errorf("hour: %w", err) } // Day of month (1-31) s.DayOfMonth, err = parseField(parts[2], 1, 31) if err != nil { return nil, fmt.Errorf("day of month: %w", err) } // Month (1-12) s.Month, err = parseField(parts[3], 1, 12) if err != nil { return nil, fmt.Errorf("month: %w", err) } // Day of week (0-6, 0=Sunday) s.DayOfWeek, err = parseField(parts[4], 0, 6) if err != nil { return nil, fmt.Errorf("day of week: %w", err) } return s, nil } // parseField parse un champ cron individuel. // Supporte: *, */n, n, n-m, n,m,o func parseField(field string, min, max int) ([]int, error) { var values []int // Gérer les listes (ex: "1,3,5") for _, part := range strings.Split(field, ",") { part = strings.TrimSpace(part) // Step (*/n ou n-m/s) step := 1 if idx := strings.Index(part, "/"); idx != -1 { stepStr := part[idx+1:] var err error step, err = strconv.Atoi(stepStr) if err != nil || step < 1 { return nil, fmt.Errorf("invalid step: %s", stepStr) } part = part[:idx] } // Range ou valeur var rangeMin, rangeMax int if part == "*" { rangeMin, rangeMax = min, max } else if idx := strings.Index(part, "-"); idx != -1 { var err error rangeMin, err = strconv.Atoi(part[:idx]) if err != nil { return nil, fmt.Errorf("invalid range start: %s", part[:idx]) } rangeMax, err = strconv.Atoi(part[idx+1:]) if err != nil { return nil, fmt.Errorf("invalid range end: %s", part[idx+1:]) } } else { val, err := strconv.Atoi(part) if err != nil { return nil, fmt.Errorf("invalid value: %s", part) } rangeMin, rangeMax = val, val } // Valider les bornes if rangeMin < min || rangeMax > max || rangeMin > rangeMax { return nil, fmt.Errorf("value out of range [%d-%d]: %d-%d", min, max, rangeMin, rangeMax) } // Générer les valeurs for v := rangeMin; v <= rangeMax; v += step { values = append(values, v) } } if len(values) == 0 { return nil, fmt.Errorf("no values") } return values, nil } // Next calcule la prochaine exécution après 'from'. func (s *Schedule) Next(from time.Time) time.Time { // Convertir dans la timezone du schedule t := from.In(s.Location) // Commencer à la minute suivante t = t.Truncate(time.Minute).Add(time.Minute) // Limite de recherche (1 an) limit := t.Add(366 * 24 * time.Hour) for t.Before(limit) { // Vérifier le mois if !contains(s.Month, int(t.Month())) { // Passer au premier jour du mois suivant t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, s.Location) continue } // Vérifier le jour du mois ET le jour de la semaine // En cron standard, si les deux sont spécifiés (non-*), c'est un OR dayMatch := contains(s.DayOfMonth, t.Day()) weekdayMatch := contains(s.DayOfWeek, int(t.Weekday())) // Si les deux champs sont "*", les deux sont vrais // Si l'un est spécifié et pas l'autre, seul celui spécifié compte // Si les deux sont spécifiés, c'est OR (comportement cron standard) bothWildcard := len(s.DayOfMonth) == 31 && len(s.DayOfWeek) == 7 if bothWildcard { // Les deux sont *, donc on accepte tous les jours } else if len(s.DayOfMonth) == 31 { // Jour du mois est *, seul le jour de semaine compte if !weekdayMatch { t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location) continue } } else if len(s.DayOfWeek) == 7 { // Jour de semaine est *, seul le jour du mois compte if !dayMatch { t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location) continue } } else { // Les deux sont spécifiés : OR if !dayMatch && !weekdayMatch { t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location) continue } } // Vérifier l'heure if !contains(s.Hour, t.Hour()) { // Passer à l'heure suivante t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, s.Location) continue } // Vérifier la minute if !contains(s.Minute, t.Minute()) { // Passer à la minute suivante t = t.Add(time.Minute) continue } // Trouvé ! return t } // Pas trouvé dans l'année, retourner zero return time.Time{} } // NextN retourne les N prochaines exécutions. func (s *Schedule) NextN(from time.Time, n int) []time.Time { var times []time.Time t := from for i := 0; i < n; i++ { next := s.Next(t) if next.IsZero() { break } times = append(times, next) t = next } return times } // contains vérifie si une valeur est dans une liste. func contains(list []int, val int) bool { for _, v := range list { if v == val { return true } } return false } // LoadLocation charge une timezone par nom (ex: "Europe/Paris"). func LoadLocation(name string) (*time.Location, error) { if name == "" || name == "UTC" { return time.UTC, nil } return time.LoadLocation(name) }