forked from admin/VolcanicAgenticCore
65 lines
3.7 KiB
C#
65 lines
3.7 KiB
C#
using System.Globalization;
|
|
using System.Text.RegularExpressions;
|
|
using Cronos;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
|
|
|
/// <summary>Cron (Cronos, 5 fields, in a zone) and one-off times.</summary>
|
|
public static partial class Schedules
|
|
{
|
|
private const string FieldsHint = "Use 5 fields: minute hour day-of-month month day-of-week, e.g. \"0 8 * * 1-5\".";
|
|
|
|
/// <summary>Parses a standard 5-field expression or a macro (<c>@daily</c>). Seconds and <c>H</c> are rejected.</summary>
|
|
public static CronExpression ParseCron(string expression)
|
|
{
|
|
var text = expression?.Trim() ?? "";
|
|
if (text.Length == 0) throw new JobValidationException("cron is empty. " + FieldsHint);
|
|
if (!text.StartsWith('@'))
|
|
{
|
|
var fields = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
|
if (fields.Any(f => f.Split(',').Any(t => t == "H" || t.StartsWith("H(", StringComparison.Ordinal) || t.StartsWith("H/", StringComparison.Ordinal))))
|
|
throw new JobValidationException($"cron \"{text}\": the H (hash) syntax isn't supported; write the minute explicitly. {FieldsHint}");
|
|
if (fields.Length == 6)
|
|
throw new JobValidationException($"cron \"{text}\" has 6 fields; seconds aren't supported. {FieldsHint}");
|
|
if (fields.Length != 5)
|
|
throw new JobValidationException($"cron \"{text}\" has {fields.Length.ToString(CultureInfo.InvariantCulture)} fields. {FieldsHint}");
|
|
}
|
|
try
|
|
{
|
|
return CronExpression.Parse(text, CronFormat.Standard);
|
|
}
|
|
catch (Exception ex) when (ex is not OutOfMemoryException)
|
|
{
|
|
throw new JobValidationException($"cron \"{text}\" is invalid: {ex.Message.TrimEnd('.')}. {FieldsHint}");
|
|
}
|
|
}
|
|
|
|
/// <summary>The next occurrence strictly after <paramref name="after"/>, as wall-clock time in <paramref name="zone"/> (Cronos DST rules).</summary>
|
|
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
|
|
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
|
|
|
|
/// <summary>ISO 8601. With an offset (or <c>Z</c>) it's used as given; without one it's wall-clock time in <paramref name="zone"/>.</summary>
|
|
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
|
|
{
|
|
var trimmed = text?.Trim() ?? "";
|
|
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
|
|
if (HasOffset().IsMatch(trimmed))
|
|
{
|
|
return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var absolute)
|
|
? absolute
|
|
: throw new JobValidationException(hint);
|
|
}
|
|
if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) throw new JobValidationException(hint);
|
|
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
|
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
|
|
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
|
|
// An ambiguous fall-back time is read as the first of the two (the larger offset).
|
|
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
|
|
return new DateTimeOffset(wall, offset);
|
|
}
|
|
|
|
/// <summary>A time of day followed by <c>Z</c> or an offset. A bare date such as <c>2026-09-27</c> must not match (its <c>-27</c> isn't an offset).</summary>
|
|
[GeneratedRegex(@"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$", RegexOptions.IgnoreCase)]
|
|
private static partial Regex HasOffset();
|
|
}
|