namespace ClawdDotNet.Core.Scheduling; /// /// 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) /// public sealed class CronExpression { private readonly HashSet _minutes; private readonly HashSet _hours; private readonly HashSet _daysOfMonth; private readonly HashSet _months; private readonly HashSet _daysOfWeek; private CronExpression( HashSet minutes, HashSet hours, HashSet daysOfMonth, HashSet months, HashSet 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 ParseField(string field, int min, int max) { var result = new HashSet(); 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}."); }