443 lines
15 KiB
C#
443 lines
15 KiB
C#
|
using System;
|
|||
|
using System.Collections.Generic;
|
|||
|
using System.Linq;
|
|||
|
using JetBrains.Annotations;
|
|||
|
|
|||
|
namespace Microsoft.Win32.TaskScheduler
|
|||
|
{
|
|||
|
public abstract partial class Trigger
|
|||
|
{
|
|||
|
/// <summary>Creates a trigger using a cron string.</summary>
|
|||
|
/// <param name="cronString">String using cron defined syntax for specifying a time interval. See remarks for syntax.</param>
|
|||
|
/// <returns>Array of <see cref="Trigger"/> representing the specified cron string.</returns>
|
|||
|
/// <exception cref="System.NotImplementedException">Unsupported cron string.</exception>
|
|||
|
/// <remarks>
|
|||
|
/// <note type="note"> 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.</note>
|
|||
|
/// <para>The following combinations are known <c>not</c> to work:</para>
|
|||
|
/// <list type="bullet">
|
|||
|
/// <item><description>Intervals on months (e.g. "* * * */5 *")</description></item>
|
|||
|
/// <item><description>Intervals on DOW (e.g. "* * * * MON/3")</description></item>
|
|||
|
/// </list>
|
|||
|
/// <para>
|
|||
|
/// 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:
|
|||
|
/// </para>
|
|||
|
/// <code>
|
|||
|
/// <Minute> <Hour> <Day_of_the_Month> <Month_of_the_Year> <Day_of_the_Week>
|
|||
|
/// </code>
|
|||
|
/// <para>Each item has bounds as defined by the following:</para>
|
|||
|
/// <code>
|
|||
|
/// * * * * *
|
|||
|
/// | | | | |
|
|||
|
/// | | | | +---- 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)
|
|||
|
/// </code>
|
|||
|
/// <para>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.</para>
|
|||
|
/// <para>
|
|||
|
/// 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.
|
|||
|
/// </para>
|
|||
|
/// <para>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).</para>
|
|||
|
/// <para>
|
|||
|
/// 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".
|
|||
|
/// </para>
|
|||
|
/// </remarks>
|
|||
|
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<Trigger>();
|
|||
|
|
|||
|
// 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<Trigger> 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 = @"^(?:(?<A>\*)|(?<D1>\d+)(?:-(?<D2>\d+))?)(?:\/(?<I>\d+))?$";
|
|||
|
private readonly static Dictionary<string, string> dow = new Dictionary<string, string>(7)
|
|||
|
{
|
|||
|
{ "SUN", "0" },
|
|||
|
{ "MON", "1" },
|
|||
|
{ "TUE", "2" },
|
|||
|
{ "WED", "3" },
|
|||
|
{ "THU", "4" },
|
|||
|
{ "FRI", "5" },
|
|||
|
{ "SAT", "6" },
|
|||
|
};
|
|||
|
private readonly static Dictionary<string, string> mon = new Dictionary<string, string>(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<CronFieldType, MinMax> validRange = new Dictionary<CronFieldType, MinMax>(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<int> 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; }
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|