v0.50.1 a637010

cron.go RawHistoryFolder

1// Package scheduler provides portable, in-process calendar scheduling.
2package scheduler
3
4import (
5	"fmt"
6	"strconv"
7	"strings"
8	"time"
9)
10
11// Cron is a five-field cron schedule (minute, hour, day of month, month, day
12// of week). It evaluates calendar times in the location captured when the
13// schedule is created. A repeated local minute during a DST fall-back matches
14// twice; a local minute skipped during a spring-forward does not match.
15type Cron struct {
16	minute field
17	hour   field
18	day    field
19	month  field
20	week   field
21	loc    *time.Location
22}
23
24type field struct {
25	values   []bool
26	wildcard bool
27}
28
29var cronMacros = map[string]string{
30	"@yearly":   "0 0 1 1 *",
31	"@annually": "0 0 1 1 *",
32	"@monthly":  "0 0 1 * *",
33	"@weekly":   "0 0 * * 0",
34	"@daily":    "0 0 * * *",
35	"@midnight": "0 0 * * *",
36	"@hourly":   "0 * * * *",
37}
38
39var monthNames = map[string]int{
40	"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
41	"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
42}
43
44var weekdayNames = map[string]int{
45	"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
46}
47
48// ParseCron parses a conventional five-field cron expression. Month and
49// weekday names use the English three-letter abbreviations. The supplied
50// location is retained by the schedule, so later changes to time.Local do not
51// change an existing task's calendar semantics.
52func ParseCron(expression string, location *time.Location) (Cron, error) {
53	if location == nil {
54		return Cron{}, fmt.Errorf("cron timezone is required")
55	}
56	expression = strings.ToLower(strings.TrimSpace(expression))
57	if replacement, ok := cronMacros[expression]; ok {
58		expression = replacement
59	}
60	parts := strings.Fields(expression)
61	if len(parts) != 5 {
62		return Cron{}, fmt.Errorf("cron requires five fields (minute hour day-of-month month day-of-week)")
63	}
64	minute, err := parseField(parts[0], 0, 59, nil, false)
65	if err != nil {
66		return Cron{}, fmt.Errorf("invalid cron minute: %w", err)
67	}
68	hour, err := parseField(parts[1], 0, 23, nil, false)
69	if err != nil {
70		return Cron{}, fmt.Errorf("invalid cron hour: %w", err)
71	}
72	day, err := parseField(parts[2], 1, 31, nil, false)
73	if err != nil {
74		return Cron{}, fmt.Errorf("invalid cron day-of-month: %w", err)
75	}
76	month, err := parseField(parts[3], 1, 12, monthNames, false)
77	if err != nil {
78		return Cron{}, fmt.Errorf("invalid cron month: %w", err)
79	}
80	week, err := parseField(parts[4], 0, 7, weekdayNames, true)
81	if err != nil {
82		return Cron{}, fmt.Errorf("invalid cron day-of-week: %w", err)
83	}
84	return Cron{minute: minute, hour: hour, day: day, month: month, week: week, loc: location}, nil
85}
86
87// Next returns the first schedule occurrence strictly after after. An invalid
88// calendar expression such as 31 February has no occurrence and returns the
89// zero time.
90func (c Cron) Next(after time.Time) time.Time {
91	if c.loc == nil {
92		return time.Time{}
93	}
94	candidate := after.In(c.loc).Truncate(time.Minute).Add(time.Minute)
95	// Eight years covers every leap-year placement while keeping impossible
96	// expressions bounded. Normal schedules resolve in a handful of steps.
97	const maxMinutes = 8 * 366 * 24 * 60
98	for range maxMinutes {
99		if c.matches(candidate) {
100			return candidate
101		}
102		candidate = candidate.Add(time.Minute)
103	}
104	return time.Time{}
105}
106
107func (c Cron) matches(t time.Time) bool {
108	if !c.minute.matches(t.Minute()) || !c.hour.matches(t.Hour()) ||
109		!c.month.matches(int(t.Month())) {
110		return false
111	}
112	dom := c.day.matches(t.Day())
113	dow := c.week.matches(int(t.Weekday()))
114	// Traditional cron treats restricted day-of-month and day-of-week fields
115	// as an OR. When either is *, the other field controls matching.
116	if !c.day.wildcard && !c.week.wildcard {
117		return dom || dow
118	}
119	return dom && dow
120}
121
122func (f field) matches(value int) bool {
123	return value >= 0 && value < len(f.values) && f.values[value]
124}
125
126func parseField(raw string, min, max int, aliases map[string]int, sundaySeven bool) (field, error) {
127	out := field{values: make([]bool, max+1), wildcard: raw == "*"}
128	if raw == "" {
129		return field{}, fmt.Errorf("empty field")
130	}
131	for _, part := range strings.Split(raw, ",") {
132		if part == "" {
133			return field{}, fmt.Errorf("empty list item")
134		}
135		base, step, hasStep, err := splitStep(part)
136		if err != nil {
137			return field{}, err
138		}
139		start, end := min, max
140		switch {
141		case base == "*":
142		case strings.Contains(base, "-"):
143			pieces := strings.Split(base, "-")
144			if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
145				return field{}, fmt.Errorf("invalid range %q", base)
146			}
147			start, err = parseCronValue(pieces[0], min, max, aliases, sundaySeven)
148			if err != nil {
149				return field{}, err
150			}
151			end, err = parseCronValue(pieces[1], min, max, aliases, sundaySeven)
152			if err != nil {
153				return field{}, err
154			}
155			if start > end {
156				return field{}, fmt.Errorf("range %q descends", base)
157			}
158		default:
159			if hasStep {
160				return field{}, fmt.Errorf("step requires * or a range")
161			}
162			start, err = parseCronValue(base, min, max, aliases, sundaySeven)
163			if err != nil {
164				return field{}, err
165			}
166			end = start
167		}
168		if !hasStep {
169			step = 1
170		}
171		for value := start; value <= end; value += step {
172			out.values[value] = true
173		}
174	}
175	return out, nil
176}
177
178func splitStep(part string) (base string, step int, hasStep bool, err error) {
179	pieces := strings.Split(part, "/")
180	if len(pieces) == 1 {
181		return part, 0, false, nil
182	}
183	if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
184		return "", 0, false, fmt.Errorf("invalid step %q", part)
185	}
186	step, err = strconv.Atoi(pieces[1])
187	if err != nil || step <= 0 {
188		return "", 0, false, fmt.Errorf("invalid step %q", part)
189	}
190	return pieces[0], step, true, nil
191}
192
193func parseCronValue(raw string, min, max int, aliases map[string]int, sundaySeven bool) (int, error) {
194	if value, ok := aliases[raw]; ok {
195		return value, nil
196	}
197	value, err := strconv.Atoi(raw)
198	if err != nil {
199		return 0, fmt.Errorf("%q is not a number", raw)
200	}
201	if sundaySeven && value == 7 {
202		return 0, nil
203	}
204	if value < min || value > max {
205		return 0, fmt.Errorf("%d is outside %d-%d", value, min, max)
206	}
207	return value, nil
208}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close