using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; namespace Microsoft.Win32.TaskScheduler { public abstract partial class Trigger { /// Creates a trigger using a cron string. /// String using cron defined syntax for specifying a time interval. See remarks for syntax. /// Array of representing the specified cron string. /// Unsupported cron string. /// /// This method does not support all combinations of cron strings. Please test extensively before use. Please post an issue with any /// syntax that should work, but doesn't. /// The following combinations are known not to work: /// /// Intervals on months (e.g. "* * * */5 *") /// Intervals on DOW (e.g. "* * * * MON/3") /// /// /// This section borrows liberally from the site http://www.nncron.ru/help/EN/working/cron-format.htm. The cron format consists of five fields separated /// by white spaces: /// /// /// <Minute> <Hour> <Day_of_the_Month> <Month_of_the_Year> <Day_of_the_Week> /// /// Each item has bounds as defined by the following: /// /// * * * * * /// | | | | | /// | | | | +---- Day of the Week (range: 1-7, 1 standing for Monday) /// | | | +------ Month of the Year (range: 1-12) /// | | +-------- Day of the Month (range: 1-31) /// | +---------- Hour (range: 0-23) /// +------------ Minute (range: 0-59) /// /// Any of these 5 fields may be an asterisk (*). This would mean the entire range of possible values, i.e. each minute, each hour, etc. /// /// Any of the first 4 fields can be a question mark ("?"). It stands for the current time, i.e. when a field is processed, the current time will be /// substituted for the question mark: minutes for Minute field, hour for Hour field, day of the month for Day of month field and month for Month field. /// /// Any field may contain a list of values separated by commas, (e.g. 1,3,7) or a range of values (two integers separated by a hyphen, e.g. 1-5). /// /// After an asterisk (*) or a range of values, you can use character / to specify that values are repeated over and over with a certain interval between /// them. For example, you can write "0-23/2" in Hour field to specify that some action should be performed every two hours (it will have the same effect /// as "0,2,4,6,8,10,12,14,16,18,20,22"); value "*/4" in Minute field means that the action should be performed every 4 minutes, "1-30/3" means the same /// as "1,4,7,10,13,16,19,22,25,28". /// /// public static Trigger[] FromCronFormat([NotNull] string cronString) { var cron = CronExpression.Parse(cronString); System.Diagnostics.Debug.WriteLine($"{cronString}=M:{cron.Minutes}; H:{cron.Hours}; D:{cron.Days}; M:{cron.Months}; W:{cron.DOW}"); var ret = new List(); // There isn't a clean mechanism to handle intervals on DOW or months, so punt //if (cron.DOW.IsIncr) throw new NotSupportedException(); //if (cron.Months.IsIncr) throw new NotSupportedException(); // WeeklyTrigger if (cron.Days.FullRange && cron.Months.FullRange && !cron.DOW.IsEvery) { var tr = new WeeklyTrigger(cron.DOW.ToDOW()); ret.AddRange(ProcessCronTimes(cron, tr)); } // MonthlyDOWTrigger if (!cron.DOW.FullRange && (!cron.Days.FullRange || !cron.Months.FullRange)) { var tr = new MonthlyDOWTrigger(cron.DOW.ToDOW(), cron.Months.ToMOY(), WhichWeek.AllWeeks); ret.AddRange(ProcessCronTimes(cron, tr)); } // MonthlyTrigger if (!cron.Days.FullRange || !cron.Months.FullRange && cron.DOW.FullRange) { var tr = new MonthlyTrigger(1, cron.Months.ToMOY()) { DaysOfMonth = cron.Days.Values.ToArray() }; ret.AddRange(ProcessCronTimes(cron, tr)); } // DailyTrigger if (cron.Days.FullRange && cron.Months.FullRange && cron.DOW.IsEvery) { var tr = new DailyTrigger((short)cron.Days.Increment); ret.AddRange(ProcessCronTimes(cron, tr)); } // Fail out if (ret.Count == 0) throw new NotSupportedException(); return ret.ToArray(); } private static IEnumerable ProcessCronTimes(CronExpression cron, Trigger baseTrigger) { // Sequential hours, every minute // "* * * * *" // "* 2-6 * * *" if (cron.Minutes.FullRange && (cron.Hours.IsEvery || cron.Hours.IsRange)) { System.Diagnostics.Debug.WriteLine("Minutes.FullRange && (Hours.IsEvery || Hours.IsRange)"); yield return MakeTrigger( new TimeSpan(cron.Hours.FirstValue, 0, 0), TimeSpan.FromMinutes(cron.Minutes.Increment), TimeSpan.FromHours(cron.Hours.Duration)); } // Non-sequential hours, every minute // "* 3,5,6 * * *" // "* 3-15/3 * * *" else if (cron.Minutes.FullRange && (cron.Hours.IsList || cron.Hours.IsIncr)) { System.Diagnostics.Debug.WriteLine("Minutes.FullRange && (Hours.IsList || Hours.IsIncr)"); foreach (var h in cron.Hours.Values) { yield return MakeTrigger( new TimeSpan(h, 0, 0), TimeSpan.FromMinutes(cron.Minutes.Increment), TimeSpan.FromHours(1)); } } // Non-repeating minutes, every hour // "3,6 * * * *" Every hour starting at 12:03 and 12:06 // "3-33 * * * *" // "3-33/6 * * * *" // "3,6 * 3-5 * *" // "3-33 3-5 * * *" // "3-33/6 3-5 * * *" else if (!cron.Minutes.FullRange && (cron.Hours.IsEvery || cron.Hours.IsRange)) { System.Diagnostics.Debug.WriteLine("!Minutes.FullRange && (Hours.IsEvery || Hours.IsRange)"); foreach (var m in cron.Minutes.Values) { yield return MakeTrigger( new TimeSpan(cron.Hours.FirstValue, m, 0), TimeSpan.FromHours(1), TimeSpan.FromHours(cron.Hours.Duration)); } } // Sequential or repeating minutes, and non-sequential hours else if ((cron.Minutes.IsRange || cron.Minutes.IsIncr) && (cron.Hours.IsList || cron.Hours.IsIncr)) { System.Diagnostics.Debug.WriteLine("(Minutes.IsRange || Minutes.IsIncr) && (Hours.IsList || Hours.IsIncr)"); foreach (var h in cron.Hours.Values) { yield return MakeTrigger( new TimeSpan(h, cron.Minutes.FirstValue, 0), TimeSpan.FromMinutes(cron.Minutes.Increment), TimeSpan.FromMinutes(cron.Minutes.Duration)); } } // Non-sequential, hours and minutes // "3,6 3,6 * * *" Every day at 3:03, 3:06, 6:03 and 6:06 // "3/6 3/6 * * *" Every day at 3:03, 3:06, 6:03 and 6:06 else { System.Diagnostics.Debug.WriteLine("Minutes.IsList && (Hours.IsIncr || Hours.IsList)"); foreach (var h in cron.Hours.Values) foreach (var m in cron.Minutes.Values) yield return MakeTrigger(new TimeSpan(h, m, 0)); } Trigger MakeTrigger(TimeSpan start, TimeSpan interval = default, TimeSpan duration = default) { var newTr = (Trigger)baseTrigger.Clone(); newTr.StartBoundary = newTr.StartBoundary.Date + start; if (interval != default) { newTr.Repetition.Interval = interval; newTr.Repetition.Duration = duration; } return newTr; } } internal class CronExpression { private FieldVal[] Fields = new FieldVal[5]; private CronExpression() { } public enum CronFieldType { Minutes, Hours, Days, Months, DaysOfWeek }; public FieldVal Days => Fields[2]; public FieldVal DOW => Fields[4]; public FieldVal Hours => Fields[1]; public FieldVal Minutes => Fields[0]; public FieldVal Months => Fields[3]; public static CronExpression Parse(string cronString) { var ret = new CronExpression(); if (cronString == null) throw new ArgumentNullException(nameof(cronString)); var tokens = cronString.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); if (tokens.Length != 5) { throw new ArgumentException($"'{cronString}' is not a valid crontab expression. It must contain at least 5 components of a schedule " + "(in the sequence of minutes, hours, days, months, days of week)."); } // min, hr, days, months, daysOfWeek for (var i = 0; i < ret.Fields.Length; i++) ret.Fields[i] = FieldVal.Parse(tokens[i], (CronFieldType)i); return ret; } public struct FieldVal { private const string rangeRegEx = @"^(?:(?\*)|(?\d+)(?:-(?\d+))?)(?:\/(?\d+))?$"; private readonly static Dictionary dow = new Dictionary(7) { { "SUN", "0" }, { "MON", "1" }, { "TUE", "2" }, { "WED", "3" }, { "THU", "4" }, { "FRI", "5" }, { "SAT", "6" }, }; private readonly static Dictionary mon = new Dictionary(12) { { "JAN", "1" }, { "FEB", "2" }, { "MAR", "3" }, { "APR", "4" }, { "MAY", "5" }, { "JUN", "6" }, { "JUL", "7" }, { "AUG", "8" }, { "SEP", "9" }, { "OCT", "10" }, { "NOV", "11" }, { "DEC", "12" }, }; private readonly static Dictionary validRange = new Dictionary(5) { { CronFieldType.Days, new MinMax(1, 31) }, { CronFieldType.DaysOfWeek, new MinMax(0, 6) }, { CronFieldType.Hours, new MinMax(0, 23) }, { CronFieldType.Minutes, new MinMax(0, 59) }, { CronFieldType.Months, new MinMax(1, 12) }, }; private CronFieldType cft; private FieldFlags flags; private int incr; private int[] vals; public FieldVal(CronFieldType cft) { this.cft = cft; flags = 0; vals = new int[0]; incr = 1; FullRange = false; } enum FieldFlags { List, Every, Range, Increment }; public int Duration => vals.Length == 1 ? 1 : vals[1] - vals[0] + 1; public int Increment => incr; public bool IsEvery { get => flags == FieldFlags.Every; private set => flags = FieldFlags.Every; } public bool IsIncr { get => flags == FieldFlags.Increment; private set => flags = FieldFlags.Increment; } public bool IsList { get => flags == 0; private set => flags = FieldFlags.List; } public bool IsRange { get => flags == FieldFlags.Range; private set => flags = FieldFlags.Range; } public bool FullRange { get; private set; } public int FirstValue => vals[0]; public IEnumerable Values { get { if (flags == 0) { foreach (var i in vals) yield return i; } else { for (int i = vals[0]; i <= vals[1]; i += incr) yield return i; } } } public DaysOfTheWeek ToDOW() { if (IsEvery) return DaysOfTheWeek.AllDays; DaysOfTheWeek ret = 0; foreach (var i in Values) ret |= (DaysOfTheWeek)(1 << i); return ret; } public MonthsOfTheYear ToMOY() { if (IsEvery) return MonthsOfTheYear.AllMonths; MonthsOfTheYear ret = 0; foreach (var i in Values) ret |= (MonthsOfTheYear)(1 << (i - 1)); return ret; } public static FieldVal Parse(string str, CronFieldType cft) { var res = new FieldVal(cft); if (string.IsNullOrEmpty(str)) throw new ArgumentNullException(nameof(str), "A crontab field value cannot be empty."); // Do substitutions str = DoSubs(str, cft); // Look first for a list of values (e.g. 1,2,3). if (System.Text.RegularExpressions.Regex.IsMatch(str, @"^\d+(,\d+)*$")) { if (str.Contains("/")) throw new NotSupportedException(); res.vals = str.Split(',').Select(ParseInt).OrderBy(i => i).Distinct().ToArray(); res.Validate(); return res; } // Look for *|nn[-nn][/n] pattern var match = System.Text.RegularExpressions.Regex.Match(str, rangeRegEx); if (match.Success) { bool hasAst = res.FullRange = match.Groups["A"].Success; if (match.Groups["I"].Success) { res.incr = ParseInt(match.Groups["I"].Value); res.IsIncr = true; } else { if (hasAst) res.IsEvery = true; else res.IsRange = true; } var mm = validRange[cft]; var start = hasAst ? mm.Min : ParseInt(match.Groups["D1"].Value); var end = hasAst ? mm.Max : (match.Groups["D2"].Success ? ParseInt(match.Groups["D2"].Value) : (res.IsIncr ? mm.Max : start)); if (end < start) throw new ArgumentOutOfRangeException(); if (start == end && res.IsRange) { res.IsList = true; res.vals = new[] { start }; } else res.vals = new[] { start, end }; res.Validate(); return res; } throw new FormatException(); } public override string ToString() => $"Type:{flags}; Vals:{string.Join(",", vals.Select(i => i.ToString()).ToArray())}; Incr:{incr}"; private void Validate() { var l = validRange[cft]; if (vals.Any(i => i < l.Min || i > l.Max)) throw new ArgumentOutOfRangeException(); if (IsIncr && (incr < l.Min || incr > l.Max)) throw new ArgumentOutOfRangeException(); } private static string DoSubs(string str, CronFieldType cft) { var sb = new System.Text.StringBuilder(str); // Handle SUN-SAT strings if (cft == CronFieldType.DaysOfWeek) { foreach (var kv in dow) sb.Replace(kv.Key, kv.Value); } // Handle JAN–DEC strings if (cft == CronFieldType.Months) { foreach (var kv in mon) sb.Replace(kv.Key, kv.Value); } // Check for "?" and substitute current time if (sb.Length == 1 && sb.ToString() == "?") { var now = DateTime.Now; var nval = 0; switch (cft) { case CronFieldType.Minutes: nval = now.Minute; break; case CronFieldType.Hours: nval = now.Hour; break; case CronFieldType.Days: nval = now.Day; break; case CronFieldType.Months: nval = now.Month; break; case CronFieldType.DaysOfWeek: nval = (int)now.DayOfWeek; break; default: break; } sb.Remove(0, 1); sb.Append(nval); } // Expand or collapse ranges var minMax = validRange[cft]; foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(sb.ToString(), @"(\d+)-(\d+)")) { var low = ParseInt(m.Groups[1].Value); var high = ParseInt(m.Groups[2].Value); if (low == minMax.Min && high == minMax.Max) sb.Replace(m.Value, "*"); else if (sb.ToString().Contains(',')) { var rsb = new System.Text.StringBuilder(low.ToString()); for (int i = low; i < high; i++) rsb.Append($",{i + 1}"); sb.Replace(m.Value, rsb.ToString()); } } return sb.ToString(); } private static int ParseInt(string str) => int.Parse(str.Trim()); private struct MinMax { public int Min, Max; public MinMax(int min, int max) { Min = min; Max = max; } } } } } }