Der Nachrichten-Digest im Dauerbetrieb der Test-VM lief nie: CronExpression verstand 'von-bis/schritt' nicht, TaskSchedule verschluckte den Fehler. Version 0.1.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
5.3 KiB
C#
156 lines
5.3 KiB
C#
namespace ClawdDotNet.Core.Scheduling;
|
|
|
|
/// <summary>
|
|
/// Einfaches Cron-Parsing für 5-Felder-Ausdrücke: Minute Stunde Tag Monat Wochentag
|
|
/// Unterstützt: Zahlen, Wildcards (*), Bereiche (1-5), Listen (1,3,5), Schritte (*/5, 7-22/3, 10/15)
|
|
/// </summary>
|
|
public sealed class CronExpression
|
|
{
|
|
private readonly HashSet<int> _minutes;
|
|
private readonly HashSet<int> _hours;
|
|
private readonly HashSet<int> _daysOfMonth;
|
|
private readonly HashSet<int> _months;
|
|
private readonly HashSet<int> _daysOfWeek;
|
|
|
|
private CronExpression(
|
|
HashSet<int> minutes, HashSet<int> hours,
|
|
HashSet<int> daysOfMonth, HashSet<int> months,
|
|
HashSet<int> daysOfWeek)
|
|
{
|
|
_minutes = minutes;
|
|
_hours = hours;
|
|
_daysOfMonth = daysOfMonth;
|
|
_months = months;
|
|
_daysOfWeek = daysOfWeek;
|
|
}
|
|
|
|
public static CronExpression Parse(string expression)
|
|
{
|
|
var parts = expression.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length != 5)
|
|
throw new FormatException($"Cron expression must have 5 fields, got {parts.Length}: '{expression}'");
|
|
|
|
return new CronExpression(
|
|
ParseField(parts[0], 0, 59),
|
|
ParseField(parts[1], 0, 23),
|
|
ParseField(parts[2], 1, 31),
|
|
ParseField(parts[3], 1, 12),
|
|
ParseField(parts[4], 0, 6)
|
|
);
|
|
}
|
|
|
|
public bool Matches(DateTime dt)
|
|
{
|
|
return _minutes.Contains(dt.Minute)
|
|
&& _hours.Contains(dt.Hour)
|
|
&& _daysOfMonth.Contains(dt.Day)
|
|
&& _months.Contains(dt.Month)
|
|
&& _daysOfWeek.Contains((int)dt.DayOfWeek);
|
|
}
|
|
|
|
public DateTime? GetNextOccurrence(DateTime after)
|
|
{
|
|
var candidate = new DateTime(after.Year, after.Month, after.Day, after.Hour, after.Minute, 0)
|
|
.AddMinutes(1);
|
|
|
|
// Suche maximal 2 Jahre in die Zukunft
|
|
var limit = after.AddYears(2);
|
|
|
|
while (candidate < limit)
|
|
{
|
|
if (Matches(candidate))
|
|
return candidate;
|
|
|
|
candidate = candidate.AddMinutes(1);
|
|
|
|
// Optimierung: überspringe ungültige Stunden/Tage
|
|
if (!_months.Contains(candidate.Month))
|
|
{
|
|
candidate = new DateTime(candidate.Year, candidate.Month, 1).AddMonths(1);
|
|
continue;
|
|
}
|
|
if (!_daysOfMonth.Contains(candidate.Day) || !_daysOfWeek.Contains((int)candidate.DayOfWeek))
|
|
{
|
|
candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day).AddDays(1);
|
|
continue;
|
|
}
|
|
if (!_hours.Contains(candidate.Hour))
|
|
{
|
|
candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day, candidate.Hour, 0, 0)
|
|
.AddHours(1);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static HashSet<int> ParseField(string field, int min, int max)
|
|
{
|
|
var result = new HashSet<int>();
|
|
|
|
foreach (var part in field.Split(','))
|
|
{
|
|
if (part.Length == 0)
|
|
throw Bad(field, "leeres Teilfeld");
|
|
|
|
if (part == "*")
|
|
{
|
|
for (var i = min; i <= max; i++) result.Add(i);
|
|
}
|
|
else if (part.Contains('/'))
|
|
{
|
|
var split = part.Split('/');
|
|
if (split.Length != 2)
|
|
throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
|
|
|
|
// Basis: "*" (ganzer Bereich), "a-b" (Bereich, #597) oder "a" (ab a bis Feldende).
|
|
// "0 7-22/3 * * *" fiel vorher durch, und der Scanner verschwieg es.
|
|
var (start, end) = split[0] == "*"
|
|
? (min, max)
|
|
: split[0].Contains('-')
|
|
? ParseRange(field, split[0], min, max)
|
|
: (ParseNumber(field, split[0], min, max), max);
|
|
var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
|
|
for (var i = start; i <= end; i += step) result.Add(i);
|
|
}
|
|
else if (part.Contains('-'))
|
|
{
|
|
var (from, to) = ParseRange(field, part, min, max);
|
|
for (var i = from; i <= to; i++) result.Add(i);
|
|
}
|
|
else
|
|
{
|
|
result.Add(ParseNumber(field, part, min, max));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static (int From, int To) ParseRange(string field, string value, int min, int max)
|
|
{
|
|
var split = value.Split('-');
|
|
if (split.Length != 2)
|
|
throw Bad(field, "Bereich erwartet die Form 'von-bis'");
|
|
|
|
var from = ParseNumber(field, split[0], min, max);
|
|
var to = ParseNumber(field, split[1], min, max);
|
|
if (from > to)
|
|
throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
|
|
|
|
return (from, to);
|
|
}
|
|
|
|
private static int ParseNumber(string field, string value, int min, int max)
|
|
{
|
|
if (!int.TryParse(value, out var n))
|
|
throw Bad(field, $"'{value}' ist keine Zahl");
|
|
if (n < min || n > max)
|
|
throw Bad(field, $"{n} liegt außerhalb von {min}-{max}");
|
|
return n;
|
|
}
|
|
|
|
private static FormatException Bad(string field, string reason)
|
|
=> new($"Ungültiges Cron-Feld '{field}': {reason}.");
|
|
}
|