go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/sdk/cron/parse_schedule.go (about) 1 /* 2 3 Copyright (c) 2023 - Present. Will Charczuk. All rights reserved. 4 Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository. 5 6 */ 7 8 package cron 9 10 import ( 11 "errors" 12 "fmt" 13 "sort" 14 "strconv" 15 "strings" 16 "time" 17 ) 18 19 /* 20 ParseSchedule parses a cron formatted string into a schedule. 21 22 The string must be at least 5 components, whitespace separated. 23 If the string has 5 components a 0 will be prepended for the seconds component, and a * appended for the year component. 24 If the string has 6 components a * appended for the year component. 25 26 The components are (in short form / 5 component): 27 28 (minutes) (hours) (day of month) (month) (day of week) 29 30 The components are (in medium form / 6 component): 31 32 (seconds) (hours) (day of month) (month) (day of week) 33 34 The components are (in long form / 7 component): 35 36 (seconds) (minutes) (hours) (day of month) (month) (day of week) (year) 37 38 The full list of possible field values: 39 40 Field name Mandatory? Allowed values Allowed special characters 41 ---------- ---------- -------------- -------------------------- 42 Seconds No 0-59 * / , - 43 Minutes Yes 0-59 * / , - 44 Hours Yes 0-23 * / , - 45 Day of month Yes 1-31 * / , - L W 46 Month Yes 1-12 or JAN-DEC * / , - 47 Day of week Yes 0-6 or SUN-SAT * / , - L # 48 Year No 1970–2099 * / , - 49 50 You can also use shorthands: 51 52 "@yearly" is equivalent to "0 0 0 1 1 * *" 53 "@monthly" is equivalent to "0 0 0 1 * * *" 54 "@weekly" is equivalent to "0 0 0 * * 0 *" 55 "@daily" is equivalent to "0 0 0 * * * *" 56 "@hourly" is equivalent to "0 0 * * * * *" 57 "@every 500ms" is equivalent to "cron.Every(500 * time.Millisecond)"" 58 "@immediately-then @every 500ms" is equivalent to "cron.Immediately().Then(cron.Every(500*time.Millisecond))" 59 "@once-at 2021-06-05 13:04" is "cron.OnceAtUTC(time.Date(...))" 60 "@never" is equivalent to an unset schedule (i.e., only on demand) to avoid defaults 61 */ 62 func ParseSchedule(cronString string) (schedule Schedule, err error) { 63 // remove leading and trailing whitespace, including newlines and tabs 64 cronString = strings.TrimSpace(cronString) 65 66 // check for "@never" 67 if cronString == StringScheduleNever { 68 schedule = Never() 69 return 70 } 71 72 // check for "@immediately" 73 if cronString == StringScheduleImmediately { 74 schedule = Immediately() 75 return 76 } 77 78 // check for "@once-at" 79 if strings.HasPrefix(cronString, StringScheduleOnceAt) { 80 cronString = strings.TrimPrefix(cronString, StringScheduleOnceAt) 81 cronString = strings.TrimSpace(cronString) 82 83 onceAtUTC, errOnceAtUTC := time.Parse(time.RFC3339, cronString) 84 if errOnceAtUTC != nil { 85 err = fmt.Errorf("%w: %v", ErrStringScheduleInvalid, errOnceAtUTC) 86 return 87 } 88 schedule = OnceAt(onceAtUTC.UTC()) 89 return 90 } 91 92 // here we assume the rest of the schedule is either 93 // @immediately-then ... 94 // @delay ... 95 // @immediately-then @delay ... 96 // etc. 97 98 // pull the @immediately-then off the beginning of 99 // the schedule if it's present 100 var immediately bool 101 if strings.HasPrefix(cronString, StringScheduleImmediatelyThen) { 102 immediately = true 103 cronString = strings.TrimPrefix(cronString, StringScheduleImmediatelyThen) 104 cronString = strings.TrimSpace(cronString) 105 } 106 107 var delay time.Duration 108 if strings.HasPrefix(cronString, StringScheduleDelay) { 109 cronString = strings.TrimPrefix(cronString, StringScheduleDelay) 110 cronString = strings.TrimSpace(cronString) 111 112 remainingFields := strings.Fields(cronString) 113 if len(remainingFields) < 2 { 114 err = fmt.Errorf("@delay must be in the form `@delay <DURATION> <THEN>`: %w", ErrStringScheduleInvalid) 115 return 116 } 117 durationPart := remainingFields[0] 118 cronString = strings.TrimPrefix(cronString, durationPart) 119 cronString = strings.TrimSpace(cronString) 120 delay, err = time.ParseDuration(durationPart) 121 if err != nil { 122 err = fmt.Errorf("%w: %v", ErrStringScheduleInvalid, err) 123 return 124 } 125 } 126 127 // DEFER / FINALLY BLOCK 128 // we add the optional immediately or delay 129 // directives into the final schedule. 130 defer func() { 131 if schedule != nil { 132 if delay > 0 { 133 schedule = Delay(delay, schedule) 134 } 135 if immediately { 136 schedule = Immediately().Then(schedule) 137 } 138 } 139 }() 140 141 // at this point, the rest of the cron string is considered 142 // a complete, single representation of a schedule 143 144 // handle the specific @every string representation 145 if strings.HasPrefix(cronString, StringScheduleEvery) { 146 cronString = strings.TrimPrefix(cronString, StringScheduleEvery) 147 cronString = strings.TrimSpace(cronString) 148 duration, durationErr := time.ParseDuration(cronString) 149 if durationErr != nil { 150 err = fmt.Errorf("%w: %v", ErrStringScheduleInvalid, durationErr) 151 return 152 } 153 schedule = Every(duration) 154 return 155 } 156 157 // we now assume the string is a 'cron-like' string 158 // that is in the form "* * * * *" etc. 159 160 // check for shorthands, replace the shorthand with a proper cron-like string 161 if shorthand, ok := StringScheduleShorthands[cronString]; ok { 162 cronString = shorthand 163 } 164 165 parts := strings.Fields(cronString) 166 if len(parts) < 5 || len(parts) > 7 { 167 return nil, fmt.Errorf("%w: %v; provided string; %s", ErrStringScheduleInvalid, ErrStringScheduleComponents, cronString) 168 } 169 // fill in optional components 170 if len(parts) == 5 { 171 parts = append([]string{"0"}, parts...) 172 parts = append(parts, "*") 173 } else if len(parts) == 6 { 174 parts = append(parts, "*") 175 } 176 177 seconds, err := parsePart(parts[0], parseInt, below(60)) 178 if err != nil { 179 return nil, fmt.Errorf("provided string; seconds invalid; %v: %w", ErrStringScheduleInvalid, err) 180 } 181 182 minutes, err := parsePart(parts[1], parseInt, below(60)) 183 if err != nil { 184 return nil, fmt.Errorf("provided string; minutes invalid; %v: %w", ErrStringScheduleInvalid, err) 185 } 186 187 hours, err := parsePart(parts[2], parseInt, below(24)) 188 if err != nil { 189 return nil, fmt.Errorf("provided string; hours invalid; %v: %w", ErrStringScheduleInvalid, err) 190 } 191 192 days, err := parsePart(parts[3], parseInt, between(1, 32)) 193 if err != nil { 194 return nil, fmt.Errorf("provided string; days invalid; %v: %w", ErrStringScheduleInvalid, err) 195 } 196 197 months, err := parsePart(parts[4], parseMonth, between(1, 13)) 198 if err != nil { 199 return nil, fmt.Errorf("provided string; months invalid; %v: %w", ErrStringScheduleInvalid, err) 200 } 201 202 daysOfWeek, err := parsePart(parts[5], parseDayOfWeek, between(0, 7)) 203 if err != nil { 204 return nil, fmt.Errorf("provided string; days of week invalid; %v: %w", ErrStringScheduleInvalid, err) 205 } 206 207 years, err := parsePart(parts[6], parseInt, between(1970, 2100)) 208 if err != nil { 209 return nil, fmt.Errorf("provided string; years invalid; %v: %w", ErrStringScheduleInvalid, err) 210 } 211 212 schedule = &StringSchedule{ 213 Original: cronString, 214 Seconds: seconds, 215 Minutes: minutes, 216 Hours: hours, 217 DaysOfMonth: days, 218 Months: months, 219 DaysOfWeek: daysOfWeek, 220 Years: years, 221 } 222 return 223 } 224 225 // Error Constants 226 var ( 227 ErrStringScheduleInvalid = errors.New("cron: schedule string invalid") 228 ErrStringScheduleComponents = errors.New("cron: must have at least (5) components space delimited; ex: '0 0 * * * * *'") 229 ErrStringScheduleValueOutOfRange = errors.New("cron: string schedule part out of range") 230 ErrStringScheduleInvalidRange = errors.New("cron: range (from-to) invalid") 231 ) 232 233 // String schedule constants 234 const ( 235 StringScheduleImmediately = "@immediately" 236 StringScheduleDelay = "@delay" 237 StringScheduleImmediatelyThen = "@immediately-then" 238 StringScheduleEvery = "@every" 239 StringScheduleOnceAt = "@once-at" 240 StringScheduleNever = "@never" 241 ) 242 243 // String schedule shorthands labels 244 const ( 245 StringScheduleShorthandAnnually = "@annually" 246 StringScheduleShorthandYearly = "@yearly" 247 StringScheduleShorthandMonthly = "@monthly" 248 StringScheduleShorthandWeekly = "@weekly" 249 StringScheduleShorthandDaily = "@daily" 250 StringScheduleShorthandHourly = "@hourly" 251 ) 252 253 // String schedule shorthand values 254 var ( 255 StringScheduleShorthands = map[string]string{ 256 StringScheduleShorthandAnnually: "0 0 0 1 1 * *", 257 StringScheduleShorthandYearly: "0 0 0 1 1 * *", 258 StringScheduleShorthandMonthly: "0 0 0 1 * * *", 259 StringScheduleShorthandDaily: "0 0 0 * * * *", 260 StringScheduleShorthandHourly: "0 0 * * * * *", 261 } 262 ) 263 264 // Interface assertions. 265 var ( 266 _ Schedule = (*StringSchedule)(nil) 267 _ fmt.Stringer = (*StringSchedule)(nil) 268 ) 269 270 // StringSchedule is a schedule generated from a cron string. 271 type StringSchedule struct { 272 Original string 273 274 Seconds []int 275 Minutes []int 276 Hours []int 277 DaysOfMonth []int 278 Months []int 279 DaysOfWeek []int 280 Years []int 281 } 282 283 // String returns the original string schedule. 284 func (ss *StringSchedule) String() string { 285 return ss.Original 286 } 287 288 // FullString returns a fully formed string representation of the schedule's components. 289 // It shows fields as expanded. 290 func (ss *StringSchedule) FullString() string { 291 fields := []string{ 292 csvOfInts(ss.Seconds, "*"), 293 csvOfInts(ss.Minutes, "*"), 294 csvOfInts(ss.Hours, "*"), 295 csvOfInts(ss.DaysOfMonth, "*"), 296 csvOfInts(ss.Months, "*"), 297 csvOfInts(ss.DaysOfWeek, "*"), 298 csvOfInts(ss.Years, "*"), 299 } 300 return strings.Join(fields, " ") 301 } 302 303 // Next implements cron.Schedule. 304 func (ss *StringSchedule) Next(after time.Time) time.Time { 305 working := after 306 if after.IsZero() { 307 working = Now() 308 } 309 original := working 310 311 if len(ss.Years) > 0 { 312 for _, year := range ss.Years { 313 if year >= working.Year() { 314 working = advanceYearTo(working, year) 315 break 316 } 317 } 318 } 319 320 if len(ss.Months) > 0 { 321 var didSet bool 322 for _, month := range ss.Months { 323 if time.Month(month) == working.Month() && working.After(original) { 324 didSet = true 325 break 326 } 327 if time.Month(month) > working.Month() { 328 working = advanceMonthTo(working, time.Month(month)) 329 didSet = true 330 break 331 } 332 } 333 if !didSet { 334 working = advanceYear(working) 335 for _, month := range ss.Months { 336 if time.Month(month) >= working.Month() { 337 working = advanceMonthTo(working, time.Month(month)) 338 break 339 } 340 } 341 } 342 } 343 344 if len(ss.DaysOfMonth) > 0 { 345 var didSet bool 346 for _, day := range ss.DaysOfMonth { 347 if day == working.Day() && working.After(original) { 348 didSet = true 349 break 350 } 351 if day > working.Day() { 352 working = advanceDayTo(working, day) 353 didSet = true 354 break 355 } 356 } 357 if !didSet { 358 working = advanceMonth(working) 359 for _, day := range ss.DaysOfMonth { 360 if day >= working.Day() { 361 working = advanceDayTo(working, day) 362 break 363 } 364 } 365 } 366 } 367 368 if len(ss.DaysOfWeek) > 0 { 369 var didSet bool 370 for _, dow := range ss.DaysOfWeek { 371 if dow == int(working.Weekday()) && working.After(original) { 372 didSet = true 373 break 374 } 375 if dow > int(working.Weekday()) { 376 working = advanceDayBy(working, (dow - int(working.Weekday()))) 377 didSet = true 378 break 379 } 380 } 381 if !didSet { 382 working = advanceToNextSunday(working) 383 for _, dow := range ss.DaysOfWeek { 384 if dow >= int(working.Weekday()) { 385 working = advanceDayBy(working, (dow - int(working.Weekday()))) 386 break 387 } 388 } 389 } 390 } 391 392 if len(ss.Hours) > 0 { 393 var didSet bool 394 for _, hour := range ss.Hours { 395 if hour == working.Hour() && working.After(original) { 396 didSet = true 397 break 398 } 399 if hour > working.Hour() { 400 working = advanceHourTo(working, hour) 401 didSet = true 402 break 403 } 404 } 405 if !didSet { 406 working = advanceDay(working) 407 for _, hour := range ss.Hours { 408 if hour >= working.Hour() { 409 working = advanceHourTo(working, hour) 410 break 411 } 412 } 413 } 414 } 415 416 if len(ss.Minutes) > 0 { 417 var didSet bool 418 for _, minute := range ss.Minutes { 419 if minute == working.Minute() && working.After(original) { 420 didSet = true 421 break 422 } 423 if minute > working.Minute() { 424 working = advanceMinuteTo(working, minute) 425 didSet = true 426 break 427 } 428 } 429 if !didSet { 430 working = advanceHour(working) 431 for _, minute := range ss.Minutes { 432 if minute >= working.Minute() { 433 working = advanceMinuteTo(working, minute) 434 break 435 } 436 } 437 } 438 } 439 440 if len(ss.Seconds) > 0 { 441 var didSet bool 442 for _, second := range ss.Seconds { 443 if second == working.Second() && working.After(original) { 444 didSet = true 445 break 446 } 447 if second > working.Second() { 448 working = advanceSecondTo(working, second) 449 didSet = true 450 break 451 } 452 } 453 if !didSet { 454 working = advanceMinute(working) 455 for _, second := range ss.Hours { 456 if second >= working.Second() { 457 working = advanceSecondTo(working, second) 458 break 459 } 460 } 461 } 462 } 463 464 return working 465 } 466 467 func parsePart(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) { 468 if values == string(cronSpecialStar) { 469 return nil, nil 470 } 471 472 // check if we need to expand an "every" pattern 473 if strings.HasPrefix(values, cronSpecialEvery) { 474 return parseEvery(values, parseInt, validator) 475 } 476 477 components := strings.Split(values, string(cronSpecialComma)) 478 479 output := map[int]bool{} 480 var component string 481 for x := 0; x < len(components); x++ { 482 component = components[x] 483 if strings.Contains(component, string(cronSpecialDash)) { 484 rangeValues, err := parseRange(values, parser, validator) 485 if err != nil { 486 return nil, err 487 } 488 489 for _, value := range rangeValues { 490 output[value] = true 491 } 492 continue 493 } 494 495 part, err := parser(component) 496 if err != nil { 497 return nil, err 498 } 499 if validator != nil && !validator(part) { 500 return nil, err 501 } 502 output[part] = true 503 } 504 return mapKeysToArray(output), nil 505 } 506 507 func parseEvery(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) { 508 every, err := parser(strings.TrimPrefix(values, "*/")) 509 if err != nil { 510 return nil, err 511 } 512 if validator != nil && !validator(every) { 513 return nil, ErrStringScheduleValueOutOfRange 514 } 515 516 var output []int 517 for x := 0; x < 60; x += every { 518 output = append(output, x) 519 } 520 return output, nil 521 } 522 523 func parseRange(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) { 524 parts := strings.Split(values, string(cronSpecialDash)) 525 526 if len(parts) != 2 { 527 return nil, fmt.Errorf("invalid range: %s; %w", values, ErrStringScheduleInvalidRange) 528 } 529 530 from, err := parser(parts[0]) 531 if err != nil { 532 return nil, err 533 } 534 to, err := parser(parts[1]) 535 if err != nil { 536 return nil, err 537 } 538 539 if validator != nil && !validator(from) { 540 return nil, fmt.Errorf("invalid range from; %w", ErrStringScheduleValueOutOfRange) 541 } 542 if validator != nil && !validator(to) { 543 return nil, fmt.Errorf("invalid range to; %w", ErrStringScheduleValueOutOfRange) 544 } 545 546 if from >= to { 547 return nil, fmt.Errorf("invalid range; from greater than to; %w", ErrStringScheduleValueOutOfRange) 548 } 549 550 var output []int 551 for x := from; x <= to; x++ { 552 output = append(output, x) 553 } 554 return output, nil 555 } 556 557 func parseInt(s string) (int, error) { 558 return strconv.Atoi(s) 559 } 560 561 func parseMonth(s string) (int, error) { 562 if value, ok := validMonths[s]; ok { 563 return value, nil 564 } 565 value, err := strconv.Atoi(s) 566 if err != nil { 567 return 0, fmt.Errorf("%v; month not a valid integer: %w", err, ErrStringScheduleValueOutOfRange) 568 } 569 if value < 1 || value > 12 { 570 return 0, fmt.Errorf("month out of range (1-12): %w", ErrStringScheduleValueOutOfRange) 571 } 572 return value, nil 573 } 574 575 func parseDayOfWeek(s string) (int, error) { 576 if value, ok := validDaysOfWeek[s]; ok { 577 return value, nil 578 } 579 value, err := strconv.Atoi(s) 580 if err != nil { 581 return 0, fmt.Errorf("%v; day of week not a valid integer: %w", err, ErrStringScheduleValueOutOfRange) 582 } 583 if value < 0 || value > 6 { 584 return 0, fmt.Errorf("%v; day of week out of range (0-6)", ErrStringScheduleValueOutOfRange) 585 } 586 return value, nil 587 } 588 589 func below(max int) func(int) bool { 590 return between(0, max) 591 } 592 593 func between(min, max int) func(int) bool { 594 return func(value int) bool { 595 return value >= min && value < max 596 } 597 } 598 599 func mapKeysToArray(values map[int]bool) []int { 600 output := make([]int, len(values)) 601 var index int 602 for key := range values { 603 output[index] = key 604 index++ 605 } 606 sort.Ints(output) 607 return output 608 } 609 610 // 611 // time helpers 612 // 613 614 func advanceYear(t time.Time) time.Time { 615 return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location()).AddDate(1, 0, 0) 616 } 617 618 func advanceYearTo(t time.Time, year int) time.Time { 619 return time.Date(year, 1, 1, 0, 0, 0, 0, t.Location()) 620 } 621 622 func advanceMonth(t time.Time) time.Time { 623 return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0) 624 } 625 626 func advanceMonthTo(t time.Time, month time.Month) time.Time { 627 return time.Date(t.Year(), month, 1, 0, 0, 0, 0, t.Location()) 628 } 629 630 func advanceDayTo(t time.Time, day int) time.Time { 631 return time.Date(t.Year(), t.Month(), day, 0, 0, 0, 0, t.Location()) 632 } 633 634 func advanceToNextSunday(t time.Time) time.Time { 635 daysUntilSunday := 7 - int(t.Weekday()) 636 return t.AddDate(0, 0, daysUntilSunday) 637 } 638 639 func advanceDay(t time.Time) time.Time { 640 return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1) 641 } 642 643 func advanceDayBy(t time.Time, days int) time.Time { 644 return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, days) 645 } 646 647 func advanceHour(t time.Time) time.Time { 648 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour) 649 } 650 651 func advanceHourTo(t time.Time, hour int) time.Time { 652 return time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, t.Location()) 653 } 654 655 func advanceMinute(t time.Time) time.Time { 656 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, t.Location()).Add(time.Minute) 657 } 658 659 func advanceMinuteTo(t time.Time, minute int) time.Time { 660 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), minute, 0, 0, t.Location()) 661 } 662 663 func advanceSecondTo(t time.Time, second int) time.Time { 664 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), second, 0, t.Location()) 665 } 666 667 func csvOfInts(values []int, placeholder string) string { 668 if len(values) == 0 { 669 return placeholder 670 } 671 valueStrings := make([]string, len(values)) 672 for x := 0; x < len(values); x++ { 673 valueStrings[x] = strconv.Itoa(values[x]) 674 } 675 return strings.Join(valueStrings, ",") 676 } 677 678 // these are special characters 679 const ( 680 cronSpecialComma = ',' // 681 cronSpecialDash = '-' 682 cronSpecialStar = '*' 683 684 // these are unused 685 // cronSpecialSlash = '/' 686 // cronSpecialQuestion = '?' // sometimes used as the startup time, sometimes as a * 687 688 // cronSpecialLast = 'L' 689 // cronSpecialWeekday = 'W' // nearest weekday to the given day of the month 690 // cronSpecialDayOfMonth = '#' // 691 692 cronSpecialEvery = "*/" 693 ) 694 695 var ( 696 validMonths = map[string]int{ 697 "JAN": 1, 698 "FEB": 2, 699 "MAR": 3, 700 "APR": 4, 701 "MAY": 5, 702 "JUN": 6, 703 "JUL": 7, 704 "AUG": 8, 705 "SEP": 9, 706 "OCT": 10, 707 "NOV": 11, 708 "DEC": 12, 709 } 710 711 validDaysOfWeek = map[string]int{ 712 "SUN": 0, 713 "MON": 1, 714 "TUE": 2, 715 "WED": 3, 716 "THU": 4, 717 "FRI": 5, 718 "SAT": 6, 719 } 720 )