Files
Playa/extensions/Volcanic.AgenticCore.Extensions.Scheduler/Schedules.cs

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();
}