← All source files

V15 / 313404a015e8

ScheduleExtensions.cs

Lilith/Core/Scheduling/Extensions/ScheduleExtensions.cs

Unchanged from the preceding version
CS
using Cronos;
using Lilith.Core.Scenarios;
using System.Text.Json;

namespace Lilith.Core.Scheduling.Extensions;

internal static class ScheduleExtensions
{
    internal static ScheduleEntry CreateEntry(this ScheduleRequest request, DateTimeOffset now, int revision, ScenarioFileService files)
    {
        if (!ScenarioXmlParser.ValidName(request.Name) || request.Name.Length > 64)
            throw new ArgumentException("Use a schedule name of up to 64 letters, digits, underscores or hyphens.");
        if (request.Message.Length > 2000 || request.InputsJson.Length > 20000 || request.Cron.Length > 128 || request.TimeZone.Length > 128)
            throw new ArgumentException("Schedule input is too large.");
        var inputs = JsonSerializer.Deserialize<Dictionary<string, string>>(request.InputsJson) ?? throw new ArgumentException("Expected scenario input object.");
        if (inputs.Any(p => p.Value is null)) throw new ArgumentException("Scenario inputs must be strings.");
        if (request.Scenario.Length > 0)
        {
            if (request.Scenario.StartsWith("schedule_", StringComparison.Ordinal)) throw new ArgumentException("Scheduled jobs cannot manage schedules.");
            var definition = ScenarioXmlParser.Parse(files.LoadScenarioXml(request.Scenario));
            if (definition.AllowedTools.Any(n => ScheduleTool.Operation(n) is not null)) throw new ArgumentException("Scheduled jobs cannot manage schedules.");
            if (inputs.Keys.Any(k => !definition.Inputs.Any(i => i.Name == k)) ||
                definition.Inputs.Any(i => i.Required && string.IsNullOrWhiteSpace(inputs.TryGetValue(i.Name, out var value) ? value : i.Value)))
                throw new ArgumentException("Unknown or missing scheduled scenario inputs.");
        }
        else if (string.IsNullOrWhiteSpace(request.Message)) throw new ArgumentException("A reminder message or saved scenario is required.");
        string zone = string.IsNullOrWhiteSpace(request.TimeZone) ? TimeZoneInfo.Local.Id : request.TimeZone;
        _ = TimeZoneInfo.FindSystemTimeZoneById(zone);
        var entry = new ScheduleEntry(request.Name, request.Kind, request.Seconds, request.Cron, zone,
            request.Message, request.Scenario, inputs, null, "active", revision);
        return entry with { NextUtc = entry.NextAfter(now) ?? throw new ArgumentException("Schedule has no future occurrence.") };
    }

    internal static DateTimeOffset? NextAfter(this ScheduleEntry entry, DateTimeOffset now) => entry.Kind switch
    {
        "timer" or "interval" when entry.Seconds is >= 1 and <= 31536000 => now.AddSeconds(entry.Seconds),
        "cron" => ParseCron(entry.Cron).GetNextOccurrence(now, TimeZoneInfo.FindSystemTimeZoneById(entry.TimeZone))?.ToUniversalTime(),
        _ => throw new ArgumentException("Choose timer or interval with 1–31536000 seconds, or a five-field cron expression.")
    };

    private static CronExpression ParseCron(string expression) => expression.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 5
        ? CronExpression.Parse(expression, CronFormat.Standard)
        : throw new ArgumentException("Cron requires exactly five fields: minute hour day month weekday.");
}